diff --git a/package.json b/package.json index b7c649a1..7e6688d6 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,6 @@ "activationEvents": [ "onResolveRemoteAuthority:ssh-remote", "onCommand:coder.connect", - "onCommand:coder.open", - "onCommand:coder.login", - "onView:coderRemote", "onUri" ], "extensionDependencies": [ @@ -77,6 +74,11 @@ { "command": "coder.open", "title": "Coder: Open Workspace" + }, + { + "command": "coder.workspace.update", + "title": "Coder: Update Workspace", + "when": "coder.workspace.updatable" } ] }, diff --git a/src/commands.ts b/src/commands.ts index 6ca5e24e..c25a0635 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,12 +1,12 @@ import axios from "axios" -import { getUser, getWorkspaces } from "coder/site/src/api/api" +import { getUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api" import { Workspace } from "coder/site/src/api/typesGenerated" import * as vscode from "vscode" import { Remote } from "./remote" import { Storage } from "./storage" export class Commands { - public constructor(private readonly storage: Storage) {} + public constructor(private readonly vscodeProposed: typeof vscode, private readonly storage: Storage) {} public async login(...args: string[]): Promise { let url: string | undefined = args.length >= 1 ? args[0] : undefined @@ -215,4 +215,22 @@ export class Commands { reuseWindow: !newWindow, }) } + + public async updateWorkspace(): Promise { + if (!this.storage.workspace) { + return + } + const action = await this.vscodeProposed.window.showInformationMessage( + "Update Workspace", + { + useCustom: true, + modal: true, + detail: `${this.storage.workspace.owner_name}/${this.storage.workspace.name} will be updated then this window will reload to watch the build logs and reconnect.`, + }, + "Update", + ) + if (action === "Update") { + await updateWorkspaceVersion(this.storage.workspace) + } + } } diff --git a/src/extension.ts b/src/extension.ts index 64ab01d2..97c703d7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,12 +50,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }) - const commands = new Commands(storage) - - vscode.commands.registerCommand("coder.login", commands.login.bind(commands)) - vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands)) - vscode.commands.registerCommand("coder.open", commands.open.bind(commands)) - // The Remote SSH extension's proposed APIs are used to override // the SSH host name in VS Code itself. It's visually unappealing // having a lengthy name! @@ -75,6 +69,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { false, ) + const commands = new Commands(vscodeProposed, storage) + + vscode.commands.registerCommand("coder.login", commands.login.bind(commands)) + 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)) + // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. diff --git a/src/remote.ts b/src/remote.ts index 5b0f5920..df3b076a 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -72,9 +72,8 @@ export class Remote { } // Find the workspace from the URI scheme provided! - let workspace: Workspace try { - workspace = await getWorkspaceByOwnerAndName(parts[0], parts[1]) + this.storage.workspace = await getWorkspaceByOwnerAndName(parts[0], parts[1]) } catch (error) { if (!axios.isAxiosError(error)) { throw error @@ -120,10 +119,10 @@ export class Remote { const disposables: vscode.Disposable[] = [] // Register before connection so the label still displays! - disposables.push(this.registerLabelFormatter(`${workspace.owner_name}/${workspace.name}`)) + disposables.push(this.registerLabelFormatter(`${this.storage.workspace.owner_name}/${this.storage.workspace.name}`)) let buildComplete: undefined | (() => void) - if (workspace.latest_build.status === "stopped") { + if (this.storage.workspace.latest_build.status === "stopped") { this.vscodeProposed.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -135,15 +134,18 @@ export class Remote { buildComplete = r }), ) - workspace = { - ...workspace, - latest_build: await startWorkspace(workspace.id), + this.storage.workspace = { + ...this.storage.workspace, + latest_build: await startWorkspace(this.storage.workspace.id), } } // If a build is running we should stream the logs to the user so they can // watch what's going on! - if (workspace.latest_build.status === "pending" || workspace.latest_build.status === "starting") { + if ( + this.storage.workspace.latest_build.status === "pending" || + this.storage.workspace.latest_build.status === "starting" + ) { const writeEmitter = new vscode.EventEmitter() // We use a terminal instead of an output channel because it feels more // familiar to a user! @@ -160,11 +162,11 @@ export class Remote { } as Partial as any, }) // This fetches the initial bunch of logs. - const logs = await getWorkspaceBuildLogs(workspace.latest_build.id, new Date()) + const logs = await getWorkspaceBuildLogs(this.storage.workspace.latest_build.id, new Date()) logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")) terminal.show(true) // This follows the logs for new activity! - let path = `/api/v2/workspacebuilds/${workspace.latest_build.id}/logs?follow=true` + let path = `/api/v2/workspacebuilds/${this.storage.workspace.latest_build.id}/logs?follow=true` if (logs.length) { path += `&after=${logs[logs.length - 1].id}` } @@ -198,15 +200,15 @@ export class Remote { }) }) writeEmitter.fire("Build complete") - workspace = await getWorkspace(workspace.id) - terminal.hide() + this.storage.workspace = await getWorkspace(this.storage.workspace.id) + terminal.dispose() if (buildComplete) { buildComplete() } } - const agents = workspace.latest_build.resources.reduce((acc, resource) => { + const agents = this.storage.workspace.latest_build.resources.reduce((acc, resource) => { return acc.concat(resource.agents || []) }, [] as WorkspaceAgent[]) @@ -250,7 +252,7 @@ export class Remote { await fs.writeFile(this.storage.getUserSettingsPath(), jsonc.applyEdits(settingsContent, edits)) const workspaceUpdate = new vscode.EventEmitter() - const watchURL = new URL(`${this.storage.getURL()}/api/v2/workspaces/${workspace.id}/watch`) + const watchURL = new URL(`${this.storage.getURL()}/api/v2/workspaces/${this.storage.workspace.id}/watch`) const eventSource = new EventSource(watchURL.toString(), { headers: { "Coder-Session-Token": await this.storage.getSessionToken(), @@ -262,11 +264,48 @@ export class Remote { eventSource.addEventListener("error", () => { // TODO: Add debug output that we got an error here! }) + + const workspaceUpdatedStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 999) + disposables.push(workspaceUpdatedStatus) + + let hasShownOutdatedNotification = false + const refreshWorkspaceUpdatedStatus = (newWorkspace: Workspace) => { + // If the newly gotten workspace was updated, then we show a notification + // to the user that they should update. + if (newWorkspace.outdated) { + if (!this.storage.workspace?.outdated || !hasShownOutdatedNotification) { + hasShownOutdatedNotification = true + vscode.window + .showInformationMessage("A new version of your workspace is available.", "Update") + .then((action) => { + if (action === "Update") { + vscode.commands.executeCommand("coder.workspace.update", newWorkspace) + } + }) + } + } + if (!newWorkspace.outdated) { + vscode.commands.executeCommand("setContext", "coder.workspace.updatable", false) + workspaceUpdatedStatus.hide() + return + } + workspaceUpdatedStatus.name = "Coder Workspace Update" + workspaceUpdatedStatus.text = "$(fold-up) Update Workspace" + workspaceUpdatedStatus.command = "coder.workspace.update" + // Important for hiding the "Update Workspace" command. + vscode.commands.executeCommand("setContext", "coder.workspace.updatable", true) + workspaceUpdatedStatus.show() + } + // Show an initial status! + refreshWorkspaceUpdatedStatus(this.storage.workspace) + eventSource.addEventListener("data", (event: MessageEvent) => { const workspace = JSON.parse(event.data) as Workspace if (!workspace) { return } + refreshWorkspaceUpdatedStatus(workspace) + this.storage.workspace = workspace workspaceUpdate.fire(workspace) if (workspace.latest_build.status === "stopping" || workspace.latest_build.status === "stopped") { const action = this.vscodeProposed.window.showInformationMessage( @@ -283,6 +322,13 @@ export class Remote { } this.reloadWindow() } + // If a new build is initialized for a workspace, we automatically + // reload the window. Then the build log will appear, and startup + // will continue as expected. + if (workspace.latest_build.status === "starting") { + this.reloadWindow() + return + } }) if (agent.status === "connecting") { @@ -352,7 +398,7 @@ export class Remote { }) // Register the label formatter again because SSH overrides it! - let label = `${workspace.owner_name}/${workspace.name}` + let label = `${this.storage.workspace.owner_name}/${this.storage.workspace.name}` if (agents.length > 1) { label += `/${agent.name}` } diff --git a/src/storage.ts b/src/storage.ts index f192940d..41c43fbe 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,8 +1,9 @@ import axios from "axios" import { execFile } from "child_process" import { getBuildInfo } from "coder/site/src/api/api" +import { Workspace } from "coder/site/src/api/typesGenerated" import * as crypto from "crypto" -import { createWriteStream, createReadStream } from "fs" +import { createReadStream, createWriteStream } from "fs" import fs from "fs/promises" import { ensureDir } from "fs-extra" import { IncomingMessage } from "http" @@ -12,6 +13,8 @@ import prettyBytes from "pretty-bytes" import * as vscode from "vscode" export class Storage { + public workspace?: Workspace + constructor( private readonly output: vscode.OutputChannel, private readonly memento: vscode.Memento,