From 93b50e61af795fdbd7058c624d6467146e27d7b8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 4 Jun 2026 12:12:25 +0200 Subject: [PATCH] feat: add machine setup dialog --- .changeset/add-machine-dialog.md | 5 + src/client/src/appState.ts | 2 + .../src/components/MachineDialog.test.ts | 28 +++ src/client/src/components/MachineDialog.ts | 192 ++++++++++++++++++ src/client/src/components/PiWebApp.ts | 19 +- .../src/controllers/machineController.ts | 4 +- 6 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-machine-dialog.md create mode 100644 src/client/src/components/MachineDialog.test.ts create mode 100644 src/client/src/components/MachineDialog.ts diff --git a/.changeset/add-machine-dialog.md b/.changeset/add-machine-dialog.md new file mode 100644 index 0000000..30c4559 --- /dev/null +++ b/.changeset/add-machine-dialog.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Replace add-machine browser prompts with a PI WEB form that asks for the remote URL first, suggests a machine name, and supports an optional bearer token. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 910217d..8d844fa 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -36,6 +36,7 @@ export interface AppState { authDialog: AuthDialogState | undefined; actionPaletteOpen: boolean; projectDialogOpen: boolean; + machineDialogOpen: boolean; workspaceTool: QualifiedContributionId; mainView: "navigation" | "chat" | QualifiedContributionId; fileTree: FileTreeEntry[]; @@ -130,6 +131,7 @@ export function initialAppState(): AppState { authDialog: undefined, actionPaletteOpen: false, projectDialogOpen: false, + machineDialogOpen: false, workspaceTool: "core:workspace.files", mainView: "chat", fileTree: [], diff --git a/src/client/src/components/MachineDialog.test.ts b/src/client/src/components/MachineDialog.test.ts new file mode 100644 index 0000000..bc761bc --- /dev/null +++ b/src/client/src/components/MachineDialog.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { machineBaseUrlValidationMessage, suggestedMachineNameFromUrl } from "./MachineDialog"; + +describe("suggestedMachineNameFromUrl", () => { + it("suggests the host without protocol or port", () => { + expect(suggestedMachineNameFromUrl("http://127.0.0.1:8504")).toBe("127.0.0.1"); + expect(suggestedMachineNameFromUrl("https://devbox.example.test:8504/pi-web")).toBe("devbox.example.test"); + }); + + it("also suggests a host while the URL protocol is being typed", () => { + expect(suggestedMachineNameFromUrl("devbox.local:8504")).toBe("devbox.local"); + }); +}); + +describe("machineBaseUrlValidationMessage", () => { + it("accepts http and https base URLs", () => { + expect(machineBaseUrlValidationMessage("http://127.0.0.1:8504")).toBeUndefined(); + expect(machineBaseUrlValidationMessage("https://devbox.example.test/pi-web")).toBeUndefined(); + }); + + it("explains invalid machine URLs", () => { + expect(machineBaseUrlValidationMessage("")).toBe("Remote PI WEB URL is required."); + expect(machineBaseUrlValidationMessage("devbox.local:8504")).toBe("Use an http:// or https:// URL."); + expect(machineBaseUrlValidationMessage("ftp://devbox.example.test")).toBe("Use an http:// or https:// URL."); + expect(machineBaseUrlValidationMessage("https://user@devbox.example.test")).toBe("Do not include credentials in the machine URL."); + expect(machineBaseUrlValidationMessage("https://devbox.example.test?q=1")).toBe("Do not include a query string or fragment."); + }); +}); diff --git a/src/client/src/components/MachineDialog.ts b/src/client/src/components/MachineDialog.ts new file mode 100644 index 0000000..92786fa --- /dev/null +++ b/src/client/src/components/MachineDialog.ts @@ -0,0 +1,192 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; + +export interface MachineDialogSubmit { + name: string; + baseUrl: string; + token?: string; +} + +@customElement("machine-dialog") +export class MachineDialog extends LitElement { + @property({ attribute: false }) onSubmit?: (input: MachineDialogSubmit) => void | Promise; + @property({ attribute: false }) onCancel?: () => void; + @property() error = ""; + + @state() private url = ""; + @state() private name = ""; + @state() private token = ""; + @state() private submitting = false; + @query("input[name='baseUrl']") private urlInput?: HTMLInputElement; + @query("input[name='name']") private nameInput?: HTMLInputElement; + + private nameEdited = false; + private previousSuggestedName = ""; + + override firstUpdated(): void { + this.urlInput?.focus(); + } + + private handleUrlInput(event: InputEvent): void { + if (!(event.target instanceof HTMLInputElement)) return; + const url = event.target.value; + const suggestedName = suggestedMachineNameFromUrl(url); + if (!this.nameEdited || this.name.trim() === "" || this.name === this.previousSuggestedName) this.name = suggestedName; + this.previousSuggestedName = suggestedName; + this.url = url; + } + + private handleNameInput(event: InputEvent): void { + if (!(event.target instanceof HTMLInputElement)) return; + this.nameEdited = true; + this.name = event.target.value; + } + + private handleTokenInput(event: InputEvent): void { + if (!(event.target instanceof HTMLInputElement)) return; + this.token = event.target.value; + } + + private handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") { + event.preventDefault(); + this.onCancel?.(); + return; + } + if (event.key === "Enter" && event.target instanceof HTMLInputElement && event.target.name === "baseUrl" && machineBaseUrlValidationMessage(this.url) === undefined) { + event.preventDefault(); + void this.updateComplete.then(() => { + this.nameInput?.focus(); + this.nameInput?.select(); + }); + } + } + + private handleSubmit(event: SubmitEvent): void { + event.preventDefault(); + void this.submit(); + } + + private async submit(): Promise { + const input = this.validInput(); + if (input === undefined || this.submitting) return; + this.submitting = true; + try { + await this.onSubmit?.(input); + } finally { + if (this.isConnected) this.submitting = false; + } + } + + private validInput(): MachineDialogSubmit | undefined { + const baseUrl = this.url.trim(); + const name = this.name.trim(); + if (baseUrl === "" || name === "" || machineBaseUrlValidationMessage(baseUrl) !== undefined) return undefined; + const token = this.token.trim(); + return { name, baseUrl, ...(token === "" ? {} : { token }) }; + } + + override render() { + const hasUrl = this.url.trim() !== ""; + const urlError = hasUrl ? machineBaseUrlValidationMessage(this.url) : undefined; + const canSubmit = this.validInput() !== undefined && !this.submitting; + return html` +
this.onCancel?.()}> +
{ event.stopPropagation(); }}> +
{ this.handleSubmit(event); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}> +
+ Add machine + +
+
+ ${this.error === "" ? null : html``} + + ${urlError ?? "Enter the reachable base URL first, including http:// or https://."} + ${hasUrl ? html` + + Suggested from the URL. Edit it to use a friendlier sidebar label. + + Paste only the token value; PI WEB sends it as an Authorization: Bearer header. + ` : html`

After you enter a URL, PI WEB will suggest a machine name and let you add an optional bearer token.

`} +
+
+ + +
+
+
+
+ `; + } + + static override styles = css` + :host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; } + .backdrop { display: grid; place-items: start center; width: 100%; height: 100%; padding-top: min(12vh, 90px); box-sizing: border-box; background: var(--pi-overlay); } + section { width: min(560px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } + form { display: flex; flex-direction: column; max-height: inherit; min-height: 0; } + header, footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } + footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; } + .body { display: grid; gap: 8px; padding: 12px; min-height: 0; overflow: auto; } + label { display: grid; gap: 6px; color: var(--pi-muted); } + input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; } + .hint { color: var(--pi-muted); } + .intro { margin: 4px 0 0; line-height: 1.4; } + .optional { color: var(--pi-muted); font-weight: 400; } + .field-error { color: var(--pi-danger); } + .dialog-error { border: 1px solid var(--pi-danger); border-radius: 8px; background: color-mix(in srgb, var(--pi-danger) 10%, transparent); color: var(--pi-danger); padding: 9px; line-height: 1.35; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + header button { border: 0; background: transparent; color: var(--pi-muted); font-size: 22px; padding: 0 8px; } + .primary { border-color: var(--pi-success-border); background: var(--pi-success-border); } + button:disabled { opacity: .5; cursor: not-allowed; } + `; +} + +export function suggestedMachineNameFromUrl(value: string): string { + const raw = value.trim(); + if (raw === "") return ""; + const parsed = parseUrlForSuggestion(raw) ?? parseUrlForSuggestion(`http://${raw.replace(/^\/+/u, "")}`); + if (parsed !== undefined && parsed.hostname !== "") return parsed.hostname.replace(/^\[(.*)\]$/u, "$1"); + return fallbackSuggestedName(raw); +} + +export function machineBaseUrlValidationMessage(value: string): string | undefined { + const raw = value.trim(); + if (raw === "") return "Remote PI WEB URL is required."; + let url: URL; + try { + url = new URL(raw); + } catch { + return "Enter a valid URL including http:// or https://."; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return "Use an http:// or https:// URL."; + if (url.username !== "" || url.password !== "") return "Do not include credentials in the machine URL."; + if (url.search !== "" || url.hash !== "") return "Do not include a query string or fragment."; + return undefined; +} + +function parseUrlForSuggestion(value: string): URL | undefined { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url : undefined; + } catch { + return undefined; + } +} + +function fallbackSuggestedName(value: string): string { + const withoutProtocol = value.replace(/^[a-z][a-z\d+.-]*:\/\//iu, ""); + const withoutCredentials = withoutProtocol.slice(withoutProtocol.lastIndexOf("@") + 1); + const host = withoutCredentials.split(/[/?#]/u)[0] ?? ""; + if (host.startsWith("[") && host.includes("]")) return host.slice(1, host.indexOf("]")); + return host.replace(/:\d+$/u, ""); +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 00a5520..b98e7b9 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -46,6 +46,8 @@ import "./CommandPicker"; import "./ActionPalette"; import "./AuthDialog"; import "./ProjectDialog"; +import "./MachineDialog"; +import type { MachineDialogSubmit } from "./MachineDialog"; import "./SettingsDialog"; import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; @@ -949,7 +951,7 @@ export class PiWebApp extends LitElement { openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, focusPrompt: () => { this.promptEditor?.focusInput(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, - addMachine: () => this.addMachineFromPrompt(), + addMachine: () => { this.openMachineDialog(); }, refreshSelectedMachine: () => this.machines.refreshMachineHealth(), removeSelectedMachine: () => this.removeMachine(), openSelectedMachine: () => { this.openSelectedMachine(); }, @@ -1082,13 +1084,13 @@ export class PiWebApp extends LitElement { } } - private async addMachineFromPrompt(): Promise { - const name = window.prompt("Machine name", "Dev Box")?.trim(); - if (name === undefined || name === "") return; - const baseUrl = window.prompt("Remote PI WEB base URL", "http://127.0.0.1:8504")?.trim(); - if (baseUrl === undefined || baseUrl === "") return; - const token = window.prompt("Bearer token (optional)", "")?.trim(); - await this.machines.addMachine({ name, baseUrl, ...(token === undefined || token === "" ? {} : { token }) }); + private openMachineDialog(): void { + this.setState({ machineDialogOpen: true, error: "" }); + } + + private async submitMachineDialog(input: MachineDialogSubmit): Promise { + const machine = await this.machines.addMachine(input); + if (machine !== undefined) this.setState({ machineDialogOpen: false }); } private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise { @@ -1324,6 +1326,7 @@ export class PiWebApp extends LitElement { ${this.renderWorkspacePanel()} ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} + ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index d878815..f179df7 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -49,14 +49,16 @@ export class MachineController { void this.refreshMachineHealth(machine.id); } - async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise { + async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise { this.setState({ error: "" }); try { const machine = await api.addMachine(input); this.setState({ machines: [...this.getState().machines.filter((candidate) => candidate.id !== machine.id), machine] }); await this.selectMachine(machine); + return machine; } catch (error) { this.setState({ error: String(error) }); + return undefined; } }