diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6ea7b0..e90589c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changed - Always enable verbose (`-v`) flag when a log directory is configured (`coder.proxyLogDir`). -- Automatically start a workspace if it is opened but not running. +- Automatically start a workspace without prompting if it is explicitly opened but not running. ### Added diff --git a/src/commands.ts b/src/commands.ts index 2e4ba705..61cf39d6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -451,7 +451,7 @@ export class Commands { throw new Error("You are not logged in"); } if (item instanceof AgentTreeItem) { - await openWorkspace( + await this.openWorkspace( baseUrl, item.workspace, item.agent, @@ -465,7 +465,13 @@ export class Commands { // User declined to pick an agent. return; } - await openWorkspace(baseUrl, item.workspace, agent, undefined, true); + await this.openWorkspace( + baseUrl, + item.workspace, + agent, + undefined, + true, + ); } else { throw new Error("Unable to open unknown sidebar item"); } @@ -583,7 +589,7 @@ export class Commands { return; } - await openWorkspace(baseUrl, workspace, agent, folderPath, openRecent); + await this.openWorkspace(baseUrl, workspace, agent, folderPath, openRecent); } /** @@ -605,15 +611,49 @@ export class Commands { throw new Error("You are not logged in"); } - await openDevContainer( + const remoteAuthority = toRemoteAuthority( baseUrl, workspaceOwner, workspaceName, workspaceAgent, - devContainerName, - devContainerFolder, - localWorkspaceFolder, - localConfigFile, + ); + + const hostPath = localWorkspaceFolder ? localWorkspaceFolder : undefined; + const configFile = + hostPath && localConfigFile + ? { + path: localConfigFile, + scheme: "vscode-fileHost", + } + : undefined; + const devContainer = Buffer.from( + JSON.stringify({ + containerName: devContainerName, + hostPath, + configFile, + localDocker: false, + }), + "utf-8", + ).toString("hex"); + + const type = localWorkspaceFolder ? "dev-container" : "attached-container"; + const devContainerAuthority = `${type}+${devContainer}@${remoteAuthority}`; + + let newWindow = true; + if (!vscode.workspace.workspaceFolders?.length) { + newWindow = false; + } + + // Only set the memento if when opening a new folder + await this.storage.setFirstConnect(); + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.from({ + scheme: "vscode-remote", + authority: devContainerAuthority, + path: devContainerFolder, + }), + newWindow, ); } @@ -722,141 +762,89 @@ export class Commands { } return agents; } -} - -/** - * Given a workspace and agent, build the host name, find a directory to open, - * and pass both to the Remote SSH plugin in the form of a remote authority - * URI. - * - * If provided, folderPath is always used, otherwise expanded_directory from - * the agent is used. - */ -async function openWorkspace( - baseUrl: string, - workspace: Workspace, - agent: WorkspaceAgent, - folderPath: string | undefined, - openRecent: boolean = false, -) { - const remoteAuthority = toRemoteAuthority( - baseUrl, - workspace.owner_name, - workspace.name, - agent.name, - ); - - let newWindow = true; - // Open in the existing window if no workspaces are open. - if (!vscode.workspace.workspaceFolders?.length) { - newWindow = false; - } - - if (!folderPath) { - folderPath = agent.expanded_directory; - } - // If the agent had no folder or we have been asked to open the most recent, - // we can try to open a recently opened folder/workspace. - if (!folderPath || openRecent) { - const output: { - workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[]; - } = await vscode.commands.executeCommand("_workbench.getRecentlyOpened"); - const opened = output.workspaces.filter( - // Remove recents that do not belong to this connection. The remote - // authority maps to a workspace/agent combination (using the SSH host - // name). There may also be some legacy connections that still may - // reference a workspace without an agent name, which will be missed. - (opened) => opened.folderUri?.authority === remoteAuthority, + /** + * Given a workspace and agent, build the host name, find a directory to open, + * and pass both to the Remote SSH plugin in the form of a remote authority + * URI. + * + * If provided, folderPath is always used, otherwise expanded_directory from + * the agent is used. + */ + async openWorkspace( + baseUrl: string, + workspace: Workspace, + agent: WorkspaceAgent, + folderPath: string | undefined, + openRecent: boolean = false, + ) { + const remoteAuthority = toRemoteAuthority( + baseUrl, + workspace.owner_name, + workspace.name, + agent.name, ); - // openRecent will always use the most recent. Otherwise, if there are - // multiple we ask the user which to use. - if (opened.length === 1 || (opened.length > 1 && openRecent)) { - folderPath = opened[0].folderUri.path; - } else if (opened.length > 1) { - const items = opened.map((f) => f.folderUri.path); - folderPath = await vscode.window.showQuickPick(items, { - title: "Select a recently opened folder", - }); - if (!folderPath) { - // User aborted. - return; - } + let newWindow = true; + // Open in the existing window if no workspaces are open. + if (!vscode.workspace.workspaceFolders?.length) { + newWindow = false; } - } - if (folderPath) { - await vscode.commands.executeCommand( - "vscode.openFolder", - vscode.Uri.from({ - scheme: "vscode-remote", - authority: remoteAuthority, - path: folderPath, - }), - // Open this in a new window! - newWindow, - ); - return; - } + if (!folderPath) { + folderPath = agent.expanded_directory; + } - // This opens the workspace without an active folder opened. - await vscode.commands.executeCommand("vscode.newWindow", { - remoteAuthority: remoteAuthority, - reuseWindow: !newWindow, - }); -} + // If the agent had no folder or we have been asked to open the most recent, + // we can try to open a recently opened folder/workspace. + if (!folderPath || openRecent) { + const output: { + workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[]; + } = await vscode.commands.executeCommand("_workbench.getRecentlyOpened"); + const opened = output.workspaces.filter( + // Remove recents that do not belong to this connection. The remote + // authority maps to a workspace/agent combination (using the SSH host + // name). There may also be some legacy connections that still may + // reference a workspace without an agent name, which will be missed. + (opened) => opened.folderUri?.authority === remoteAuthority, + ); -async function openDevContainer( - baseUrl: string, - workspaceOwner: string, - workspaceName: string, - workspaceAgent: string, - devContainerName: string, - devContainerFolder: string, - localWorkspaceFolder: string = "", - localConfigFile: string = "", -) { - const remoteAuthority = toRemoteAuthority( - baseUrl, - workspaceOwner, - workspaceName, - workspaceAgent, - ); - - const hostPath = localWorkspaceFolder ? localWorkspaceFolder : undefined; - const configFile = - hostPath && localConfigFile - ? { - path: localConfigFile, - scheme: "vscode-fileHost", + // openRecent will always use the most recent. Otherwise, if there are + // multiple we ask the user which to use. + if (opened.length === 1 || (opened.length > 1 && openRecent)) { + folderPath = opened[0].folderUri.path; + } else if (opened.length > 1) { + const items = opened.map((f) => f.folderUri.path); + folderPath = await vscode.window.showQuickPick(items, { + title: "Select a recently opened folder", + }); + if (!folderPath) { + // User aborted. + return; } - : undefined; - const devContainer = Buffer.from( - JSON.stringify({ - containerName: devContainerName, - hostPath, - configFile, - localDocker: false, - }), - "utf-8", - ).toString("hex"); - - const type = localWorkspaceFolder ? "dev-container" : "attached-container"; - const devContainerAuthority = `${type}+${devContainer}@${remoteAuthority}`; - - let newWindow = true; - if (!vscode.workspace.workspaceFolders?.length) { - newWindow = false; - } + } + } - await vscode.commands.executeCommand( - "vscode.openFolder", - vscode.Uri.from({ - scheme: "vscode-remote", - authority: devContainerAuthority, - path: devContainerFolder, - }), - newWindow, - ); + // Only set the memento if when opening a new folder/window + await this.storage.setFirstConnect(); + if (folderPath) { + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.from({ + scheme: "vscode-remote", + authority: remoteAuthority, + path: folderPath, + }), + // Open this in a new window! + newWindow, + ); + return; + } + + // This opens the workspace without an active folder opened. + await vscode.commands.executeCommand("vscode.newWindow", { + remoteAuthority: remoteAuthority, + reuseWindow: !newWindow, + }); + } } diff --git a/src/extension.ts b/src/extension.ts index e765ee1b..b4a0e22a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -57,6 +57,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.logUri, ); + // Try to clear this flag ASAP then pass it around if needed + const isFirstConnect = await storage.getAndClearFirstConnect(); + // This client tracks the current login and will be used through the life of // the plugin to poll workspaces for the current login, as well as being used // in commands that operate on the current login. @@ -309,7 +312,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.extensionMode, ); try { - const details = await remote.setup(vscodeProposed.env.remoteAuthority); + const details = await remote.setup( + vscodeProposed.env.remoteAuthority, + isFirstConnect, + ); if (details) { // Authenticate the plugin client which is used in the sidebar to display // workspaces belonging to this deployment. diff --git a/src/remote.ts b/src/remote.ts index 25c00541..85ccc779 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -54,6 +54,18 @@ export class Remote { private readonly mode: vscode.ExtensionMode, ) {} + private async confirmStart(workspaceName: string): Promise { + const action = await this.vscodeProposed.window.showInformationMessage( + `Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`, + { + useCustom: true, + modal: true, + }, + "Start", + ); + return action === "Start"; + } + /** * Try to get the workspace running. Return undefined if the user canceled. */ @@ -63,6 +75,7 @@ export class Remote { label: string, binPath: string, featureSet: FeatureSet, + firstConnect: boolean, ): Promise { const workspaceName = `${workspace.owner_name}/${workspace.name}`; @@ -120,6 +133,12 @@ export class Remote { ); break; case "stopped": + if ( + !firstConnect && + !(await this.confirmStart(workspaceName)) + ) { + return undefined; + } writeEmitter = initWriteEmitterAndTerminal(); this.storage.output.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( @@ -135,6 +154,12 @@ export class Remote { // On a first attempt, we will try starting a failed workspace // (for example canceling a start seems to cause this state). if (attempts === 1) { + if ( + !firstConnect && + !(await this.confirmStart(workspaceName)) + ) { + return undefined; + } writeEmitter = initWriteEmitterAndTerminal(); this.storage.output.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( @@ -185,6 +210,7 @@ export class Remote { */ public async setup( remoteAuthority: string, + firstConnect: boolean, ): Promise { const parts = parseRemoteAuthority(remoteAuthority); if (!parts) { @@ -224,7 +250,7 @@ export class Remote { undefined, parts.label, ); - await this.setup(remoteAuthority); + await this.setup(remoteAuthority, firstConnect); } return; } @@ -344,7 +370,7 @@ export class Remote { undefined, parts.label, ); - await this.setup(remoteAuthority); + await this.setup(remoteAuthority, firstConnect); } return; } @@ -371,6 +397,7 @@ export class Remote { parts.label, binaryPath, featureSet, + firstConnect, ); if (!updatedWorkspace) { // User declined to start the workspace. diff --git a/src/storage.ts b/src/storage.ts index 614b52aa..734de737 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -70,6 +70,27 @@ export class Storage { : Array.from(urls); } + /** + * Mark this as the first connection to a workspace, which influences whether + * the workspace startup confirmation is shown to the user. + */ + public async setFirstConnect(): Promise { + return this.memento.update("firstConnect", true); + } + + /** + * Check if this is the first connection to a workspace and clear the flag. + * Used to determine whether to automatically start workspaces without + * prompting the user for confirmation. + */ + public async getAndClearFirstConnect(): Promise { + const isFirst = this.memento.get("firstConnect"); + if (isFirst !== undefined) { + await this.memento.update("firstConnect", undefined); + } + return isFirst === true; + } + /** * Set or unset the last used token. */