diff --git a/src/commands.ts b/src/commands.ts index 3c0792ef..31d7da0c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,42 +8,81 @@ import { Remote } from "./remote" import { Storage } from "./storage" import { OpenableTreeItem } from "./workspacesProvider" -// maybeAskUrl asks the user for the URL if it was not provided and normalizes -// the returned URL. -export async function maybeAskUrl( - providedUrl: string | undefined | null, - lastUsedUrl?: string, -): Promise { - let url = - providedUrl || - (await vscode.window.showInputBox({ - title: "Coder URL", - prompt: "Enter the URL of your Coder deployment.", - placeHolder: "https://example.coder.com", - value: lastUsedUrl, - })) - if (!url) { - return undefined - } - if (!url.startsWith("http://") && !url.startsWith("https://")) { - // Default to HTTPS if not provided! - // https://github.com/coder/vscode-coder/issues/44 - url = "https://" + url - } - while (url.endsWith("/")) { - url = url.substring(0, url.length - 1) - } - return url -} - export class Commands { public constructor( private readonly vscodeProposed: typeof vscode, private readonly storage: Storage, ) {} + /** + * Ask the user for the URL, letting them choose from a list of recent URLs or + * CODER_URL or enter a new one. Undefined means the user aborted. + */ + private async askURL(selection?: string): Promise { + const quickPick = vscode.window.createQuickPick() + quickPick.value = selection || process.env.CODER_URL || "" + quickPick.placeholder = "https://example.coder.com" + quickPick.title = "Enter the URL of your Coder deployment." + + // Initial items. + quickPick.items = this.storage.withUrlHistory(process.env.CODER_URL).map((url) => ({ + alwaysShow: true, + label: url, + })) + + // Quick picks do not allow arbitrary values, so we add the value itself as + // an option in case the user wants to connect to something that is not in + // the list. + quickPick.onDidChangeValue((value) => { + quickPick.items = this.storage.withUrlHistory(process.env.CODER_URL, value).map((url) => ({ + alwaysShow: true, + label: url, + })) + }) + + quickPick.show() + + const selected = await new Promise((resolve) => { + quickPick.onDidHide(() => resolve(undefined)) + quickPick.onDidChangeSelection((selected) => resolve(selected[0]?.label)) + }) + quickPick.dispose() + return selected + } + + /** + * Ask the user for the URL if it was not provided, letting them choose from a + * list of recent URLs or CODER_URL or enter a new one, and normalizes the + * returned URL. Undefined means the user aborted. + */ + public async maybeAskUrl(providedUrl: string | undefined | null, lastUsedUrl?: string): Promise { + let url = providedUrl || (await this.askURL(lastUsedUrl)) + if (!url) { + // User aborted. + return undefined + } + + // Normalize URL. + if (!url.startsWith("http://") && !url.startsWith("https://")) { + // Default to HTTPS if not provided so URLs can be typed more easily. + url = "https://" + url + } + while (url.endsWith("/")) { + url = url.substring(0, url.length - 1) + } + return url + } + + /** + * Log into the provided deployment. If the deployment URL is not specified, + * ask for it first with a menu showing recent URLs and CODER_URL, if set. + */ public async login(...args: string[]): Promise { - const url = await maybeAskUrl(args[0]) + const url = await this.maybeAskUrl(args[0]) + if (!url) { + return + } + let token: string | undefined = args.length >= 2 ? args[1] : undefined if (!token) { const opened = await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)) diff --git a/src/extension.ts b/src/extension.ts index 9541b68b..8167c7ff 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -139,7 +139,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // queries will default to localhost) so ask for it if missing. // Pre-populate in case we do have the right URL so the user can just // hit enter and move on. - const url = await maybeAskUrl(params.get("url"), storage.getURL()) + const url = await commands.maybeAskUrl(params.get("url"), storage.getURL()) if (url) { await storage.setURL(url) } else { diff --git a/src/storage.ts b/src/storage.ts index fa173cdd..70d30907 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -13,6 +13,9 @@ import prettyBytes from "pretty-bytes" import * as vscode from "vscode" import { getHeaderCommand, getHeaders } from "./headers" +// Maximium number of recent URLs to store. +const MAX_URLS = 10 + export class Storage { public workspace?: Workspace public workspaceLogPath?: string @@ -25,23 +28,57 @@ export class Storage { private readonly logUri: vscode.Uri, ) {} - // init ensures that the storage places values in the - // appropriate default values. + /** + * Set the URL and session token on the Axios client and on disk for the cli + * if they are set. + */ public async init(): Promise { - await this.updateURL() + await this.updateURL(this.getURL()) await this.updateSessionToken() } - public setURL(url?: string): Thenable { - return this.memento.update("url", url).then(() => { - return this.updateURL() - }) + /** + * Add the URL to the list of recently accessed URLs in global storage, then + * set it as the current URL and update it on the Axios client and on disk for + * the cli. + * + * If the URL is falsey, then remove it as the currently accessed URL and do + * not touch the history. + */ + public async setURL(url?: string): Promise { + await this.memento.update("url", url) + this.updateURL(url) + if (url) { + const history = this.withUrlHistory(url) + await this.memento.update("urlHistory", history) + } } + /** + * Get the currently configured URL. + */ public getURL(): string | undefined { return this.memento.get("url") } + /** + * Get the most recently accessed URLs (oldest to newest) with the provided + * values appended. Duplicates will be removed. + */ + public withUrlHistory(...append: (string | undefined)[]): string[] { + const val = this.memento.get("urlHistory") + const urls = Array.isArray(val) ? new Set(val) : new Set() + for (const url of append) { + if (url) { + // It might exist; delete first so it gets appended. + urls.delete(url) + urls.add(url) + } + } + // Slice off the head if the list is too large. + return urls.size > MAX_URLS ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) : Array.from(urls) + } + public setSessionToken(sessionToken?: string): Thenable { if (!sessionToken) { return this.secrets.delete("sessionToken").then(() => { @@ -323,8 +360,11 @@ export class Storage { // attention to it. } - private async updateURL(): Promise { - const url = this.getURL() + /** + * Set the URL on the global Axios client and write the URL to disk which will + * be used by the CLI via --url-file. + */ + private async updateURL(url: string | undefined): Promise { axios.defaults.baseURL = url if (url) { await ensureDir(this.globalStorageUri.fsPath)