Skip to content

WIP: feat: crud workspaces #72

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
/.vscode-test/
/.nyc_output/
/coverage/
*.vsix
*.vsix
yarn-error.log
91 changes: 79 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/api-helper.ts
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +12 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is more than 1 agent, does everything fail? Should we randomly choose one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a good question. I am not changing the actual behavior, just extracted it to a new function. the current version also does this agents[0] choice =/ . I am not sure what is the best way to handle multiple agents...

return [agents, folderPath]
}
61 changes: 48 additions & 13 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -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)
}
Comment on lines +84 to +86
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to check for the owner role? We prefer to do capability checks. You can send a payload to https://dev.coder.com/api/v2/authcheck to see if you can do something. Eg:

{
   "checks":{
      "readAllUsers":{
         "object":{
            "resource_type":"user"
         },
         "action":"read"
      },
   },
}

Is this just to see if we have arbitrary execution of all workspaces?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok for now, we but we might want to change this soon, especially with coder/coder#6875

Copy link
Contributor Author

@rodrimaia rodrimaia Mar 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided to only show the "all Workspaces" view for users who have the owner role. sure we can revisit this after the owner connections PR

vscode.window
.showInformationMessage(
`Welcome to Coder, ${user.username}!`,
Expand Down Expand Up @@ -108,7 +113,37 @@ export class Commands {
})
}

public async open(...args: string[]): Promise<void> {
public async createWorkspace(): Promise<void> {
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<void> {
let workspaceOwner: string
let workspaceName: string
let folderPath: string | undefined
Expand Down Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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!
Expand Down Expand Up @@ -76,6 +88,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
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
Expand Down
60 changes: 60 additions & 0 deletions src/workspacesProvider.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceTreeItem> {
constructor(private readonly getWorkspacesQuery: WorkspaceQuery) {}

private _onDidChangeTreeData: vscode.EventEmitter<WorkspaceTreeItem | undefined | null | void> =
new vscode.EventEmitter<WorkspaceTreeItem | undefined | null | void>()
readonly onDidChangeTreeData: vscode.Event<WorkspaceTreeItem | undefined | null | void> =
this._onDidChangeTreeData.event

refresh(): void {
this._onDidChangeTreeData.fire()
}

getTreeItem(element: WorkspaceTreeItem): vscode.TreeItem {
return element
}

getChildren(): Thenable<WorkspaceTreeItem[]> {
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"),
}
}