diff --git a/.changeset/actions-click-feedback.md b/.changeset/actions-click-feedback.md deleted file mode 100644 index b7cf5ad..0000000 --- a/.changeset/actions-click-feedback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web-actions": patch ---- - -Prevent redundant workspace action panel re-renders from resetting mobile scroll position or replacing action buttons mid-click, and show feedback for stale, cancelled, or already-starting actions. diff --git a/.changeset/built-in-plugin-docs.md b/.changeset/built-in-plugin-docs.md new file mode 100644 index 0000000..309e0cb --- /dev/null +++ b/.changeset/built-in-plugin-docs.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks. diff --git a/.changeset/mobile-actions-button.md b/.changeset/mobile-actions-button.md new file mode 100644 index 0000000..88308c7 --- /dev/null +++ b/.changeset/mobile-actions-button.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile. diff --git a/.changeset/mobile-tab-icons.md b/.changeset/mobile-tab-icons.md new file mode 100644 index 0000000..ad794bc --- /dev/null +++ b/.changeset/mobile-tab-icons.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Use compact icons, initials, and inline badges for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling; let workspace panel plugins provide custom SVG tab icons; and add icons for bundled Info, Updates, and Tasks plugin panels. diff --git a/.changeset/plugin-disable-config.md b/.changeset/plugin-disable-config.md new file mode 100644 index 0000000..2aacf91 --- /dev/null +++ b/.changeset/plugin-disable-config.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add plugin enablement settings so discovered PI WEB plugins can be disabled before the browser imports them. diff --git a/.changeset/pwa-refresh-menu.md b/.changeset/pwa-refresh-menu.md index 8308575..7bb77b6 100644 --- a/.changeset/pwa-refresh-menu.md +++ b/.changeset/pwa-refresh-menu.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Fix the PWA refresh control menu so its reload options are visible when opened from the compact header button. +Keep the PWA refresh control menu visible above mobile tab navigation and workspace tab content. diff --git a/.changeset/settings-config-ui.md b/.changeset/settings-config-ui.md new file mode 100644 index 0000000..38c42d2 --- /dev/null +++ b/.changeset/settings-config-ui.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a deep-linked Settings UI for editing the active PI WEB config file and viewing registered keyboard shortcuts. diff --git a/.changeset/shortcut-config-foundation.md b/.changeset/shortcut-config-foundation.md new file mode 100644 index 0000000..467023f --- /dev/null +++ b/.changeset/shortcut-config-foundation.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add shortcut preferences to the PI WEB config schema so keyboard shortcuts can be overridden or disabled by action id. diff --git a/.changeset/updates-plugin-rename.md b/.changeset/updates-plugin-rename.md new file mode 100644 index 0000000..1ddfd22 --- /dev/null +++ b/.changeset/updates-plugin-rename.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Rename the built-in Updates plugin id from `pi-web` to `updates` for clearer plugin configuration. diff --git a/.changeset/workspace-tasks-click-feedback.md b/.changeset/workspace-tasks-click-feedback.md new file mode 100644 index 0000000..731b4e2 --- /dev/null +++ b/.changeset/workspace-tasks-click-feedback.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Prevent redundant Workspace Tasks panel re-renders from resetting mobile scroll position or replacing task buttons mid-click, and show feedback for stale, cancelled, or already-starting tasks. diff --git a/.changeset/workspace-tasks-rename.md b/.changeset/workspace-tasks-rename.md new file mode 100644 index 0000000..d195d8b --- /dev/null +++ b/.changeset/workspace-tasks-rename.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Bundle Workspace Tasks with PI WEB as a built-in plugin for running `.pi-web/tasks.json` commands in workspace terminals. diff --git a/README.md b/README.md index 2507927..b47dd45 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed. -The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, and `pi-web-plugins/pi-web` demonstrates a dynamic status panel. +The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals. A useful prompt for AI agents: @@ -124,6 +124,8 @@ Validate with /pi-web-plugins/manifest.json and explain reload/debug steps. Do not modify PI WEB itself. ``` +Manage discovered plugins in **Settings → Plugins** or with the top-level `plugins` config key. Plugins are enabled by default; set `plugins..enabled` to `false` and reload the browser tab to prevent PI WEB from importing that plugin. + Reload the browser tab after adding or editing a plugin. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead of `~/.pi-web/plugins`. Check discovery with: ```bash diff --git a/docs/plugins.html b/docs/plugins.html index 44ce4b6..f618e62 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -4,7 +4,7 @@ PI WEB plugins - + @@ -52,7 +52,7 @@

Plugin development

-

Customize PI WEB with local UI plugins.

+

Customize PI WEB with UI plugins.

Plugins are trusted browser-side ES modules. They can add actions, workspace panels, and compact workspace labels to the PI WEB UI. @@ -67,6 +67,8 @@ What can be extended What to ask AI to build Canonical example + Built-in plugins + Manage plugins Production usage AI-friendly docs Develop and debug @@ -160,11 +162,133 @@ After editing, check the manifest endpoint and browser-console failure cases.info plugin.

- The bundled pi-web status plugin demonstrates dynamic visible and badge + The bundled updates plugin demonstrates dynamic visible and badge callbacks for tabs that only appear when the host has status messages or needs extra install visibility.

+
+

Built-in plugins

+

+ PI WEB ships core, discoverable plugins in the main @jmfederico/pi-web npm package. No + separate pi install step is required: update PI WEB, reload the browser tab, and the bundled + plugins appear in /pi-web-plugins/manifest.json. +

+

+ Built-in plugins can be managed from Settings → Plugins or with the top-level + plugins config key. +

+ +

Updates

+

+ Updates adds a conditional Updates workspace tab with PI WEB update, + restart, and installed-service guidance. It is built into PI WEB and enabled by default. +

+ +
+
+ Disable Updates + +
+
{
+  "plugins": {
+    "updates": { "enabled": false }
+  }
+}
+
+ +

Workspace Tasks

+

+ Workspace Tasks adds a Tasks workspace tab for running configured shell + commands in dedicated PI WEB terminals. It is built into PI WEB and enabled by default. +

+ +
+
+ Disable Workspace Tasks + +
+
{
+  "plugins": {
+    "workspace-tasks": { "enabled": false }
+  }
+}
+
+
+
+ Example .pi-web/tasks.json + +
+
{
+  "version": 1,
+  "tasks": [
+    {
+      "id": "docker.start",
+      "title": "Start Docker",
+      "group": "Docker",
+      "description": "Start the local Docker Compose environment.",
+      "command": "./docker/scripts/docker-compose-dev up -d"
+    },
+    {
+      "id": "db.reset",
+      "title": "Reset DB",
+      "group": "Database",
+      "command": "go -C klingit-go run ./cli db reset",
+      "confirm": true
+    }
+  ]
+}
+
+

+ Open a workspace, choose the Tasks tab, and click Run next to a task. + Commands run in the workspace root because PI WEB creates the terminal for that workspace. +

+

+ Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted + shell commands from your repositories. +

+
+ +
+

Manage plugins

+

+ Open Settings → Plugins to review discovered bundled, local, dev, and Pi package plugins. + PI WEB can disable any discovered plugin before the browser imports it. Core app contributions such as + the command palette, base workspace tools, and themes are not managed through this plugin list. +

+
+
+ Plugin config shape + +
+
{
+  "plugins": {
+    "workspace-tasks": {
+      "enabled": true,
+      "settings": {}
+    },
+    "info": {
+      "enabled": false
+    }
+  }
+}
+
+

+ Plugins are enabled by default. Set enabled to false to remove a plugin from + /pi-web-plugins/manifest.json so it is not imported or activated on the next page load. + The optional settings object is reserved for plugin-specific settings. +

+

+ After changing plugin enablement, reload the PI WEB browser tab. Already-loaded plugin JavaScript is not + unloaded from the current page. +

+
+

Production usage

