diff --git a/.changeset/shortcut-config-foundation.md b/.changeset/shortcut-config-foundation.md new file mode 100644 index 0000000..467023f --- /dev/null +++ b/.changeset/shortcut-config-foundation.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add shortcut preferences to the PI WEB config schema so keyboard shortcuts can be overridden or disabled by action id. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index e925b41..0b69beb 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ 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, 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"; +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, PiWebShortcutConfig, 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/parsers.test.ts b/src/client/src/api/parsers.test.ts index c192a27..710119a 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -6,13 +6,13 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"] }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }, 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"] }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, envOverrides: { host: true, port: false, allowedHosts: false }, }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index f6d840b..2ca0ea3 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, 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"; +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, PiWebShortcutConfig, 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; @@ -374,6 +374,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("host", optionalString(record, "host")), ...optionalField("port", optionalNumber(record, "port")), ...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])), + ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), }; } @@ -384,6 +385,15 @@ function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] throw new Error("Invalid PI WEB allowedHosts field"); } +function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field"); + return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { + if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) throw new Error("Invalid PI WEB shortcut field"); + return [actionId, shortcut]; + })); +} + function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { const record = requireRecord(value); return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") }; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 8817c61..342ed08 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; +import { configApi, piWebApi, terminalsApi, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -26,6 +26,7 @@ import { MobileNavigationController, type NavigationSection } from "../appShell/ import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { readRoute, writeRoute, type AppRoute } from "../route"; import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute"; +import { applyShortcutPreferences } from "../shortcutPreferences"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; import "./ProjectList"; @@ -124,6 +125,7 @@ export class PiWebApp extends LitElement { @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private isRefreshingApp = false; @state() private settingsSection: SettingsSection | undefined = readSettingsSection(); + @state() private shortcutConfig: PiWebShortcutConfig = {}; private readonly onPopState = () => void this.withChatScrollTransition(async () => { this.restoreSettingsRoute(); await this.restoreRoute(false); @@ -174,6 +176,7 @@ export class PiWebApp extends LitElement { this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); + void this.loadClientConfig(); void this.loadExternalPlugins(); void this.loadProjectsAndRestoreRoute(); } @@ -228,6 +231,18 @@ export class PiWebApp extends LitElement { } } + private async loadClientConfig(): Promise { + try { + this.applyClientConfig((await configApi.config()).config); + } catch (error) { + console.warn("Failed to load PI WEB config", error); + } + } + + private applyClientConfig(config: PiWebConfigValues): void { + this.shortcutConfig = config.shortcuts ?? {}; + } + private async refreshAppData(): Promise { if (this.isRefreshingApp) return; this.isRefreshingApp = true; @@ -236,6 +251,7 @@ export class PiWebApp extends LitElement { this.sessions.refreshSelectedSession(), this.refreshPiWebStatus(), this.refreshWorkspaceActivity(), + this.loadClientConfig(), this.refreshWorkspaceDeletionRuns(), this.refreshCurrentWorkspaceSurface(), ]); @@ -672,7 +688,7 @@ export class PiWebApp extends LitElement { } private getActions(): AppAction[] { - return this.plugins.getActions(this.createPluginRuntimeContext()); + return applyShortcutPreferences(this.plugins.getActions(this.createPluginRuntimeContext()), this.shortcutConfig); } private async loadExternalPlugins(): Promise { @@ -695,14 +711,16 @@ export class PiWebApp extends LitElement { private createPluginRuntimeContext(): PluginRuntimeContext { const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({ state: this.state, - piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) }, + piWebInternal: { + terminalCommandRuns: this.terminalCommandRunsForOrigin(origin), + openSettings: (section) => { this.openSettings(section); }, + }, openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, focusPrompt: () => { this.promptEditor?.focusInput(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, 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); }, @@ -1029,7 +1047,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} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} `; } diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 4b268f1..91c583c 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -1,16 +1,10 @@ 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 { configApi, type PiWebConfigResponse, type PiWebConfigValues } from "../api"; import type { SettingsSection } from "../settingsRoute"; - -interface ConfigDraft { - host: string; - port: string; - allowedHostsMode: "list" | "all"; - allowedHostsText: string; -} +import "./settings/SettingsGeneralPanel"; +import "./settings/SettingsShortcutsPanel"; @customElement("settings-dialog") export class SettingsDialog extends LitElement { @@ -18,18 +12,25 @@ export class SettingsDialog extends LitElement { @property({ attribute: false }) actions: AppAction[] = []; @property({ attribute: false }) onNavigate?: (section: SettingsSection) => void; @property({ attribute: false }) onClose?: () => void; + @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => 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 = ""; + private savedMessageTimer: number | undefined; override connectedCallback(): void { super.connectedCallback(); void this.loadConfig(); } + override disconnectedCallback(): void { + if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer); + this.savedMessageTimer = undefined; + super.disconnectedCallback(); + } + override render(): TemplateResult { return html`
this.onClose?.()}> @@ -47,7 +48,7 @@ export class SettingsDialog extends LitElement { ${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
- ${this.section === "shortcuts" ? this.renderShortcuts() : this.renderGeneral()} + ${this.renderActiveSection()}
@@ -55,6 +56,23 @@ export class SettingsDialog extends LitElement { `; } + private renderActiveSection(): TemplateResult { + if (this.section === "shortcuts") { + return html``; + } + return html` + this.loadConfig()} + .onSave=${(config: PiWebConfigValues) => this.saveConfig(config)} + > + `; + } + private renderNavButton(section: SettingsSection, label: string, detail: string): TemplateResult { const selected = this.section === section; return html` @@ -65,119 +83,6 @@ export class SettingsDialog extends LitElement { `; } - 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); } @@ -186,9 +91,7 @@ export class SettingsDialog extends LitElement { this.loading = true; this.error = ""; try { - const response = await configApi.config(); - this.configResponse = response; - this.draft = draftFromConfig(response.config); + this.configResponse = await configApi.config(); } catch (error) { this.error = `Failed to load config: ${errorMessage(error)}`; } finally { @@ -196,20 +99,16 @@ export class SettingsDialog extends LitElement { } } - private async saveConfig(event: Event): Promise { - event.preventDefault(); + private async saveConfig(config: PiWebConfigValues): Promise { if (this.saving) return; this.saving = true; this.error = ""; this.savedMessage = ""; try { - const response = await configApi.saveConfig(configFromDraft(this.draft)); + const response = await configApi.saveConfig(config); this.configResponse = response; - this.draft = draftFromConfig(response.config); - this.savedMessage = "Config saved."; - window.setTimeout(() => { - if (this.savedMessage === "Config saved.") this.savedMessage = ""; - }, 3000); + this.onConfigSaved?.(response.config); + this.showSavedMessage(); } catch (error) { this.error = `Failed to save config: ${errorMessage(error)}`; } finally { @@ -217,9 +116,13 @@ export class SettingsDialog extends LitElement { } } - private updateDraft(patch: Partial): void { - this.draft = { ...this.draft, ...patch }; - this.savedMessage = ""; + private showSavedMessage(): void { + this.savedMessage = "Config saved."; + if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer); + this.savedMessageTimer = window.setTimeout(() => { + if (this.savedMessage === "Config saved.") this.savedMessage = ""; + this.savedMessageTimer = undefined; + }, 3000); } private handleKeyDown(event: KeyboardEvent): void { @@ -235,14 +138,8 @@ export class SettingsDialog extends LitElement { .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; } + h1 { margin: 0; font-size: 20px; line-height: 1.2; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; } .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); } @@ -252,43 +149,6 @@ export class SettingsDialog extends LitElement { .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; } @@ -298,77 +158,10 @@ export class SettingsDialog extends LitElement { .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/settings/SettingsGeneralPanel.ts b/src/client/src/components/settings/SettingsGeneralPanel.ts new file mode 100644 index 0000000..b551b26 --- /dev/null +++ b/src/client/src/components/settings/SettingsGeneralPanel.ts @@ -0,0 +1,190 @@ +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft"; + +@customElement("settings-general-panel") +export class SettingsGeneralPanel extends LitElement { + @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + @property({ type: Boolean }) loading = false; + @property({ type: Boolean }) saving = false; + @property() error = ""; + @property() savedMessage = ""; + @property({ attribute: false }) onReload?: () => void | Promise; + @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; + @state() private draft: ConfigDraft = emptyConfigDraft(); + @state() private localError = ""; + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("configResponse") && this.configResponse !== undefined) { + this.draft = draftFromConfig(this.configResponse.config); + this.localError = ""; + } + } + + override render(): 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 { + const error = this.localError || this.error; + if (error !== "") return html`
${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 async saveConfig(event: Event): Promise { + event.preventDefault(); + this.localError = ""; + try { + await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {})); + } catch (error) { + this.localError = errorMessage(error); + } + } + + private updateDraft(patch: Partial): void { + this.draft = { ...this.draft, ...patch }; + this.localError = ""; + } + + static override styles = css` + :host { display: block; } + .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; } + h2, h3, p { margin: 0; } + 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; } + .secondary { flex: 0 0 auto; } + .message, .loading-card, .config-path-card, .effective-card { 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 { 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 { 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); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } + .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } + } + `; +} + +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 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/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts new file mode 100644 index 0000000..528a9fc --- /dev/null +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -0,0 +1,124 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { AppAction } from "../../actions"; +import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api"; +import { formatShortcut } from "../../keyboardShortcuts"; + +@customElement("settings-shortcuts-panel") +export class SettingsShortcutsPanel extends LitElement { + @property({ attribute: false }) actions: AppAction[] = []; + @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + + override render(): TemplateResult { + const groups = shortcutGroups(this.actions); + return html` +
+
+

Keyboard shortcuts

+

Review registered app actions and the shortcut config that will become editable here. Manual config entries use action ids and can override a default shortcut or set it to null to disable it.

+
+
+
Config key: shortcuts. Example: { "core:view.chat": "mod+1", "core:session.stop": null }
+ ${groups.length === 0 ? html`
No actions registered.
` : groups.map((group) => html` +
+

${group.name}

+
+ ${group.actions.map((action) => this.renderShortcutRow(action))} +
+
+ `)} + `; + } + + private renderShortcutRow(action: AppAction): TemplateResult { + const shortcuts = this.configResponse?.config.shortcuts; + const configured = shortcutPreference(action.id, shortcuts); + const shortcut = configured === null ? undefined : configured ?? action.shortcut; + const state = shortcutState(action, shortcuts); + return html` +
+
+ ${action.title} + ${action.description !== undefined && action.description !== "" ? html`${action.description}` : null} + ${action.id} +
+
+ ${shortcut !== undefined && shortcut !== "" ? html`${formatShortcut(shortcut)}` : html`${state === "disabled" ? "Disabled" : "Unassigned"}`} + ${shortcutStateLabel(state)} +
+
+ `; + } + + static override styles = css` + :host { display: block; } + .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; } + h2, h3, p { margin: 0; } + 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; } + 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; } + .loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .loading-card, .shortcut-note { color: var(--pi-muted); } + .shortcut-note { margin-bottom: 14px; } + .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; } + .shortcut-main small { color: var(--pi-muted); } + .shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; } + 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; color: var(--pi-muted); font-size: 12px; } + .shortcut-value small { color: var(--pi-muted); font-size: 11px; } + .shortcut-value small.custom { color: var(--pi-accent); } + .shortcut-value small.disabled { color: var(--pi-warning); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } + .shortcut-value { justify-self: start; justify-items: start; } + kbd, .unassigned { justify-self: start; } + } + `; +} + +type ShortcutState = "default" | "custom" | "disabled" | "unassigned"; + +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 shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | undefined): string | null | undefined { + if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined; + return shortcuts[actionId]; +} + +function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState { + const configured = shortcutPreference(action.id, shortcuts); + if (configured === null) return "disabled"; + if (configured !== undefined) return "custom"; + return action.shortcut === undefined || action.shortcut === "" ? "unassigned" : "default"; +} + +function shortcutStateLabel(state: ShortcutState): string { + switch (state) { + case "default": return "Default"; + case "custom": return "Config override"; + case "disabled": return "Config disabled"; + case "unassigned": return "No default"; + } +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts new file mode 100644 index 0000000..5c75c1c --- /dev/null +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { configFromDraft, draftFromConfig } from "./settingsConfigDraft"; + +describe("settings config drafts", () => { + it("converts PI WEB config values to editable general settings drafts", () => { + expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({ + host: "0.0.0.0", + port: "8504", + allowedHostsMode: "list", + allowedHostsText: "example.local\n192.168.1.20", + }); + expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); + }); + + it("converts drafts back to config while preserving shortcut preferences", () => { + expect(configFromDraft({ + host: " 127.0.0.1 ", + port: "9000", + allowedHostsMode: "list", + allowedHostsText: "example.local, 192.168.1.20\n", + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } })).toEqual({ + host: "127.0.0.1", + port: 9000, + allowedHosts: ["example.local", "192.168.1.20"], + shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + }); + }); +}); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts new file mode 100644 index 0000000..4ef718c --- /dev/null +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -0,0 +1,41 @@ +import type { PiWebConfigValues } from "../../api"; + +export interface ConfigDraft { + host: string; + port: string; + allowedHostsMode: "list" | "all"; + allowedHostsText: string; +} + +export function emptyConfigDraft(): ConfigDraft { + return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; +} + +export 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") : "", + }; +} + +export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { + const config: PiWebConfigValues = { + ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), + }; + 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 !== ""); +} diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 4b70355..b7ba10b 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -55,7 +55,7 @@ export function createCoreActions(): PluginAction[] { description: "Manage PI WEB configuration and keyboard shortcuts", shortcut: "mod+,", group: "Preferences", - run: (context) => { context.openSettings(); }, + run: (context) => { context.piWebInternal?.openSettings?.(); }, }, { id: "app.refresh-data", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index b86843b..246ec4f 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -18,6 +18,7 @@ function createContext(statePatch: Partial = {}) { getCommandRun: vi.fn(), open: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`terminal.open:${options?.terminalId ?? ""}`); }), }, + openSettings: vi.fn(() => { calls.push("openSettings"); }), }, openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }), focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }), @@ -25,7 +26,6 @@ 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 ?? ""}`); }), @@ -143,7 +143,7 @@ describe("PluginRegistry", () => { expect(calls).toEqual(["refreshGit"]); }); - it("routes app refresh and reload actions through the runtime context", () => { + it("routes app refresh, reload, and settings actions through the runtime context", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); const { context, calls } = createContext(); @@ -151,8 +151,9 @@ describe("PluginRegistry", () => { void actions.find((candidate) => candidate.id === "core:app.refresh-data")?.run(); void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run(); + void actions.find((candidate) => candidate.id === "core:settings.open")?.run(); - expect(calls).toEqual(["refreshAppData", "reloadPage"]); + expect(calls).toEqual(["refreshAppData", "reloadPage", "openSettings"]); }); it("exposes terminal navigation as a shortcut-backed action", () => { diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 4f2307a..1f28529 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -39,6 +39,7 @@ export interface PluginContributions { export interface PiWebInternalRuntimeContext { terminalCommandRuns: TerminalCommandRunsInternalRuntime; + openSettings?: (section?: SettingsSection) => void; } export interface TerminalCommandRunsInternalRuntime { @@ -57,7 +58,6 @@ 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/shortcutPreferences.test.ts b/src/client/src/shortcutPreferences.test.ts new file mode 100644 index 0000000..51128e9 --- /dev/null +++ b/src/client/src/shortcutPreferences.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { AppAction } from "./actions"; +import { applyShortcutPreferences } from "./shortcutPreferences"; + +const noop = () => undefined; + +describe("shortcut preferences", () => { + it("keeps default shortcuts when there is no matching preference", () => { + const actions = [action({ id: "core:view.chat", shortcut: "mod+1" })]; + + expect(applyShortcutPreferences(actions, { "core:view.files": "mod+2" })).toEqual(actions); + }); + + it("overrides action shortcuts by action id", () => { + expect(applyShortcutPreferences([ + action({ id: "core:view.chat", shortcut: "mod+1" }), + ], { "core:view.chat": "mod+shift+1" })).toEqual([ + action({ id: "core:view.chat", shortcut: "mod+shift+1" }), + ]); + }); + + it("removes shortcuts with null preferences", () => { + expect(applyShortcutPreferences([ + action({ id: "core:view.chat", shortcut: "mod+1" }), + ], { "core:view.chat": null })).toEqual([ + action({ id: "core:view.chat" }), + ]); + }); +}); + +function action(patch: Partial): AppAction { + return { id: "action", title: "Action", run: noop, ...patch }; +} diff --git a/src/client/src/shortcutPreferences.ts b/src/client/src/shortcutPreferences.ts new file mode 100644 index 0000000..ff9902e --- /dev/null +++ b/src/client/src/shortcutPreferences.ts @@ -0,0 +1,21 @@ +import type { AppAction } from "./actions"; +import type { PiWebShortcutConfig } from "./api"; + +export function applyShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] { + if (shortcuts === undefined) return actions; + return actions.map((action) => applyShortcutPreference(action, shortcuts)); +} + +export function applyShortcutPreference(action: AppAction, shortcuts: PiWebShortcutConfig): AppAction { + if (!Object.hasOwn(shortcuts, action.id)) return action; + const shortcut = shortcuts[action.id]; + if (shortcut === undefined) return action; + if (shortcut === null) return withoutShortcut(action); + return { ...action, shortcut }; +} + +function withoutShortcut(action: AppAction): AppAction { + const copy = { ...action }; + delete copy.shortcut; + return copy; +} diff --git a/src/config.test.ts b/src/config.test.ts index b0527d6..2430e5c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,9 +18,9 @@ afterEach(async () => { 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()); + const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }, testOptions()); - expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"] } }); + expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } } }); expect(loadPiWebConfig(testOptions())).toEqual(saved); }); diff --git a/src/config.ts b/src/config.ts index 8ff8352..82c4dbb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["host"]; delete existing["port"]; delete existing["allowedHosts"]; + delete existing["shortcuts"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); @@ -92,6 +93,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.host !== undefined ? { host: config.host } : {}), ...(config.port !== undefined ? { port: config.port } : {}), ...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}), + ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), }; } @@ -100,6 +102,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}), ...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}), ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), + ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), }; } @@ -127,6 +130,16 @@ function parseAllowedHostsEnv(value: string): string[] | true { return value.split(",").map((host) => host.trim()).filter((host) => host !== ""); } +function parseShortcuts(value: unknown, path: string): Record { + if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`); + return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { + if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) { + throw new Error(`PI WEB config shortcut values must be non-empty strings or null: ${path}`); + } + return [actionId, shortcut]; + })); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index fa63660..536ba9c 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -37,11 +37,11 @@ describe("config routes", () => { const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true } }, + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } } }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true }); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }); expect(response.json().config).toEqual(savedConfig); }); diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 3819bc8..22a4ade 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -55,6 +55,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { const host = value["host"]; const port = value["port"]; const allowedHosts = value["allowedHosts"]; + const shortcuts = value["shortcuts"]; if (host !== undefined) { if (typeof host !== "string") throw new Error("PI WEB config host must be a string"); config.host = host; @@ -64,6 +65,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { config.port = port; } if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts); + if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts); return config; } @@ -75,6 +77,14 @@ function parseAllowedHostsRequest(value: unknown): string[] | true { return value; } +function parseShortcutsRequest(value: unknown): Record { + if (!isRecord(value)) throw new Error("PI WEB config shortcuts must be an object"); + return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { + if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) throw new Error("PI WEB config shortcut values must be non-empty strings or null"); + return [actionId, shortcut]; + })); +} + function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides { return { host: isEnvSet(env["PI_WEB_HOST"]), diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 1c0b15e..c974b1d 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -1,7 +1,10 @@ +export type PiWebShortcutConfig = Record; + export interface PiWebConfigValues { host?: string; port?: number; allowedHosts?: string[] | true; + shortcuts?: PiWebShortcutConfig; } export interface PiWebConfigEnvOverrides {