From 4495a26ffdef24613f5322b9b4b479c49d87d3e2 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 3 Jun 2026 20:20:54 +0200 Subject: [PATCH] feat: add settings config UI --- .changeset/mobile-actions-button.md | 5 + .changeset/settings-config-ui.md | 5 + src/client/src/api.ts | 4 +- src/client/src/api/clients.ts | 9 +- src/client/src/api/parsers.test.ts | 18 +- src/client/src/api/parsers.ts | 34 +- src/client/src/components/PiWebApp.ts | 31 +- src/client/src/components/SettingsDialog.ts | 374 ++++++++++++++++++ .../src/components/appShell/AppContextBar.ts | 33 +- .../components/appShell/AppNavigationPanel.ts | 1 + src/client/src/plugins/core/actions.ts | 8 + src/client/src/plugins/registry.test.ts | 2 + src/client/src/plugins/types.ts | 2 + src/client/src/settingsRoute.test.ts | 64 +++ src/client/src/settingsRoute.ts | 22 ++ src/config.test.ts | 38 ++ src/config.ts | 40 +- src/server/app.ts | 3 + src/server/configRoutes.test.ts | 69 ++++ src/server/configRoutes.ts | 100 +++++ src/shared/apiTypes.ts | 20 + 21 files changed, 863 insertions(+), 19 deletions(-) create mode 100644 .changeset/mobile-actions-button.md create mode 100644 .changeset/settings-config-ui.md create mode 100644 src/client/src/components/SettingsDialog.ts create mode 100644 src/client/src/settingsRoute.test.ts create mode 100644 src/client/src/settingsRoute.ts create mode 100644 src/config.test.ts create mode 100644 src/server/configRoutes.test.ts create mode 100644 src/server/configRoutes.ts diff --git a/.changeset/mobile-actions-button.md b/.changeset/mobile-actions-button.md new file mode 100644 index 0000000..88308c7 --- /dev/null +++ b/.changeset/mobile-actions-button.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile. diff --git a/.changeset/settings-config-ui.md b/.changeset/settings-config-ui.md new file mode 100644 index 0000000..38c42d2 --- /dev/null +++ b/.changeset/settings-config-ui.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a deep-linked Settings UI for editing the active PI WEB config file and viewing registered keyboard shortcuts. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 701108a..e925b41 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ -export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; +export { activityApi, api, configApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 0c0f9b6..e7abe35 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { FileSuggestion, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; +import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; import { request } from "./http"; import { arrayOf, @@ -17,6 +17,7 @@ import { parseMessagePage, parseModelSelectionResponse, parseOAuthFlowState, + parsePiWebConfigResponse, parsePiWebStatusResponse, parseProject, parseRestored, @@ -36,6 +37,11 @@ export const piWebApi = { piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse), }; +export const configApi = { + config: () => request("/api/config", parsePiWebConfigResponse), + saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), +}; + export const activityApi = { workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse), }; @@ -156,6 +162,7 @@ export const gitApi = { export const api = { ...piWebApi, + ...configApi, ...activityApi, ...projectsApi, ...workspacesApi, diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 9629ebd..c192a27 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,7 +1,23 @@ import { describe, expect, it } from "vitest"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { + it("parses PI WEB config responses", () => { + expect(parsePiWebConfigResponse({ + path: "/tmp/config.json", + exists: true, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"] }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + envOverrides: { host: true, port: false, allowedHosts: false }, + })).toEqual({ + path: "/tmp/config.json", + exists: true, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"] }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + envOverrides: { host: true, port: false, allowedHosts: false }, + }); + }); + it("accepts legacy array message pages and paged message responses", () => { expect(parseMessagePage(["a", "b"])).toEqual({ messages: ["a", "b"], start: 0, total: 2 }); expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 5b41b6d..f6d840b 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -357,6 +357,38 @@ export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivit return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") }; } +export function parsePiWebConfigResponse(value: unknown): PiWebConfigResponse { + const record = requireRecord(value); + return { + path: requireString(record, "path"), + exists: requireBoolean(record, "exists"), + config: parsePiWebConfigValues(record["config"]), + effectiveConfig: parsePiWebConfigValues(record["effectiveConfig"]), + envOverrides: parsePiWebConfigEnvOverrides(record["envOverrides"]), + }; +} + +function parsePiWebConfigValues(value: unknown): PiWebConfigValues { + const record = requireRecord(value); + return { + ...optionalField("host", optionalString(record, "host")), + ...optionalField("port", optionalNumber(record, "port")), + ...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])), + }; +} + +function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined { + if (value === undefined) return undefined; + if (value === true) return true; + if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value; + throw new Error("Invalid PI WEB allowedHosts field"); +} + +function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { + const record = requireRecord(value); + return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") }; +} + export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse { const record = requireRecord(value); return { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 2eb64d0..8817c61 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -25,6 +25,7 @@ import { AppShellController } from "../appShell/appShellController"; import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState"; import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { readRoute, writeRoute, type AppRoute } from "../route"; +import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; import "./ProjectList"; @@ -39,6 +40,7 @@ import "./CommandPicker"; import "./ActionPalette"; import "./AuthDialog"; import "./ProjectDialog"; +import "./SettingsDialog"; import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; import "./appShell/AppContextBar"; @@ -121,7 +123,11 @@ export class PiWebApp extends LitElement { private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private isRefreshingApp = false; - private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); + @state() private settingsSection: SettingsSection | undefined = readSettingsSection(); + private readonly onPopState = () => void this.withChatScrollTransition(async () => { + this.restoreSettingsRoute(); + await this.restoreRoute(false); + }); private readonly onPageShow = () => { this.appShell.repairViewportPosition(); }; @@ -200,6 +206,7 @@ export class PiWebApp extends LitElement { } private async loadProjectsAndRestoreRoute() { + this.restoreSettingsRoute(); await this.projects.loadProjects(); await this.withChatScrollTransition(() => this.restoreRoute(false)); await this.refreshWorkspaceDeletionRuns(); @@ -381,6 +388,25 @@ export class PiWebApp extends LitElement { this.git.updatePolling(); } + private openSettings(section: SettingsSection = "general"): void { + this.settingsSection = section; + writeSettingsSection(section); + } + + private closeSettings(): void { + this.settingsSection = undefined; + writeSettingsSection(undefined); + } + + private navigateSettings(section: SettingsSection): void { + this.settingsSection = section; + writeSettingsSection(section); + } + + private restoreSettingsRoute(): void { + this.settingsSection = readSettingsSection(); + } + private handleWorkspaceChange(previous: AppState, next: AppState) { if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return; this.terminalAutoStartWorkspaceId = undefined; @@ -676,6 +702,7 @@ export class PiWebApp extends LitElement { configureAuth: () => this.auth.openLogin(), logoutAuth: () => this.auth.openLogout(), openThemePicker: () => { this.openThemeDialog(); }, + openSettings: (section) => { this.openSettings(section); }, selectMainView: (view) => { this.selectMainView(view); }, selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); }, openTerminal: (options) => { this.openTerminal(options); }, @@ -949,6 +976,7 @@ export class PiWebApp extends LitElement { .session=${this.state.selectedSession} .refreshControl=${this.appShell.shouldShowAppRefreshInContextBar() ? this.renderAppRefresh() : undefined} .onOpenSection=${(section: NavigationSection) => { this.openNavigationSection(section); }} + .onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }} > `; } @@ -1001,6 +1029,7 @@ export class PiWebApp extends LitElement { ${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.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }}>` : null} `; } diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts new file mode 100644 index 0000000..4b268f1 --- /dev/null +++ b/src/client/src/components/SettingsDialog.ts @@ -0,0 +1,374 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { AppAction } from "../actions"; +import { configApi, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../api"; +import { formatShortcut } from "../keyboardShortcuts"; +import type { SettingsSection } from "../settingsRoute"; + +interface ConfigDraft { + host: string; + port: string; + allowedHostsMode: "list" | "all"; + allowedHostsText: string; +} + +@customElement("settings-dialog") +export class SettingsDialog extends LitElement { + @property({ attribute: false }) section: SettingsSection = "general"; + @property({ attribute: false }) actions: AppAction[] = []; + @property({ attribute: false }) onNavigate?: (section: SettingsSection) => void; + @property({ attribute: false }) onClose?: () => void; + @state() private configResponse: PiWebConfigResponse | undefined; + @state() private draft: ConfigDraft = emptyDraft(); + @state() private loading = true; + @state() private saving = false; + @state() private error = ""; + @state() private savedMessage = ""; + + override connectedCallback(): void { + super.connectedCallback(); + void this.loadConfig(); + } + + override render(): TemplateResult { + return html` +
this.onClose?.()}> + +
+ `; + } + + private renderNavButton(section: SettingsSection, label: string, detail: string): TemplateResult { + const selected = this.section === section; + return html` + + `; + } + + private renderGeneral(): TemplateResult { + const config = this.configResponse; + return html` +
+
+

General configuration

+

Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.

+
+ +
+ ${this.renderMessages()} + ${config === undefined && this.loading ? html`
Loading configuration…
` : html` +
+ Config file + ${config?.path ?? "Unknown"} + ${config?.exists === true ? "Existing file" : "This file will be created on save"} +
+
{ void this.saveConfig(event); }}> + + + + +
+ + Allowed hosts + ${this.renderOverrideBadge("allowedHosts")} + + + + Enter one host per line, or choose “Allow every host” to write true. +
+ + ${this.renderEffectiveConfig()} + +
+ +
+
+ `} + `; + } + + private renderMessages(): TemplateResult | null { + if (this.error !== "") return html`
${this.error}
`; + if (this.savedMessage !== "") return html`
${this.savedMessage}
`; + return null; + } + + private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null { + if (this.configResponse?.envOverrides[key] !== true) return null; + return html`environment override`; + } + + private renderEffectiveConfig(): TemplateResult { + const effective = this.configResponse?.effectiveConfig ?? {}; + return html` +
+

Effective after environment overrides

+
+
Host
${effective.host ?? html`127.0.0.1 default`}
+
Port
${effective.port ?? html`8504 default`}
+
Allowed hosts
${formatAllowedHosts(effective.allowedHosts)}
+
+
+ `; + } + + private renderShortcuts(): TemplateResult { + const groups = shortcutGroups(this.actions); + return html` +
+
+

Keyboard shortcuts

+

This is the shortcut inventory that the editable shortcut UI will build on. It already supports deep links with ?settings=shortcuts.

+
+
+
Editing shortcuts will use this settings surface and persist to the same PI WEB config file in the next step.
+ ${groups.length === 0 ? html`
No actions registered.
` : groups.map((group) => html` +
+

${group.name}

+
+ ${group.actions.map((action) => html` +
+
+ ${action.title} + ${action.description !== undefined && action.description !== "" ? html`${action.description}` : null} +
+ ${action.shortcut !== undefined && action.shortcut !== "" ? html`${formatShortcut(action.shortcut)}` : html`Unassigned`} +
+ `)} +
+
+ `)} + `; + } + + private navigate(section: SettingsSection): void { + this.onNavigate?.(section); + } + + private async loadConfig(): Promise { + this.loading = true; + this.error = ""; + try { + const response = await configApi.config(); + this.configResponse = response; + this.draft = draftFromConfig(response.config); + } catch (error) { + this.error = `Failed to load config: ${errorMessage(error)}`; + } finally { + this.loading = false; + } + } + + private async saveConfig(event: Event): Promise { + event.preventDefault(); + if (this.saving) return; + this.saving = true; + this.error = ""; + this.savedMessage = ""; + try { + const response = await configApi.saveConfig(configFromDraft(this.draft)); + this.configResponse = response; + this.draft = draftFromConfig(response.config); + this.savedMessage = "Config saved."; + window.setTimeout(() => { + if (this.savedMessage === "Config saved.") this.savedMessage = ""; + }, 3000); + } catch (error) { + this.error = `Failed to save config: ${errorMessage(error)}`; + } finally { + this.saving = false; + } + } + + private updateDraft(patch: Partial): void { + this.draft = { ...this.draft, ...patch }; + this.savedMessage = ""; + } + + private handleKeyDown(event: KeyboardEvent): void { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + this.onClose?.(); + } + + static override styles = css` + :host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; } + .backdrop { box-sizing: border-box; width: 100%; height: 100dvh; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); overflow: hidden; } + .settings-shell { width: min(980px, 100%); max-height: min(760px, 100%); min-height: min(620px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } + .settings-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border); } + .eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } + h1, h2, h3, p { margin: 0; } + h1 { font-size: 20px; line-height: 1.2; } + h2 { font-size: 17px; line-height: 1.25; } + h3 { font-size: 13px; line-height: 1.3; } + p { color: var(--pi-muted); line-height: 1.45; } + button, input, select, textarea { font: inherit; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + button:disabled { opacity: .55; cursor: not-allowed; } + .close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; } + .close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); } + .settings-body { min-height: 0; display: grid; grid-template-columns: 220px minmax(0, 1fr); } + .settings-nav { min-height: 0; padding: 10px; border-right: 1px solid var(--pi-border); background: var(--pi-surface); overflow: auto; } + .settings-nav button { display: grid; gap: 2px; width: 100%; margin: 0 0 6px; text-align: left; border-color: transparent; background: transparent; } + .settings-nav button:hover, .settings-nav button:focus { background: var(--pi-surface-hover); } + .settings-nav button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .settings-nav small { color: var(--pi-muted); } + .settings-content { min-width: 0; min-height: 0; overflow: auto; padding: 18px; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .section-heading > div { display: grid; gap: 6px; min-width: 0; } + .secondary { flex: 0 0 auto; } + .message, .loading-card, .config-path-card, .effective-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message { margin-bottom: 12px; } + .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } + .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card { color: var(--pi-muted); } + .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } + .config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } + .config-path-card small, .field small, .shortcut-main small { color: var(--pi-muted); } + .config-form { display: grid; gap: 14px; } + .field { display: grid; gap: 7px; } + .field-heading { display: flex; align-items: center; gap: 8px; } + input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; outline: none; } + input:focus, select:focus, textarea:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); } + textarea { resize: vertical; min-height: 94px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + textarea:disabled { opacity: .55; } + .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } + .effective-card { display: grid; gap: 10px; } + .effective-card dl { display: grid; gap: 8px; margin: 0; } + .effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; } + dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } + .muted, .unassigned { color: var(--pi-muted); } + .form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; } + .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } + .shortcut-note { margin-bottom: 14px; color: var(--pi-muted); } + .shortcut-group { margin: 0 0 16px; } + .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + .shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; } + .shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); } + .shortcut-row:last-child { border-bottom: 0; } + .shortcut-main { min-width: 0; display: grid; gap: 3px; } + .shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + kbd { justify-self: end; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } + .unassigned { justify-self: end; font-size: 12px; } + + @media (max-width: 760px) { + .backdrop { padding: 0; place-items: stretch; } + .settings-shell { width: 100%; height: 100dvh; max-height: none; min-height: 0; border: 0; border-radius: 0; } + .settings-header { padding: max(12px, env(safe-area-inset-top)) 12px 12px; } + .settings-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + .settings-nav { display: flex; gap: 8px; padding: 8px; border-right: 0; border-bottom: 1px solid var(--pi-border); overflow-x: auto; overflow-y: hidden; } + .settings-nav button { flex: 0 0 auto; width: auto; min-width: 128px; margin: 0; } + .settings-content { padding: 14px 12px calc(18px + env(safe-area-inset-bottom)); } + .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } + .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } + .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } + kbd, .unassigned { justify-self: start; } + } + `; +} + +function emptyDraft(): ConfigDraft { + return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; +} + +function draftFromConfig(config: PiWebConfigValues): ConfigDraft { + return { + host: config.host ?? "", + port: config.port === undefined ? "" : String(config.port), + allowedHostsMode: config.allowedHosts === true ? "all" : "list", + allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "", + }; +} + +function configFromDraft(draft: ConfigDraft): PiWebConfigValues { + const config: PiWebConfigValues = {}; + const host = draft.host.trim(); + const port = draft.port.trim(); + if (host !== "") config.host = host; + if (port !== "") { + const parsed = Number(port); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("Port must be an integer from 1 to 65535."); + config.port = parsed; + } + config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText); + return config; +} + +function parseAllowedHostsText(value: string): string[] { + return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== ""); +} + +function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string | TemplateResult { + if (value === true) return "Any host"; + if (Array.isArray(value)) return value.length === 0 ? html`None listed` : value.join(", "); + return html`Unset`; +} + +function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] { + const grouped = new Map(); + for (const action of [...actions].sort(compareActions)) { + const group = action.group ?? "Other"; + grouped.set(group, [...(grouped.get(group) ?? []), action]); + } + return [...grouped.entries()].map(([name, groupActions]) => ({ name, actions: groupActions })); +} + +function compareActions(left: AppAction, right: AppAction): number { + return (left.group ?? "Other").localeCompare(right.group ?? "Other") || left.title.localeCompare(right.title); +} + +function inputValue(event: Event): string { + return event.target instanceof HTMLInputElement ? event.target.value : ""; +} + +function selectValue(event: Event): string { + return event.target instanceof HTMLSelectElement ? event.target.value : ""; +} + +function textAreaValue(event: Event): string { + return event.target instanceof HTMLTextAreaElement ? event.target.value : ""; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts index f7c87b4..a8351b4 100644 --- a/src/client/src/components/appShell/AppContextBar.ts +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -10,6 +10,7 @@ export class AppContextBar extends LitElement { @property({ attribute: false }) session?: SessionInfo; @property({ attribute: false }) refreshControl: unknown; @property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void; + @property({ attribute: false }) onShowActions?: () => void; @query(".context-items") private contextItems?: HTMLElement | null; @state() private canScrollLeft = false; @state() private canScrollRight = false; @@ -60,19 +61,35 @@ export class AppContextBar extends LitElement { - ${this.refreshControl === undefined ? null : html`
${this.refreshControl}
`} + ${this.hasContextActions() ? html`
${this.renderActionsButton()}${this.refreshControl}
` : null} `; } + private renderActionsButton() { + if (this.onShowActions === undefined) return null; + return html` + + `; + } + private contextBarClass(): string { const classes = ["context-bar"]; - if (this.refreshControl !== undefined) classes.push("has-context-actions"); + if (this.hasContextActions()) classes.push("has-context-actions"); + if (this.refreshControl !== undefined && this.onShowActions !== undefined) classes.push("has-context-actions-double"); if (this.canScrollLeft) classes.push("can-scroll-left"); if (this.canScrollRight) classes.push("can-scroll-right"); return classes.join(" "); } + private hasContextActions(): boolean { + return this.refreshControl !== undefined || this.onShowActions !== undefined; + } + private observeContextItems(): void { const contextItems = this.contextItemsElement(); if (this.observedContextItems === contextItems) return; @@ -114,11 +131,15 @@ export class AppContextBar extends LitElement { .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; } .context-bar-label { display: none; } .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } - .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; } + .context-bar.has-context-actions .context-items { padding-right: 58px; scroll-padding-inline: 8px 58px; } + .context-bar.has-context-actions-double .context-items { padding-right: 102px; scroll-padding-inline: 8px 102px; } .context-item { flex: 0 0 auto; min-width: 0; display: flex; } - .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; } - .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; } - app-refresh-control { pointer-events: auto; } + .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 0 8px; background: var(--pi-bg); pointer-events: none; } + .context-actions::before { content: ""; position: absolute; top: 0; bottom: 0; left: -24px; z-index: 0; width: 24px; background: linear-gradient(90deg, transparent, var(--pi-bg)); pointer-events: none; } + app-refresh-control, .context-action-button { position: relative; z-index: 1; pointer-events: auto; } + .context-action-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; } + .context-action-button:hover, .context-action-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .context-action-icon { width: 18px; height: 18px; fill: currentColor; pointer-events: none; } .context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; } .context-chip:hover { background: var(--pi-surface-hover); } .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 7ac7982..4361d44 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -100,6 +100,7 @@ export class AppNavigationPanel extends LitElement { :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } :host([collapsible]) { flex: 1 1 auto; } header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } + :host([collapsible]) header { display: none; } .header-actions { display: flex; align-items: center; gap: 8px; } project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; } diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 219e178..4b70355 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -49,6 +49,14 @@ export function createCoreActions(): PluginAction[] { group: "Preferences", run: (context) => { context.openThemePicker(); }, }, + { + id: "settings.open", + title: "Open Settings", + description: "Manage PI WEB configuration and keyboard shortcuts", + shortcut: "mod+,", + group: "Preferences", + run: (context) => { context.openSettings(); }, + }, { id: "app.refresh-data", title: "Refresh App Data", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index ac8d12c..b86843b 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -25,6 +25,7 @@ function createContext(statePatch: Partial = {}) { configureAuth: vi.fn(() => { calls.push("configureAuth"); }), logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }), openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }), + openSettings: vi.fn(() => { calls.push("openSettings"); }), selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }), selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }), openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }), @@ -175,6 +176,7 @@ describe("PluginRegistry", () => { expect(shortcuts).toEqual([ ["core:actions.show", "mod+k"], + ["core:settings.open", "mod+,"], ["core:view.chat", "mod+1"], ["core:view.files", "mod+2"], ["core:view.git", "mod+3"], diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 3071c0e..4f2307a 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -2,6 +2,7 @@ import type { TemplateResult } from "lit"; import type { AppAction } from "../actions"; import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api"; import type { AppState } from "../appState"; +import type { SettingsSection } from "../settingsRoute"; import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; export type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; @@ -56,6 +57,7 @@ export interface PluginRuntimeContext { configureAuth: () => void | Promise; logoutAuth: () => void | Promise; openThemePicker: () => void; + openSettings: (section?: SettingsSection) => void; selectMainView: (view: AppState["mainView"]) => void; selectWorkspaceTool: (tool: QualifiedContributionId) => void; openTerminal: (options?: { terminalId?: string | undefined }) => void; diff --git a/src/client/src/settingsRoute.test.ts b/src/client/src/settingsRoute.test.ts new file mode 100644 index 0000000..5341d21 --- /dev/null +++ b/src/client/src/settingsRoute.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseSettingsSection, readSettingsSection, writeSettingsSection } from "./settingsRoute"; + +const originalWindow = globalThis.window; + +afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true }); +}); + +function installWindow(href: string): { pushed: string[]; replaced: string[] } { + const url = new URL(href); + const pushed: string[] = []; + const replaced: string[] = []; + const fakeWindow = { + location: { + href: url.href, + pathname: url.pathname, + search: url.search, + hash: url.hash, + }, + history: { + pushState: vi.fn((_state: object, _title: string, next: URL | string) => { + pushed.push(String(next)); + }), + replaceState: vi.fn((_state: object, _title: string, next: URL | string) => { + replaced.push(String(next)); + }), + }, + }; + Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true }); + return { pushed, replaced }; +} + +describe("settings route helpers", () => { + it("parses supported settings deep links and aliases", () => { + expect(parseSettingsSection("general")).toBe("general"); + expect(parseSettingsSection("shortcuts")).toBe("shortcuts"); + expect(parseSettingsSection("keyboard")).toBe("shortcuts"); + expect(parseSettingsSection("unknown")).toBeUndefined(); + }); + + it("reads the settings section from the current URL", () => { + installWindow("http://localhost/app?project=p1&settings=shortcuts"); + + expect(readSettingsSection()).toBe("shortcuts"); + }); + + it("writes settings deep links while preserving other route fields", () => { + const { pushed } = installWindow("http://localhost/app?project=p1#bottom"); + + writeSettingsSection("general"); + + expect(pushed).toEqual(["http://localhost/app?project=p1&settings=general#bottom"]); + }); + + it("removes settings deep links with replace when closing", () => { + const { replaced } = installWindow("http://localhost/app?project=p1&settings=general#bottom"); + + writeSettingsSection(undefined, { replace: true }); + + expect(replaced).toEqual(["http://localhost/app?project=p1#bottom"]); + }); +}); diff --git a/src/client/src/settingsRoute.ts b/src/client/src/settingsRoute.ts new file mode 100644 index 0000000..3f0e61f --- /dev/null +++ b/src/client/src/settingsRoute.ts @@ -0,0 +1,22 @@ +export type SettingsSection = "general" | "shortcuts"; + +export function readSettingsSection(): SettingsSection | undefined { + return parseSettingsSection(new URLSearchParams(window.location.search).get("settings")); +} + +export function writeSettingsSection(section: SettingsSection | undefined, options?: { replace?: boolean | undefined }): void { + const url = new URL(window.location.href); + if (section === undefined) url.searchParams.delete("settings"); + else url.searchParams.set("settings", section); + const next = `${url.pathname}${url.search}${url.hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (next === current) return; + if (options?.replace === true) window.history.replaceState({}, "", url); + else window.history.pushState({}, "", url); +} + +export function parseSettingsSection(value: string | null): SettingsSection | undefined { + if (value === "general") return "general"; + if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts"; + return undefined; +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..b0527d6 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,38 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadPiWebConfig, savePiWebConfig } from "./config.js"; + +let tempDir: string; +let configPath: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-web-config-test-")); + configPath = join(tempDir, "config.json"); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("PI WEB config persistence", () => { + it("writes and reads the configured PI WEB config path", () => { + const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"] }, testOptions()); + + expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"] } }); + expect(loadPiWebConfig(testOptions())).toEqual(saved); + }); + + it("preserves unrelated config keys while replacing managed keys", async () => { + await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, future: { enabled: true } }, null, 2)}\n`, "utf8"); + + savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions()); + + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] }); + }); +}); + +function testOptions(): { env: NodeJS.ProcessEnv } { + return { env: { PI_WEB_CONFIG: configPath } }; +} diff --git a/src/config.ts b/src/config.ts index fc9aaa5..8ff8352 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,9 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import type { PiWebConfigValues } from "./shared/apiTypes.js"; -export interface PiWebConfig { - host?: string; - port?: number; - allowedHosts?: string[] | true; -} +export type PiWebConfig = PiWebConfigValues; export interface LoadedPiWebConfig { path: string; @@ -14,7 +11,7 @@ export interface LoadedPiWebConfig { config: PiWebConfig; } -interface LoadOptions { +export interface LoadOptions { env?: NodeJS.ProcessEnv; cwd?: string; } @@ -69,6 +66,35 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf }; } +export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): LoadedPiWebConfig { + const env = options.env ?? process.env; + const path = piWebConfigPath(env, options.cwd ?? process.cwd()); + const normalized = parsePiWebConfig(piWebConfigRecord(config), path); + const existing = readExistingConfigObject(path); + delete existing["host"]; + delete existing["port"]; + delete existing["allowedHosts"]; + const merged = { ...existing, ...piWebConfigRecord(normalized) }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); + return { path, exists: true, config: normalized }; +} + +function readExistingConfigObject(path: string): Record { + if (!existsSync(path)) return {}; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`); + return parsed; +} + +function piWebConfigRecord(config: PiWebConfig): Record { + return { + ...(config.host !== undefined ? { host: config.host } : {}), + ...(config.port !== undefined ? { port: config.port } : {}), + ...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}), + }; +} + function parsePiWebConfig(value: Record, path: string): PiWebConfig { return { ...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}), diff --git a/src/server/app.ts b/src/server/app.ts index 99ee439..da9869f 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -13,6 +13,7 @@ import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js"; import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js"; import { registerGitRoutes } from "./gitRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; +import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { PiWebPluginService } from "./piWebPluginService.js"; import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; @@ -20,6 +21,7 @@ export interface AppDependencies { projects?: ProjectService; workspaces?: WorkspaceService; piWebPlugins?: Pick; + config?: PiWebConfigService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; } @@ -42,6 +44,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus()); app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); + registerConfigRoutes(app, deps.config); app.get("/api/projects", async () => projects.list()); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts new file mode 100644 index 0000000..fa63660 --- /dev/null +++ b/src/server/configRoutes.test.ts @@ -0,0 +1,69 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; +import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; + +let app: FastifyInstance; +let savedConfig: PiWebConfigValues; +let service: PiWebConfigService; + +beforeEach(async () => { + savedConfig = { host: "127.0.0.1", port: 8504, allowedHosts: [] }; + service = { + read: vi.fn(() => responseFor(savedConfig, true)), + write: vi.fn((config: PiWebConfigValues) => { + savedConfig = config; + return responseFor(savedConfig, true); + }), + }; + app = Fastify({ logger: false }); + registerConfigRoutes(app, service); + await app.ready(); +}); + +afterEach(async () => { + await app.close(); +}); + +describe("config routes", () => { + it("returns the PI WEB config contract", async () => { + const response = await app.inject({ method: "GET", url: "/api/config" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual(responseFor(savedConfig, true)); + }); + + it("updates config through the service", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true } }, + }); + + expect(response.statusCode).toBe(200); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true }); + expect(response.json().config).toEqual(savedConfig); + }); + + it("rejects invalid config payloads before writing", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { host: 42 } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toHaveProperty("error"); + expect(service.write).not.toHaveBeenCalled(); + }); +}); + +function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse { + return { + path: "/tmp/pi-web/config.json", + exists, + config, + effectiveConfig: config, + envOverrides: { host: false, port: false, allowedHosts: false }, + }; +} diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts new file mode 100644 index 0000000..3819bc8 --- /dev/null +++ b/src/server/configRoutes.ts @@ -0,0 +1,100 @@ +import type { FastifyInstance } from "fastify"; +import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; + +export interface PiWebConfigService { + read: () => PiWebConfigResponse | Promise; + write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise; +} + +export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService { + return { + read: () => currentPiWebConfigResponse(options), + write: (config) => { + savePiWebConfig(config, options); + return currentPiWebConfigResponse(options); + }, + }; +} + +export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse { + const loaded = loadPiWebConfig(options); + const effective = effectivePiWebConfig(options); + const env = options.env ?? process.env; + return { + path: loaded.path, + exists: loaded.exists, + config: loaded.config, + effectiveConfig: effective.config, + envOverrides: piWebConfigEnvOverrides(env), + }; +} + +export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void { + app.get("/api/config", async (_request, reply) => { + try { + return await service.read(); + } catch (error) { + return reply.code(500).send({ error: errorMessage(error) }); + } + }); + + app.put<{ Body: { config?: unknown } | undefined }>("/api/config", async (request, reply) => { + try { + return await service.write(parseConfigRequest(request.body?.config)); + } catch (error) { + const status = isConfigValidationError(error) ? 400 : 500; + return reply.code(status).send({ error: errorMessage(error) }); + } + }); +} + +function parseConfigRequest(value: unknown): PiWebConfig { + if (!isRecord(value)) throw new Error("PI WEB config update must include a config object"); + const config: PiWebConfig = {}; + const host = value["host"]; + const port = value["port"]; + const allowedHosts = value["allowedHosts"]; + if (host !== undefined) { + if (typeof host !== "string") throw new Error("PI WEB config host must be a string"); + config.host = host; + } + if (port !== undefined) { + if (typeof port !== "number") throw new Error("PI WEB config port must be a number"); + config.port = port; + } + if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts); + return config; +} + +function parseAllowedHostsRequest(value: unknown): string[] | true { + if (value === true) return true; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new Error("PI WEB config allowedHosts must be true or an array of strings"); + } + return value; +} + +function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides { + return { + host: isEnvSet(env["PI_WEB_HOST"]), + port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]), + allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]), + }; +} + +function isEnvSet(value: string | undefined): boolean { + return value !== undefined && value !== ""; +} + +function isConfigValidationError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("PI WEB config"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index f55661c..1c0b15e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -1,3 +1,23 @@ +export interface PiWebConfigValues { + host?: string; + port?: number; + allowedHosts?: string[] | true; +} + +export interface PiWebConfigEnvOverrides { + host: boolean; + port: boolean; + allowedHosts: boolean; +} + +export interface PiWebConfigResponse { + path: string; + exists: boolean; + config: PiWebConfigValues; + effectiveConfig: PiWebConfigValues; + envOverrides: PiWebConfigEnvOverrides; +} + export interface Project { id: string; name: string;