diff --git a/docs/plugins.md b/docs/plugins.md index a4730c8..97d5fe8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -100,11 +100,11 @@ Module shape excerpt: export default { apiVersion: 1, name: "Info Plugin", - activate: ({ html }) => ({ + activate: ({ html, svg }) => ({ contributions: { actions: [/* action definitions */], workspaceLabels: [/* compact label definitions */], - workspacePanels: [/* panel definitions using html */], + workspacePanels: [/* panel definitions using html, optional icons using svg */], }, }), }; @@ -112,7 +112,7 @@ export default { When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin. -PI WEB also ships a `pi-web` status plugin that demonstrates dynamic `visible` and `badge` callbacks for tabs that only appear when the host has status messages or needs extra install visibility. +PI WEB also ships an `updates` plugin that demonstrates dynamic `visible` and `badge` callbacks for tabs that only appear when the host has status messages or needs extra install visibility. ## Local plugin usage @@ -127,26 +127,105 @@ ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id Reload the PI WEB browser tab. PI WEB serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes. -## First-party separate plugin packages +## Manage plugins -First-party plugins that are published as their own npm packages can live in this repository under `plugins/*` as npm workspaces. These packages are **not bundled** into the main `@jmfederico/pi-web` npm package automatically; they are separate packages that share CI, tests, and local development tooling with the main repo. +Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins. PI WEB can disable any discovered plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list. -A separate plugin package should: +Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file: -- use type-only imports from `@jmfederico/pi-web/plugin-api` when it needs shared PI WEB plugin interfaces; this subpath is currently a `.d.ts`-only dogfooding surface, not a runtime JavaScript module; -- keep its PI WEB metadata in its own `package.json` with `piWeb.plugins` entries pointing at built JavaScript in `dist/`; -- include a package-level `build` script and `prepack` script so `npm pack --workspace ` and `npm publish --workspace ` produce a usable plugin package; -- use a local symlink into `~/.pi-web/plugins/` while developing; -- document any private PI WEB APIs it dogfoods until those APIs become stable plugin runtime helpers. - -Typical local development loop from this repository: - -```bash -npm run dev -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +```json +{ + "plugins": { + "workspace-tasks": { + "enabled": true, + "settings": {} + }, + "info": { + "enabled": false + } + } +} ``` -The main PI WEB `dev` command watches bundled plugins in `pi-web-plugins/`, builds/watches separate plugin packages in `plugins/*`, and discovers those source-checkout plugin packages without symlinking them into `~/.pi-web/plugins`. +Plugins are enabled by default. Set `enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` so the browser will not import or activate it on the next page load. The optional `settings` object is reserved for plugin-specific settings. + +After changing plugin enablement, reload the PI WEB browser tab. Already-loaded plugin JavaScript is not unloaded from the current page. + +## Built-in plugins + +PI WEB ships core, discoverable plugins in the main `@jmfederico/pi-web` npm package. No separate `pi install` step is required: update PI WEB, reload the browser tab, and the bundled plugins appear in `/pi-web-plugins/manifest.json`. + +Built-in plugins can be managed from **Settings → Plugins** or with the top-level `plugins` config key. + +### Updates + +**Plugin id:** `updates` +**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. + +Updates is enabled by default. To hide it, disable `updates` in **Settings → Plugins** or set: + +```json +{ + "plugins": { + "updates": { "enabled": false } + } +} +``` + +### Workspace Tasks + +**Plugin id:** `workspace-tasks` +**Config file:** `.pi-web/tasks.json` +**What it does:** adds a **Tasks** workspace tab for running configured shell commands in dedicated PI WEB terminals. + +Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → Plugins** or set: + +```json +{ + "plugins": { + "workspace-tasks": { "enabled": false } + } +} +``` + +Configure workspace tasks in `.pi-web/tasks.json`: + +```json +{ + "version": 1, + "tasks": [ + { + "id": "docker.start", + "title": "Start Docker", + "group": "Docker", + "description": "Start the local Docker Compose environment.", + "command": "./docker/scripts/docker-compose-dev up -d" + }, + { + "id": "db.reset", + "title": "Reset DB", + "group": "Database", + "command": "go -C klingit-go run ./cli db reset", + "confirm": true + } + ] +} +``` + +Open a workspace, choose the **Tasks** tab, and click **Run** next to a task. Commands run in the workspace root because PI WEB creates the terminal for that workspace. + +Task fields: + +- `version`: must be `1`. +- `tasks`: array of task definitions. +- `id`: stable task id, matching `^[a-z][a-z0-9.-]*$`. +- `title`: button label. +- `command`: literal shell command sent to the terminal. +- `description`: optional explanatory text. +- `group`: optional group heading. +- `confirm`: optional boolean. When true, the browser asks before dispatching the command. + +Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted shell commands from your repositories. ## Discovery and packaging @@ -239,6 +318,7 @@ interface PluginActivationContext { apiVersion: 1; pluginId: string; html: typeof import("lit").html; + svg: typeof import("lit").svg; } interface PluginActivationResult { @@ -369,6 +449,13 @@ workspacePanels: [ { id: "workspace.info", title: "Info", + icon: svg` + + + + + + `, order: 100, visible: ({ workspace }) => workspace.isGitRepo, render: ({ workspace }) => html` @@ -388,6 +475,7 @@ Panel type: interface WorkspacePanelContribution { id: string; title: string; + icon?: TemplateResult; order?: number; visible?: (context: { workspace: Workspace }) => boolean; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; @@ -400,6 +488,8 @@ interface WorkspacePanelContext { } ``` +`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide. + `workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated. Useful workspace shape: diff --git a/package-lock.json b/package-lock.json index d24c484..d9b2bf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,6 @@ "name": "@jmfederico/pi-web", "version": "1.202606.0", "license": "MIT", - "workspaces": [ - ".", - "plugins/*" - ], "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", @@ -2326,14 +2322,6 @@ } } }, - "node_modules/@jmfederico/pi-web": { - "resolved": "", - "link": true - }, - "node_modules/@jmfederico/pi-web-actions": { - "resolved": "plugins/actions", - "link": true - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -8678,23 +8666,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "plugins/actions": { - "name": "@jmfederico/pi-web-actions", - "version": "0.1.2", - "license": "MIT", - "devDependencies": { - "typescript": "^5.9.3", - "vitest": "^4.1.5" - }, - "peerDependencies": { - "@jmfederico/pi-web": ">=1.202605.14" - }, - "peerDependenciesMeta": { - "@jmfederico/pi-web": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index e81618c..0327881 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,6 @@ "license": "MIT", "author": "Federico Jaramillo Martinez", "type": "module", - "workspaces": [ - ".", - "plugins/*" - ], "bin": { "pi-web": "dist/cli.js", "pi-web-server": "dist/server/index.js", @@ -27,21 +23,19 @@ "scripts": { "dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", "dev:sessiond": "tsx watch src/server/sessiond.ts", - "dev:web": "bash -c 'set -e; npm run build:plugins; npm run build:plugin-packages; trap \"kill 0\" EXIT; npm run dev:plugins & npm run dev:plugin-packages & tsx watch src/server/index.ts & wait'", + "dev:web": "bash -c 'set -e; npm run build:plugins; trap \"kill 0\" EXIT; npm run dev:plugins & tsx watch src/server/index.ts & wait'", "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", "dev:plugins": "node scripts/build-plugins.mjs --watch", - "dev:plugin-packages": "node scripts/dev-plugin-packages.mjs", - "build": "tsc -p tsconfig.build.json && npm run build:plugins && npm run build:plugin-packages && vite build", + "build": "tsc -p tsconfig.build.json && npm run build:plugins && vite build", "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", - "build:plugin-packages": "bash -c 'set -e; shopt -s nullglob; for package in plugins/*/package.json; do dir=${package%/package.json}; (cd \"$dir\" && npm run build --if-present); done'", "typecheck": "tsc --noEmit", - "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" \"plugins/**/*.ts\" vite.config.ts vitest.config.ts", + "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm run lint && npm test", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts", - "clean": "rm -rf dist plugins/*/dist", + "clean": "rm -rf dist", "prepack": "npm run build", "pack:dry": "npm pack --dry-run", "prepublishOnly": "npm run verify", diff --git a/pi-web-plugins/info/pi-web-plugin.ts b/pi-web-plugins/info/pi-web-plugin.ts index 52dc709..a04be40 100644 --- a/pi-web-plugins/info/pi-web-plugin.ts +++ b/pi-web-plugins/info/pi-web-plugin.ts @@ -3,7 +3,7 @@ import type { PiWebPlugin } from "../../src/client/src/plugins/types"; const plugin: PiWebPlugin = { apiVersion: 1, name: "Info Plugin", - activate: ({ html }) => ({ + activate: ({ html, svg }) => ({ contributions: { actions: [ { @@ -28,6 +28,13 @@ const plugin: PiWebPlugin = { { id: "workspace.info", title: "Info", + icon: svg` + + + + + + `, order: 1000, render: (context) => html`

Info
diff --git a/pi-web-plugins/pi-web/package.json b/pi-web-plugins/pi-web/package.json deleted file mode 100644 index 136c5cf..0000000 --- a/pi-web-plugins/pi-web/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@pi-web/status-plugin", - "private": true, - "piWeb": { - "plugins": [ - { "id": "pi-web", "module": "pi-web-plugin.js" } - ] - } -} diff --git a/pi-web-plugins/updates/package.json b/pi-web-plugins/updates/package.json new file mode 100644 index 0000000..e53b362 --- /dev/null +++ b/pi-web-plugins/updates/package.json @@ -0,0 +1,9 @@ +{ + "name": "@pi-web/updates-plugin", + "private": true, + "piWeb": { + "plugins": [ + { "id": "updates", "module": "pi-web-plugin.js" } + ] + } +} diff --git a/pi-web-plugins/pi-web/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts similarity index 66% rename from pi-web-plugins/pi-web/pi-web-plugin.ts rename to pi-web-plugins/updates/pi-web-plugin.ts index bf78596..fda5c17 100644 --- a/pi-web-plugins/pi-web/pi-web-plugin.ts +++ b/pi-web-plugins/updates/pi-web-plugin.ts @@ -19,7 +19,7 @@ function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | unde return installation === undefined || installation.kind === "local" || installation.kind === "unknown"; } -function shouldShowStatusPanel(state: AppState): boolean { +function shouldShowUpdatesPanel(state: AppState): boolean { const status = statusFor(state); if (messageCount(state) > 0) return true; if (status === undefined) return false; @@ -50,7 +50,7 @@ function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus) ? "restart needed" : "current"; return html` -
+
${component.label} ${status} running ${formatVersion(component.runtimeVersion)} · installed ${formatVersion(component.installedVersion)} @@ -61,7 +61,7 @@ function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus) function renderCommand(html: HtmlTemplateTag, label: string, command: string): TemplateResult { return html` -
+
${label} ${command} @@ -87,7 +87,7 @@ function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): Tem `; } -function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult { +function renderUpdatesPanel(html: HtmlTemplateTag, state: AppState): TemplateResult { const status = statusFor(state); if (status === undefined) { return html` @@ -99,29 +99,29 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu const messages = status.messages; return html`
Updatesbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
-
+
${messages.length === 0 ? html`

No PI WEB update or restart messages.

` : messages.map((message) => html` -
-
${message.title}${message.severity}
+
+
${message.title}${message.severity}

${message.body}

${message.command === undefined ? null : html`${message.command}`}
@@ -136,7 +136,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu ${renderCommands(html, status)} -
+
Generated ${status.generatedAt} ${status.release.latestVersion === undefined ? null : html`Latest npm release ${status.release.latestVersion}`} ${status.release.skipped === true ? html`Remote version check skipped.` : null} @@ -148,20 +148,28 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu const plugin: PiWebPlugin = { apiVersion: 1, - name: "PI WEB Updates", - activate: ({ html }) => ({ + name: "Updates", + activate: ({ html, svg }) => ({ contributions: { workspacePanels: [ { - id: "workspace.status", + id: "workspace.updates", title: "Updates", + icon: svg` + + + + + + + `, order: 100, - visible: (context) => shouldShowStatusPanel(context.state), + visible: (context) => shouldShowUpdatesPanel(context.state), badge: (context) => { const count = messageCount(context.state); return html`beta${count > 0 ? html` · ${String(count)}` : null}`; }, - render: (context) => renderStatusPanel(html, context.state), + render: (context) => renderUpdatesPanel(html, context.state), }, ], }, diff --git a/plugins/actions/src/config.test.ts b/pi-web-plugins/workspace-tasks/config.test.ts similarity index 54% rename from plugins/actions/src/config.test.ts rename to pi-web-plugins/workspace-tasks/config.test.ts index aa8a19b..9c3f95d 100644 --- a/plugins/actions/src/config.test.ts +++ b/pi-web-plugins/workspace-tasks/config.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; -import { parseActionsConfigText } from "./config"; +import { parseTasksConfigText } from "./config"; -describe("workspace actions config", () => { +describe("workspace tasks config", () => { it("parses a minimal version 1 config", () => { - expect(parseActionsConfigText(JSON.stringify({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, - actions: [ + tasks: [ { id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset" }, ], }))).toEqual({ ok: true, config: { version: 1, - actions: [ + tasks: [ { id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset", confirm: false }, ], }, @@ -20,9 +20,9 @@ describe("workspace actions config", () => { }); it("parses optional group, description, and confirm fields", () => { - expect(parseActionsConfigText(JSON.stringify({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, - actions: [ + tasks: [ { id: "docker.start", title: "Start Docker", @@ -36,7 +36,7 @@ describe("workspace actions config", () => { ok: true, config: { version: 1, - actions: [ + tasks: [ { id: "docker.start", title: "Start Docker", @@ -50,50 +50,50 @@ describe("workspace actions config", () => { }); }); - it("accepts an empty actions array", () => { - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [] }))).toEqual({ + it("accepts an empty tasks array", () => { + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [] }))).toEqual({ ok: true, - config: { version: 1, actions: [] }, + config: { version: 1, tasks: [] }, }); }); it("rejects invalid JSON and unsupported versions", () => { - expect(parseActionsConfigText("{")).toMatchObject({ ok: false }); - expect(parseActionsConfigText(JSON.stringify({ version: 2, actions: [] }))).toEqual({ + expect(parseTasksConfigText("{")).toMatchObject({ ok: false }); + expect(parseTasksConfigText(JSON.stringify({ version: 2, tasks: [] }))).toEqual({ ok: false, error: "Config version must be 1", }); }); it("rejects missing, empty, or duplicate required fields", () => { - expect(parseActionsConfigText(JSON.stringify({ version: 1 }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1 }))).toEqual({ ok: false, - error: "Config actions must be an array", + error: "Config tasks must be an array", }); - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({ ok: false, - error: "Action 1 id must be a non-empty string", + error: "Task 1 id must be a non-empty string", }); - expect(parseActionsConfigText(JSON.stringify({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, - actions: [ + tasks: [ { id: "one", title: "One", command: "cmd" }, { id: "one", title: "Again", command: "cmd" }, ], }))).toEqual({ ok: false, - error: "Duplicate action id: one", + error: "Duplicate task id: one", }); }); it("rejects invalid optional field types", () => { - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({ ok: false, - error: "Action 1 confirm must be a boolean", + error: "Task 1 confirm must be a boolean", }); - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({ ok: false, - error: "Action 1 group must be a non-empty string when provided", + error: "Task 1 group must be a non-empty string when provided", }); }); }); diff --git a/plugins/actions/src/config.ts b/pi-web-plugins/workspace-tasks/config.ts similarity index 62% rename from plugins/actions/src/config.ts rename to pi-web-plugins/workspace-tasks/config.ts index 8dc57fd..e109e16 100644 --- a/plugins/actions/src/config.ts +++ b/pi-web-plugins/workspace-tasks/config.ts @@ -1,14 +1,14 @@ -export const ACTIONS_CONFIG_PATH = ".pi-web/actions.json"; -export const ACTIONS_CONFIG_VERSION = 1; +export const TASKS_CONFIG_PATH = ".pi-web/tasks.json"; +export const TASKS_CONFIG_VERSION = 1; -const actionIdPattern = /^[a-z][a-z0-9.-]*$/u; +const taskIdPattern = /^[a-z][a-z0-9.-]*$/u; -export interface WorkspaceActionsConfig { - version: typeof ACTIONS_CONFIG_VERSION; - actions: WorkspaceAction[]; +export interface WorkspaceTasksConfig { + version: typeof TASKS_CONFIG_VERSION; + tasks: WorkspaceTask[]; } -export interface WorkspaceAction { +export interface WorkspaceTask { id: string; title: string; command: string; @@ -17,51 +17,51 @@ export interface WorkspaceAction { confirm: boolean; } -export type ParseActionsConfigResult = - | { ok: true; config: WorkspaceActionsConfig } +export type ParseTasksConfigResult = + | { ok: true; config: WorkspaceTasksConfig } | { ok: false; error: string }; -export function parseActionsConfigText(text: string): ParseActionsConfigResult { +export function parseTasksConfigText(text: string): ParseTasksConfigResult { let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { return { ok: false, error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; } - return parseActionsConfig(parsed); + return parseTasksConfig(parsed); } -export function parseActionsConfig(value: unknown): ParseActionsConfigResult { +export function parseTasksConfig(value: unknown): ParseTasksConfigResult { if (!isRecord(value)) return invalid("Config must be an object"); - if (value["version"] !== ACTIONS_CONFIG_VERSION) return invalid("Config version must be 1"); + if (value["version"] !== TASKS_CONFIG_VERSION) return invalid("Config version must be 1"); - const actions = value["actions"]; - if (!Array.isArray(actions)) return invalid("Config actions must be an array"); + const tasks = value["tasks"]; + if (!Array.isArray(tasks)) return invalid("Config tasks must be an array"); const ids = new Set(); - const parsedActions: WorkspaceAction[] = []; - for (const [index, action] of actions.entries()) { - const parsedAction = parseAction(action, index); - if (!parsedAction.ok) return parsedAction; - if (ids.has(parsedAction.action.id)) return invalid(`Duplicate action id: ${parsedAction.action.id}`); - ids.add(parsedAction.action.id); - parsedActions.push(parsedAction.action); + const parsedTasks: WorkspaceTask[] = []; + for (const [index, task] of tasks.entries()) { + const parsedTask = parseTask(task, index); + if (!parsedTask.ok) return parsedTask; + if (ids.has(parsedTask.task.id)) return invalid(`Duplicate task id: ${parsedTask.task.id}`); + ids.add(parsedTask.task.id); + parsedTasks.push(parsedTask.task); } - return { ok: true, config: { version: ACTIONS_CONFIG_VERSION, actions: parsedActions } }; + return { ok: true, config: { version: TASKS_CONFIG_VERSION, tasks: parsedTasks } }; } -type ParseActionResult = - | { ok: true; action: WorkspaceAction } +type ParseTaskResult = + | { ok: true; task: WorkspaceTask } | { ok: false; error: string }; -function parseAction(value: unknown, index: number): ParseActionResult { - const label = `Action ${String(index + 1)}`; +function parseTask(value: unknown, index: number): ParseTaskResult { + const label = `Task ${String(index + 1)}`; if (!isRecord(value)) return invalid(`${label} must be an object`); const id = requireNonEmptyString(value, "id", label); if (!id.ok) return id; - if (!actionIdPattern.test(id.value)) return invalid(`${label} id must match ${actionIdPattern.source}`); + if (!taskIdPattern.test(id.value)) return invalid(`${label} id must match ${taskIdPattern.source}`); const title = requireNonEmptyString(value, "title", label); if (!title.ok) return title; @@ -80,7 +80,7 @@ function parseAction(value: unknown, index: number): ParseActionResult { return { ok: true, - action: { + task: { id: id.value, title: title.value, command: command.value, diff --git a/pi-web-plugins/workspace-tasks/package.json b/pi-web-plugins/workspace-tasks/package.json new file mode 100644 index 0000000..075a9d3 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/package.json @@ -0,0 +1,9 @@ +{ + "name": "@pi-web/workspace-tasks-plugin", + "private": true, + "piWeb": { + "plugins": [ + { "id": "workspace-tasks", "module": "pi-web-plugin.js" } + ] + } +} diff --git a/pi-web-plugins/workspace-tasks/pi-web-plugin.ts b/pi-web-plugins/workspace-tasks/pi-web-plugin.ts new file mode 100644 index 0000000..6f5a031 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/pi-web-plugin.ts @@ -0,0 +1,51 @@ +import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api"; +import { TASKS_CONFIG_PATH } from "./config.js"; +import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js"; +import { terminalCommandRunsFromContext } from "./piWebInternal.js"; + +const plugin: PiWebPlugin = { + apiVersion: 1, + name: "Workspace Tasks", + activate: ({ pluginId, html, svg }) => { + defineTasksPanelElement(); + + return { + contributions: { + actions: [ + { + id: "workspace.open-tasks", + title: "Open Workspace Tasks", + description: `Open the workspace Tasks tab. Configure tasks in ${TASKS_CONFIG_PATH}.`, + group: "Workspace", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + if (context.state.selectedWorkspace === undefined) return; + context.selectWorkspaceTool(`${pluginId}:workspace.tasks`); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.tasks", + title: "Tasks", + icon: svg` + + + + + + + + + `, + order: 40, + badge: ({ workspace }) => tasksPanelBadge(workspace), + render: (context) => html``, + }, + ], + }, + }; + }, +}; + +export default plugin; diff --git a/plugins/actions/src/piWebInternal.ts b/pi-web-plugins/workspace-tasks/piWebInternal.ts similarity index 100% rename from plugins/actions/src/piWebInternal.ts rename to pi-web-plugins/workspace-tasks/piWebInternal.ts diff --git a/plugins/actions/src/piWebPrivateUi.ts b/pi-web-plugins/workspace-tasks/piWebPrivateUi.ts similarity index 100% rename from plugins/actions/src/piWebPrivateUi.ts rename to pi-web-plugins/workspace-tasks/piWebPrivateUi.ts diff --git a/plugins/actions/src/actionRunner.test.ts b/pi-web-plugins/workspace-tasks/taskRunner.test.ts similarity index 66% rename from plugins/actions/src/actionRunner.test.ts rename to pi-web-plugins/workspace-tasks/taskRunner.test.ts index 62658af..74fd0b8 100644 --- a/plugins/actions/src/actionRunner.test.ts +++ b/pi-web-plugins/workspace-tasks/taskRunner.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { runWorkspaceActionInTerminal } from "./actionRunner"; -import type { WorkspaceAction } from "./config"; +import { runWorkspaceTaskInTerminal } from "./taskRunner"; +import type { WorkspaceTask } from "./config"; import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal"; const workspace: Workspace = { @@ -16,7 +16,7 @@ const workspace: Workspace = { const run: InternalTerminalCommandRun = { id: "run1", - origin: "actions", + origin: "workspace-tasks", projectId: workspace.projectId, workspaceId: workspace.id, terminalId: "term1", @@ -24,19 +24,19 @@ const run: InternalTerminalCommandRun = { command: "npm run build", status: "running", createdAt: "2026-05-25T00:00:00.000Z", - metadata: { "pi.plugin": "actions", "action.id": "build" }, + metadata: { "pi.plugin": "workspace-tasks", "task.id": "build" }, }; -describe("action runner", () => { - it("starts workspace actions through the internal terminal command-run helper", async () => { - const action: WorkspaceAction = { id: "build", title: "Build", command: "npm run build", confirm: false }; +describe("task runner", () => { + it("starts workspace tasks through the internal terminal command-run helper", async () => { + const task: WorkspaceTask = { id: "build", title: "Build", command: "npm run build", confirm: false }; const runCommand = vi.fn(() => Promise.resolve({ run, completed: Promise.resolve(run) })); const terminal: InternalTerminalCommandRunsRuntime = { runCommand, open: vi.fn(), }; - const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); + const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task); expect(handle.run).toEqual(run); await expect(handle.completed).resolves.toEqual(run); @@ -45,7 +45,7 @@ describe("action runner", () => { title: "Build", command: "npm run build", open: true, - metadata: { "pi.plugin": "actions", "action.id": "build" }, + metadata: { "pi.plugin": "workspace-tasks", "task.id": "build" }, }); }); }); diff --git a/pi-web-plugins/workspace-tasks/taskRunner.ts b/pi-web-plugins/workspace-tasks/taskRunner.ts new file mode 100644 index 0000000..32f7a17 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/taskRunner.ts @@ -0,0 +1,16 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import type { WorkspaceTask } from "./config.js"; +import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; + +export function runWorkspaceTaskInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, task: WorkspaceTask): ReturnType { + return terminal.runCommand({ + workspace, + title: task.title, + command: task.command, + open: true, + metadata: { + "pi.plugin": "workspace-tasks", + "task.id": task.id, + }, + }); +} diff --git a/plugins/actions/src/actionsPanelElement.ts b/pi-web-plugins/workspace-tasks/tasksPanelElement.ts similarity index 61% rename from plugins/actions/src/actionsPanelElement.ts rename to pi-web-plugins/workspace-tasks/tasksPanelElement.ts index 2d8fbf9..d698328 100644 --- a/plugins/actions/src/actionsPanelElement.ts +++ b/pi-web-plugins/workspace-tasks/tasksPanelElement.ts @@ -1,21 +1,21 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js"; -import { runWorkspaceActionInTerminal } from "./actionRunner.js"; +import { TASKS_CONFIG_PATH, type WorkspaceTask } from "./config.js"; +import { runWorkspaceTaskInTerminal } from "./taskRunner.js"; import { requestPiWebRender } from "./piWebPrivateUi.js"; import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; -import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; +import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js"; -export const actionsPanelTagName = "pi-web-actions-panel"; +export const tasksPanelTagName = "pi-web-workspace-tasks-panel"; export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void; -const configChangedEvent = "pi-web-actions-config-changed"; +const configChangedEvent = "pi-web-workspace-tasks-config-changed"; type ConfigState = | { kind: "loading" } - | WorkspaceActionsConfigLoadResult; + | WorkspaceTasksConfigLoadResult; -interface ActionStatus { +interface TaskStatus { kind: "info" | "success" | "error"; message: string; detail?: string; @@ -23,23 +23,23 @@ interface ActionStatus { const configCache = new Map(); -export function defineActionsPanelElement(): void { - if (!customElements.get(actionsPanelTagName)) customElements.define(actionsPanelTagName, PiWebActionsPanel); +export function defineTasksPanelElement(): void { + if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel); } -export function actionsPanelBadge(workspace: Workspace): string | number | undefined { +export function tasksPanelBadge(workspace: Workspace): string | number | undefined { const state = getCachedWorkspaceConfig(workspace); if (state?.kind === "unavailable") return "!"; - if (state?.kind === "loaded" && state.config.actions.length > 0) return state.config.actions.length; + if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length; return undefined; } -class PiWebActionsPanel extends HTMLElement { +class PiWebTasksPanel extends HTMLElement { private workspaceValue: Workspace | undefined; private openTerminalValue: OpenTerminal | undefined; private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined; - private runningActionId: string | undefined; - private status: ActionStatus | undefined; + private runningTaskId: string | undefined; + private status: TaskStatus | undefined; private readonly root: ShadowRoot; private readonly onConfigChanged = () => { this.render(); @@ -57,7 +57,7 @@ class PiWebActionsPanel extends HTMLElement { // Parent app updates should not rebuild this shadow DOM for the same workspace: // doing so resets the mobile scroll position and can replace buttons mid-click. if (previousKey === nextKey) return; - this.runningActionId = undefined; + this.runningTaskId = undefined; this.status = undefined; this.render(); } @@ -82,22 +82,22 @@ class PiWebActionsPanel extends HTMLElement { private render(): void { const workspace = this.workspaceValue; if (workspace === undefined) { - this.root.innerHTML = `${actionStyles()}
Select a workspace.
`; + this.root.innerHTML = `${taskStyles()}
Select a workspace.
`; return; } const state = getOrLoadWorkspaceConfig(workspace); this.root.innerHTML = ` - ${actionStyles()} + ${taskStyles()}
- Workspace Actions - + Workspace Tasks +
${this.renderStatus()} -
+
${this.renderConfigState(state)}
`; @@ -106,9 +106,9 @@ class PiWebActionsPanel extends HTMLElement { void this.refreshConfig(workspace); }); - for (const button of this.root.querySelectorAll("button[data-action-id]")) { + for (const button of this.root.querySelectorAll("button[data-task-id]")) { button.addEventListener("click", () => { - void this.dispatchActionById(workspace, button.getAttribute("data-action-id")); + void this.dispatchTaskById(workspace, button.getAttribute("data-task-id")); }); } @@ -117,15 +117,15 @@ class PiWebActionsPanel extends HTMLElement { }); } - private dispatchActionById(workspace: Workspace, actionId: string | null): Promise { + private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise { if (!this.isCurrentWorkspace(workspace)) return Promise.resolve(); - const action = actionFromConfigState(getCachedWorkspaceConfig(workspace), actionId); - if (action === undefined) { - this.status = { kind: "error", message: "That action is no longer available. Click Refresh, then try again." }; + const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId); + if (task === undefined) { + this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." }; this.render(); return Promise.resolve(); } - return this.dispatchAction(workspace, action); + return this.dispatchTask(workspace, task); } private isCurrentWorkspace(workspace: Workspace): boolean { @@ -133,13 +133,14 @@ class PiWebActionsPanel extends HTMLElement { } private renderConfigState(state: ConfigState): string { - if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

`; + if (state.kind === "loading") return `

Loading ${escapeHtml(TASKS_CONFIG_PATH)}…

`; if (state.kind === "missing") return renderMissingState(state); if (state.kind === "unavailable") return renderUnavailableState(state); - if (state.config.actions.length === 0) return `

No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.

`; + + if (state.config.tasks.length === 0) return `

No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.

`; return ` -

Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

- ${renderActionGroups(state.config.actions, this.runningActionId)} +

Tasks run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(state.path)} and click Refresh to reload.

+ ${renderTaskGroups(state.config.tasks, this.runningTaskId)} `; } @@ -150,26 +151,26 @@ class PiWebActionsPanel extends HTMLElement { } private async refreshConfig(workspace: Workspace): Promise { - this.status = { kind: "info", message: `Refreshing ${ACTIONS_CONFIG_PATH}…` }; + this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` }; configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" }); this.render(); const state = await refreshWorkspaceConfig(workspace); if (!this.isCurrentWorkspace(workspace)) return; this.status = state.kind === "loaded" - ? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` } + ? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` } : undefined; this.render(); } - private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise { - if (this.runningActionId !== undefined) { - this.status = { kind: "info", message: "Another action is already starting. Wait for it to finish dispatching, then try again." }; + private async dispatchTask(workspace: Workspace, task: WorkspaceTask): Promise { + if (this.runningTaskId !== undefined) { + this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." }; this.render(); return; } - if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) { - this.status = { kind: "info", message: `Cancelled ${action.title}.` }; + if (task.confirm && !window.confirm(`Run ${task.title}?\n\n${task.command}`)) { + this.status = { kind: "info", message: `Cancelled ${task.title}.` }; this.render(); return; } @@ -181,23 +182,23 @@ class PiWebActionsPanel extends HTMLElement { return; } - this.runningActionId = action.id; - this.status = { kind: "info", message: `Starting ${action.title}…` }; + this.runningTaskId = task.id; + this.status = { kind: "info", message: `Starting ${task.title}…` }; this.render(); try { - const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); + const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task); if (!this.isCurrentWorkspace(workspace)) return; this.status = { kind: "success", message: `Started terminal command “${handle.run.title}”.`, - detail: action.command, + detail: task.command, }; - this.runningActionId = undefined; + this.runningTaskId = undefined; this.render(); } catch (error) { if (!this.isCurrentWorkspace(workspace)) return; - this.runningActionId = undefined; + this.runningTaskId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); } @@ -234,10 +235,10 @@ function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState { async function refreshWorkspaceConfig(workspace: Workspace): Promise { const key = cacheKeyForWorkspace(workspace); - const state = await loadWorkspaceActionsConfig(workspace).catch((error: unknown): ConfigState => ({ + const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({ kind: "unavailable", - message: actionsConfigUnavailableMessage, - hint: actionsConfigRefreshHint, + message: tasksConfigUnavailableMessage, + hint: tasksConfigRefreshHint, detail: error instanceof Error ? error.message : String(error), })); configCache.set(key, state); @@ -259,64 +260,64 @@ function renderUnavailableState(state: Extract${escapeHtml(state.message)}

${escapeHtml(state.hint)}

${detail}
`; } -function renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string { - return `
${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}
`; +function renderTaskGroups(tasks: WorkspaceTask[], runningTaskId: string | undefined): string { + return `
${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}
`; } -function groupActions(actions: WorkspaceAction[]): { title: string | undefined; actions: WorkspaceAction[] }[] { - const groups: { title: string | undefined; actions: WorkspaceAction[] }[] = []; - for (const action of actions) { - const title = action.group; +function groupTasks(tasks: WorkspaceTask[]): { title: string | undefined; tasks: WorkspaceTask[] }[] { + const groups: { title: string | undefined; tasks: WorkspaceTask[] }[] = []; + for (const task of tasks) { + const title = task.group; let group = groups.find((candidate) => candidate.title === title); if (group === undefined) { - group = { title, actions: [] }; + group = { title, tasks: [] }; groups.push(group); } - group.actions.push(action); + group.tasks.push(task); } return groups; } -function renderActionGroup(group: { title: string | undefined; actions: WorkspaceAction[] }, runningActionId: string | undefined): string { +function renderTaskGroup(group: { title: string | undefined; tasks: WorkspaceTask[] }, runningTaskId: string | undefined): string { const title = group.title === undefined ? "" : `

${escapeHtml(group.title)}

`; - return `
${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}
`; + return `
${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}
`; } -function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string { - const running = runningActionId === action.id; - const disabled = runningActionId !== undefined; - const description = action.description === undefined ? "" : `${escapeHtml(action.description)}`; +function renderTask(task: WorkspaceTask, runningTaskId: string | undefined): string { + const running = runningTaskId === task.id; + const disabled = runningTaskId !== undefined; + const description = task.description === undefined ? "" : `${escapeHtml(task.description)}`; return ` -
-
- ${escapeHtml(action.title)} +
+
+ ${escapeHtml(task.title)} ${description} - ${escapeHtml(action.command)} + ${escapeHtml(task.command)}
- +
`; } -function actionFromConfigState(state: ConfigState | undefined, actionId: string | null): WorkspaceAction | undefined { - if (state?.kind !== "loaded" || actionId === null) return undefined; - return state.config.actions.find((action) => action.id === actionId); +function taskFromConfigState(state: ConfigState | undefined, taskId: string | null): WorkspaceTask | undefined { + if (state?.kind !== "loaded" || taskId === null) return undefined; + return state.config.tasks.find((task) => task.id === taskId); } -function actionStyles(): string { +function taskStyles(): string { return ` `; diff --git a/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts b/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts new file mode 100644 index 0000000..e9cd758 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { TASKS_CONFIG_PATH } from "./config"; +import { loadWorkspaceTasksConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceTasksClient"; + +const workspace: Workspace = { + id: "workspace 1", + projectId: "project/1", + path: "/repo", + label: "repo", + isMain: false, + isGitRepo: true, + isGitWorktree: true, +}; + +describe("workspace tasks client", () => { + it("builds the private workspace file URL", () => { + expect(workspaceFileUrl(workspace, TASKS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Ftasks.json"); + }); + + it("loads and parses a valid tasks config", async () => { + const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }), + truncated: false, + binary: false, + })); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + kind: "loaded", + path: TASKS_CONFIG_PATH, + config: { + version: 1, + tasks: [{ id: "build", title: "Build", command: "npm run build", confirm: false }], + }, + }); + }); + + it("treats a missing optional tasks config as unconfigured", async () => { + const fetcher: FetchLike = () => Promise.resolve(missingResponse()); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + kind: "missing", + message: "No workspace tasks configured here.", + hint: `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`, + }); + }); + + it("returns a visible unavailable state instead of throwing on request failures", async () => { + const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + kind: "unavailable", + message: "Could not load workspace tasks.", + hint: `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`, + detail: `Unable to read ${TASKS_CONFIG_PATH}: HTTP 400: nope`, + }); + }); + + it("returns parser details for invalid config files", async () => { + const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + content: JSON.stringify({ version: 2, tasks: [] }), + truncated: false, + binary: false, + })); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + kind: "unavailable", + detail: "Config version must be 1", + }); + }); + + it("validates workspace file responses", () => { + expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false }); + expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined(); + }); +}); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); +} + +function missingResponse(): Response { + return new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 }); +} diff --git a/plugins/actions/src/workspaceActionsClient.ts b/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts similarity index 59% rename from plugins/actions/src/workspaceActionsClient.ts rename to pi-web-plugins/workspace-tasks/workspaceTasksClient.ts index 3075433..835ba73 100644 --- a/plugins/actions/src/workspaceActionsClient.ts +++ b/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts @@ -1,17 +1,17 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH, parseActionsConfigText, type WorkspaceActionsConfig } from "./config.js"; +import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js"; -export const actionsConfigMissingMessage = "No workspace actions configured here."; -export const actionsConfigMissingHint = `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`; -export const actionsConfigUnavailableMessage = "Could not load workspace actions."; -export const actionsConfigRefreshHint = `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`; +export const tasksConfigMissingMessage = "No workspace tasks configured here."; +export const tasksConfigMissingHint = `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`; +export const tasksConfigUnavailableMessage = "Could not load workspace tasks."; +export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`; const missingWorkspaceFileError = "Path does not exist"; export type FetchLike = (input: string, init?: RequestInit) => Promise; -export type WorkspaceActionsConfigLoadResult = - | { kind: "loaded"; config: WorkspaceActionsConfig } +export type WorkspaceTasksConfigLoadResult = + | { kind: "loaded"; config: WorkspaceTasksConfig; path: string } | { kind: "missing"; message: string; hint: string } | { kind: "unavailable"; message: string; hint: string; detail?: string }; @@ -21,39 +21,39 @@ interface WorkspaceFileResponse { binary: boolean; } -export async function loadWorkspaceActionsConfig( +export async function loadWorkspaceTasksConfig( workspace: Workspace, deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) }, -): Promise { +): Promise { let response: Response; try { - response = await deps.fetch(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH), { cache: "no-store" }); + response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" }); } catch (error) { - return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); + return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`); } if (!response.ok) { const errorMessage = await readResponseErrorMessage(response); if (errorMessage === missingWorkspaceFileError) return missing(); const responseSummary = errorMessage === undefined ? `HTTP ${String(response.status)}` : `HTTP ${String(response.status)}: ${errorMessage}`; - return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${responseSummary}`); + return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${responseSummary}`); } let body: unknown; try { body = await response.json(); } catch (error) { - return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); + return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`); } const file = parseWorkspaceFileResponse(body); - if (file === undefined) return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}`); - if (file.binary) return unavailable(`${ACTIONS_CONFIG_PATH} must be a text file`); - if (file.truncated) return unavailable(`${ACTIONS_CONFIG_PATH} is too large and was truncated`); + if (file === undefined) return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}`); + if (file.binary) return unavailable(`${TASKS_CONFIG_PATH} must be a text file`); + if (file.truncated) return unavailable(`${TASKS_CONFIG_PATH} is too large and was truncated`); - const result = parseActionsConfigText(file.content); + const result = parseTasksConfigText(file.content); if (!result.ok) return unavailable(result.error); - return { kind: "loaded", config: result.config }; + return { kind: "loaded", config: result.config, path: TASKS_CONFIG_PATH }; } export function workspaceFileUrl(workspace: Workspace, path: string): string { @@ -69,19 +69,19 @@ export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileRespons return { content, truncated, binary }; } -function missing(): WorkspaceActionsConfigLoadResult { +function missing(): WorkspaceTasksConfigLoadResult { return { kind: "missing", - message: actionsConfigMissingMessage, - hint: actionsConfigMissingHint, + message: tasksConfigMissingMessage, + hint: tasksConfigMissingHint, }; } -function unavailable(detail: string): WorkspaceActionsConfigLoadResult { +function unavailable(detail: string): WorkspaceTasksConfigLoadResult { return { kind: "unavailable", - message: actionsConfigUnavailableMessage, - hint: actionsConfigRefreshHint, + message: tasksConfigUnavailableMessage, + hint: tasksConfigRefreshHint, detail, }; } diff --git a/plugin-api.d.ts b/plugin-api.d.ts index 96cca2d..ff2b9a8 100644 --- a/plugin-api.d.ts +++ b/plugin-api.d.ts @@ -4,6 +4,7 @@ export type PluginId = string; export type LocalContributionId = string; export type QualifiedContributionId = string; export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; +export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; export interface PiWebPlugin { apiVersion: 1; @@ -15,6 +16,7 @@ export interface PluginActivationContext { apiVersion: 1; pluginId: PluginId; html: HtmlTemplateTag; + svg: SvgTemplateTag; } export interface PluginActivationResult { @@ -84,9 +86,12 @@ export interface WorkspacePanelContext { openTerminal: (options?: { terminalId?: string | undefined }) => void; } +export type WorkspacePanelIcon = TemplateResult; + export interface WorkspacePanelContribution { id: LocalContributionId; title: string; + icon?: WorkspacePanelIcon; order?: number; visible?: (context: WorkspacePanelContext) => boolean; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; diff --git a/plugins/actions/CHANGELOG.md b/plugins/actions/CHANGELOG.md deleted file mode 100644 index e9a915d..0000000 --- a/plugins/actions/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# @jmfederico/pi-web-actions - -## 0.1.2 - -### Patch Changes - -- 711c4f3: Run workspace deletion and configurable workspace actions in visible PI WEB terminals with reload-safe command-run tracking, mobile-friendly cancellation, and shell continuation after command completion. -- Updated dependencies [57a6a4a] -- Updated dependencies [34e657d] -- Updated dependencies [8247281] -- Updated dependencies [4bfd4ac] -- Updated dependencies [679008d] -- Updated dependencies [56fa641] -- Updated dependencies [711c4f3] - - @jmfederico/pi-web@1.202605.13 - -## 0.1.1 - -### Patch Changes - -- 698a899: Load and watch first-party workspace plugin packages from the single Pi Web development command without requiring local symlinks. -- fb7903f: Document and harden separate Pi Web plugin package development, including the Actions plugin refresh flow and public terminal navigation helper. -- 73fe658: Treat missing workspace actions configuration as an empty optional state instead of an error, with clearer guidance for invalid configs. -- Updated dependencies [1f06b25] -- Updated dependencies [619840a] -- Updated dependencies [9d4a017] -- Updated dependencies [698a899] -- Updated dependencies [fb7903f] -- Updated dependencies [32182a5] -- Updated dependencies [8fbdd6e] -- Updated dependencies [1f06b25] -- Updated dependencies [2631a63] -- Updated dependencies [3da2fcf] -- Updated dependencies [894c4d0] -- Updated dependencies [cf1b0ed] -- Updated dependencies [ea5d863] -- Updated dependencies [0a086c9] -- Updated dependencies [3cce6d2] -- Updated dependencies [e5bc87b] - - @jmfederico/pi-web@1.202605.11 diff --git a/plugins/actions/README.md b/plugins/actions/README.md deleted file mode 100644 index 81e70d4..0000000 --- a/plugins/actions/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# PI WEB Actions - -Configurable workspace actions for PI WEB. - -The plugin adds an **Actions** workspace tab. Actions run the configured shell command in a dedicated PI WEB terminal and switch to that terminal so the user can monitor progress. - -## Configuration - -Create `.pi-web/actions.json` in the workspace root where you want actions. The file is optional per workspace; workspaces without it simply show no actions. - -```json -{ - "version": 1, - "actions": [ - { - "id": "docker.start", - "title": "Start Docker", - "group": "Docker", - "description": "Start the local Docker Compose environment.", - "command": "./docker/scripts/docker-compose-dev up -d" - }, - { - "id": "db.reset", - "title": "Reset DB", - "group": "Database", - "command": "go -C klingit-go run ./cli db reset", - "confirm": true - } - ] -} -``` - -Fields: - -- `version`: must be `1`. -- `actions`: array of action definitions. -- `id`: stable action id, matching `^[a-z][a-z0-9.-]*$`. -- `title`: button label. -- `command`: literal shell command sent to the terminal. -- `description`: optional explanatory text. -- `group`: optional group heading. -- `confirm`: optional boolean. When true, the browser asks before dispatching the command. - -Commands run in the workspace root because PI WEB creates the terminal for that workspace. - -After editing `.pi-web/actions.json`, click **Refresh** in the Actions tab or reload the browser tab. The plugin does not watch the file automatically. - -## Development in this monorepo - -This package is developed as a separate npm package, not as a bundled PI WEB plugin. From the PI WEB repository, the single root dev command builds, watches, and auto-loads this package without symlinking it into `~/.pi-web/plugins`: - -```bash -npm run dev -``` - -Then reload PI WEB and check discovery: - -```bash -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json -``` - -Build the package before publishing or packing: - -```bash -npm --workspace @jmfederico/pi-web-actions run build -npm pack --workspace @jmfederico/pi-web-actions --dry-run -``` - -## Beta/private API note - -This first-party plugin dogfoods PI WEB's internal terminal command-run helper for command execution while that API incubates. It also reads `.pi-web/actions.json` through PI WEB's private workspace file endpoint. These internals are not stable public plugin APIs yet, so compatibility is best-effort and may require updates alongside PI WEB releases. - -## Notes - -This plugin intentionally keeps v1 simple: - -- static JSON only; -- no variables or templating; -- every action creates a new terminal; -- command prompting/extra input should be handled by the script itself. diff --git a/plugins/actions/package.json b/plugins/actions/package.json deleted file mode 100644 index 78d924b..0000000 --- a/plugins/actions/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@jmfederico/pi-web-actions", - "version": "0.1.2", - "description": "Configurable workspace actions plugin for PI WEB.", - "license": "MIT", - "type": "module", - "files": [ - "dist", - "README.md" - ], - "scripts": { - "dev": "tsc -w -p tsconfig.json", - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --config ../../vitest.config.ts", - "prepack": "npm run build" - }, - "keywords": [ - "pi-package", - "pi-web", - "pi-web-plugin", - "actions", - "workspace" - ], - "peerDependencies": { - "@jmfederico/pi-web": ">=1.202605.14" - }, - "peerDependenciesMeta": { - "@jmfederico/pi-web": { - "optional": true - } - }, - "devDependencies": { - "typescript": "^5.9.3", - "vitest": "^4.1.5" - }, - "piWeb": { - "plugins": [ - { - "id": "actions", - "module": "dist/pi-web-plugin.js" - } - ] - }, - "publishConfig": { - "access": "public" - } -} diff --git a/plugins/actions/src/actionRunner.ts b/plugins/actions/src/actionRunner.ts deleted file mode 100644 index e5d3f1f..0000000 --- a/plugins/actions/src/actionRunner.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import type { WorkspaceAction } from "./config.js"; -import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; - -export function runWorkspaceActionInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, action: WorkspaceAction): ReturnType { - return terminal.runCommand({ - workspace, - title: action.title, - command: action.command, - open: true, - metadata: { - "pi.plugin": "actions", - "action.id": action.id, - }, - }); -} diff --git a/plugins/actions/src/pi-web-plugin.ts b/plugins/actions/src/pi-web-plugin.ts deleted file mode 100644 index 22a5874..0000000 --- a/plugins/actions/src/pi-web-plugin.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH } from "./config.js"; -import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js"; -import { terminalCommandRunsFromContext } from "./piWebInternal.js"; - -const plugin: PiWebPlugin = { - apiVersion: 1, - name: "Workspace Actions", - activate: ({ pluginId, html }) => { - defineActionsPanelElement(); - - return { - contributions: { - actions: [ - { - id: "workspace.open-actions", - title: "Open Workspace Actions", - description: `Open the workspace Actions tab. Configure actions in ${ACTIONS_CONFIG_PATH}.`, - group: "Workspace", - enabled: (context) => context.state.selectedWorkspace !== undefined, - run: (context) => { - if (context.state.selectedWorkspace === undefined) return; - context.selectWorkspaceTool(`${pluginId}:workspace.actions`); - }, - }, - ], - workspacePanels: [ - { - id: "workspace.actions", - title: "Actions", - order: 40, - badge: ({ workspace }) => actionsPanelBadge(workspace), - render: (context) => html``, - }, - ], - }, - }; - }, -}; - -export default plugin; diff --git a/plugins/actions/src/workspaceActionsClient.test.ts b/plugins/actions/src/workspaceActionsClient.test.ts deleted file mode 100644 index f6057c9..0000000 --- a/plugins/actions/src/workspaceActionsClient.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH } from "./config"; -import { loadWorkspaceActionsConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceActionsClient"; - -const workspace: Workspace = { - id: "workspace 1", - projectId: "project/1", - path: "/repo", - label: "repo", - isMain: false, - isGitRepo: true, - isGitWorktree: true, -}; - -describe("workspace actions client", () => { - it("builds the private workspace file URL", () => { - expect(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Factions.json"); - }); - - it("loads and parses a valid actions config", async () => { - const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ - content: JSON.stringify({ version: 1, actions: [{ id: "build", title: "Build", command: "npm run build" }] }), - truncated: false, - binary: false, - })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({ - kind: "loaded", - config: { - version: 1, - actions: [{ id: "build", title: "Build", command: "npm run build", confirm: false }], - }, - }); - }); - - it("treats a missing optional actions config as unconfigured", async () => { - const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({ - kind: "missing", - message: "No workspace actions configured here.", - hint: `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`, - }); - }); - - it("returns a visible unavailable state instead of throwing on request failures", async () => { - const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ - kind: "unavailable", - message: "Could not load workspace actions.", - hint: `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, - detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400: nope`, - }); - }); - - it("returns parser details for invalid config files", async () => { - const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ - content: JSON.stringify({ version: 2, actions: [] }), - truncated: false, - binary: false, - })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ - kind: "unavailable", - detail: "Config version must be 1", - }); - }); - - it("validates workspace file responses", () => { - expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false }); - expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined(); - }); -}); - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); -} diff --git a/plugins/actions/tsconfig.json b/plugins/actions/tsconfig.json deleted file mode 100644 index 9423744..0000000 --- a/plugins/actions/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "sourceMap": true, - "types": [] - }, - "include": ["src/**/*.ts", "../../plugin-api.d.ts"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/scripts/build-plugins.mjs b/scripts/build-plugins.mjs index 1a626b6..1f2c0d8 100644 --- a/scripts/build-plugins.mjs +++ b/scripts/build-plugins.mjs @@ -40,7 +40,7 @@ async function buildDirectory(sourceDir, targetDir) { } if (!entry.isFile()) continue; - if (entry.name.endsWith(".d.ts")) continue; + if (entry.name.endsWith(".d.ts") || isTestSource(entry.name)) continue; if (isPluginSource(entry.name)) { await buildFile(sourcePath, targetPath.replace(/\.ts$/u, ".js")); @@ -94,6 +94,10 @@ function isPluginSource(fileName) { return fileName.endsWith(".ts") && !fileName.endsWith(".d.ts"); } +function isTestSource(fileName) { + return /\.(?:test|spec)\.ts$/u.test(fileName); +} + async function hasTypeScriptSource(javaScriptPath) { const typeScriptPath = javaScriptPath.replace(/\.js$/u, ".ts"); try { diff --git a/scripts/dev-plugin-packages.mjs b/scripts/dev-plugin-packages.mjs deleted file mode 100644 index 7aa3ae9..0000000 --- a/scripts/dev-plugin-packages.mjs +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import { relative, resolve } from "node:path"; - -const cwd = process.cwd(); -const pluginsRoot = resolve(cwd, "plugins"); -const devPackages = await findPluginPackagesWithDevScripts(pluginsRoot); -const children = new Set(); -let stopping = false; - -process.on("SIGINT", () => { stopAndExit(130); }); -process.on("SIGTERM", () => { stopAndExit(143); }); - -if (devPackages.length === 0) { - console.log("[plugin-packages] no plugin package dev scripts found"); - await stayAlive(); -} - -for (const packageInfo of devPackages) startPackageDev(packageInfo); -console.log(`[plugin-packages] watching ${String(devPackages.length)} plugin package${devPackages.length === 1 ? "" : "s"}`); - -await stayAlive(); - -async function findPluginPackagesWithDevScripts(root) { - if (!existsSync(root)) return []; - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - const packages = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const dir = resolve(root, entry.name); - const packageInfo = await readPluginPackageInfo(dir); - if (packageInfo !== undefined) packages.push(packageInfo); - } - return packages.sort((left, right) => left.name.localeCompare(right.name)); -} - -async function readPluginPackageInfo(dir) { - const packagePath = resolve(dir, "package.json"); - const content = await readFile(packagePath, "utf8").catch(() => undefined); - if (content === undefined) return undefined; - const parsed = JSON.parse(content); - if (!isRecord(parsed)) return undefined; - const scripts = parsed["scripts"]; - if (!isRecord(scripts) || typeof scripts["dev"] !== "string") return undefined; - const rawName = parsed["name"]; - return { dir, name: typeof rawName === "string" && rawName !== "" ? rawName : relative(cwd, dir) }; -} - -function startPackageDev(packageInfo) { - const child = spawn("npm", ["run", "dev"], { - cwd: packageInfo.dir, - stdio: ["ignore", "pipe", "pipe"], - }); - children.add(child); - pipeWithPrefix(child.stdout, process.stdout, `[${packageInfo.name}]`); - pipeWithPrefix(child.stderr, process.stderr, `[${packageInfo.name}]`); - child.on("error", (error) => { - children.delete(child); - if (stopping) return; - console.error(`[plugin-packages] failed to start ${packageInfo.name} dev: ${error instanceof Error ? error.message : String(error)}`); - stopAndExit(1); - }); - child.on("exit", (code, signal) => { - children.delete(child); - if (stopping) return; - const reason = signal === null ? `code ${String(code ?? 0)}` : `signal ${signal}`; - console.error(`[plugin-packages] ${packageInfo.name} dev exited with ${reason}`); - stopAndExit(code === null || code === 0 ? 1 : code); - }); -} - -function pipeWithPrefix(stream, output, prefix) { - let pending = ""; - stream.setEncoding("utf8"); - stream.on("data", (chunk) => { - pending += chunk; - const lines = pending.split(/\r?\n/u); - pending = lines.pop() ?? ""; - for (const line of lines) output.write(`${prefix} ${line}\n`); - }); - stream.on("end", () => { - if (pending !== "") output.write(`${prefix} ${pending}\n`); - }); -} - -function stopAndExit(code) { - if (stopping) return; - stopping = true; - for (const child of children) child.kill("SIGTERM"); - setTimeout(() => { process.exit(code); }, 100); -} - -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -async function stayAlive() { - await new Promise(() => undefined); -} diff --git a/src/client/src/api.ts b/src/client/src/api.ts index a6b88e1..547421a 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ -export { activityApi, api, filesApi, gitApi, machinesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; +export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, 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, Machine, MachineHealth, MachineKind, MachineStatus, 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, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index bb8e3f2..0d24f3a 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { FileSuggestion, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; +import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; import { request } from "./http"; import { arrayOf, @@ -20,6 +20,8 @@ import { parseMessagePage, parseModelSelectionResponse, parseOAuthFlowState, + parsePiWebConfigResponse, + parsePiWebPluginsResponse, parsePiWebStatusResponse, parseProject, parseRestored, @@ -48,6 +50,15 @@ export const machinesApi = { health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), }; +export const configApi = { + config: () => request("/api/config", parsePiWebConfigResponse), + saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), +}; + +export const pluginsApi = { + plugins: () => request("/api/plugins", parsePiWebPluginsResponse), +}; + export const activityApi = { workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse), }; @@ -170,6 +181,8 @@ export const gitApi = { export const api = { ...piWebApi, ...machinesApi, + ...configApi, + ...pluginsApi, ...activityApi, ...projectsApi, ...workspacesApi, diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 9629ebd..1c36ed0 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,7 +1,31 @@ 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, parsePiWebPluginsResponse, 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"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, + 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"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + envOverrides: { host: true, port: false, allowedHosts: false }, + }); + }); + + 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", enabled: false }], + })).toEqual({ + plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], + }); + }); + it("accepts legacy array message pages and paged message responses", () => { expect(parseMessagePage(["a", "b"])).toEqual({ messages: ["a", "b"], start: 0, total: 2 }); expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 0329148..6da7872 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, 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, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -408,6 +408,83 @@ 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"])), + ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), + ...optionalField("plugins", optionalPlugins(record["plugins"])), + }; +} + +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 optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field"); + return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { + if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) throw new Error("Invalid PI WEB shortcut field"); + return [actionId, shortcut]; + })); +} + +function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB plugins field"); + return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { + if (!isRecord(config) || Array.isArray(config)) throw new Error("Invalid PI WEB plugin config field"); + const enabled = config["enabled"]; + if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("Invalid PI WEB plugin enabled field"); + const settings = config["settings"]; + if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("Invalid PI WEB plugin settings field"); + return [pluginId, config]; + })); +} + +function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { + const record = requireRecord(value); + return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") }; +} + +export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse { + const record = requireRecord(value); + return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) }; +} + +function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo { + const record = requireRecord(value); + return { + id: requireString(record, "id"), + module: requireString(record, "module"), + source: requireString(record, "source"), + scope: parsePiWebPluginScope(record["scope"]), + enabled: requireBoolean(record, "enabled"), + }; +} + +function parsePiWebPluginScope(value: unknown): PiWebPluginScope { + if (value !== "bundled" && value !== "local" && value !== "user" && value !== "project") throw new Error("Invalid PI WEB plugin scope"); + return value; +} + export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse { const record = requireRecord(value); return { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index dc717f2..fae6329 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { piWebApi, terminalsApi, type Machine, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; +import { configApi, piWebApi, terminalsApi, type Machine, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -27,6 +27,8 @@ 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 { applyShortcutPreferences } from "../shortcutPreferences"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; import "./MachineList"; @@ -42,11 +44,12 @@ import "./CommandPicker"; import "./ActionPalette"; import "./AuthDialog"; import "./ProjectDialog"; +import "./SettingsDialog"; import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; import "./appShell/AppContextBar"; import "./appShell/AppMobileMainTabs"; -import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs"; +import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs"; import "./appShell/AppNavigationPanel"; import "./appShell/AppPanelEdgeControl"; import "./appShell/AppRefreshControl"; @@ -131,7 +134,12 @@ 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(); + @state() private shortcutConfig: PiWebShortcutConfig = {}; + private readonly onPopState = () => void this.withChatScrollTransition(async () => { + this.restoreSettingsRoute(); + await this.restoreRoute(false); + }); private readonly onPageShow = () => { this.appShell.repairViewportPosition(); }; @@ -178,6 +186,7 @@ export class PiWebApp extends LitElement { this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); + void this.loadClientConfig(); void this.loadExternalPlugins(); void this.loadProjectsAndRestoreRoute(); } @@ -211,6 +220,7 @@ export class PiWebApp extends LitElement { } private async loadProjectsAndRestoreRoute() { + this.restoreSettingsRoute(); const route = readRoute(); await this.machines.loadMachines(route.machineId); const machineFallbackMessage = this.state.error; @@ -238,6 +248,18 @@ export class PiWebApp extends LitElement { } } + private async loadClientConfig(): Promise { + try { + this.applyClientConfig((await configApi.config()).config); + } catch (error) { + console.warn("Failed to load PI WEB config", error); + } + } + + private applyClientConfig(config: PiWebConfigValues): void { + this.shortcutConfig = config.shortcuts ?? {}; + } + private async refreshAppData(): Promise { if (this.isRefreshingApp) return; this.isRefreshingApp = true; @@ -246,6 +268,7 @@ export class PiWebApp extends LitElement { this.sessions.refreshSelectedSession(), this.refreshPiWebStatus(), this.refreshWorkspaceActivity(), + this.loadClientConfig(), this.refreshWorkspaceDeletionRuns(), this.refreshCurrentWorkspaceSurface(), ]); @@ -447,6 +470,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; @@ -695,12 +737,19 @@ export class PiWebApp extends LitElement { return "Select a project and workspace to start a session."; } - private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) { + private mobilePanelBadge(panel: QualifiedWorkspacePanelContribution): unknown { const workspace = this.state.selectedWorkspace; - if (workspace === undefined) return panel.title; - const badge = panel.badge?.(this.createWorkspacePanelContext(workspace)); - if (badge === undefined || badge === "") return panel.title; - return html`${panel.title} ${badge}`; + if (workspace === undefined) return undefined; + return panel.badge?.(this.createWorkspacePanelContext(workspace)); + } + + private mobilePanelIcon(panel: QualifiedWorkspacePanelContribution): AppMobileMainTabIcon | undefined { + switch (panel.id) { + case "core:workspace.files": return "files"; + case "core:workspace.git": return "git"; + case "core:workspace.terminal": return "terminal"; + default: return undefined; + } } private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext { @@ -733,7 +782,7 @@ export class PiWebApp extends LitElement { } private getActions(): AppAction[] { - return this.plugins.getActions(this.createPluginRuntimeContext()); + return applyShortcutPreferences(this.plugins.getActions(this.createPluginRuntimeContext()), this.shortcutConfig); } private async loadExternalPlugins(): Promise { @@ -756,7 +805,10 @@ export class PiWebApp extends LitElement { private createPluginRuntimeContext(): PluginRuntimeContext { const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({ state: this.state, - piWebInternal: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin) }, + piWebInternal: { + terminalCommandRuns: this.terminalCommandRunsForOrigin(origin), + openSettings: (section) => { this.openSettings(section); }, + }, openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, focusPrompt: () => { this.promptEditor?.focusInput(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, @@ -1070,6 +1122,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 }); }} > `; } @@ -1086,9 +1139,17 @@ export class PiWebApp extends LitElement { private mobileMainTabs(): AppMobileMainTab[] { return [ - { id: "navigation", label: "Sessions", className: "navigation-tab" }, - { id: "chat", label: "Chat" }, - ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })), + { id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" }, + { id: "chat", label: "Chat", icon: "chat" }, + ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => { + const icon = panel.icon ?? this.mobilePanelIcon(panel); + return { + id: panel.id, + label: panel.title, + ...(icon === undefined ? {} : { icon }), + badge: this.mobilePanelBadge(panel), + }; + }), ]; } @@ -1122,6 +1183,7 @@ export class PiWebApp extends LitElement { ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null}
`; } diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts new file mode 100644 index 0000000..3f601f7 --- /dev/null +++ b/src/client/src/components/SettingsDialog.ts @@ -0,0 +1,208 @@ +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 type { SettingsSection } from "../settingsRoute"; +import "./settings/SettingsGeneralPanel"; +import "./settings/SettingsPluginsPanel"; +import "./settings/SettingsShortcutsPanel"; + +@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; + @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void; + @state() private configResponse: PiWebConfigResponse | undefined; + @state() private pluginsResponse: PiWebPluginsResponse | undefined; + @state() private loading = true; + @state() private saving = false; + @state() private error = ""; + @state() private savedMessage = ""; + private savedMessageTimer: number | undefined; + + override connectedCallback(): void { + super.connectedCallback(); + void this.loadConfig(); + } + + override disconnectedCallback(): void { + if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer); + this.savedMessageTimer = undefined; + super.disconnectedCallback(); + } + + override render(): TemplateResult { + return html` +
this.onClose?.()}> + +
+ `; + } + + private renderActiveSection(): TemplateResult { + if (this.section === "shortcuts") { + return html``; + } + if (this.section === "plugins") { + return html` + this.loadConfig()} + .onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)} + > + `; + } + return html` + this.loadConfig()} + .onSave=${(config: PiWebConfigValues) => this.saveConfig(config)} + > + `; + } + + private renderNavButton(section: SettingsSection, label: string, detail: string): TemplateResult { + const selected = this.section === section; + return html` + + `; + } + + private navigate(section: SettingsSection): void { + this.onNavigate?.(section); + } + + private async loadConfig(): Promise { + this.loading = true; + this.error = ""; + try { + const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]); + this.configResponse = config; + this.pluginsResponse = plugins; + } catch (error) { + this.error = `Failed to load settings: ${errorMessage(error)}`; + } finally { + this.loading = false; + } + } + + private async togglePlugin(pluginId: string, enabled: boolean): Promise { + const baseConfig = this.configResponse?.config ?? {}; + const currentPlugins = baseConfig.plugins ?? {}; + const currentPluginConfig = currentPlugins[pluginId] ?? {}; + await this.saveConfig({ + ...baseConfig, + plugins: { + ...currentPlugins, + [pluginId]: { ...currentPluginConfig, enabled }, + }, + }); + await this.refreshPlugins(); + } + + private async saveConfig(config: PiWebConfigValues): Promise { + if (this.saving) return; + this.saving = true; + this.error = ""; + this.savedMessage = ""; + try { + const response = await configApi.saveConfig(config); + this.configResponse = response; + this.onConfigSaved?.(response.config); + this.showSavedMessage(); + } catch (error) { + this.error = `Failed to save config: ${errorMessage(error)}`; + } finally { + this.saving = false; + } + } + + private async refreshPlugins(): Promise { + try { + this.pluginsResponse = await pluginsApi.plugins(); + } catch (error) { + this.error = `Failed to refresh plugins: ${errorMessage(error)}`; + } + } + + private showSavedMessage(): void { + this.savedMessage = "Config saved."; + if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer); + this.savedMessageTimer = window.setTimeout(() => { + if (this.savedMessage === "Config saved.") this.savedMessage = ""; + this.savedMessageTimer = undefined; + }, 3000); + } + + private handleKeyDown(event: KeyboardEvent): void { + 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 { margin: 0; font-size: 20px; line-height: 1.2; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; } + .close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; } + .close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); } + .settings-body { min-height: 0; display: grid; grid-template-columns: 220px minmax(0, 1fr); } + .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; } + + @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)); } + } + `; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts index 78774e1..766a454 100644 --- a/src/client/src/components/appShell/AppContextBar.ts +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -11,6 +11,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; @@ -68,19 +69,35 @@ export class AppContextBar extends LitElement { - ${this.refreshControl === undefined ? null : html`
${this.refreshControl}
`} + ${this.hasContextActions() ? html`
${this.renderActionsButton()}${this.refreshControl}
` : null} `; } + private renderActionsButton() { + if (this.onShowActions === undefined) return null; + return html` + + `; + } + private contextBarClass(): string { const classes = ["context-bar"]; - if (this.refreshControl !== undefined) classes.push("has-context-actions"); + if (this.hasContextActions()) classes.push("has-context-actions"); + if (this.refreshControl !== undefined && this.onShowActions !== undefined) classes.push("has-context-actions-double"); if (this.canScrollLeft) classes.push("can-scroll-left"); if (this.canScrollRight) classes.push("can-scroll-right"); return classes.join(" "); } + private hasContextActions(): boolean { + return this.refreshControl !== undefined || this.onShowActions !== undefined; + } + private observeContextItems(): void { const contextItems = this.contextItemsElement(); if (this.observedContextItems === contextItems) return; @@ -113,7 +130,8 @@ export class AppContextBar extends LitElement { }; static override styles = css` - :host { flex: 0 0 auto; min-width: 0; } + /* Keep the refresh menu in this shadow tree above the following mobile tab strip. */ + :host { position: relative; z-index: 20; flex: 0 0 auto; min-width: 0; } .context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } .context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } .context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } @@ -121,11 +139,15 @@ export class AppContextBar extends LitElement { .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; } .context-bar-label { display: none; } .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } - .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; } + .context-bar.has-context-actions .context-items { padding-right: 58px; scroll-padding-inline: 8px 58px; } + .context-bar.has-context-actions-double .context-items { padding-right: 102px; scroll-padding-inline: 8px 102px; } .context-item { flex: 0 0 auto; min-width: 0; display: flex; } - .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; } - .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; } - app-refresh-control { pointer-events: auto; } + .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 0 8px; background: var(--pi-bg); pointer-events: none; } + .context-actions::before { content: ""; position: absolute; top: 0; bottom: 0; left: -24px; z-index: 0; width: 24px; background: linear-gradient(90deg, transparent, var(--pi-bg)); pointer-events: none; } + app-refresh-control, .context-action-button { position: relative; z-index: 1; pointer-events: auto; } + .context-action-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; } + .context-action-button:hover, .context-action-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .context-action-icon { width: 18px; height: 18px; fill: currentColor; pointer-events: none; } .context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; } .context-chip:hover { background: var(--pi-surface-hover); } .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts index 4eaf5fa..8c85b3a 100644 --- a/src/client/src/components/appShell/AppMobileMainTabs.ts +++ b/src/client/src/components/appShell/AppMobileMainTabs.ts @@ -1,10 +1,15 @@ -import { LitElement, css, html } from "lit"; +import { LitElement, css, html, svg, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import type { AppState } from "../../appState"; +export type AppMobileMainTabBuiltinIcon = "navigation" | "chat" | "files" | "git" | "terminal"; +export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult; + export interface AppMobileMainTab { id: AppState["mainView"]; - label: unknown; + label: string; + icon?: AppMobileMainTabIcon; + badge?: unknown; className?: string | undefined; } @@ -37,12 +42,20 @@ export class AppMobileMainTabs extends LitElement { } override render() { + const fallbackLabels = this.fallbackLabels(); return html`
- ${this.tabs.map((tab) => html` - - `)} + ${this.tabs.map((tab) => { + const selected = this.selectedView === tab.id; + return html` + + `; + })}
`; @@ -59,6 +72,99 @@ export class AppMobileMainTabs extends LitElement { ].join(" "); } + private tabAriaLabel(tab: AppMobileMainTab): string { + if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label; + const badge = String(tab.badge).trim(); + return badge === "" ? tab.label : `${tab.label}, ${badge}`; + } + + private isEmptyBadge(badge: unknown): boolean { + return badge === undefined || badge === ""; + } + + private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map) { + return tab.icon === undefined + ? html`` + : this.renderIcon(tab.icon); + } + + private fallbackLabels(): Map { + const fallbackTabs = this.tabs.filter((tab) => tab.icon === undefined); + const counts = new Map(); + for (const tab of fallbackTabs) { + const initials = this.initialsLabel(tab.label); + counts.set(initials, (counts.get(initials) ?? 0) + 1); + } + + const labels = new Map(); + for (const tab of fallbackTabs) { + const initials = this.initialsLabel(tab.label); + labels.set(tab.id, (counts.get(initials) ?? 0) > 1 ? this.fullFallbackLabel(tab.label) : initials); + } + return labels; + } + + private initialsLabel(label: string): string { + const words = label.match(/[\p{L}\p{N}]+/gu) ?? []; + const initials = words.map((word) => Array.from(word)[0] ?? "").join("").toLocaleUpperCase(); + return initials === "" ? "?" : initials; + } + + private fullFallbackLabel(label: string): string { + const trimmed = label.trim(); + return trimmed === "" ? "?" : trimmed; + } + + private renderIcon(icon: AppMobileMainTabIcon) { + if (typeof icon !== "string") return html``; + switch (icon) { + case "navigation": + return svg` + + `; + case "chat": + return svg` + + `; + case "files": + return svg` + + `; + case "git": + return svg` + + `; + case "terminal": + return svg` + + `; + } + } + private observeMobileTabs(): void { const mobileTabs = this.mobileTabsElement(); if (this.observedMobileTabs === mobileTabs) return; @@ -98,13 +204,23 @@ export class AppMobileMainTabs extends LitElement { .mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } .mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; } .mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; } - .mobile-tabs button { flex: 0 0 auto; white-space: nowrap; } - .navigation-tab { display: none; } + .mobile-tabs button { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } + .mobile-tabs .navigation-tab { display: none; } .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } - .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } + .tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .tab-custom-icon { flex: 0 0 auto; width: 18px; height: 18px; display: inline-grid; place-items: center; color: currentColor; pointer-events: none; } + .tab-custom-icon svg { width: 18px; height: 18px; pointer-events: none; } + .tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; } + .tab-label { min-width: 0; } + .tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } @media (max-width: 760px) { - .navigation-tab { display: block; } + .mobile-tabs { gap: 4px; padding: 6px 8px; } + .mobile-tabs button { min-width: 40px; height: 36px; justify-content: center; gap: 4px; padding: 0 8px; } + .mobile-tabs .navigation-tab { display: inline-flex; } + .tab-fallback { display: inline-block; } + .tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } + .tab-badge { min-width: 13px; padding: 0 4px; font-size: 10px; line-height: 13px; } } `; } diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 34ac8d2..f81b59b 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -120,6 +120,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; } machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; } diff --git a/src/client/src/components/appShell/AppRefreshControl.ts b/src/client/src/components/appShell/AppRefreshControl.ts index 624abbd..90a050d 100644 --- a/src/client/src/components/appShell/AppRefreshControl.ts +++ b/src/client/src/components/appShell/AppRefreshControl.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { actionMenuPanelStyle } from "../actionMenu"; const REFRESH_LONG_PRESS_MS = 550; +const REFRESH_MENU_PORTAL_STYLE_ID = "pi-web-app-refresh-menu-portal-style"; @customElement("app-refresh-control") export class AppRefreshControl extends LitElement { @@ -10,7 +11,8 @@ export class AppRefreshControl extends LitElement { @property({ attribute: false }) onRefresh?: () => void | Promise; @property({ attribute: false }) onReload?: () => void; @state() private menuOpen = false; - @state() private menuStyle = ""; + private menuStyle = ""; + private menuPortal: HTMLDivElement | undefined; private longPressTimer: number | undefined; private suppressNextClick = false; @@ -24,6 +26,7 @@ export class AppRefreshControl extends LitElement { document.removeEventListener("click", this.onDocumentClick); document.removeEventListener("keydown", this.onDocumentKeyDown); this.clearLongPressTimer(); + this.removePortalMenu(); super.disconnectedCallback(); } @@ -44,17 +47,6 @@ export class AppRefreshControl extends LitElement { @pointercancel=${() => { this.clearLongPressTimer(); }} @pointerleave=${() => { this.clearLongPressTimer(); }} >${this.renderRefreshIcon()} - ${this.renderMenu()} - `; - } - - private renderMenu() { - if (!this.menuOpen) return null; - return html` - `; } @@ -100,7 +92,8 @@ export class AppRefreshControl extends LitElement { }; private readonly onDocumentClick = (event: MouseEvent): void => { - if (event.composedPath().includes(this)) return; + const path = event.composedPath(); + if (path.includes(this) || (this.menuPortal !== undefined && path.includes(this.menuPortal))) return; this.closeMenu(); }; @@ -114,11 +107,13 @@ export class AppRefreshControl extends LitElement { private openMenu(target: EventTarget | null): void { this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" }); this.menuOpen = true; + this.renderPortalMenu(); } private closeMenu(): void { this.menuOpen = false; this.suppressNextClick = false; + this.removePortalMenu(); } private refresh(): void { @@ -137,15 +132,96 @@ export class AppRefreshControl extends LitElement { this.longPressTimer = undefined; } + private renderPortalMenu(): void { + const ownerDocument = this.ownerDocument; + ensurePortalMenuStyles(ownerDocument); + + const menu = this.menuPortal ?? ownerDocument.createElement("div"); + this.menuPortal = menu; + menu.className = "pi-web-app-refresh-menu-portal"; + menu.setAttribute("role", "menu"); + menu.setAttribute("style", this.menuStyle); + menu.replaceChildren( + this.createPortalMenuButton("Refresh app data", () => { this.refresh(); }), + this.createPortalMenuButton("Full page reload", () => { this.reload(); }), + ); + menu.addEventListener("click", this.onPortalMenuClick); + if (!menu.isConnected) ownerDocument.body.append(menu); + } + + private createPortalMenuButton(label: string, onClick: () => void): HTMLButtonElement { + const button = this.ownerDocument.createElement("button"); + button.type = "button"; + button.setAttribute("role", "menuitem"); + button.textContent = label; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; + } + + private removePortalMenu(): void { + this.menuPortal?.removeEventListener("click", this.onPortalMenuClick); + this.menuPortal?.remove(); + this.menuPortal = undefined; + } + + private readonly onPortalMenuClick = (event: MouseEvent): void => { + event.stopPropagation(); + }; + static override styles = css` :host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } :host, :host * { -webkit-user-select: none; user-select: none; } .app-refresh-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; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; } .app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } .app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; } - .app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; } - .app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; } - .app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); } @keyframes app-refresh-spin { to { transform: rotate(360deg); } } `; } + +function ensurePortalMenuStyles(ownerDocument: Document): void { + if (ownerDocument.getElementById(REFRESH_MENU_PORTAL_STYLE_ID) !== null) return; + const style = ownerDocument.createElement("style"); + style.id = REFRESH_MENU_PORTAL_STYLE_ID; + style.textContent = ` + .pi-web-app-refresh-menu-portal { + position: fixed; + z-index: 2147483647; + box-sizing: border-box; + min-width: min(170px, calc(100vw - 16px)); + overflow: auto; + padding: 4px; + border: 1px solid var(--pi-border); + border-radius: 8px; + background: var(--pi-surface); + color: var(--pi-text); + box-shadow: 0 8px 24px var(--pi-shadow); + overflow-wrap: anywhere; + font: 14px system-ui, sans-serif; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + .pi-web-app-refresh-menu-portal button { + display: block; + width: 100%; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--pi-text); + padding: 7px 9px; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + font: inherit; + cursor: pointer; + } + .pi-web-app-refresh-menu-portal button:hover, + .pi-web-app-refresh-menu-portal button:focus { + background: var(--pi-selection-bg); + } + `; + ownerDocument.head.append(style); +} diff --git a/src/client/src/components/settings/SettingsGeneralPanel.ts b/src/client/src/components/settings/SettingsGeneralPanel.ts new file mode 100644 index 0000000..b551b26 --- /dev/null +++ b/src/client/src/components/settings/SettingsGeneralPanel.ts @@ -0,0 +1,190 @@ +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft"; + +@customElement("settings-general-panel") +export class SettingsGeneralPanel extends LitElement { + @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + @property({ type: Boolean }) loading = false; + @property({ type: Boolean }) saving = false; + @property() error = ""; + @property() savedMessage = ""; + @property({ attribute: false }) onReload?: () => void | Promise; + @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; + @state() private draft: ConfigDraft = emptyConfigDraft(); + @state() private localError = ""; + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("configResponse") && this.configResponse !== undefined) { + this.draft = draftFromConfig(this.configResponse.config); + this.localError = ""; + } + } + + override render(): TemplateResult { + const config = this.configResponse; + return html` +
+
+

General configuration

+

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

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

Effective after environment overrides

+
+
Host
${effective.host ?? html`127.0.0.1 default`}
+
Port
${effective.port ?? html`8504 default`}
+
Allowed hosts
${formatAllowedHosts(effective.allowedHosts)}
+
+
+ `; + } + + private async saveConfig(event: Event): Promise { + event.preventDefault(); + this.localError = ""; + try { + await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {})); + } catch (error) { + this.localError = errorMessage(error); + } + } + + private updateDraft(patch: Partial): void { + this.draft = { ...this.draft, ...patch }; + this.localError = ""; + } + + static override styles = css` + :host { display: block; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .section-heading > div { display: grid; gap: 6px; min-width: 0; } + h2, h3, p { margin: 0; } + h2 { font-size: 17px; line-height: 1.25; } + h3 { font-size: 13px; line-height: 1.3; } + p { color: var(--pi-muted); line-height: 1.45; } + button, input, select, textarea { font: inherit; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + button:disabled { opacity: .55; cursor: not-allowed; } + .secondary { flex: 0 0 auto; } + .message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message { margin-bottom: 12px; } + .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } + .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card { color: var(--pi-muted); } + .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } + .config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } + .config-path-card small, .field small { color: var(--pi-muted); } + .config-form { display: grid; gap: 14px; } + .field { display: grid; gap: 7px; } + .field-heading { display: flex; align-items: center; gap: 8px; } + input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; outline: none; } + input:focus, select:focus, textarea:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); } + textarea { resize: vertical; min-height: 94px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + textarea:disabled { opacity: .55; } + .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } + .effective-card { display: grid; gap: 10px; } + .effective-card dl { display: grid; gap: 8px; margin: 0; } + .effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; } + dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } + .muted { color: var(--pi-muted); } + .form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; } + .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } + .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } + } + `; +} + +function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string | TemplateResult { + if (value === true) return "Any host"; + if (Array.isArray(value)) return value.length === 0 ? html`None listed` : value.join(", "); + return html`Unset`; +} + +function inputValue(event: Event): string { + return event.target instanceof HTMLInputElement ? event.target.value : ""; +} + +function selectValue(event: Event): string { + return event.target instanceof HTMLSelectElement ? event.target.value : ""; +} + +function textAreaValue(event: Event): string { + return event.target instanceof HTMLTextAreaElement ? event.target.value : ""; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/client/src/components/settings/SettingsPluginsPanel.ts b/src/client/src/components/settings/SettingsPluginsPanel.ts new file mode 100644 index 0000000..3d5d6b1 --- /dev/null +++ b/src/client/src/components/settings/SettingsPluginsPanel.ts @@ -0,0 +1,99 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api"; + +@customElement("settings-plugins-panel") +export class SettingsPluginsPanel extends LitElement { + @property({ attribute: false }) pluginsResponse: PiWebPluginsResponse | undefined; + @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + @property({ type: Boolean }) loading = false; + @property({ type: Boolean }) saving = false; + @property() error = ""; + @property() savedMessage = ""; + @property({ attribute: false }) onReload?: () => void | Promise; + @property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise; + + override render(): TemplateResult { + const plugins = this.pluginsResponse?.plugins ?? []; + return html` +
+
+

Plugins

+

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.

+
+ +
+ ${this.renderMessages()} +
Config key: plugins. Plugins are enabled unless their entry sets enabled to false.
+ ${this.loading && plugins.length === 0 ? html`
Loading plugins…
` : plugins.length === 0 ? html`
No external or bundled plugins discovered.
` : html` +
+ ${plugins.map((plugin) => this.renderPlugin(plugin))} +
+ `} + `; + } + + private renderMessages(): TemplateResult | null { + if (this.error !== "") return html`
${this.error}
`; + if (this.savedMessage !== "") return html`
${this.savedMessage} Reload the browser tab to apply plugin changes.
`; + return null; + } + + private renderPlugin(plugin: PiWebPluginInfo): TemplateResult { + const configured = this.configResponse?.config.plugins?.[plugin.id]; + const configuredState = configured?.enabled === false ? "Config disabled" : configured?.enabled === true ? "Config enabled" : "Default enabled"; + return html` +
+
+ ${plugin.id} + ${plugin.source} · ${plugin.scope} + ${configuredState} +
+ +
+ `; + } + + private async togglePlugin(plugin: PiWebPluginInfo, event: Event): Promise { + const enabled = event.target instanceof HTMLInputElement ? event.target.checked : plugin.enabled; + await this.onTogglePlugin?.(plugin.id, enabled); + } + + static override styles = css` + :host { display: block; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .section-heading > div { display: grid; gap: 6px; min-width: 0; } + h2, p { margin: 0; } + h2 { font-size: 17px; line-height: 1.25; } + p { 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; } + .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; } + .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); } + .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; } + .plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; } + .plugin-card.disabled { opacity: .75; } + .plugin-main { min-width: 0; display: grid; gap: 3px; } + .plugin-main strong, .plugin-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .plugin-main small { color: var(--pi-muted); } + .toggle { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; } + .toggle input { width: 18px; height: 18px; accent-color: var(--pi-accent); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } + .plugin-card { grid-template-columns: minmax(0, 1fr); align-items: start; } + .toggle { justify-self: start; } + } + `; +} diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts new file mode 100644 index 0000000..528a9fc --- /dev/null +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -0,0 +1,124 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { AppAction } from "../../actions"; +import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api"; +import { formatShortcut } from "../../keyboardShortcuts"; + +@customElement("settings-shortcuts-panel") +export class SettingsShortcutsPanel extends LitElement { + @property({ attribute: false }) actions: AppAction[] = []; + @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + + override render(): TemplateResult { + const groups = shortcutGroups(this.actions); + return html` +
+
+

