Archived
feat: add Pi package management settings
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { filesApi, machinesApi, piPackagesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -60,6 +60,38 @@ describe("machine-scoped runtime API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi package API", () => {
|
||||
it("uses the local Pi package-management routes for list and mutations", async () => {
|
||||
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
|
||||
jsonResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages }),
|
||||
jsonResponse({ action: "update", source: "npm:@acme/tools", packages }),
|
||||
jsonResponse({ action: "update", packages }),
|
||||
]);
|
||||
|
||||
await expect(piPackagesApi.packages()).resolves.toEqual({ packages });
|
||||
await piPackagesApi.install("npm:@acme/new-tools");
|
||||
await piPackagesApi.remove("../project-tools", "project");
|
||||
await piPackagesApi.update("npm:@acme/tools");
|
||||
await piPackagesApi.update();
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/pi-packages",
|
||||
"/api/pi-packages/install",
|
||||
"/api/pi-packages/remove",
|
||||
"/api/pi-packages/update",
|
||||
"/api/pi-packages/update",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "../project-tools", scope: "project" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
|
||||
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session API compatibility", () => {
|
||||
it("posts session cleanup preview and execute requests through the selected machine", async () => {
|
||||
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
parseModelSelectionResponse,
|
||||
parseMoveWorkspaceFileResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiPackageMutationResponse,
|
||||
parsePiPackagesResponse,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
parsePiWebRuntimeResponse,
|
||||
@@ -119,6 +121,22 @@ export const pluginsApi = {
|
||||
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
|
||||
};
|
||||
|
||||
export const piPackagesApi = {
|
||||
packages: () => request("/api/pi-packages", parsePiPackagesResponse),
|
||||
install: (source: string) => {
|
||||
const body: PiPackageInstallRequest = { source };
|
||||
return request("/api/pi-packages/install", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
remove: (source: string, scope?: PiPackageScope) => {
|
||||
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
|
||||
return request("/api/pi-packages/remove", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
update: (source?: string) => {
|
||||
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
|
||||
return request("/api/pi-packages/update", parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
},
|
||||
};
|
||||
|
||||
export const activityApi = {
|
||||
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
|
||||
};
|
||||
@@ -285,6 +303,7 @@ export const api = {
|
||||
...machinesApi,
|
||||
...configApi,
|
||||
...pluginsApi,
|
||||
...piPackagesApi,
|
||||
...activityApi,
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("parses PI WEB config responses", () => {
|
||||
@@ -31,6 +31,28 @@ describe("API parsers", () => {
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
});
|
||||
|
||||
it("parses Pi package list and mutation responses", () => {
|
||||
const packages = [
|
||||
{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
];
|
||||
|
||||
expect(parsePiPackagesResponse({ packages })).toEqual({ packages });
|
||||
expect(parsePiPackageMutationResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages })).toEqual({
|
||||
action: "remove",
|
||||
source: "../project-tools",
|
||||
scope: "project",
|
||||
removed: true,
|
||||
packages,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed Pi package responses", () => {
|
||||
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "global", filtered: false }] })).toThrow("Invalid Pi package scope");
|
||||
expect(() => parsePiPackageMutationResponse({ action: "sync", packages: [] })).toThrow("Invalid Pi package mutation action");
|
||||
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
|
||||
});
|
||||
|
||||
it("parses PI WEB plugin status responses", () => {
|
||||
expect(parsePiWebPluginsResponse({
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||
import { isPiWebCapability } from "../../../shared/capabilities";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -628,6 +629,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
|
||||
}
|
||||
|
||||
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
|
||||
const record = requireRecord(value);
|
||||
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
|
||||
}
|
||||
|
||||
export function parsePiPackageMutationResponse(value: unknown): PiPackageMutationResponse {
|
||||
const record = requireRecord(value);
|
||||
const source = optionalString(record, "source");
|
||||
const scope = record["scope"] === undefined ? undefined : parsePiPackageScope(record["scope"]);
|
||||
const removed = parseOptionalBoolean(record["removed"], "removed");
|
||||
return {
|
||||
action: parsePiPackageMutationAction(record["action"]),
|
||||
...optionalField("source", source),
|
||||
...optionalField("scope", scope),
|
||||
...optionalField("removed", removed),
|
||||
packages: arrayOf(parsePiPackageInfo)(record["packages"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiPackageInfo(value: unknown): PiPackageInfo {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
source: requireString(record, "source"),
|
||||
scope: parsePiPackageScope(record["scope"]),
|
||||
filtered: requireBoolean(record, "filtered"),
|
||||
...optionalField("installedPath", optionalString(record, "installedPath")),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiPackageScope(value: unknown): PiPackageScope {
|
||||
if (value !== "user" && value !== "project") throw new Error("Invalid Pi package scope");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiPackageMutationAction(value: unknown): PiPackageMutationAction {
|
||||
if (value !== "install" && value !== "remove" && value !== "update") throw new Error("Invalid Pi package mutation action");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
|
||||
const record = requireRecord(value);
|
||||
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { AppAction } from "../actions";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import { configApi, piPackagesApi, pluginsApi, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import type { SettingsSection } from "../settingsRoute";
|
||||
import "./settings/SettingsGeneralPanel";
|
||||
import "./settings/SettingsSessiondPanel";
|
||||
import "./settings/SettingsPackagesPanel";
|
||||
import "./settings/SettingsPluginsPanel";
|
||||
import "./settings/SettingsShortcutsPanel";
|
||||
import { piPackageMutationFollowUpMessage, type PiPackageOperationState } from "./settings/piPackageSettings";
|
||||
|
||||
@customElement("settings-dialog")
|
||||
export class SettingsDialog extends LitElement {
|
||||
@@ -17,10 +19,13 @@ export class SettingsDialog extends LitElement {
|
||||
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
||||
@state() private configResponse: PiWebConfigResponse | undefined;
|
||||
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private packagesResponse: PiPackagesResponse | undefined;
|
||||
@state() private loading = true;
|
||||
@state() private saving = false;
|
||||
@state() private packageOperation: PiPackageOperationState | undefined;
|
||||
@state() private error = "";
|
||||
@state() private savedMessage = "";
|
||||
@state() private packageMessage = "";
|
||||
private savedMessageTimer: number | undefined;
|
||||
|
||||
override connectedCallback(): void {
|
||||
@@ -49,7 +54,8 @@ export class SettingsDialog extends LitElement {
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Server config")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
|
||||
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
|
||||
${this.renderNavButton("packages", "Pi packages", "Install and manage")}
|
||||
${this.renderNavButton("plugins", "PI WEB plugins", "Enable and disable")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
||||
</nav>
|
||||
<main class="settings-content">
|
||||
@@ -89,6 +95,21 @@ export class SettingsDialog extends LitElement {
|
||||
></settings-shortcuts-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "packages") {
|
||||
return html`
|
||||
<settings-packages-panel
|
||||
.packagesResponse=${this.packagesResponse}
|
||||
.loading=${this.loading}
|
||||
.operation=${this.packageOperation}
|
||||
.error=${this.error}
|
||||
.operationMessage=${this.packageMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
|
||||
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
|
||||
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
|
||||
></settings-packages-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
<settings-plugins-panel
|
||||
@@ -134,9 +155,10 @@ export class SettingsDialog extends LitElement {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
|
||||
const [config, plugins, packages] = await Promise.all([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages()]);
|
||||
this.configResponse = config;
|
||||
this.pluginsResponse = plugins;
|
||||
this.packagesResponse = packages;
|
||||
} catch (error) {
|
||||
this.error = `Failed to load settings: ${errorMessage(error)}`;
|
||||
} finally {
|
||||
@@ -163,6 +185,7 @@ export class SettingsDialog extends LitElement {
|
||||
this.saving = true;
|
||||
this.error = "";
|
||||
this.savedMessage = "";
|
||||
this.packageMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config);
|
||||
this.configResponse = response;
|
||||
@@ -175,11 +198,44 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async installPiPackage(source: string): Promise<void> {
|
||||
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", () => piPackagesApi.install(source));
|
||||
}
|
||||
|
||||
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
|
||||
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", () => piPackagesApi.remove(source, scope));
|
||||
}
|
||||
|
||||
private async updatePiPackage(source?: string): Promise<void> {
|
||||
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", () => piPackagesApi.update(source));
|
||||
}
|
||||
|
||||
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
|
||||
if (this.saving) throw new Error("A settings operation is already running.");
|
||||
this.saving = true;
|
||||
this.packageOperation = operation;
|
||||
this.error = "";
|
||||
this.savedMessage = "";
|
||||
this.packageMessage = "";
|
||||
try {
|
||||
const response = await mutate();
|
||||
this.packagesResponse = { packages: response.packages };
|
||||
await this.refreshPlugins();
|
||||
this.packageMessage = piPackageMutationFollowUpMessage(response.action);
|
||||
} catch (error) {
|
||||
this.error = `Failed to ${label}: ${errorMessage(error)}`;
|
||||
throw error;
|
||||
} finally {
|
||||
this.packageOperation = undefined;
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPlugins(): Promise<void> {
|
||||
try {
|
||||
this.pluginsResponse = await pluginsApi.plugins();
|
||||
} catch (error) {
|
||||
this.error = `Failed to refresh plugins: ${errorMessage(error)}`;
|
||||
this.error = `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api";
|
||||
import { isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageOperationState } from "./piPackageSettings";
|
||||
|
||||
@customElement("settings-packages-panel")
|
||||
export class SettingsPackagesPanel extends LitElement {
|
||||
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
|
||||
@property() error = "";
|
||||
@property() operationMessage = "";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onInstallPackage?: (source: string) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemovePackage?: (source: string, scope: PiPackageScope) => void | Promise<void>;
|
||||
@property({ attribute: false }) onUpdatePackage?: (source?: string) => void | Promise<void>;
|
||||
@state() private installSource = "";
|
||||
@state() private validationMessage = "";
|
||||
|
||||
override render(): TemplateResult {
|
||||
const packages = this.packagesResponse?.packages ?? [];
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Pi packages</h2>
|
||||
<p>Install, remove, and update packages managed by Pi. Pi packages can provide extensions, skills, prompt templates, themes, and PI WEB browser plugins.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading || this.isOperating} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
<div class="trust-warning"><strong>Trusted code warning:</strong> Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.</div>
|
||||
${this.renderMessages()}
|
||||
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
|
||||
<label for="package-source">Pi package source</label>
|
||||
<div class="install-row">
|
||||
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
|
||||
<button type="submit" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
|
||||
</div>
|
||||
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
|
||||
<small>Installs use Pi's default package location, equivalent to <code>pi install <source></code>. PI WEB does not ask you to choose an install location.</small>
|
||||
</form>
|
||||
${this.renderPackageList(packages)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
|
||||
if (this.operationMessage !== "") return html`<div class="message success-message">${this.operationMessage}</div>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderPackageList(packages: PiPackageInfo[]): TemplateResult {
|
||||
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
|
||||
return html`
|
||||
<section class="package-section" aria-label="Configured Pi packages">
|
||||
<div class="package-toolbar">
|
||||
<div>
|
||||
<h3>Configured Pi packages</h3>
|
||||
<p>This list comes from Pi's package manager settings visible to this PI WEB process.</p>
|
||||
</div>
|
||||
<button class="secondary" title=${updateAllReason ?? "Update all user-scope Pi packages"} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
|
||||
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
|
||||
</button>
|
||||
</div>
|
||||
${updateAllReason === undefined ? null : html`<div class="action-note">${updateAllReason}</div>`}
|
||||
${this.loading && packages.length === 0 ? html`<div class="loading-card">Loading Pi packages…</div>` : packages.length === 0 ? html`<div class="loading-card">No Pi packages configured in Pi settings yet.</div>` : html`
|
||||
<div class="package-list">
|
||||
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
|
||||
const updateReason = piPackageUpdateDisabledReason(packageInfo);
|
||||
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
|
||||
const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source);
|
||||
return html`
|
||||
<article class=${`package-card${packageInfo.filtered ? " filtered" : ""}`}>
|
||||
<div class="package-main">
|
||||
<strong>${packageInfo.source}</strong>
|
||||
<small>${piPackageScopeLabel(packageInfo)} · ${piPackageFilteredLabel(packageInfo)}</small>
|
||||
<small>Installed path: <code>${piPackageInstalledPathLabel(packageInfo)}</code></small>
|
||||
${updateReason === undefined ? null : html`<small class="action-note">${updateReason}</small>`}
|
||||
</div>
|
||||
<div class="package-actions">
|
||||
<button class="secondary" title=${updateReason ?? "Update this Pi package"} ?disabled=${this.isOperating || updateReason !== undefined} @click=${() => { void this.updatePackage(packageInfo.source); }}>${updating ? "Updating…" : "Update"}</button>
|
||||
<button class="danger" ?disabled=${this.isOperating} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateInstallSource(event: Event): void {
|
||||
this.installSource = event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
this.validationMessage = "";
|
||||
}
|
||||
|
||||
private async installPackage(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
const validationMessage = piPackageSourceValidationMessage(this.installSource);
|
||||
if (validationMessage !== undefined) {
|
||||
this.validationMessage = validationMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
const source = normalizePiPackageSource(this.installSource);
|
||||
try {
|
||||
await this.onInstallPackage?.(source);
|
||||
this.installSource = "";
|
||||
this.validationMessage = "";
|
||||
} catch {
|
||||
// The parent owns network error presentation so package errors are consistent across Settings.
|
||||
}
|
||||
}
|
||||
|
||||
private async removePackage(packageInfo: PiPackageInfo): Promise<void> {
|
||||
try {
|
||||
await this.onRemovePackage?.(packageInfo.source, packageInfo.scope);
|
||||
} catch {
|
||||
// The parent owns network error presentation so package errors are consistent across Settings.
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePackage(source?: string): Promise<void> {
|
||||
try {
|
||||
await this.onUpdatePackage?.(source);
|
||||
} catch {
|
||||
// The parent owns network error presentation so package errors are consistent across Settings.
|
||||
}
|
||||
}
|
||||
|
||||
private get isOperating(): boolean {
|
||||
return this.operation !== undefined;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.section-heading, .package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-heading > div, .package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: 17px; line-height: 1.25; }
|
||||
h3 { font-size: 15px; line-height: 1.25; }
|
||||
p, small { color: var(--pi-muted); line-height: 1.45; }
|
||||
button, input { 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, input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
input { 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: 8px 9px; }
|
||||
label { font-weight: 700; }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.danger { border-color: color-mix(in srgb, var(--pi-danger) 55%, var(--pi-border)); color: var(--pi-danger); }
|
||||
.message, .loading-card, .trust-warning, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message, .trust-warning, .install-card { margin-bottom: 12px; }
|
||||
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
|
||||
.error-message, .field-error { color: var(--pi-danger); }
|
||||
.error-message { border-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); }
|
||||
.install-card { display: grid; gap: 8px; }
|
||||
.install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.field-error { font-size: 12px; }
|
||||
.package-section { display: block; }
|
||||
.package-toolbar { margin-top: 16px; }
|
||||
.loading-card, .action-note { color: var(--pi-muted); }
|
||||
.action-note { margin-bottom: 10px; font-size: 12px; }
|
||||
.package-list { display: grid; gap: 10px; }
|
||||
.package-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
|
||||
.package-card.filtered { opacity: .82; }
|
||||
.package-main strong, .package-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.package-actions { display: flex; align-items: center; gap: 8px; }
|
||||
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; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-heading, .package-toolbar { display: grid; gap: 12px; }
|
||||
.section-heading .secondary, .package-toolbar .secondary { justify-self: start; }
|
||||
.install-row, .package-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
.package-actions { justify-self: start; flex-wrap: wrap; }
|
||||
.package-main strong, .package-main small { white-space: normal; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -18,14 +18,15 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Plugins</h2>
|
||||
<p>Enable or disable discovered PI WEB plugins. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
|
||||
<h2>PI WEB plugins</h2>
|
||||
<p>Enable or disable discovered PI WEB browser plugins. This is separate from installing Pi packages. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="trust-warning"><strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.</div>
|
||||
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
|
||||
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No external or bundled plugins discovered.</div>` : html`
|
||||
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered.</div>` : html`
|
||||
<div class="plugin-list">
|
||||
${plugins.map((plugin) => this.renderPlugin(plugin))}
|
||||
</div>
|
||||
@@ -73,11 +74,12 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
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, input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.message, .loading-card, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message { margin-bottom: 12px; }
|
||||
.message, .loading-card, .trust-warning, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message, .trust-warning { 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, .plugin-note { color: var(--pi-muted); }
|
||||
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
|
||||
.plugin-note { margin-bottom: 14px; }
|
||||
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; }
|
||||
.plugin-list { display: grid; gap: 10px; }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiPackageInfo } from "../../api";
|
||||
import { canUpdateAllPiPackages, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason } from "./piPackageSettings";
|
||||
|
||||
const userPackage: PiPackageInfo = { source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" };
|
||||
const projectPackage: PiPackageInfo = { source: "../project-tools", scope: "project", filtered: true };
|
||||
|
||||
describe("Pi package settings helpers", () => {
|
||||
it("normalizes and validates install sources without adding location choices", () => {
|
||||
expect(normalizePiPackageSource(" npm:@acme/tools ")).toBe("npm:@acme/tools");
|
||||
expect(piPackageSourceValidationMessage(" npm:@acme/tools ")).toBeUndefined();
|
||||
expect(piPackageSourceValidationMessage(" ")).toContain("Pi package source accepted by Pi");
|
||||
});
|
||||
|
||||
it("formats package metadata with Pi package terminology", () => {
|
||||
expect(piPackageScopeLabel(userPackage)).toBe("User scope");
|
||||
expect(piPackageScopeLabel(projectPackage)).toBe("Project scope");
|
||||
expect(piPackageFilteredLabel(userPackage)).toBe("Available in this PI WEB process");
|
||||
expect(piPackageFilteredLabel(projectPackage)).toBe("Filtered by current Pi package settings");
|
||||
});
|
||||
|
||||
it("allows updates for user-scope packages and explains project-scope limits", () => {
|
||||
expect(piPackageUpdateDisabledReason(userPackage)).toBeUndefined();
|
||||
expect(piPackageUpdateDisabledReason(projectPackage)).toContain("user-scope Pi packages");
|
||||
expect(canUpdateAllPiPackages([userPackage])).toBe(true);
|
||||
expect(canUpdateAllPiPackages([userPackage, projectPackage])).toBe(false);
|
||||
expect(updateAllPiPackagesDisabledReason([])).toBe("No Pi packages are configured yet.");
|
||||
expect(updateAllPiPackagesDisabledReason([userPackage, projectPackage])).toContain("project-scope Pi packages");
|
||||
});
|
||||
|
||||
it("matches pending operations by action and source", () => {
|
||||
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/tools")).toBe(true);
|
||||
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/other")).toBe(false);
|
||||
expect(isPiPackageOperationPending({ kind: "update-all" }, "update-all")).toBe(true);
|
||||
});
|
||||
|
||||
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
|
||||
const message = piPackageMutationFollowUpMessage("install");
|
||||
|
||||
expect(message).toContain("Reload the browser page");
|
||||
expect(message).toContain("Reload existing Pi sessions");
|
||||
expect(message).not.toContain("session daemon");
|
||||
expect(message).not.toContain("sessiond");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PiPackageInfo, PiPackageMutationAction } from "../../api";
|
||||
|
||||
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
|
||||
|
||||
export interface PiPackageOperationState {
|
||||
kind: PiPackageOperationKind;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export function normalizePiPackageSource(source: string): string {
|
||||
return source.trim();
|
||||
}
|
||||
|
||||
export function piPackageSourceValidationMessage(source: string): string | undefined {
|
||||
if (normalizePiPackageSource(source) !== "") return undefined;
|
||||
return "Enter a Pi package source accepted by Pi, such as npm:@scope/package, a git/URL source, or a local path.";
|
||||
}
|
||||
|
||||
export function piPackageScopeLabel(packageInfo: Pick<PiPackageInfo, "scope">): string {
|
||||
return packageInfo.scope === "project" ? "Project scope" : "User scope";
|
||||
}
|
||||
|
||||
export function piPackageFilteredLabel(packageInfo: Pick<PiPackageInfo, "filtered">): string {
|
||||
return packageInfo.filtered ? "Filtered by current Pi package settings" : "Available in this PI WEB process";
|
||||
}
|
||||
|
||||
export function piPackageInstalledPathLabel(packageInfo: Pick<PiPackageInfo, "installedPath">): string {
|
||||
return packageInfo.installedPath ?? "Installed path not reported by Pi";
|
||||
}
|
||||
|
||||
export function canUpdatePiPackage(packageInfo: Pick<PiPackageInfo, "scope">): boolean {
|
||||
return packageInfo.scope === "user";
|
||||
}
|
||||
|
||||
export function piPackageUpdateDisabledReason(packageInfo: Pick<PiPackageInfo, "scope">): string | undefined {
|
||||
if (canUpdatePiPackage(packageInfo)) return undefined;
|
||||
return "Project-scope Pi packages are listed for visibility, but PI WEB only updates user-scope Pi packages safely from this view.";
|
||||
}
|
||||
|
||||
export function canUpdateAllPiPackages(packages: readonly Pick<PiPackageInfo, "scope">[]): boolean {
|
||||
return packages.length > 0 && packages.every(canUpdatePiPackage);
|
||||
}
|
||||
|
||||
export function updateAllPiPackagesDisabledReason(packages: readonly Pick<PiPackageInfo, "scope">[]): string | undefined {
|
||||
if (packages.length === 0) return "No Pi packages are configured yet.";
|
||||
if (canUpdateAllPiPackages(packages)) return undefined;
|
||||
return "Update all is disabled while project-scope Pi packages are listed; update user-scope packages individually.";
|
||||
}
|
||||
|
||||
export function isPiPackageOperationPending(operation: PiPackageOperationState | undefined, kind: PiPackageOperationKind, source?: string): boolean {
|
||||
if (operation?.kind !== kind) return false;
|
||||
return source === undefined || operation.source === source;
|
||||
}
|
||||
|
||||
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction): string {
|
||||
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
|
||||
return `Pi package ${verb}. Reload the browser page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use /reload in Pi, so extensions, skills, prompt templates, and themes are rediscovered.`;
|
||||
}
|
||||
@@ -37,6 +37,8 @@ describe("settings route helpers", () => {
|
||||
expect(parseSettingsSection("general")).toBe("general");
|
||||
expect(parseSettingsSection("sessiond")).toBe("sessiond");
|
||||
expect(parseSettingsSection("sessions")).toBe("sessiond");
|
||||
expect(parseSettingsSection("packages")).toBe("packages");
|
||||
expect(parseSettingsSection("pi-packages")).toBe("packages");
|
||||
expect(parseSettingsSection("plugins")).toBe("plugins");
|
||||
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
|
||||
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
|
||||
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts";
|
||||
|
||||
export function readSettingsSection(): SettingsSection | undefined {
|
||||
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
|
||||
@@ -18,6 +18,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
|
||||
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
|
||||
if (value === "general") return "general";
|
||||
if (value === "sessiond" || value === "sessions") return "sessiond";
|
||||
if (value === "packages" || value === "pi-packages") return "packages";
|
||||
if (value === "plugins") return "plugins";
|
||||
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user