Archived
feat: add settings config UI
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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 {
|
||||
|
||||
@@ -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 }); }}
|
||||
></app-context-bar>
|
||||
`;
|
||||
}
|
||||
@@ -1001,6 +1029,7 @@ export class PiWebApp extends LitElement {
|
||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
|
||||
<section class="settings-shell" role="dialog" aria-modal="true" aria-label="PI WEB settings" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||
<header class="settings-header">
|
||||
<div>
|
||||
<span class="eyebrow">Settings</span>
|
||||
<h1>PI WEB</h1>
|
||||
</div>
|
||||
<button class="close-button" title="Close settings" aria-label="Close settings" @click=${() => this.onClose?.()}>×</button>
|
||||
</header>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Server config")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
||||
</nav>
|
||||
<main class="settings-content">
|
||||
${this.section === "shortcuts" ? this.renderShortcuts() : this.renderGeneral()}
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavButton(section: SettingsSection, label: string, detail: string): TemplateResult {
|
||||
const selected = this.section === section;
|
||||
return html`
|
||||
<button class=${selected ? "selected" : ""} aria-current=${selected ? "page" : "false"} @click=${() => { this.navigate(section); }}>
|
||||
<strong>${label}</strong>
|
||||
<small>${detail}</small>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGeneral(): TemplateResult {
|
||||
const config = this.configResponse;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>General configuration</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.loadConfig(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Host</span>
|
||||
${this.renderOverrideBadge("host")}
|
||||
</span>
|
||||
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}>
|
||||
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Port</span>
|
||||
${this.renderOverrideBadge("port")}
|
||||
</span>
|
||||
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}>
|
||||
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allowed hosts</span>
|
||||
${this.renderOverrideBadge("allowedHosts")}
|
||||
</span>
|
||||
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
|
||||
<option value="list">Only listed hosts</option>
|
||||
<option value="all">Allow every host</option>
|
||||
</select>
|
||||
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local 192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
|
||||
if (this.configResponse?.envOverrides[key] !== true) return null;
|
||||
return html`<span class="override-badge">environment override</span>`;
|
||||
}
|
||||
|
||||
private renderEffectiveConfig(): TemplateResult {
|
||||
const effective = this.configResponse?.effectiveConfig ?? {};
|
||||
return html`
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderShortcuts(): TemplateResult {
|
||||
const groups = shortcutGroups(this.actions);
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>This is the shortcut inventory that the editable shortcut UI will build on. It already supports deep links with <code>?settings=shortcuts</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcut-note">Editing shortcuts will use this settings surface and persist to the same PI WEB config file in the next step.</div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => html`
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
</div>
|
||||
${action.shortcut !== undefined && action.shortcut !== "" ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : html`<span class="unassigned">Unassigned</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private navigate(section: SettingsSection): void {
|
||||
this.onNavigate?.(section);
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
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<void> {
|
||||
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<ConfigDraft>): 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`<span class="muted">None listed</span>` : value.join(", ");
|
||||
return html`<span class="muted">Unset</span>`;
|
||||
}
|
||||
|
||||
function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] {
|
||||
const grouped = new Map<string, AppAction[]>();
|
||||
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);
|
||||
}
|
||||
@@ -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 {
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
${this.refreshControl === undefined ? null : html`<div class="context-actions">${this.refreshControl}</div>`}
|
||||
${this.hasContextActions() ? html`<div class="context-actions">${this.renderActionsButton()}${this.refreshControl}</div>` : null}
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActionsButton() {
|
||||
if (this.onShowActions === undefined) return null;
|
||||
return html`
|
||||
<button type="button" class="context-action-button" title="Show Actions" aria-label="Show Actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.onShowActions?.(); }}>
|
||||
<svg class="context-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M13 2 4 14h7l-1 8 10-13h-7V2Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
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"],
|
||||
|
||||
@@ -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<void>;
|
||||
logoutAuth: () => void | Promise<void>;
|
||||
openThemePicker: () => void;
|
||||
openSettings: (section?: SettingsSection) => void;
|
||||
selectMainView: (view: AppState["mainView"]) => void;
|
||||
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
||||
openTerminal: (options?: { terminalId?: string | undefined }) => void;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
+33
-7
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return {
|
||||
...(config.host !== undefined ? { host: config.host } : {}),
|
||||
...(config.port !== undefined ? { port: config.port } : {}),
|
||||
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebConfig {
|
||||
return {
|
||||
...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}),
|
||||
|
||||
@@ -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<PiWebPluginService, "manifest" | "readAsset">;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
}
|
||||
@@ -42,6 +44,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
|
||||
app.get("/api/pi-web/status", async () => getPiWebStatus());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
|
||||
registerConfigRoutes(app, deps.config);
|
||||
|
||||
app.get("/api/projects", async () => projects.list());
|
||||
|
||||
|
||||
@@ -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<PiWebConfigResponse>()).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<PiWebConfigResponse>().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 },
|
||||
};
|
||||
}
|
||||
@@ -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<PiWebConfigResponse>;
|
||||
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user