Archived
feat: add machine setup dialog
This commit is contained in:
@@ -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.
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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://[email protected]")).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.");
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
@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<void> {
|
||||
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`
|
||||
<div class="backdrop" @click=${() => this.onCancel?.()}>
|
||||
<section @click=${(event: Event) => { event.stopPropagation(); }}>
|
||||
<form @submit=${(event: SubmitEvent) => { this.handleSubmit(event); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||
<header>
|
||||
<strong>Add machine</strong>
|
||||
<button type="button" @click=${() => { this.onCancel?.(); }} aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
${this.error === "" ? null : html`<div class="dialog-error" role="alert">${this.error}</div>`}
|
||||
<label>
|
||||
Remote PI WEB URL
|
||||
<input name="baseUrl" type="url" .value=${this.url} @input=${(event: InputEvent) => { this.handleUrlInput(event); }} placeholder="http://dev-box.local:8504" autocomplete="url" inputmode="url" autofocus />
|
||||
</label>
|
||||
<small class=${urlError === undefined ? "hint" : "field-error"}>${urlError ?? "Enter the reachable base URL first, including http:// or https://."}</small>
|
||||
${hasUrl ? html`
|
||||
<label>
|
||||
Machine name
|
||||
<input name="name" type="text" .value=${this.name} @input=${(event: InputEvent) => { this.handleNameInput(event); }} placeholder=${this.previousSuggestedName || "Dev Box"} autocomplete="off" />
|
||||
</label>
|
||||
<small class="hint">Suggested from the URL. Edit it to use a friendlier sidebar label.</small>
|
||||
<label>
|
||||
Bearer token <span class="optional">optional</span>
|
||||
<input name="token" type="password" .value=${this.token} @input=${(event: InputEvent) => { this.handleTokenInput(event); }} placeholder="Leave blank if the remote machine does not require one" autocomplete="off" />
|
||||
</label>
|
||||
<small class="hint">Paste only the token value; PI WEB sends it as an Authorization: Bearer header.</small>
|
||||
` : html`<p class="hint intro">After you enter a URL, PI WEB will suggest a machine name and let you add an optional bearer token.</p>`}
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" @click=${() => { this.onCancel?.(); }}>Cancel</button>
|
||||
<button class="primary" type="submit" ?disabled=${!canSubmit}>${this.submitting ? "Adding…" : "Add machine"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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, "");
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const machine = await this.machines.addMachine(input);
|
||||
if (machine !== undefined) this.setState({ machineDialogOpen: false });
|
||||
}
|
||||
|
||||
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
|
||||
@@ -1324,6 +1326,7 @@ export class PiWebApp extends LitElement {
|
||||
${this.renderWorkspacePanel()}
|
||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
|
||||
@@ -49,14 +49,16 @@ export class MachineController {
|
||||
void this.refreshMachineHealth(machine.id);
|
||||
}
|
||||
|
||||
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<void> {
|
||||
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<Machine | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user