Skip to content

Commit 4662ebd

Browse files
ytchengkylecarbsrodrimaia
authored
feat: multiple agent support (#59)
Co-authored-by: Kyle Carberry <[email protected]> Co-authored-by: Rodrigo Maia <[email protected]>
1 parent 5a54059 commit 4662ebd

File tree

5 files changed

+183
-88
lines changed

5 files changed

+183
-88
lines changed

package.json

+10-5
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@
9494
"title": "Coder: Open Workspace",
9595
"icon": "$(play)"
9696
},
97+
{
98+
"command": "coder.openFromSidebar",
99+
"title": "Coder: Open Workspace",
100+
"icon": "$(play)"
101+
},
97102
{
98103
"command": "coder.createWorkspace",
99104
"title": "Create Workspace",
@@ -147,18 +152,18 @@
147152
],
148153
"view/item/context": [
149154
{
150-
"command": "coder.open",
151-
"when": "coder.authenticated && view == myWorkspaces || coder.authenticated && view == allWorkspaces",
155+
"command": "coder.openFromSidebar",
156+
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderAgent",
152157
"group": "inline"
153158
},
154159
{
155160
"command": "coder.navigateToWorkspace",
156-
"when": "coder.authenticated && view == myWorkspaces || coder.authenticated && view == allWorkspaces",
161+
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderWorkspaceMultipleAgents",
157162
"group": "inline"
158163
},
159164
{
160165
"command": "coder.navigateToWorkspaceSettings",
161-
"when": "coder.authenticated && view == myWorkspaces || coder.authenticated && view == allWorkspaces",
166+
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderWorkspaceMultipleAgents",
162167
"group": "inline"
163168
}
164169
]
@@ -223,4 +228,4 @@
223228
"ws": "^8.11.0",
224229
"yaml": "^1.10.0"
225230
}
226-
}
231+
}

src/api-helper.ts

+2-9
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
22

3-
export function extractAgentsAndFolderPath(
4-
workspace: Workspace,
5-
): [agents: WorkspaceAgent[], folderPath: string | undefined] {
6-
// TODO: multiple agent support
3+
export function extractAgents(workspace: Workspace): WorkspaceAgent[] {
74
const agents = workspace.latest_build.resources.reduce((acc, resource) => {
85
return acc.concat(resource.agents || [])
96
}, [] as WorkspaceAgent[])
107

11-
let folderPath = undefined
12-
if (agents.length === 1) {
13-
folderPath = agents[0].expanded_directory
14-
}
15-
return [agents, folderPath]
8+
return agents
169
}

src/commands.ts

+132-69
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import axios from "axios"
22
import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api"
3-
import { Workspace } from "coder/site/src/api/typesGenerated"
3+
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
44
import * as vscode from "vscode"
5-
import { extractAgentsAndFolderPath } from "./api-helper"
5+
import { extractAgents } from "./api-helper"
66
import { Remote } from "./remote"
77
import { Storage } from "./storage"
88
import { WorkspaceTreeItem } from "./workspacesProvider"
@@ -143,9 +143,21 @@ export class Commands {
143143
}
144144
}
145145

