Archived
Merge remote-tracking branch 'origin/main' into review/pr-5-machine-federation-fixes
# Conflicts: # src/client/src/api.ts # src/client/src/api/clients.ts # src/client/src/api/parsers.ts # src/client/src/components/PiWebApp.ts # src/server/app.ts # src/shared/apiTypes.ts
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Rename the built-in Updates plugin id from `pi-web` to `updates` for clearer plugin configuration.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.<plugin-id>.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
|
||||
|
||||
+127
-3
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PI WEB plugins</title>
|
||||
<meta name="description" content="Use and develop trusted local PI WEB UI plugins." />
|
||||
<meta name="description" content="Use built-in PI WEB plugins and develop trusted local UI plugins." />
|
||||
<meta property="og:title" content="PI WEB plugins" />
|
||||
<meta property="og:image" content="assets/pi-web-banner.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
@@ -52,7 +52,7 @@
|
||||
<section class="page-hero">
|
||||
<div class="container">
|
||||
<p class="eyebrow"><span class="pulse"></span> Plugin development</p>
|
||||
<h1>Customize PI WEB with local UI plugins.</h1>
|
||||
<h1>Customize PI WEB with UI plugins.</h1>
|
||||
<p>
|
||||
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 @@
|
||||
<a href="#extend">What can be extended</a>
|
||||
<a href="#ask-ai">What to ask AI to build</a>
|
||||
<a href="#example">Canonical example</a>
|
||||
<a href="#built-in-plugins">Built-in plugins</a>
|
||||
<a href="#manage-plugins">Manage plugins</a>
|
||||
<a href="#production">Production usage</a>
|
||||
<a href="#agent-docs">AI-friendly docs</a>
|
||||
<a href="#develop">Develop and debug</a>
|
||||
@@ -160,11 +162,133 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
If you copy it, choose a new plugin id so it does not conflict with the bundled <code>info</code> plugin.
|
||||
</p>
|
||||
<p>
|
||||
The bundled <code>pi-web</code> status plugin demonstrates dynamic <code>visible</code> and <code>badge</code>
|
||||
The bundled <code>updates</code> plugin demonstrates dynamic <code>visible</code> and <code>badge</code>
|
||||
callbacks for tabs that only appear when the host has status messages or needs extra install visibility.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="built-in-plugins">
|
||||
<h2>Built-in plugins</h2>
|
||||
<p>
|
||||
PI WEB ships core, discoverable plugins in the main <code>@jmfederico/pi-web</code> npm package. No
|
||||
separate <code>pi install</code> step is required: update PI WEB, reload the browser tab, and the bundled
|
||||
plugins appear in <code>/pi-web-plugins/manifest.json</code>.
|
||||
</p>
|
||||
<p>
|
||||
Built-in plugins can be managed from <strong>Settings → Plugins</strong> or with the top-level
|
||||
<code>plugins</code> config key.
|
||||
</p>
|
||||
|
||||
<h3>Updates</h3>
|
||||
<p>
|
||||
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
||||
restart, and installed-service guidance. It is built into PI WEB and enabled by default.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Plugin id: <code>updates</code></li>
|
||||
</ul>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Disable Updates</strong>
|
||||
<button class="copy-button" data-copy="#updates-disable">Copy</button>
|
||||
</div>
|
||||
<pre id="updates-disable"><code>{
|
||||
"plugins": {
|
||||
"updates": { "enabled": false }
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<h3>Workspace Tasks</h3>
|
||||
<p>
|
||||
<strong>Workspace Tasks</strong> adds a <strong>Tasks</strong> workspace tab for running configured shell
|
||||
commands in dedicated PI WEB terminals. It is built into PI WEB and enabled by default.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Plugin id: <code>workspace-tasks</code></li>
|
||||
<li>Config file: <code>.pi-web/tasks.json</code></li>
|
||||
</ul>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Disable Workspace Tasks</strong>
|
||||
<button class="copy-button" data-copy="#workspace-tasks-disable">Copy</button>
|
||||
</div>
|
||||
<pre id="workspace-tasks-disable"><code>{
|
||||
"plugins": {
|
||||
"workspace-tasks": { "enabled": false }
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Example .pi-web/tasks.json</strong>
|
||||
<button class="copy-button" data-copy="#workspace-tasks-config">Copy</button>
|
||||
</div>
|
||||
<pre id="workspace-tasks-config"><code>{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Open a workspace, choose the <strong>Tasks</strong> tab, and click <strong>Run</strong> next to a task.
|
||||
Commands run in the workspace root because PI WEB creates the terminal for that workspace.
|
||||
</p>
|
||||
<p>
|
||||
Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted
|
||||
shell commands from your repositories.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="manage-plugins">
|
||||
<h2>Manage plugins</h2>
|
||||
<p>
|
||||
Open <strong>Settings → Plugins</strong> 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.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Plugin config shape</strong>
|
||||
<button class="copy-button" data-copy="#plugin-config-shape">Copy</button>
|
||||
</div>
|
||||
<pre id="plugin-config-shape"><code>{
|
||||
"plugins": {
|
||||
"workspace-tasks": {
|
||||
"enabled": true,
|
||||
"settings": {}
|
||||
},
|
||||
"info": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Plugins are enabled by default. Set <code>enabled</code> to <code>false</code> to remove a plugin from
|
||||
<code>/pi-web-plugins/manifest.json</code> so it is not imported or activated on the next page load.
|
||||
The optional <code>settings</code> object is reserved for plugin-specific settings.
|
||||
</p>
|
||||
<p>
|
||||
After changing plugin enablement, reload the PI WEB browser tab. Already-loaded plugin JavaScript is not
|
||||
unloaded from the current page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="production">
|
||||
<h2>Production usage</h2>
|
||||
<p>
|
||||
|
||||
+108
-18
@@ -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 <package>` and `npm publish --workspace <package>` produce a usable plugin package;
|
||||
- use a local symlink into `~/.pi-web/plugins/<plugin-id>` 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`
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 10v6"></path>
|
||||
<path d="M12 7h.01"></path>
|
||||
</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:
|
||||
|
||||
Generated
-29
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-10
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 11v5"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
`,
|
||||
order: 1000,
|
||||
render: (context) => html`
|
||||
<section class="toolbar"><strong>Info</strong></section>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@pi-web/status-plugin",
|
||||
"private": true,
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{ "id": "pi-web", "module": "pi-web-plugin.js" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@pi-web/updates-plugin",
|
||||
"private": true,
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{ "id": "updates", "module": "pi-web-plugin.js" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<div class="pi-web-version-row">
|
||||
<div class="updates-version-row">
|
||||
<strong>${component.label}</strong>
|
||||
<span>${status}</span>
|
||||
<small>running ${formatVersion(component.runtimeVersion)} · installed ${formatVersion(component.installedVersion)}</small>
|
||||
@@ -61,7 +61,7 @@ function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus)
|
||||
|
||||
function renderCommand(html: HtmlTemplateTag, label: string, command: string): TemplateResult {
|
||||
return html`
|
||||
<div class="pi-web-command">
|
||||
<div class="updates-command">
|
||||
<span>${label}</span>
|
||||
<code>${command}</code>
|
||||
<button @click=${() => { void navigator.clipboard.writeText(command); }}>Copy</button>
|
||||
@@ -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`
|
||||
<style>
|
||||
.viewer.pi-web-status { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 12px; overflow-y: auto; overflow-x: hidden; }
|
||||
.viewer.pi-web-status section { flex: 0 0 auto; min-width: 0; display: grid; gap: 8px; }
|
||||
.pi-web-message { display: grid; gap: 5px; border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; background: var(--pi-surface); }
|
||||
.pi-web-message.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
|
||||
.pi-web-message.error { border-color: var(--pi-danger); }
|
||||
.pi-web-message-title { display: flex; gap: 8px; align-items: baseline; }
|
||||
.pi-web-message-title span { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.pi-web-version-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; }
|
||||
.pi-web-version-row small { grid-column: 1 / -1; color: var(--pi-muted); }
|
||||
.pi-web-command { min-width: 0; display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.pi-web-command code { overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); padding: 5px 7px; white-space: nowrap; }
|
||||
.pi-web-meta { display: grid; gap: 2px; color: var(--pi-muted); font-size: 12px; }
|
||||
.viewer.updates-status { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 12px; overflow-y: auto; overflow-x: hidden; }
|
||||
.viewer.updates-status section { flex: 0 0 auto; min-width: 0; display: grid; gap: 8px; }
|
||||
.updates-message { display: grid; gap: 5px; border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; background: var(--pi-surface); }
|
||||
.updates-message.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
|
||||
.updates-message.error { border-color: var(--pi-danger); }
|
||||
.updates-message-title { display: flex; gap: 8px; align-items: baseline; }
|
||||
.updates-message-title span { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.updates-version-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; }
|
||||
.updates-version-row small { grid-column: 1 / -1; color: var(--pi-muted); }
|
||||
.updates-command { min-width: 0; display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.updates-command code { overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); padding: 5px 7px; white-space: nowrap; }
|
||||
.updates-meta { display: grid; gap: 2px; color: var(--pi-muted); font-size: 12px; }
|
||||
@media (max-width: 520px) {
|
||||
.pi-web-command { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.pi-web-command > span { grid-column: 1 / -1; }
|
||||
.updates-command { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.updates-command > span { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
<section class="toolbar"><strong>Updates</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
|
||||
<section class="viewer pi-web-status">
|
||||
<section class="viewer updates-status">
|
||||
<section>
|
||||
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
|
||||
<article class=${`pi-web-message ${message.severity}`}>
|
||||
<div class="pi-web-message-title"><strong>${message.title}</strong><span>${message.severity}</span></div>
|
||||
<article class=${`updates-message ${message.severity}`}>
|
||||
<div class="updates-message-title"><strong>${message.title}</strong><span>${message.severity}</span></div>
|
||||
<p>${message.body}</p>
|
||||
${message.command === undefined ? null : html`<code>${message.command}</code>`}
|
||||
</article>
|
||||
@@ -136,7 +136,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu
|
||||
|
||||
${renderCommands(html, status)}
|
||||
|
||||
<section class="pi-web-meta">
|
||||
<section class="updates-meta">
|
||||
<span>Generated ${status.generatedAt}</span>
|
||||
${status.release.latestVersion === undefined ? null : html`<span>Latest npm release ${status.release.latestVersion}</span>`}
|
||||
${status.release.skipped === true ? html`<span>Remote version check skipped.</span>` : 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`
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6v5h-5"></path>
|
||||
<path d="M4 18v-5h5"></path>
|
||||
<path d="M18.4 9A7 7 0 0 0 6.1 6.7L4 8.8"></path>
|
||||
<path d="M5.6 15A7 7 0 0 0 17.9 17.3L20 15.2"></path>
|
||||
</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),
|
||||
},
|
||||
],
|
||||
},
|
||||
+24
-24
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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,
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@pi-web/workspace-tasks-plugin",
|
||||
"private": true,
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{ "id": "workspace-tasks", "module": "pi-web-plugin.js" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6h11"></path>
|
||||
<path d="M9 12h11"></path>
|
||||
<path d="M9 18h11"></path>
|
||||
<path d="m4 6 .8 .8L6.5 5"></path>
|
||||
<path d="m4 12 .8 .8 1.7-1.8"></path>
|
||||
<path d="m4 18 .8 .8 1.7-1.8"></path>
|
||||
</svg>
|
||||
`,
|
||||
order: 40,
|
||||
badge: ({ workspace }) => tasksPanelBadge(workspace),
|
||||
render: (context) => html`<pi-web-workspace-tasks-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-workspace-tasks-panel>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
+9
-9
@@ -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<InternalTerminalCommandRunsRuntime["runCommand"]>(() => 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<InternalTerminalCommandRunsRuntime["runCommand"]> {
|
||||
return terminal.runCommand({
|
||||
workspace,
|
||||
title: task.title,
|
||||
command: task.command,
|
||||
open: true,
|
||||
metadata: {
|
||||
"pi.plugin": "workspace-tasks",
|
||||
"task.id": task.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
+81
-80
@@ -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<string, ConfigState>();
|
||||
|
||||
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()}<section class="empty">Select a workspace.</section>`;
|
||||
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrLoadWorkspaceConfig(workspace);
|
||||
this.root.innerHTML = `
|
||||
${actionStyles()}
|
||||
${taskStyles()}
|
||||
<section class="toolbar">
|
||||
<strong>Workspace Actions</strong>
|
||||
<span class="toolbar-actions">
|
||||
<strong>Workspace Tasks</strong>
|
||||
<span class="toolbar-tasks">
|
||||
<button class="secondary" data-refresh-config ${state.kind === "loading" ? "disabled" : ""}>Refresh</button>
|
||||
<button class="secondary" data-open-terminal>Open Terminal</button>
|
||||
</span>
|
||||
</section>
|
||||
${this.renderStatus()}
|
||||
<section class="viewer actions-viewer">
|
||||
<section class="viewer tasks-viewer">
|
||||
${this.renderConfigState(state)}
|
||||
</section>
|
||||
`;
|
||||
@@ -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<void> {
|
||||
private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise<void> {
|
||||
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 `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>`;
|
||||
if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(TASKS_CONFIG_PATH)}…</p>`;
|
||||
if (state.kind === "missing") return renderMissingState(state);
|
||||
if (state.kind === "unavailable") return renderUnavailableState(state);
|
||||
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>`;
|
||||
|
||||
if (state.config.tasks.length === 0) return `<p class="muted">No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.</p>`;
|
||||
return `
|
||||
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
|
||||
${renderActionGroups(state.config.actions, this.runningActionId)}
|
||||
<p class="muted">Tasks run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(state.path)} and click Refresh to reload.</p>
|
||||
${renderTaskGroups(state.config.tasks, this.runningTaskId)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -150,26 +151,26 @@ class PiWebActionsPanel extends HTMLElement {
|
||||
}
|
||||
|
||||
private async refreshConfig(workspace: Workspace): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ConfigState> {
|
||||
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<ConfigState, { kind: "unavailable
|
||||
return `<div class="status error"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p>${detail}</div>`;
|
||||
}
|
||||
|
||||
function renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string {
|
||||
return `<div class="actions">${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}</div>`;
|
||||
function renderTaskGroups(tasks: WorkspaceTask[], runningTaskId: string | undefined): string {
|
||||
return `<div class="tasks">${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}</div>`;
|
||||
}
|
||||
|
||||
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 ? "" : `<h3>${escapeHtml(group.title)}</h3>`;
|
||||
return `<section class="action-group">${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}</section>`;
|
||||
return `<section class="task-group">${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}</section>`;
|
||||
}
|
||||
|
||||
function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string {
|
||||
const running = runningActionId === action.id;
|
||||
const disabled = runningActionId !== undefined;
|
||||
const description = action.description === undefined ? "" : `<span>${escapeHtml(action.description)}</span>`;
|
||||
function renderTask(task: WorkspaceTask, runningTaskId: string | undefined): string {
|
||||
const running = runningTaskId === task.id;
|
||||
const disabled = runningTaskId !== undefined;
|
||||
const description = task.description === undefined ? "" : `<span>${escapeHtml(task.description)}</span>`;
|
||||
return `
|
||||
<article class="action-card">
|
||||
<div class="action-copy">
|
||||
<strong>${escapeHtml(action.title)}</strong>
|
||||
<article class="task-card">
|
||||
<div class="task-copy">
|
||||
<strong>${escapeHtml(task.title)}</strong>
|
||||
${description}
|
||||
<code>${escapeHtml(action.command)}</code>
|
||||
<code>${escapeHtml(task.command)}</code>
|
||||
</div>
|
||||
<button data-action-id="${escapeAttr(action.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
|
||||
<button data-task-id="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<style>
|
||||
:host { display: contents; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
.toolbar-actions { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.toolbar-tasks { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.viewer { box-sizing: border-box; min-height: 0; overflow: auto; padding: 12px; }
|
||||
.actions-viewer { display: grid; align-content: start; gap: 12px; }
|
||||
.actions { display: grid; gap: 14px; }
|
||||
.action-group { display: grid; gap: 10px; }
|
||||
.action-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.action-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.action-copy { display: grid; min-width: 0; gap: 5px; }
|
||||
.action-copy span, .muted { color: var(--pi-muted); }
|
||||
.tasks-viewer { display: grid; align-content: start; gap: 12px; }
|
||||
.tasks { display: grid; gap: 14px; }
|
||||
.task-group { display: grid; gap: 10px; }
|
||||
.task-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.task-copy { display: grid; min-width: 0; gap: 5px; }
|
||||
.task-copy span, .muted { color: var(--pi-muted); }
|
||||
code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
code { overflow: auto; padding: 5px 7px; white-space: nowrap; }
|
||||
pre { margin: 8px 0 0; overflow: auto; padding: 8px; white-space: pre-wrap; }
|
||||
@@ -332,8 +333,8 @@ function actionStyles(): string {
|
||||
.status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
|
||||
.empty { padding: 16px; color: var(--pi-muted); }
|
||||
@media (max-width: 760px) {
|
||||
.action-card { grid-template-columns: 1fr; }
|
||||
.action-card button { justify-self: start; }
|
||||
.task-card { grid-template-columns: 1fr; }
|
||||
.task-card button { justify-self: start; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
@@ -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 });
|
||||
}
|
||||
+24
-24
@@ -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<Response>;
|
||||
|
||||
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<WorkspaceActionsConfigLoadResult> {
|
||||
): Promise<WorkspaceTasksConfigLoadResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Vendored
+5
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<InternalTerminalCommandRunsRuntime["runCommand"]> {
|
||||
return terminal.runCommand({
|
||||
workspace,
|
||||
title: action.title,
|
||||
command: action.command,
|
||||
open: true,
|
||||
metadata: {
|
||||
"pi.plugin": "actions",
|
||||
"action.id": action.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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`<pi-web-actions-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-actions-panel>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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} <span class="tab-badge">${badge}</span>`;
|
||||
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<void> {
|
||||
@@ -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 }); }}
|
||||
></app-context-bar>
|
||||
`;
|
||||
}
|
||||
@@ -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`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
|
||||
<section class="settings-shell" role="dialog" aria-modal="true" aria-label="PI WEB settings" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||
<header class="settings-header">
|
||||
<div>
|
||||
<span class="eyebrow">Settings</span>
|
||||
<h1>PI WEB</h1>
|
||||
</div>
|
||||
<button class="close-button" title="Close settings" aria-label="Close settings" @click=${() => this.onClose?.()}>×</button>
|
||||
</header>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Server config")}
|
||||
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
||||
</nav>
|
||||
<main class="settings-content">
|
||||
${this.renderActiveSection()}
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActiveSection(): TemplateResult {
|
||||
if (this.section === "shortcuts") {
|
||||
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
<settings-plugins-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.pluginsResponse=${this.pluginsResponse}
|
||||
.loading=${this.loading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
|
||||
></settings-plugins-panel>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<settings-general-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
></settings-general-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavButton(section: SettingsSection, label: string, detail: string): TemplateResult {
|
||||
const selected = this.section === section;
|
||||
return html`
|
||||
<button class=${selected ? "selected" : ""} aria-current=${selected ? "page" : "false"} @click=${() => { this.navigate(section); }}>
|
||||
<strong>${label}</strong>
|
||||
<small>${detail}</small>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private navigate(section: SettingsSection): void {
|
||||
this.onNavigate?.(section);
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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 {
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
${this.refreshControl === undefined ? null : html`<div class="context-actions">${this.refreshControl}</div>`}
|
||||
${this.hasContextActions() ? html`<div class="context-actions">${this.renderActionsButton()}${this.refreshControl}</div>` : null}
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActionsButton() {
|
||||
if (this.onShowActions === undefined) return null;
|
||||
return html`
|
||||
<button type="button" class="context-action-button" title="Show Actions" aria-label="Show Actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.onShowActions?.(); }}>
|
||||
<svg class="context-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M13 2 4 14h7l-1 8 10-13h-7V2Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private contextBarClass(): string {
|
||||
const classes = ["context-bar"];
|
||||
if (this.refreshControl !== undefined) classes.push("has-context-actions");
|
||||
if (this.hasContextActions()) classes.push("has-context-actions");
|
||||
if (this.refreshControl !== undefined && this.onShowActions !== undefined) classes.push("has-context-actions-double");
|
||||
if (this.canScrollLeft) classes.push("can-scroll-left");
|
||||
if (this.canScrollRight) classes.push("can-scroll-right");
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
private hasContextActions(): boolean {
|
||||
return this.refreshControl !== undefined || this.onShowActions !== undefined;
|
||||
}
|
||||
|
||||
private observeContextItems(): void {
|
||||
const contextItems = this.contextItemsElement();
|
||||
if (this.observedContextItems === contextItems) return;
|
||||
@@ -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; }
|
||||
|
||||
@@ -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`
|
||||
<div class=${this.frameClass()}>
|
||||
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
|
||||
${this.tabs.map((tab) => html`
|
||||
<button class=${this.tabClass(tab)} @click=${() => { this.onSelect?.(tab.id); }}>${tab.label}</button>
|
||||
`)}
|
||||
${this.tabs.map((tab) => {
|
||||
const selected = this.selectedView === tab.id;
|
||||
return html`
|
||||
<button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}>
|
||||
${this.renderTabMark(tab, fallbackLabels)}
|
||||
<span class="tab-label">${tab.label}</span>
|
||||
${this.isEmptyBadge(tab.badge) ? null : html`<span class="tab-badge">${tab.badge}</span>`}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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<AppState["mainView"], string>) {
|
||||
return tab.icon === undefined
|
||||
? html`<span class="tab-fallback" aria-hidden="true">${fallbackLabels.get(tab.id) ?? this.initialsLabel(tab.label)}</span>`
|
||||
: this.renderIcon(tab.icon);
|
||||
}
|
||||
|
||||
private fallbackLabels(): Map<AppState["mainView"], string> {
|
||||
const fallbackTabs = this.tabs.filter((tab) => tab.icon === undefined);
|
||||
const counts = new Map<string, number>();
|
||||
for (const tab of fallbackTabs) {
|
||||
const initials = this.initialsLabel(tab.label);
|
||||
counts.set(initials, (counts.get(initials) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const labels = new Map<AppState["mainView"], string>();
|
||||
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`<span class="tab-custom-icon" aria-hidden="true">${icon}</span>`;
|
||||
switch (icon) {
|
||||
case "navigation":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="6" cy="7" r="1.5"></circle>
|
||||
<path d="M10 7h8"></path>
|
||||
<circle cx="6" cy="12" r="1.5"></circle>
|
||||
<path d="M10 12h8"></path>
|
||||
<circle cx="6" cy="17" r="1.5"></circle>
|
||||
<path d="M10 17h8"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "chat":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M7 5h10a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-6l-5 4v-4H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z"></path>
|
||||
<path d="M8 9h8"></path>
|
||||
<path d="M8 13h5"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "files":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "git":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="6" cy="6" r="2"></circle>
|
||||
<circle cx="18" cy="6" r="2"></circle>
|
||||
<circle cx="12" cy="18" r="2"></circle>
|
||||
<path d="M8 6h6"></path>
|
||||
<path d="M6 8v2a6 6 0 0 0 6 6"></path>
|
||||
<path d="M18 8v2a6 6 0 0 1-6 6"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "terminal":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
|
||||
<path d="m7 10 3 3-3 3"></path>
|
||||
<path d="M12 16h5"></path>
|
||||
</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; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<void>;
|
||||
@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()}</button>
|
||||
${this.renderMenu()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMenu() {
|
||||
if (!this.menuOpen) return null;
|
||||
return html`
|
||||
<div class="app-refresh-menu" role="menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
<button role="menuitem" @click=${() => { this.refresh(); }}>Refresh app data</button>
|
||||
<button role="menuitem" @click=${() => { this.reload(); }}>Full page reload</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private draft: ConfigDraft = emptyConfigDraft();
|
||||
@state() private localError = "";
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>): 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`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>General configuration</h2>
|
||||
<p>Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Host</span>
|
||||
${this.renderOverrideBadge("host")}
|
||||
</span>
|
||||
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}>
|
||||
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Port</span>
|
||||
${this.renderOverrideBadge("port")}
|
||||
</span>
|
||||
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}>
|
||||
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allowed hosts</span>
|
||||
${this.renderOverrideBadge("allowedHosts")}
|
||||
</span>
|
||||
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
|
||||
<option value="list">Only listed hosts</option>
|
||||
<option value="all">Allow every host</option>
|
||||
</select>
|
||||
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local 192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
const error = this.localError || this.error;
|
||||
if (error !== "") return html`<div class="message error-message">${error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
|
||||
if (this.configResponse?.envOverrides[key] !== true) return null;
|
||||
return html`<span class="override-badge">environment override</span>`;
|
||||
}
|
||||
|
||||
private renderEffectiveConfig(): TemplateResult {
|
||||
const effective = this.configResponse?.effectiveConfig ?? {};
|
||||
return html`
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveConfig(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
this.localError = "";
|
||||
try {
|
||||
await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {}));
|
||||
} catch (error) {
|
||||
this.localError = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateDraft(patch: Partial<ConfigDraft>): 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`<span class="muted">None listed</span>` : value.join(", ");
|
||||
return html`<span class="muted">Unset</span>`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
|
||||
|
||||
override render(): TemplateResult {
|
||||
const plugins = this.pluginsResponse?.plugins ?? [];
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Plugins</h2>
|
||||
<p>Enable or disable discovered PI WEB plugins. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
|
||||
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No external or bundled plugins discovered.</div>` : html`
|
||||
<div class="plugin-list">
|
||||
${plugins.map((plugin) => this.renderPlugin(plugin))}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage} Reload the browser tab to apply plugin changes.</div>`;
|
||||
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`
|
||||
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
|
||||
<div class="plugin-main">
|
||||
<strong>${plugin.id}</strong>
|
||||
<small>${plugin.source} · ${plugin.scope}</small>
|
||||
<small>${configuredState}</small>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
|
||||
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
|
||||
</label>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private async togglePlugin(plugin: PiWebPluginInfo, event: Event): Promise<void> {
|
||||
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; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -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`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>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 <code>null</code> to disable it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcut-note">Config key: <code>shortcuts</code>. Example: <code>{ "core:view.chat": "mod+1", "core:session.stop": null }</code></div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
<small class="shortcut-id">${action.id}</small>
|
||||
</div>
|
||||
<div class="shortcut-value">
|
||||
${shortcut !== undefined && shortcut !== "" ? html`<kbd>${formatShortcut(shortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${state}>${shortcutStateLabel(state)}</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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<string, AppAction[]>();
|
||||
for (const action of [...actions].sort(compareActions)) {
|
||||
const group = action.group ?? "Other";
|
||||
grouped.set(group, [...(grouped.get(group) ?? []), action]);
|
||||
}
|
||||
return [...grouped.entries()].map(([name, groupActions]) => ({ name, actions: groupActions }));
|
||||
}
|
||||
|
||||
function compareActions(left: AppAction, right: AppAction): number {
|
||||
return (left.group ?? "Other").localeCompare(right.group ?? "Other") || left.title.localeCompare(right.title);
|
||||
}
|
||||
|
||||
function 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";
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 !== "");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -18,6 +18,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
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`<svg viewBox="0 0 24 24"><path d="M4 6h16"></path></svg>`,
|
||||
render: () => html`<p>Logs</p>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
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"],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>): AppAction {
|
||||
return { id: "action", title: "Action", run: noop, ...patch };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
+63
-7
@@ -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<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
return {
|
||||
...(config.host !== undefined ? { host: config.host } : {}),
|
||||
...(config.port !== undefined ? { port: config.port } : {}),
|
||||
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
|
||||
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebConfig(value: Record<string, unknown>, 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<string, string | null> {
|
||||
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<PiWebConfigValues["plugins"]> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+5
-1
@@ -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<PiWebPluginService, "manifest" | "readAsset">;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
}
|
||||
@@ -100,6 +102,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
|
||||
app.get("/api/pi-web/status", async () => getPiWebStatus());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerConfigRoutes(app, deps.config);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let savedConfig: PiWebConfigValues;
|
||||
let service: PiWebConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
savedConfig = { host: "127.0.0.1", port: 8504, allowedHosts: [] };
|
||||
service = {
|
||||
read: vi.fn(() => responseFor(savedConfig, true)),
|
||||
write: vi.fn((config: PiWebConfigValues) => {
|
||||
savedConfig = config;
|
||||
return responseFor(savedConfig, true);
|
||||
}),
|
||||
};
|
||||
app = Fastify({ logger: false });
|
||||
registerConfigRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("config routes", () => {
|
||||
it("returns the PI WEB config contract", async () => {
|
||||
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual(responseFor(savedConfig, true));
|
||||
});
|
||||
|
||||
it("updates config through the service", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, 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<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
it("rejects invalid config payloads before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: 42 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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<PiWebConfigResponse>;
|
||||
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
|
||||
}
|
||||
|
||||
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
|
||||
return {
|
||||
read: () => currentPiWebConfigResponse(options),
|
||||
write: (config) => {
|
||||
savePiWebConfig(config, options);
|
||||
return currentPiWebConfigResponse(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse {
|
||||
const loaded = loadPiWebConfig(options);
|
||||
const effective = effectivePiWebConfig(options);
|
||||
const env = options.env ?? process.env;
|
||||
return {
|
||||
path: loaded.path,
|
||||
exists: loaded.exists,
|
||||
config: loaded.config,
|
||||
effectiveConfig: effective.config,
|
||||
envOverrides: piWebConfigEnvOverrides(env),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
|
||||
app.get("/api/config", async (_request, reply) => {
|
||||
try {
|
||||
return await service.read();
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Body: { config?: unknown } | undefined }>("/api/config", async (request, reply) => {
|
||||
try {
|
||||
return await service.write(parseConfigRequest(request.body?.config));
|
||||
} catch (error) {
|
||||
const status = isConfigValidationError(error) ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
|
||||
const config: PiWebConfig = {};
|
||||
const host = value["host"];
|
||||
const port = value["port"];
|
||||
const allowedHosts = value["allowedHosts"];
|
||||
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<string, string | null> {
|
||||
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<PiWebConfig["plugins"]> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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" }] } },
|
||||
|
||||
@@ -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<PiWebPluginManifest> {
|
||||
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<PiWebPluginsResponse> {
|
||||
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<PluginRecord[]> {
|
||||
const records = new Map<string, PluginRecord>();
|
||||
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
|
||||
@@ -173,7 +194,7 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
|
||||
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<string, unknown>, 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 };
|
||||
});
|
||||
|
||||
@@ -22,6 +22,52 @@ export interface MachineHealth {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type PiWebShortcutConfig = Record<string, string | null>;
|
||||
export type PiWebPluginSettings = Record<string, unknown>;
|
||||
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const piWebPluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
|
||||
export function isPiWebPluginId(value: string): boolean {
|
||||
return piWebPluginIdPattern.test(value);
|
||||
}
|
||||
@@ -36,7 +36,6 @@
|
||||
"vitest.config.ts",
|
||||
"extensions/**/*.ts",
|
||||
"pi-web-plugins/**/*.ts",
|
||||
"plugins/**/*.ts",
|
||||
"plugin-api.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user