diff --git a/src/commands/create.ts b/src/commands/create.ts index 6b6872b..ca90443 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -6,8 +6,11 @@ import { cloudflareTunnelStatusBar } from "../statusbar/statusbar"; import { showErrorMessage, showInformationMessage } from "../utils"; import { globalState } from "../state/global"; import { config } from "../state/config"; +import { TunnelPreset } from "../types"; import * as constants from "../constants"; +const TRYCLOUDFLARE_LABEL = "trycloudflare.com (random subdomain)"; + function portValidateInput(value: string): string | undefined { if (!value) { return undefined; @@ -68,12 +71,141 @@ async function getHostname(): Promise { return null; } +interface PresetQuickPickItem extends vscode.QuickPickItem { + preset?: TunnelPreset; + isNew?: boolean; +} + +function presetLabel(preset: TunnelPreset): string { + const target = preset.hostname ?? TRYCLOUDFLARE_LABEL; + return `${preset.port} → ${target}`; +} + +async function pickPreset(): Promise { + const presets = globalState.tunnelPresets; + if (presets.length === 0) { + return "new"; + } + + const deleteButton: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon("trash"), + tooltip: "Delete preset", + }; + + const buildItems = (current: TunnelPreset[]): PresetQuickPickItem[] => [ + { + label: "$(add) New tunnel...", + description: "Configure a new port and hostname", + isNew: true, + }, + ...current.map((preset) => ({ + label: `$(star) ${presetLabel(preset)}`, + description: cloudflareTunnelProvider.hasPort(preset.port) + ? "Port already in use" + : undefined, + preset, + buttons: [deleteButton], + })), + ]; + + return new Promise((resolve) => { + let settled = false; + const settle = (value: TunnelPreset | "new" | null): void => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + + const quickPick = vscode.window.createQuickPick(); + quickPick.title = "Create Cloudflare Tunnel"; + quickPick.placeholder = "Select a preset or create a new tunnel"; + quickPick.ignoreFocusOut = true; + quickPick.items = buildItems(presets); + + quickPick.onDidTriggerItemButton((event) => { + if (!event.item.preset) { + return; + } + globalState + .removeTunnelPreset(event.item.preset) + .then(() => { + const remaining = globalState.tunnelPresets; + if (remaining.length === 0) { + quickPick.hide(); + settle("new"); + return; + } + quickPick.items = buildItems(remaining); + }) + .catch((err) => { + showErrorMessage(err); + quickPick.hide(); + settle(null); + }); + }); + + quickPick.onDidAccept(() => { + const [selected] = quickPick.selectedItems; + quickPick.hide(); + if (!selected) { + settle(null); + return; + } + if (selected.isNew) { + settle("new"); + return; + } + settle(selected.preset ?? null); + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + settle(null); + }); + + quickPick.show(); + }); +} + async function createTunnel(): Promise { - const port = await getPortInput(); - if (!port) { + const selection = await pickPreset(); + if (selection === null) { return; } - const hostname = await getHostname(); + + let port: number; + let hostname: string | null; + + if (selection === "new") { + const portInput = await getPortInput(); + if (!portInput) { + return; + } + port = portInput; + hostname = await getHostname(); + } else { + if (cloudflareTunnelProvider.hasPort(selection.port)) { + showErrorMessage( + Error(`Port ${selection.port} is already in use by a running tunnel.`) + ); + return; + } + if ( + selection.hostname && + cloudflareTunnelProvider.hasHostname(selection.hostname) + ) { + showErrorMessage( + Error( + `Hostname ${selection.hostname} is already in use by a running tunnel.` + ) + ); + return; + } + port = selection.port; + hostname = selection.hostname; + } try { const tunnel = new CloudflareTunnel(config.localHostname, port, hostname); @@ -110,6 +242,7 @@ async function createTunnel(): Promise { ); tunnel.status = CloudflareTunnelStatus.running; + await globalState.upsertTunnelPreset({ port, hostname }); await showInformationMessage( "Your Cloudflare Tunnel has been created!", diff --git a/src/state/global.ts b/src/state/global.ts index 4512138..ff3368f 100644 --- a/src/state/global.ts +++ b/src/state/global.ts @@ -1,10 +1,13 @@ import * as vscode from "vscode"; import * as constants from "../constants"; import { setContext } from "../utils"; +import { TunnelPreset } from "../types"; // eslint-disable-next-line no-use-before-define export let globalState: GlobalState; +const TUNNEL_PRESETS_KEY = "tunnelPresets"; + export class GlobalState { state: vscode.Memento; @@ -33,6 +36,29 @@ export class GlobalState { this.state.update("cloudflaredUri", value); } + get tunnelPresets(): TunnelPreset[] { + const stored = this.state.get(TUNNEL_PRESETS_KEY, []); + return stored.map((preset) => ({ + ...preset, + hostname: preset.hostname ?? null, + })); + } + + async upsertTunnelPreset(preset: Omit): Promise { + const presets = this.tunnelPresets.filter( + (p) => !(p.port === preset.port && p.hostname === preset.hostname) + ); + presets.unshift({ ...preset, lastUsedAt: Date.now() }); + await this.state.update(TUNNEL_PRESETS_KEY, presets); + } + + async removeTunnelPreset(preset: TunnelPreset): Promise { + const presets = this.tunnelPresets.filter( + (p) => !(p.port === preset.port && p.hostname === preset.hostname) + ); + await this.state.update(TUNNEL_PRESETS_KEY, presets); + } + setIsLoggedInContext(value: boolean): void { setContext(constants.Context.isLoggedIn, value); } diff --git a/src/types.ts b/src/types.ts index b27ac04..e0e2041 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,3 +11,9 @@ export interface ConfigItem { key: string; default: string | number | boolean; } + +export interface TunnelPreset { + port: number; + hostname: string | null; + lastUsedAt: number; +}