146+
public async openFromSidebar(treeItem: WorkspaceTreeItem) {
147+
if (treeItem) {
148+
await openWorkspace(
149+
treeItem.workspaceOwner,
150+
treeItem.workspaceName,
151+
treeItem.workspaceAgent,
152+
treeItem.workspaceFolderPath,
153+
)
154+
}
155+
}
156+
146157
public async open(...args: unknown[]): Promise<void> {
147158
let workspaceOwner: string
148159
let workspaceName: string
160+
let workspaceAgent: string | undefined
149161
let folderPath: string | undefined
150162

151163
if (args.length === 0) {
@@ -200,83 +212,61 @@ export class Commands {
200212
workspaceOwner = workspace.owner_name
201213
workspaceName = workspace.name
202214

203-
const [, folderPathExtracted] = extractAgentsAndFolderPath(workspace)
204-
folderPath = folderPathExtracted
205-
} else if (args.length === 2) {
206-
// opening a workspace from the sidebar
207-
const workspaceTreeItem = args[0] as WorkspaceTreeItem
208-
workspaceOwner = workspaceTreeItem.workspaceOwner
209-
workspaceName = workspaceTreeItem.workspaceName
210-
folderPath = workspaceTreeItem.workspaceFolderPath
211-
} else {
212-
workspaceOwner = args[0] as string
213-
workspaceName = args[1] as string
214-
// workspaceAgent is reserved for args[2], but multiple agents aren't supported yet.
215-
folderPath = args[3] as string | undefined
216-
}
217-
218-
// A workspace can have multiple agents, but that's handled
219-
// when opening a workspace unless explicitly specified.
220-
const remoteAuthority = `ssh-remote+${Remote.Prefix}${workspaceOwner}--${workspaceName}`
215+
const agents = extractAgents(workspace)
221216

222-
let newWindow = true
223-
// Open in the existing window if no workspaces are open.
224-
if (!vscode.workspace.workspaceFolders?.length) {
225-
newWindow = false
226-
}
217+
if (agents.length === 1) {
218+
folderPath = agents[0].expanded_directory
219+
workspaceAgent = agents[0].name
220+
} else {
221+
const agentQuickPick = vscode.window.createQuickPick()
222+
agentQuickPick.title = `Select an agent`
227223

228-
// If a folder isn't specified, we can try to open a recently opened folder.
229-
if (!folderPath) {
230-
const output: {
231-
workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[]
232-
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened")
233-
const opened = output.workspaces.filter(
234-
// Filter out `/` since that's added below.
235-
(opened) => opened.folderUri?.authority === remoteAuthority,
236-
)
237-
if (opened.length > 0) {
238-
let selected: (typeof opened)[0]
224+
agentQuickPick.busy = true
225+
const lastAgents = agents
226+
const agentItems: vscode.QuickPickItem[] = agents.map((agent) => {
227+
let icon = "$(debug-start)"
228+
if (agent.status !== "connected") {
229+
icon = "$(debug-stop)"
230+
}
231+
return {
232+
alwaysShow: true,
233+
label: `${icon} ${agent.name}`,
234+
detail: `${agent.name} • Status: ${agent.status}`,
235+
}
236+
})
237+
agentQuickPick.items = agentItems
238+
agentQuickPick.busy = false
239+
agentQuickPick.show()
239240

240-
if (opened.length > 1) {
241-
const items: vscode.QuickPickItem[] = opened.map((folder): vscode.QuickPickItem => {
242-
return {
243-
label: folder.folderUri.path,
244-
}
241+
const agent = await new Promise<WorkspaceAgent | undefined>((resolve) => {
242+
agentQuickPick.onDidHide(() => {
243+
resolve(undefined)
245244
})
246-
const item = await vscode.window.showQuickPick(items, {
247-
title: "Select a recently opened folder",
245+
agentQuickPick.onDidChangeSelection((selected) => {
246+
if (selected.length < 1) {
247+
return resolve(undefined)
248+
}
249+
const agent = lastAgents[agentQuickPick.items.indexOf(selected[0])]
250+
resolve(agent)
248251
})
249-
if (!item) {
250-
return
251-
}
252-
selected = opened[items.indexOf(item)]
252+
})
253+
254+
if (agent) {
255+
folderPath = agent.expanded_directory
256+
workspaceAgent = agent.name
253257
} else {
254-
selected = opened[0]
258+
folderPath = ""
259+
workspaceAgent = ""
255260
}
256-
257-
folderPath = selected.folderUri.path
258261
}
262+
} else {
263+
workspaceOwner = args[0] as string
264+
workspaceName = args[1] as string
265+
// workspaceAgent is reserved for args[2], but multiple agents aren't supported yet.
266+
folderPath = args[3] as string | undefined
259267
}
260268

261-
if (folderPath) {
262-
await vscode.commands.executeCommand(
263-
"vscode.openFolder",
264-
vscode.Uri.from({
265-
scheme: "vscode-remote",
266-
authority: remoteAuthority,
267-
path: folderPath,
268-
}),
269-
// Open this in a new window!
270-
newWindow,
271-
)
272-
return
273-
}
274-
275-
// This opens the workspace without an active folder opened.
276-
await vscode.commands.executeCommand("vscode.newWindow", {
277-
remoteAuthority: remoteAuthority,
278-
reuseWindow: !newWindow,
279-
})
269+
await openWorkspace(workspaceOwner, workspaceName, workspaceAgent, folderPath)
280270
}
281271

282272
public async updateWorkspace(): Promise<void> {
@@ -297,3 +287,76 @@ export class Commands {
297287
}
298288
}
299289
}
290+
291+
async function openWorkspace(
292+
workspaceOwner: string,
293+
workspaceName: string,
294+
workspaceAgent: string | undefined,
295+
folderPath: string | undefined,
296+
) {
297+
// A workspace can have multiple agents, but that's handled
298+
// when opening a workspace unless explicitly specified.
299+
let remoteAuthority = `ssh-remote+${Remote.Prefix}${workspaceOwner}--${workspaceName}`
300+
if (workspaceAgent) {
301+
remoteAuthority += `--${workspaceAgent}`
302+
}
303+
304+
let newWindow = true
305+
// Open in the existing window if no workspaces are open.
306+
if (!vscode.workspace.workspaceFolders?.length) {
307+
newWindow = false
308+
}
309+
310+
// If a folder isn't specified, we can try to open a recently opened folder.
311+
if (!folderPath) {
312+
const output: {
313+
workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[]
314+
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened")
315+
const opened = output.workspaces.filter(
316+
// Filter out `/` since that's added below.
317+
(opened) => opened.folderUri?.authority === remoteAuthority,
318+
)
319+
if (opened.length > 0) {
320+
let selected: (typeof opened)[0]
321+
322+
if (opened.length > 1) {
323+
const items: vscode.QuickPickItem[] = opened.map((folder): vscode.QuickPickItem => {
324+
return {
325+
label: folder.folderUri.path,
326+
}
327+
})
328+
const item = await vscode.window.showQuickPick(items, {
329+
title: "Select a recently opened folder",
330+
})
331+
if (!item) {
332+
return
333+
}
334+
selected = opened[items.indexOf(item)]
335+
} else {
336+
selected = opened[0]
337+
}
338+
339+
folderPath = selected.folderUri.path
340+
}
341+
}
342+
343+
if (folderPath) {
344+
await vscode.commands.executeCommand(
345+
"vscode.openFolder",
346+
vscode.Uri.from({
347+
scheme: "vscode-remote",
348+
authority: remoteAuthority,
349+
path: folderPath,
350+
}),
351+
// Open this in a new window!
352+
newWindow,
353+
)
354+
return
355+
}
356+
357+
// This opens the workspace without an active folder opened.
358+
await vscode.commands.executeCommand("vscode.newWindow", {
359+
remoteAuthority: remoteAuthority,
360+
reuseWindow: !newWindow,
361+
})
362+
}

src/extension.ts

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
8787
vscode.commands.registerCommand("coder.login", commands.login.bind(commands))
8888
vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands))
8989
vscode.commands.registerCommand("coder.open", commands.open.bind(commands))
90+
vscode.commands.registerCommand("coder.openFromSidebar", commands.openFromSidebar.bind(commands))
9091
vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands))
9192
vscode.commands.registerCommand("coder.createWorkspace", commands.createWorkspace.bind(commands))
9293
vscode.commands.registerCommand("coder.navigateToWorkspace", commands.navigateToWorkspace.bind(commands))

src/workspacesProvider.ts

+38-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { getWorkspaces } from "coder/site/src/api/api"
2+
import { WorkspaceAgent } from "coder/site/src/api/typesGenerated"
23
import * as path from "path"
34
import * as vscode from "vscode"
4-
import { extractAgentsAndFolderPath } from "./api-helper"
5+
import { extractAgents } from "./api-helper"
56

67
export enum WorkspaceQuery {
78
Mine = "owner:me",
@@ -24,7 +25,19 @@ export class WorkspaceProvider implements vscode.TreeDataProvider<WorkspaceTreeI
2425
return element
2526
}
2627

27-
getChildren(): Thenable<WorkspaceTreeItem[]> {
28+
getChildren(element?: WorkspaceTreeItem): Thenable<WorkspaceTreeItem[]> {
29+
if (element) {
30+
if (element.agents.length > 0) {
31+
return Promise.resolve(
32+
element.agents.map((agent) => {
33+
const label = agent.name
34+
const detail = `Status: ${agent.status}`
35+
return new WorkspaceTreeItem(label, detail, "", "", agent.name, agent.expanded_directory, [], "coderAgent")
36+
}),
37+
)
38+
}
39+
return Promise.resolve([])
40+
}
2841
return getWorkspaces({ q: this.getWorkspacesQuery }).then((workspaces) => {
2942
return workspaces.workspaces.map((workspace) => {
3043
const status =
@@ -35,22 +48,42 @@ export class WorkspaceProvider implements vscode.TreeDataProvider<WorkspaceTreeI
3548
? `${workspace.owner_name} / ${workspace.name}`
3649
: workspace.name
3750
const detail = `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`
38-
const [, folderPath] = extractAgentsAndFolderPath(workspace)
39-
return new WorkspaceTreeItem(label, detail, workspace.owner_name, workspace.name, folderPath)
51+
const agents = extractAgents(workspace)
52+
return new WorkspaceTreeItem(
53+
label,
54+
detail,
55+
workspace.owner_name,
56+
workspace.name,
57+
undefined,
58+
agents[0]?.expanded_directory,
59+
agents,
60+
agents.length > 1 ? "coderWorkspaceMultipleAgents" : "coderWorkspaceSingleAgent",
61+
)
4062
})
4163
})
4264
}
4365
}
4466

67+
type CoderTreeItemType = "coderWorkspaceSingleAgent" | "coderWorkspaceMultipleAgents" | "coderAgent"
68+
4569
export class WorkspaceTreeItem extends vscode.TreeItem {
4670
constructor(
4771
public readonly label: string,
4872
public readonly tooltip: string,
4973
public readonly workspaceOwner: string,
5074
public readonly workspaceName: string,
75+
public readonly workspaceAgent: string | undefined,
5176
public readonly workspaceFolderPath: string | undefined,
77+
public readonly agents: WorkspaceAgent[],
78+
contextValue: CoderTreeItemType,
5279
) {
53-
super(label, vscode.TreeItemCollapsibleState.None)
80+
super(
81+
label,
82+
contextValue === "coderWorkspaceMultipleAgents"
83+
? vscode.TreeItemCollapsibleState.Collapsed
84+
: vscode.TreeItemCollapsibleState.None,
85+
)
86+
this.contextValue = contextValue
5487
}
5588

5689
iconPath = {

0 commit comments

Comments
 (0)