Keyboard shortcuts

+

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

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

${group.name}

+
+ ${group.actions.map((action) => this.renderShortcutRow(action))} +
+
+ `)} + `; + } + + private renderShortcutRow(action: AppAction): TemplateResult { + const shortcuts = this.configResponse?.config.shortcuts; + const configured = shortcutPreference(action.id, shortcuts); + const shortcut = configured === null ? undefined : configured ?? action.shortcut; + const state = shortcutState(action, shortcuts); + return html` +
+
+ ${action.title} + ${action.description !== undefined && action.description !== "" ? html`${action.description}` : null} + ${action.id} +
+
+ ${shortcut !== undefined && shortcut !== "" ? html`${formatShortcut(shortcut)}` : html`${state === "disabled" ? "Disabled" : "Unassigned"}`} + ${shortcutStateLabel(state)} +
+
+ `; + } + + static override styles = css` + :host { display: block; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .section-heading > div { display: grid; gap: 6px; min-width: 0; } + h2, h3, p { margin: 0; } + h2 { font-size: 17px; line-height: 1.25; } + h3 { font-size: 13px; line-height: 1.3; } + p { color: var(--pi-muted); line-height: 1.45; } + code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } + .loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .loading-card, .shortcut-note { color: var(--pi-muted); } + .shortcut-note { margin-bottom: 14px; } + .shortcut-group { margin: 0 0 16px; } + .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + .shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; } + .shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); } + .shortcut-row:last-child { border-bottom: 0; } + .shortcut-main { min-width: 0; display: grid; gap: 3px; } + .shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .shortcut-main small { color: var(--pi-muted); } + .shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; } + kbd { justify-self: end; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } + .unassigned { justify-self: end; color: var(--pi-muted); font-size: 12px; } + .shortcut-value small { color: var(--pi-muted); font-size: 11px; } + .shortcut-value small.custom { color: var(--pi-accent); } + .shortcut-value small.disabled { color: var(--pi-warning); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } + .shortcut-value { justify-self: start; justify-items: start; } + kbd, .unassigned { justify-self: start; } + } + `; +} + +type ShortcutState = "default" | "custom" | "disabled" | "unassigned"; + +function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] { + const grouped = new Map(); + for (const action of [...actions].sort(compareActions)) { + const group = action.group ?? "Other"; + grouped.set(group, [...(grouped.get(group) ?? []), action]); + } + return [...grouped.entries()].map(([name, groupActions]) => ({ name, actions: groupActions })); +} + +function compareActions(left: AppAction, right: AppAction): number { + return (left.group ?? "Other").localeCompare(right.group ?? "Other") || left.title.localeCompare(right.title); +} + +function shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | undefined): string | null | undefined { + if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined; + return shortcuts[actionId]; +} + +function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState { + const configured = shortcutPreference(action.id, shortcuts); + if (configured === null) return "disabled"; + if (configured !== undefined) return "custom"; + return action.shortcut === undefined || action.shortcut === "" ? "unassigned" : "default"; +} + +function shortcutStateLabel(state: ShortcutState): string { + switch (state) { + case "default": return "Default"; + case "custom": return "Config override"; + case "disabled": return "Config disabled"; + case "unassigned": return "No default"; + } +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts new file mode 100644 index 0000000..13ee052 --- /dev/null +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { configFromDraft, draftFromConfig } from "./settingsConfigDraft"; + +describe("settings config drafts", () => { + it("converts PI WEB config values to editable general settings drafts", () => { + expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({ + host: "0.0.0.0", + port: "8504", + allowedHostsMode: "list", + allowedHostsText: "example.local\n192.168.1.20", + }); + expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); + }); + + it("converts drafts back to config while preserving shortcut and plugin preferences", () => { + expect(configFromDraft({ + host: " 127.0.0.1 ", + port: "9000", + allowedHostsMode: "list", + allowedHostsText: "example.local, 192.168.1.20\n", + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({ + host: "127.0.0.1", + port: 9000, + allowedHosts: ["example.local", "192.168.1.20"], + shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + plugins: { info: { enabled: false } }, + }); + }); +}); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts new file mode 100644 index 0000000..d4aabc7 --- /dev/null +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -0,0 +1,42 @@ +import type { PiWebConfigValues } from "../../api"; + +export interface ConfigDraft { + host: string; + port: string; + allowedHostsMode: "list" | "all"; + allowedHostsText: string; +} + +export function emptyConfigDraft(): ConfigDraft { + return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; +} + +export function draftFromConfig(config: PiWebConfigValues): ConfigDraft { + return { + host: config.host ?? "", + port: config.port === undefined ? "" : String(config.port), + allowedHostsMode: config.allowedHosts === true ? "all" : "list", + allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "", + }; +} + +export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { + const config: PiWebConfigValues = { + ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), + ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), + }; + const host = draft.host.trim(); + const port = draft.port.trim(); + if (host !== "") config.host = host; + if (port !== "") { + const parsed = Number(port); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("Port must be an integer from 1 to 65535."); + config.port = parsed; + } + config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText); + return config; +} + +function parseAllowedHostsText(value: string): string[] { + return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== ""); +} diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index f5d046b..99b509f 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -79,6 +79,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.piWebInternal?.openSettings?.(); }, + }, { id: "app.refresh-data", title: "Refresh App Data", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index c009241..6673508 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -18,6 +18,7 @@ function createContext(statePatch: Partial = {}) { getCommandRun: vi.fn(), open: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`terminal.open:${options?.terminalId ?? ""}`); }), }, + openSettings: vi.fn(() => { calls.push("openSettings"); }), }, openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }), focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }), @@ -54,6 +55,31 @@ describe("PluginRegistry", () => { expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]); }); + it("provides html and svg helpers to plugin activation", () => { + const registry = new PluginRegistry(); + registry.register({ + id: "example", + plugin: { + apiVersion: 1, + name: "Example", + activate: ({ html, svg }) => ({ + contributions: { + workspacePanels: [ + { + id: "workspace.logs", + title: "Logs", + icon: svg``, + render: () => html`

Logs

`, + }, + ], + }, + }), + }, + }); + + expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined(); + }); + it("rejects duplicate ids within the same namespace", () => { const registry = new PluginRegistry(); @@ -146,7 +172,7 @@ describe("PluginRegistry", () => { expect(calls).toEqual(["refreshGit"]); }); - it("routes app refresh and reload actions through the runtime context", () => { + it("routes app refresh, reload, and settings actions through the runtime context", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); const { context, calls } = createContext(); @@ -154,8 +180,9 @@ describe("PluginRegistry", () => { void actions.find((candidate) => candidate.id === "core:app.refresh-data")?.run(); void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run(); + void actions.find((candidate) => candidate.id === "core:settings.open")?.run(); - expect(calls).toEqual(["refreshAppData", "reloadPage"]); + expect(calls).toEqual(["refreshAppData", "reloadPage", "openSettings"]); }); it("exposes terminal navigation as a shortcut-backed action", () => { @@ -179,6 +206,7 @@ describe("PluginRegistry", () => { expect(shortcuts).toEqual([ ["core:actions.show", "mod+k"], + ["core:settings.open", "mod+,"], ["core:view.chat", "mod+1"], ["core:view.files", "mod+2"], ["core:view.git", "mod+3"], diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 78f6a86..1822172 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, svg } from "lit"; import type { AppState } from "../appState"; import type { Workspace } from "../api"; import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types"; @@ -31,7 +31,7 @@ export class PluginRegistry { const apiVersion: unknown = plugin.apiVersion; if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`); - const result = plugin.activate({ apiVersion: 1, pluginId: id, html }); + const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg }); const contributions = result.contributions; for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action)); for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel)); diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index d9f2588..267b0aa 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -2,10 +2,12 @@ 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"; export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; +export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; export interface PiWebPluginRegistration { id: PluginId; @@ -22,6 +24,7 @@ export interface PluginActivationContext { apiVersion: 1; pluginId: PluginId; html: HtmlTemplateTag; + svg: SvgTemplateTag; } export interface PluginActivationResult { @@ -38,6 +41,7 @@ export interface PluginContributions { export interface PiWebInternalRuntimeContext { terminalCommandRuns: TerminalCommandRunsInternalRuntime; + openSettings?: (section?: SettingsSection) => void; } export interface TerminalCommandRunsInternalRuntime { @@ -120,9 +124,12 @@ export interface WorkspacePanelContext { onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; } +export type WorkspacePanelIcon = TemplateResult; + export interface WorkspacePanelContribution { id: LocalContributionId; title: string; + icon?: WorkspacePanelIcon; order?: number; visible?: (context: WorkspacePanelVisibilityContext) => boolean; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; diff --git a/src/client/src/settingsRoute.test.ts b/src/client/src/settingsRoute.test.ts new file mode 100644 index 0000000..0666490 --- /dev/null +++ b/src/client/src/settingsRoute.test.ts @@ -0,0 +1,65 @@ +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("plugins")).toBe("plugins"); + expect(parseSettingsSection("shortcuts")).toBe("shortcuts"); + expect(parseSettingsSection("keyboard")).toBe("shortcuts"); + expect(parseSettingsSection("unknown")).toBeUndefined(); + }); + + it("reads the settings section from the current URL", () => { + installWindow("http://localhost/app?project=p1&settings=shortcuts"); + + expect(readSettingsSection()).toBe("shortcuts"); + }); + + it("writes settings deep links while preserving other route fields", () => { + const { pushed } = installWindow("http://localhost/app?project=p1#bottom"); + + writeSettingsSection("general"); + + expect(pushed).toEqual(["http://localhost/app?project=p1&settings=general#bottom"]); + }); + + it("removes settings deep links with replace when closing", () => { + const { replaced } = installWindow("http://localhost/app?project=p1&settings=general#bottom"); + + writeSettingsSection(undefined, { replace: true }); + + expect(replaced).toEqual(["http://localhost/app?project=p1#bottom"]); + }); +}); diff --git a/src/client/src/settingsRoute.ts b/src/client/src/settingsRoute.ts new file mode 100644 index 0000000..ae05114 --- /dev/null +++ b/src/client/src/settingsRoute.ts @@ -0,0 +1,23 @@ +export type SettingsSection = "general" | "plugins" | "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 === "plugins") return "plugins"; + if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts"; + return undefined; +} diff --git a/src/client/src/shortcutPreferences.test.ts b/src/client/src/shortcutPreferences.test.ts new file mode 100644 index 0000000..51128e9 --- /dev/null +++ b/src/client/src/shortcutPreferences.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { AppAction } from "./actions"; +import { applyShortcutPreferences } from "./shortcutPreferences"; + +const noop = () => undefined; + +describe("shortcut preferences", () => { + it("keeps default shortcuts when there is no matching preference", () => { + const actions = [action({ id: "core:view.chat", shortcut: "mod+1" })]; + + expect(applyShortcutPreferences(actions, { "core:view.files": "mod+2" })).toEqual(actions); + }); + + it("overrides action shortcuts by action id", () => { + expect(applyShortcutPreferences([ + action({ id: "core:view.chat", shortcut: "mod+1" }), + ], { "core:view.chat": "mod+shift+1" })).toEqual([ + action({ id: "core:view.chat", shortcut: "mod+shift+1" }), + ]); + }); + + it("removes shortcuts with null preferences", () => { + expect(applyShortcutPreferences([ + action({ id: "core:view.chat", shortcut: "mod+1" }), + ], { "core:view.chat": null })).toEqual([ + action({ id: "core:view.chat" }), + ]); + }); +}); + +function action(patch: Partial): AppAction { + return { id: "action", title: "Action", run: noop, ...patch }; +} diff --git a/src/client/src/shortcutPreferences.ts b/src/client/src/shortcutPreferences.ts new file mode 100644 index 0000000..ff9902e --- /dev/null +++ b/src/client/src/shortcutPreferences.ts @@ -0,0 +1,21 @@ +import type { AppAction } from "./actions"; +import type { PiWebShortcutConfig } from "./api"; + +export function applyShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] { + if (shortcuts === undefined) return actions; + return actions.map((action) => applyShortcutPreference(action, shortcuts)); +} + +export function applyShortcutPreference(action: AppAction, shortcuts: PiWebShortcutConfig): AppAction { + if (!Object.hasOwn(shortcuts, action.id)) return action; + const shortcut = shortcuts[action.id]; + if (shortcut === undefined) return action; + if (shortcut === null) return withoutShortcut(action); + return { ...action, shortcut }; +} + +function withoutShortcut(action: AppAction): AppAction { + const copy = { ...action }; + delete copy.shortcut; + return copy; +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..551da73 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,44 @@ +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"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions()); + + expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } }); + 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, plugins: { info: { enabled: false } }, 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: [] }); + }); + + it("rejects invalid plugin config", async () => { + await writeFile(configPath, `${JSON.stringify({ plugins: { info: { enabled: "no" } } }, null, 2)}\n`, "utf8"); + + expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans"); + }); +}); + +function testOptions(): { env: NodeJS.ProcessEnv } { + return { env: { PI_WEB_CONFIG: configPath } }; +} diff --git a/src/config.ts b/src/config.ts index fc9aaa5..5e1d6e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,10 @@ -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"; +import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; -export interface PiWebConfig { - host?: string; - port?: number; - allowedHosts?: string[] | true; -} +export type PiWebConfig = PiWebConfigValues; export interface LoadedPiWebConfig { path: string; @@ -14,7 +12,7 @@ export interface LoadedPiWebConfig { config: PiWebConfig; } -interface LoadOptions { +export interface LoadOptions { env?: NodeJS.ProcessEnv; cwd?: string; } @@ -69,11 +67,46 @@ 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"]; + delete existing["shortcuts"]; + delete existing["plugins"]; + const merged = { ...existing, ...piWebConfigRecord(normalized) }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); + return { path, exists: true, config: normalized }; +} + +function readExistingConfigObject(path: string): Record { + if (!existsSync(path)) return {}; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`); + return parsed; +} + +function piWebConfigRecord(config: PiWebConfig): Record { + return { + ...(config.host !== undefined ? { host: config.host } : {}), + ...(config.port !== undefined ? { port: config.port } : {}), + ...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}), + ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), + ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), + }; +} + function parsePiWebConfig(value: Record, path: string): PiWebConfig { return { ...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}), ...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}), ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), + ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), + ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), }; } @@ -101,6 +134,29 @@ function parseAllowedHostsEnv(value: string): string[] | true { return value.split(",").map((host) => host.trim()).filter((host) => host !== ""); } +function parseShortcuts(value: unknown, path: string): Record { + if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`); + return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { + if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) { + throw new Error(`PI WEB config shortcut values must be non-empty strings or null: ${path}`); + } + return [actionId, shortcut]; + })); +} + +function parsePlugins(value: unknown, path: string): NonNullable { + if (!isRecord(value) || Array.isArray(value)) throw new Error(`PI WEB config plugins must be an object: ${path}`); + return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { + if (!isPiWebPluginId(pluginId)) throw new Error(`PI WEB config plugin ids must match ${piWebPluginIdPattern.source}: ${path}`); + if (!isRecord(config) || Array.isArray(config)) throw new Error(`PI WEB config plugin entries must be objects: ${path}`); + const enabled = config["enabled"]; + if (enabled !== undefined && typeof enabled !== "boolean") throw new Error(`PI WEB config plugin enabled values must be booleans: ${path}`); + const settings = config["settings"]; + if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error(`PI WEB config plugin settings must be objects: ${path}`); + return [pluginId, config]; + })); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/app.test.ts b/src/server/app.test.ts index e4379bf..b7f5b0e 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -50,6 +50,7 @@ beforeEach(async () => { sessionDaemon: fakeSessionDaemon(), piWebPlugins: { manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }), + plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }), readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), }, clientDist: false, @@ -288,6 +289,10 @@ describe("buildApp", () => { expect(manifestResponse.statusCode).toBe(200); expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }); + const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" }); + expect(pluginsResponse.statusCode).toBe(200); + expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }); + const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); expect(assetResponse.statusCode).toBe(200); expect(assetResponse.headers["content-type"]).toContain("application/javascript"); diff --git a/src/server/app.ts b/src/server/app.ts index 4bfe9fe..53798fb 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -14,6 +14,7 @@ import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/ 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"; import { MachineService } from "./machines/machineService.js"; @@ -25,7 +26,8 @@ export interface AppDependencies { workspaces?: WorkspaceService; machines?: MachineService; sessionDaemon?: SessionProxyDaemon; - piWebPlugins?: Pick; + piWebPlugins?: Pick; + config?: PiWebConfigService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; } @@ -100,6 +102,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus()); app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); + app.get("/api/plugins", async () => piWebPlugins.plugins()); + registerConfigRoutes(app, deps.config); registerMachineRoutes(app, machines); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts new file mode 100644 index 0000000..599d2c5 --- /dev/null +++ b/src/server/configRoutes.test.ts @@ -0,0 +1,69 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; +import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; + +let app: FastifyInstance; +let savedConfig: PiWebConfigValues; +let service: PiWebConfigService; + +beforeEach(async () => { + savedConfig = { host: "127.0.0.1", port: 8504, allowedHosts: [] }; + service = { + read: vi.fn(() => responseFor(savedConfig, true)), + write: vi.fn((config: PiWebConfigValues) => { + savedConfig = config; + return responseFor(savedConfig, true); + }), + }; + app = Fastify({ logger: false }); + registerConfigRoutes(app, service); + await app.ready(); +}); + +afterEach(async () => { + await app.close(); +}); + +describe("config routes", () => { + it("returns the PI WEB config contract", async () => { + const response = await app.inject({ method: "GET", url: "/api/config" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual(responseFor(savedConfig, true)); + }); + + it("updates config through the service", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } }, + }); + + expect(response.statusCode).toBe(200); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } }); + expect(response.json().config).toEqual(savedConfig); + }); + + it("rejects invalid config payloads before writing", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { host: 42 } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toHaveProperty("error"); + expect(service.write).not.toHaveBeenCalled(); + }); +}); + +function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse { + return { + path: "/tmp/pi-web/config.json", + exists, + config, + effectiveConfig: config, + envOverrides: { host: false, port: false, allowedHosts: false }, + }; +} diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts new file mode 100644 index 0000000..46d83a7 --- /dev/null +++ b/src/server/configRoutes.ts @@ -0,0 +1,126 @@ +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"; +import { isPiWebPluginId } from "../shared/pluginIds.js"; + +export interface PiWebConfigService { + read: () => PiWebConfigResponse | Promise; + write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise; +} + +export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService { + return { + read: () => currentPiWebConfigResponse(options), + write: (config) => { + savePiWebConfig(config, options); + return currentPiWebConfigResponse(options); + }, + }; +} + +export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse { + const loaded = loadPiWebConfig(options); + const effective = effectivePiWebConfig(options); + const env = options.env ?? process.env; + return { + path: loaded.path, + exists: loaded.exists, + config: loaded.config, + effectiveConfig: effective.config, + envOverrides: piWebConfigEnvOverrides(env), + }; +} + +export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void { + app.get("/api/config", async (_request, reply) => { + try { + return await service.read(); + } catch (error) { + return reply.code(500).send({ error: errorMessage(error) }); + } + }); + + app.put<{ Body: { config?: unknown } | undefined }>("/api/config", async (request, reply) => { + try { + return await service.write(parseConfigRequest(request.body?.config)); + } catch (error) { + const status = isConfigValidationError(error) ? 400 : 500; + return reply.code(status).send({ error: errorMessage(error) }); + } + }); +} + +function parseConfigRequest(value: unknown): PiWebConfig { + if (!isRecord(value)) throw new Error("PI WEB config update must include a config object"); + const config: PiWebConfig = {}; + const host = value["host"]; + const port = value["port"]; + const allowedHosts = value["allowedHosts"]; + const shortcuts = value["shortcuts"]; + const plugins = value["plugins"]; + 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); + if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts); + if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins); + 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 parseShortcutsRequest(value: unknown): Record { + if (!isRecord(value)) throw new Error("PI WEB config shortcuts must be an object"); + return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { + if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) throw new Error("PI WEB config shortcut values must be non-empty strings or null"); + return [actionId, shortcut]; + })); +} + +function parsePluginsRequest(value: unknown): NonNullable { + if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object"); + return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { + if (!isPiWebPluginId(pluginId)) throw new Error("PI WEB config plugin ids are invalid"); + if (!isRecord(config) || Array.isArray(config)) throw new Error("PI WEB config plugin entries must be objects"); + const enabled = config["enabled"]; + if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("PI WEB config plugin enabled values must be booleans"); + const settings = config["settings"]; + if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("PI WEB config plugin settings must be objects"); + return [pluginId, config]; + })); +} + +function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides { + return { + host: isEnvSet(env["PI_WEB_HOST"]), + port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]), + allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]), + }; +} + +function isEnvSet(value: string | undefined): boolean { + return value !== undefined && value !== ""; +} + +function isConfigValidationError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("PI WEB config"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index b709be9..038923a 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -88,6 +88,31 @@ describe("PiWebPluginService", () => { await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined(); }); + it("filters disabled plugins from the manifest while reporting them through plugin status", async () => { + await writePlugin(join(tempDir, "plugins", "enabled"), { + packageJson: { piWeb: { plugins: [{ id: "enabled", module: "pi-web-plugin.js" }] } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + await writePlugin(join(tempDir, "plugins", "disabled"), { + packageJson: { piWeb: { plugins: [{ id: "disabled", module: "pi-web-plugin.js" }] } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + + const service = new PiWebPluginService({ + roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], + packageProvider: false, + configProvider: () => ({ plugins: { disabled: { enabled: false, settings: { hidden: true } } } }), + }); + + await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "enabled" }] }); + await expect(service.plugins()).resolves.toMatchObject({ + plugins: [ + { id: "disabled", enabled: false }, + { id: "enabled", enabled: true }, + ], + }); + }); + it("skips duplicate plugin ids", async () => { await writePlugin(join(tempDir, "plugins", "one"), { packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } }, diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 8590b7c..ce942af 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -3,15 +3,22 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; -import { piWebDataDir } from "../config.js"; +import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; +import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; +import { isPiWebPluginId } from "../shared/pluginIds.js"; -const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u; +export type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; export interface PiWebPluginManifest { - plugins: { id: string; module: string; source: string; scope: PiWebPluginScope }[]; + plugins: PiWebPluginManifestEntry[]; } -export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; +export interface PiWebPluginManifestEntry { + id: string; + module: string; + source: string; + scope: PiWebPluginScope; +} export interface ConfiguredPiPackage { source: string; @@ -38,6 +45,7 @@ interface PiWebPluginServiceOptions { cwd?: string; agentDir?: string; packageProvider?: PiPackageProvider | false; + configProvider?: () => PiWebConfig; } interface LocalPluginRoot { @@ -80,28 +88,31 @@ export class DefaultPiPackageProvider implements PiPackageProvider { export class PiWebPluginService { private readonly roots: LocalPluginRoot[]; private readonly packageProvider: PiPackageProvider | undefined; + private readonly configProvider: () => PiWebConfig; constructor(options: PiWebPluginServiceOptions = {}) { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getAgentDir(); this.roots = options.roots ?? defaultPluginRoots(cwd); this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir); + this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config); } async manifest(): Promise { - const plugins = await this.discoverPlugins(); return { - plugins: plugins.map((plugin) => ({ - id: plugin.id, - module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`, - source: plugin.source, - scope: plugin.scope, - })), + plugins: (await this.plugins()).plugins + .filter((plugin) => plugin.enabled) + .map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })), }; } + async plugins(): Promise { + const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]); + return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) }; + } + async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { - if (!pluginIdPattern.test(pluginId)) return undefined; + if (!isPiWebPluginId(pluginId)) return undefined; const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId); if (plugin === undefined) return undefined; @@ -118,6 +129,16 @@ export class PiWebPluginService { return { content: await readFile(realAsset), contentType: contentTypeFor(realAsset) }; } + private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { + return { + id: plugin.id, + module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`, + source: plugin.source, + scope: plugin.scope, + enabled: config.plugins?.[plugin.id]?.enabled !== false, + }; + } + private async discoverPlugins(): Promise { const records = new Map(); for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); @@ -173,7 +194,7 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []); const plugins: PluginRecord[] = []; for (const entry of entries) { - if (!pluginIdPattern.test(entry.name)) continue; + if (!isPiWebPluginId(entry.name)) continue; const pluginRoot = join(root.path, entry.name); const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined; if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue; @@ -236,7 +257,7 @@ function parsePluginEntries(piWeb: Record, packagePath: string) if (!isRecord(entry)) throw new Error(`PI WEB plugin entry ${String(index + 1)} must be an object in ${packagePath}`); const id = entry["id"]; const module = entry["module"]; - if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`); + if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`); if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`); return { id, module }; }); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 817530a..56019c0 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -22,6 +22,52 @@ export interface MachineHealth { error?: string; } +export type PiWebShortcutConfig = Record; +export type PiWebPluginSettings = Record; +export type PiWebPluginConfigMap = Record; + +export interface PiWebPluginConfig { + enabled?: boolean; + settings?: PiWebPluginSettings; + [key: string]: unknown; +} + +export interface PiWebConfigValues { + host?: string; + port?: number; + allowedHosts?: string[] | true; + shortcuts?: PiWebShortcutConfig; + plugins?: PiWebPluginConfigMap; +} + +export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; + +export interface PiWebPluginInfo { + id: string; + module: string; + source: string; + scope: PiWebPluginScope; + enabled: boolean; +} + +export interface PiWebPluginsResponse { + plugins: PiWebPluginInfo[]; +} + +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; diff --git a/src/shared/pluginIds.ts b/src/shared/pluginIds.ts new file mode 100644 index 0000000..a693aff --- /dev/null +++ b/src/shared/pluginIds.ts @@ -0,0 +1,5 @@ +export const piWebPluginIdPattern = /^[a-z][a-z0-9.-]*$/u; + +export function isPiWebPluginId(value: string): boolean { + return piWebPluginIdPattern.test(value); +} diff --git a/tsconfig.json b/tsconfig.json index 937bb87..40866c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,6 @@ "vitest.config.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", - "plugins/**/*.ts", "plugin-api.d.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts index 5c2e530..e8f564d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts", "plugins/**/*.test.ts"], + include: ["src/**/*.test.ts", "pi-web-plugins/**/*.test.ts"], }, });