diff --git a/.gitignore b/.gitignore index d535c22b..75f80c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /.vscode-test/ /.nyc_output/ /coverage/ -*.vsix \ No newline at end of file +*.vsix +yarn-error.log diff --git a/package.json b/package.json index 1553bdff..0c114daf 100644 --- a/package.json +++ b/package.json @@ -41,24 +41,25 @@ "views": { "coder": [ { - "id": "coderRemote", - "name": "", + "id": "myWorkspaces", + "name": "My Workspaces", + "visibility": "visible", + "icon": "media/logo.svg" + }, + { + "id": "allWorkspaces", + "name": "All Workspaces", "visibility": "visible", "icon": "media/logo.svg", - "contextualTitle": "Coder Remote" + "when": "coder.authenticated && coder.isOwner" } ] }, "viewsWelcome": [ { - "view": "coderRemote", + "view": "myWorkspaces", "contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)", "when": "!coder.authenticated && coder.loaded" - }, - { - "view": "coderRemote", - "contents": "You're logged in! \n[Open Workspace](command:coder.open)", - "when": "coder.authenticated && coder.loaded" } ], "commands": [ @@ -68,18 +69,84 @@ }, { "command": "coder.logout", - "title": "Coder: Logout" + "title": "Coder: Logout", + "when": "coder.authenticated", + "icon": "$(sign-out)" }, { "command": "coder.open", - "title": "Coder: Open Workspace" + "title": "Coder: Open Workspace", + "icon": "$(play)" + }, + { + "command": "coder.createWorkspace", + "title": "Create Workspace", + "when": "coder.authenticated", + "icon": "$(add)" + }, + { + "command": "coder.navigateToWorkspace", + "title": "Navigate to Workspace Page", + "when": "coder.authenticated", + "icon": "$(link-external)" + }, + { + "command": "coder.navigateToWorkspaceSettings", + "title": "Edit Workspace Settings", + "when": "coder.authenticated", + "icon": "$(settings-gear)" }, { "command": "coder.workspace.update", "title": "Coder: Update Workspace", "when": "coder.workspace.updatable" + }, + { + "command": "coder.refreshWorkspaces", + "title": "Coder: Refresh Workspace", + "icon": "$(refresh)", + "when": "coder.authenticated" } - ] + ], + "menus": { + "view/title": [ + { + "command": "coder.logout", + "when": "coder.authenticated && view == myWorkspaces" + }, + { + "command": "coder.login", + "when": "!coder.authenticated && view == myWorkspaces" + }, + { + "command": "coder.createWorkspace", + "when": "coder.authenticated && view == myWorkspaces", + "group": "navigation" + }, + { + "command": "coder.refreshWorkspaces", + "when": "coder.authenticated && view == myWorkspaces", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "coder.open", + "when": "coder.authenticated && view == myWorkspaces || coder.authenticated && view == allWorkspaces", + "group": "inline" + }, + { + "command": "coder.navigateToWorkspace", + "when": "coder.authenticated && view == myWorkspaces || coder.authenticated && view == allWorkspaces", + "group": "inline" + }, + { + "command": "coder.navigateToWorkspaceSettings", + "when": "coder.authenticated && view == myWorkspaces || coder.authenticated && view == allWorkspaces", + "group": "inline" + } + ] + } }, "scripts": { "vscode:prepublish": "yarn package", diff --git a/src/api-helper.ts b/src/api-helper.ts new file mode 100644 index 00000000..75c0af83 --- /dev/null +++ b/src/api-helper.ts @@ -0,0 +1,16 @@ +import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" + +export function extractAgentsAndFolderPath( + workspace: Workspace, +): [agents: WorkspaceAgent[], folderPath: string | undefined] { + // TODO: multiple agent support + const agents = workspace.latest_build.resources.reduce((acc, resource) => { + return acc.concat(resource.agents || []) + }, [] as WorkspaceAgent[]) + + let folderPath = undefined + if (agents.length === 1) { + folderPath = agents[0].expanded_directory + } + return [agents, folderPath] +} diff --git a/src/commands.ts b/src/commands.ts index ccc2c653..bd4fc1b5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,9 +1,11 @@ import axios from "axios" import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api" -import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" +import { Workspace } from "coder/site/src/api/typesGenerated" import * as vscode from "vscode" +import { extractAgentsAndFolderPath } from "./api-helper" import { Remote } from "./remote" import { Storage } from "./storage" +import { WorkspaceTreeItem } from "./workspacesProvider" export class Commands { public constructor(private readonly vscodeProposed: typeof vscode, private readonly storage: Storage) {} @@ -79,6 +81,9 @@ export class Commands { throw new Error("Failed to get authenticated user") } await vscode.commands.executeCommand("setContext", "coder.authenticated", true) + if (user.roles.find((role) => role.name === "owner")) { + await vscode.commands.executeCommand("setContext", "coder.isOwner", true) + } vscode.window .showInformationMessage( `Welcome to Coder, ${user.username}!`, @@ -108,7 +113,37 @@ export class Commands { }) } - public async open(...args: string[]): Promise { + public async createWorkspace(): Promise { + const uri = this.storage.getURL() + "/templates" + await vscode.commands.executeCommand("vscode.open", uri) + } + + public async navigateToWorkspace(workspace: WorkspaceTreeItem) { + if (workspace) { + const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}` + await vscode.commands.executeCommand("vscode.open", uri) + } else if (this.storage.workspace) { + const uri = this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}` + await vscode.commands.executeCommand("vscode.open", uri) + } else { + vscode.window.showInformationMessage("No workspace found.") + } + } + + public async navigateToWorkspaceSettings(workspace: WorkspaceTreeItem) { + if (workspace) { + const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings` + await vscode.commands.executeCommand("vscode.open", uri) + } else if (this.storage.workspace) { + const uri = + this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings` + await vscode.commands.executeCommand("vscode.open", uri) + } else { + vscode.window.showInformationMessage("No workspace found.") + } + } + + public async open(...args: unknown[]): Promise { let workspaceOwner: string let workspaceName: string let folderPath: string | undefined @@ -165,19 +200,19 @@ export class Commands { workspaceOwner = workspace.owner_name workspaceName = workspace.name - // TODO: multiple agent support - const agents = workspace.latest_build.resources.reduce((acc, resource) => { - return acc.concat(resource.agents || []) - }, [] as WorkspaceAgent[]) - - if (agents.length === 1) { - folderPath = agents[0].expanded_directory - } + const [, folderPathExtracted] = extractAgentsAndFolderPath(workspace) + folderPath = folderPathExtracted + } else if (args.length === 2) { + // opening a workspace from the sidebar + const workspaceTreeItem = args[0] as WorkspaceTreeItem + workspaceOwner = workspaceTreeItem.workspaceOwner + workspaceName = workspaceTreeItem.workspaceName + folderPath = workspaceTreeItem.workspaceFolderPath } else { - workspaceOwner = args[0] - workspaceName = args[1] + workspaceOwner = args[0] as string + workspaceName = args[1] as string // workspaceAgent is reserved for args[2], but multiple agents aren't supported yet. - folderPath = args[3] + folderPath = args[3] as string | undefined } // A workspace can have multiple agents, but that's handled diff --git a/src/extension.ts b/src/extension.ts index e5e73cd7..7131dd95 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,15 +6,27 @@ import * as vscode from "vscode" import { Commands } from "./commands" import { Remote } from "./remote" import { Storage } from "./storage" +import { WorkspaceQuery, WorkspaceProvider } from "./workspacesProvider" export async function activate(ctx: vscode.ExtensionContext): Promise { const output = vscode.window.createOutputChannel("Coder") const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri) await storage.init() + const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine) + const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All) + + vscode.window.registerTreeDataProvider("myWorkspaces", myWorkspacesProvider) + vscode.window.registerTreeDataProvider("allWorkspaces", allWorkspacesProvider) + getAuthenticatedUser() - .then(() => { - vscode.commands.executeCommand("setContext", "coder.authenticated", true) + .then(async (user) => { + if (user) { + vscode.commands.executeCommand("setContext", "coder.authenticated", true) + if (user.roles.find((role) => role.name === "owner")) { + await vscode.commands.executeCommand("setContext", "coder.isOwner", true) + } + } }) .catch(() => { // Not authenticated! @@ -76,6 +88,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands)) vscode.commands.registerCommand("coder.open", commands.open.bind(commands)) vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands)) + vscode.commands.registerCommand("coder.createWorkspace", commands.createWorkspace.bind(commands)) + vscode.commands.registerCommand("coder.navigateToWorkspace", commands.navigateToWorkspace.bind(commands)) + vscode.commands.registerCommand( + "coder.navigateToWorkspaceSettings", + commands.navigateToWorkspaceSettings.bind(commands), + ) + vscode.commands.registerCommand("coder.refreshWorkspaces", () => { + myWorkspacesProvider.refresh() + allWorkspacesProvider.refresh() + }) // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts new file mode 100644 index 00000000..f09b29e4 --- /dev/null +++ b/src/workspacesProvider.ts @@ -0,0 +1,60 @@ +import { getWorkspaces } from "coder/site/src/api/api" +import * as path from "path" +import * as vscode from "vscode" +import { extractAgentsAndFolderPath } from "./api-helper" + +export enum WorkspaceQuery { + Mine = "owner:me", + All = "", +} + +export class WorkspaceProvider implements vscode.TreeDataProvider { + constructor(private readonly getWorkspacesQuery: WorkspaceQuery) {} + + private _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter() + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event + + refresh(): void { + this._onDidChangeTreeData.fire() + } + + getTreeItem(element: WorkspaceTreeItem): vscode.TreeItem { + return element + } + + getChildren(): Thenable { + return getWorkspaces({ q: this.getWorkspacesQuery }).then((workspaces) => { + return workspaces.workspaces.map((workspace) => { + const status = + workspace.latest_build.status.substring(0, 1).toUpperCase() + workspace.latest_build.status.substring(1) + + const label = + this.getWorkspacesQuery === WorkspaceQuery.All + ? `${workspace.owner_name} / ${workspace.name}` + : workspace.name + const detail = `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}` + const [, folderPath] = extractAgentsAndFolderPath(workspace) + return new WorkspaceTreeItem(label, detail, workspace.owner_name, workspace.name, folderPath) + }) + }) + } +} + +export class WorkspaceTreeItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly tooltip: string, + public readonly workspaceOwner: string, + public readonly workspaceName: string, + public readonly workspaceFolderPath: string | undefined, + ) { + super(label, vscode.TreeItemCollapsibleState.None) + } + + iconPath = { + light: path.join(__filename, "..", "..", "media", "logo.svg"), + dark: path.join(__filename, "..", "..", "media", "logo.svg"), + } +}