Skip to content

Commit 26f03f2

Browse files
authored
feat: show workspace updates in the ui (#55)
1 parent fffda3f commit 26f03f2

File tree

5 files changed

+97
-27
lines changed

5 files changed

+97
-27
lines changed

package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@
2323
"activationEvents": [
2424
"onResolveRemoteAuthority:ssh-remote",
2525
"onCommand:coder.connect",
26-
"onCommand:coder.open",
27-
"onCommand:coder.login",
28-
"onView:coderRemote",
2926
"onUri"
3027
],
3128
"extensionDependencies": [
@@ -77,6 +74,11 @@
7774
{
7875
"command": "coder.open",
7976
"title": "Coder: Open Workspace"
77+
},
78+
{
79+
"command": "coder.workspace.update",
80+
"title": "Coder: Update Workspace",
81+
"when": "coder.workspace.updatable"
8082
}
8183
]
8284
},

src/commands.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import axios from "axios"
2-
import { getUser, getWorkspaces } from "coder/site/src/api/api"
2+
import { getUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api"
33
import { Workspace } from "coder/site/src/api/typesGenerated"
44
import * as vscode from "vscode"
55
import { Remote } from "./remote"
66
import { Storage } from "./storage"
77

88
export class Commands {
9-
public constructor(private readonly storage: Storage) {}
9+
public constructor(private readonly vscodeProposed: typeof vscode, private readonly storage: Storage) {}
1010

1111
public async login(...args: string[]): Promise<void> {
1212
let url: string | undefined = args.length >= 1 ? args[0] : undefined
@@ -215,4 +215,22 @@ export class Commands {
215215
reuseWindow: !newWindow,
216216
})
217217
}
218+
219+
public async updateWorkspace(): Promise<void> {
220+
if (!this.storage.workspace) {
221+
return
222+
}
223+
const action = await this.vscodeProposed.window.showInformationMessage(
224+
"Update Workspace",
225+
{
226+
useCustom: true,
227+
modal: true,
228+
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.`,
229+
},
230+
"Update",
231+
)
232+
if (action === "Update") {
233+
await updateWorkspaceVersion(this.storage.workspace)
234+
}
235+
}
218236
}

src/extension.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
5050
},
5151
})
5252

53-
const commands = new Commands(storage)
54-
55-
vscode.commands.registerCommand("coder.login", commands.login.bind(commands))
56-
vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands))
57-
vscode.commands.registerCommand("coder.open", commands.open.bind(commands))
58-
5953
// The Remote SSH extension's proposed APIs are used to override
6054
// the SSH host name in VS Code itself. It's visually unappealing
6155
// having a lengthy name!
@@ -75,6 +69,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
7569
false,
7670
)
7771

72+
const commands = new Commands(vscodeProposed, storage)
73+
74+
vscode.commands.registerCommand("coder.login", commands.login.bind(commands))
75+
vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands))
76+
vscode.commands.registerCommand("coder.open", commands.open.bind(commands))
77+
vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands))
78+
7879
// Since the "onResolveRemoteAuthority:ssh-remote" activation event exists
7980
// in package.json we're able to perform actions before the authority is
8081
// resolved by the remote SSH extension.

src/remote.ts

+61-15
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,8 @@ export class Remote {
7272
}
7373

7474
// Find the workspace from the URI scheme provided!
75-
let workspace: Workspace
7675
try {
77-
workspace = await getWorkspaceByOwnerAndName(parts[0], parts[1])
76+
this.storage.workspace = await getWorkspaceByOwnerAndName(parts[0], parts[1])
7877
} catch (error) {
7978
if (!axios.isAxiosError(error)) {
8079
throw error
@@ -120,10 +119,10 @@ export class Remote {
120119

121120
const disposables: vscode.Disposable[] = []
122121
// Register before connection so the label still displays!
123-
disposables.push(this.registerLabelFormatter(`${workspace.owner_name}/${workspace.name}`))
122+
disposables.push(this.registerLabelFormatter(`${this.storage.workspace.owner_name}/${this.storage.workspace.name}`))
124123

125124
let buildComplete: undefined | (() => void)
126-
if (workspace.latest_build.status === "stopped") {
125+
if (this.storage.workspace.latest_build.status === "stopped") {
127126
this.vscodeProposed.window.withProgress(
128127
{
129128
location: vscode.ProgressLocation.Notification,
@@ -135,15 +134,18 @@ export class Remote {
135134
buildComplete = r
136135
}),
137136
)
138-
workspace = {
139-
...workspace,
140-
latest_build: await startWorkspace(workspace.id),
137+
this.storage.workspace = {
138+
...this.storage.workspace,
139+
latest_build: await startWorkspace(this.storage.workspace.id),
141140
}
142141
}
143142

144143
// If a build is running we should stream the logs to the user so they can
145144
// watch what's going on!
146-
if (workspace.latest_build.status === "pending" || workspace.latest_build.status === "starting") {
145+
if (
146+
this.storage.workspace.latest_build.status === "pending" ||
147+
this.storage.workspace.latest_build.status === "starting"
148+
) {
147149
const writeEmitter = new vscode.EventEmitter<string>()
148150
// We use a terminal instead of an output channel because it feels more
149151
// familiar to a user!
@@ -160,11 +162,11 @@ export class Remote {
160162
} as Partial<vscode.Pseudoterminal> as any,
161163
})
162164
// This fetches the initial bunch of logs.
163-
const logs = await getWorkspaceBuildLogs(workspace.latest_build.id, new Date())
165+
const logs = await getWorkspaceBuildLogs(this.storage.workspace.latest_build.id, new Date())
164166
logs.forEach((log) => writeEmitter.fire(log.output + "\r\n"))
165167
terminal.show(true)
166168
// This follows the logs for new activity!
167-
let path = `/api/v2/workspacebuilds/${workspace.latest_build.id}/logs?follow=true`
169+
let path = `/api/v2/workspacebuilds/${this.storage.workspace.latest_build.id}/logs?follow=true`
168170
if (logs.length) {
169171
path += `&after=${logs[logs.length - 1].id}`
170172
}
@@ -198,15 +200,15 @@ export class Remote {
198200
})
199201
})
200202
writeEmitter.fire("Build complete")
201-
workspace = await getWorkspace(workspace.id)
202-
terminal.hide()
203+
this.storage.workspace = await getWorkspace(this.storage.workspace.id)
204+
terminal.dispose()
203205

204206
if (buildComplete) {
205207
buildComplete()
206208
}
207209
}
208210

209-
const agents = workspace.latest_build.resources.reduce((acc, resource) => {
211+
const agents = this.storage.workspace.latest_build.resources.reduce((acc, resource) => {
210212
return acc.concat(resource.agents || [])
211213
}, [] as WorkspaceAgent[])
212214

@@ -250,7 +252,7 @@ export class Remote {
250252
await fs.writeFile(this.storage.getUserSettingsPath(), jsonc.applyEdits(settingsContent, edits))
251253

252254
const workspaceUpdate = new vscode.EventEmitter<Workspace>()
253-
const watchURL = new URL(`${this.storage.getURL()}/api/v2/workspaces/${workspace.id}/watch`)
255+
const watchURL = new URL(`${this.storage.getURL()}/api/v2/workspaces/${this.storage.workspace.id}/watch`)
254256
const eventSource = new EventSource(watchURL.toString(), {
255257
headers: {
256258
"Coder-Session-Token": await this.storage.getSessionToken(),
@@ -262,11 +264,48 @@ export class Remote {
262264
eventSource.addEventListener("error", () => {
263265
// TODO: Add debug output that we got an error here!
264266
})
267+
268+
const workspaceUpdatedStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 999)
269+
disposables.push(workspaceUpdatedStatus)
270+
271+
let hasShownOutdatedNotification = false
272+
const refreshWorkspaceUpdatedStatus = (newWorkspace: Workspace) => {
273+
// If the newly gotten workspace was updated, then we show a notification
274+
// to the user that they should update.
275+
if (newWorkspace.outdated) {
276+
if (!this.storage.workspace?.outdated || !hasShownOutdatedNotification) {
277+
hasShownOutdatedNotification = true
278+
vscode.window
279+
.showInformationMessage("A new version of your workspace is available.", "Update")
280+
.then((action) => {
281+
if (action === "Update") {
282+
vscode.commands.executeCommand("coder.workspace.update", newWorkspace)
283+
}
284+
})
285+
}
286+
}
287+
if (!newWorkspace.outdated) {
288+
vscode.commands.executeCommand("setContext", "coder.workspace.updatable", false)
289+
workspaceUpdatedStatus.hide()
290+
return
291+
}
292+
workspaceUpdatedStatus.name = "Coder Workspace Update"
293+
workspaceUpdatedStatus.text = "$(fold-up) Update Workspace"
294+
workspaceUpdatedStatus.command = "coder.workspace.update"
295+
// Important for hiding the "Update Workspace" command.
296+
vscode.commands.executeCommand("setContext", "coder.workspace.updatable", true)
297+
workspaceUpdatedStatus.show()
298+
}
299+
// Show an initial status!
300+
refreshWorkspaceUpdatedStatus(this.storage.workspace)
301+
265302
eventSource.addEventListener("data", (event: MessageEvent<string>) => {
266303
const workspace = JSON.parse(event.data) as Workspace
267304
if (!workspace) {
268305
return
269306
}
307+
refreshWorkspaceUpdatedStatus(workspace)
308+
this.storage.workspace = workspace
270309
workspaceUpdate.fire(workspace)
271310
if (workspace.latest_build.status === "stopping" || workspace.latest_build.status === "stopped") {
272311
const action = this.vscodeProposed.window.showInformationMessage(
@@ -283,6 +322,13 @@ export class Remote {
283322
}
284323
this.reloadWindow()
285324
}
325+
// If a new build is initialized for a workspace, we automatically
326+
// reload the window. Then the build log will appear, and startup
327+
// will continue as expected.
328+
if (workspace.latest_build.status === "starting") {
329+
this.reloadWindow()
330+
return
331+
}
286332
})
287333

288334
if (agent.status === "connecting") {
@@ -352,7 +398,7 @@ export class Remote {
352398
})
353399

354400
// Register the label formatter again because SSH overrides it!
355-
let label = `${workspace.owner_name}/${workspace.name}`
401+
let label = `${this.storage.workspace.owner_name}/${this.storage.workspace.name}`
356402
if (agents.length > 1) {
357403
label += `/${agent.name}`
358404
}

src/storage.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import axios from "axios"
22
import { execFile } from "child_process"
33
import { getBuildInfo } from "coder/site/src/api/api"
4+
import { Workspace } from "coder/site/src/api/typesGenerated"
45
import * as crypto from "crypto"
5-
import { createWriteStream, createReadStream } from "fs"
6+
import { createReadStream, createWriteStream } from "fs"
67
import fs from "fs/promises"
78
import { ensureDir } from "fs-extra"
89
import { IncomingMessage } from "http"
@@ -12,6 +13,8 @@ import prettyBytes from "pretty-bytes"
1213
import * as vscode from "vscode"
1314

1415
export class Storage {
16+
public workspace?: Workspace
17+
1518
constructor(
1619
private readonly output: vscode.OutputChannel,
1720
private readonly memento: vscode.Memento,

0 commit comments

Comments
 (0)