Archived
feat(plugins): turn Info panel into a PI WEB status view
Rework the bundled Info plugin from a demo into an always-available status view: running and installed versions, installation details, release state, and per-service health rendered from host-provided state, plus machine and workspace details. Replace the window.alert demo action with a Copy PI WEB Diagnostics action that copies a plain-text summary for bug reports. Add state.selectedMachine to the stable plugin runtime state so actions and other runtime callbacks can read the selected machine's identity, and split the plugin into a copy-paste-friendly skeleton (pi-web-plugin.ts) and replaceable internals (infoInternals.ts).
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Turn the bundled Info plugin panel into an always-available PI WEB status view: it now shows the running and installed versions, installation kind and path, release state, per-service health, and machine and workspace details from host-provided status, plus a "Copy PI WEB Diagnostics" action that copies a plain-text summary for bug reports.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add `state.selectedMachine` to the stable plugin runtime state so plugin actions and other runtime callbacks can read the selected machine's identity, not just workspace panel contexts.
|
||||
+5
-1
@@ -231,13 +231,17 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>pi-web-plugins/info/package.json</code> shows the required metadata shape.</li>
|
||||
<li><code>pi-web-plugins/info/pi-web-plugin.ts</code> shows the TypeScript source shape.</li>
|
||||
<li><code>pi-web-plugins/info/pi-web-plugin.ts</code> is the skeleton: metadata and contribution definitions.</li>
|
||||
<li><code>pi-web-plugins/info/infoInternals.ts</code> holds the bundled panel and action implementation, which you can ignore or replace when copying the plugin.</li>
|
||||
<li><code>dist/pi-web-plugins/info/pi-web-plugin.js</code> is the built browser module in a checkout.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Read it on GitHub:
|
||||
<a href="https://github.com/jmfederico/pi-web/tree/main/pi-web-plugins/info">pi-web-plugins/info</a>.
|
||||
If you copy it, choose a new plugin id so it does not conflict with the bundled <code>info</code> plugin.
|
||||
Its panel also doubles as an always-available PI WEB status view: it renders the host-provided
|
||||
<code>context.state.piWebStatus</code> without issuing its own requests, and its action copies a
|
||||
plain-text diagnostics summary suitable for bug reports.
|
||||
</p>
|
||||
<p>
|
||||
The bundled <code>updates</code> plugin demonstrates dynamic <code>visible</code> and <code>badge</code>
|
||||
|
||||
+14
-9
@@ -90,8 +90,11 @@ Source files:
|
||||
```text
|
||||
pi-web-plugins/info/package.json
|
||||
pi-web-plugins/info/pi-web-plugin.ts
|
||||
pi-web-plugins/info/infoInternals.ts
|
||||
```
|
||||
|
||||
`pi-web-plugin.ts` is the plugin skeleton: metadata plus contribution definitions. `infoInternals.ts` holds everything the bundled panel and action actually render, so you can ignore or replace it when copying the plugin.
|
||||
|
||||
Built module:
|
||||
|
||||
```text
|
||||
@@ -130,6 +133,8 @@ export default {
|
||||
|
||||
When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin.
|
||||
|
||||
The Info panel doubles as an always-available PI WEB status view: it renders the host-provided `context.state.piWebStatus` (versions, installation, release state, machine, and workspace details) without issuing its own requests, and its action copies a plain-text diagnostics summary suitable for bug reports.
|
||||
|
||||
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
|
||||
@@ -433,14 +438,13 @@ Actions appear in the action palette. They can inspect app state and call UI/run
|
||||
```js
|
||||
actions: [
|
||||
{
|
||||
id: "workspace.show-path",
|
||||
title: "Show Current Workspace Path",
|
||||
description: "Display the selected workspace path",
|
||||
shortcut: "mod+shift+p",
|
||||
id: "copy-diagnostics",
|
||||
title: "Copy PI WEB Diagnostics",
|
||||
description: "Copy version, installation, and status details for this machine",
|
||||
group: "Info",
|
||||
enabled: (context) => context.state.selectedWorkspace !== undefined,
|
||||
run: (context) => {
|
||||
window.alert(context.state.selectedWorkspace?.path ?? "No workspace selected");
|
||||
run: async (context) => {
|
||||
const version = context.state.piWebStatus?.components.web.runtimeVersion ?? "unknown";
|
||||
await navigator.clipboard.writeText(`PI WEB ${version}`);
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -468,6 +472,7 @@ Stable runtime context fields:
|
||||
```ts
|
||||
interface PluginRuntimeContext {
|
||||
state: {
|
||||
selectedMachine?: PluginMachine;
|
||||
selectedWorkspace?: Workspace;
|
||||
selectedSession?: unknown;
|
||||
piWebStatus?: PiWebStatusResponse;
|
||||
@@ -492,7 +497,7 @@ interface PluginRuntimeContext {
|
||||
Notes:
|
||||
|
||||
- `state` is a snapshot of current UI state when actions are built.
|
||||
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. `state.piWebStatus` describes the currently selected machine's PI WEB runtime, or the gateway/local runtime when the local machine is selected.
|
||||
- The stable state fields are `state.selectedMachine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. `state.selectedMachine` identifies the currently selected machine. `state.piWebStatus` describes the currently selected machine's PI WEB runtime, or the gateway/local runtime when the local machine is selected.
|
||||
- Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear.
|
||||
- `enabled` is evaluated when the action palette asks for actions.
|
||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
||||
@@ -957,7 +962,7 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
|
||||
9. Add workspace panels for larger workspace UI.
|
||||
10. Add workspace labels for compact inline metadata.
|
||||
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
|
||||
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, and `prompt`.
|
||||
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedMachine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, and `prompt`.
|
||||
13. Do not fetch PI WEB `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers.
|
||||
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
|
||||
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebComponentStatus, PiWebReleaseStatus, PiWebStatusResponse, PluginMachine, Workspace } from "@jmfederico/pi-web/plugin-api";
|
||||
import { componentDetails, componentHealth, diagnosticsSummary, formatVersion, installationLabel, machineKindLabel, releaseSummary, workspaceFlags } from "./infoInternals.js";
|
||||
|
||||
describe("componentHealth", () => {
|
||||
it("reports current when the component is available and not stale", () => {
|
||||
expect(componentHealth(componentStatus())).toBe("current");
|
||||
});
|
||||
|
||||
it("reports restart needed when the installed version is newer than the running one", () => {
|
||||
expect(componentHealth(componentStatus({ stale: true }))).toBe("restart needed");
|
||||
});
|
||||
|
||||
it("reports unavailable when the component cannot be reached", () => {
|
||||
expect(componentHealth(componentStatus({ available: false, stale: true }))).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseSummary", () => {
|
||||
it("names the latest version when an update is available", () => {
|
||||
expect(releaseSummary(release({ updateAvailable: true, latestVersion: "1.2.3" }))).toBe("Update available: 1.2.3");
|
||||
});
|
||||
|
||||
it("still reports an update when the latest version is unknown", () => {
|
||||
expect(releaseSummary(release({ updateAvailable: true }))).toBe("Update available");
|
||||
});
|
||||
|
||||
it("surfaces a failed release check", () => {
|
||||
expect(releaseSummary(release({ error: "registry unreachable" }))).toBe("Update check failed: registry unreachable");
|
||||
});
|
||||
|
||||
it("reports skipped checks distinctly from an up-to-date install", () => {
|
||||
expect(releaseSummary(release({ skipped: true }))).toBe("Update check skipped");
|
||||
expect(releaseSummary(release())).toBe("Up to date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("componentDetails", () => {
|
||||
it("combines versions, health, and installation into one line", () => {
|
||||
expect(componentDetails(componentStatus())).toBe("running 1.0.0 · installed 1.0.0 · current · global npm package · /usr/lib/node_modules");
|
||||
});
|
||||
|
||||
it("includes the component error when present", () => {
|
||||
const details = componentDetails(componentStatus({ available: false, error: "connection refused" }));
|
||||
expect(details).toContain("unavailable");
|
||||
expect(details).toContain("error: connection refused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnosticsSummary", () => {
|
||||
it("renders a full status block for bug reports", () => {
|
||||
const summary = diagnosticsSummary({ status: statusResponse(), machine: machineFixture(), workspace: workspaceFixture() });
|
||||
|
||||
expect(summary).toBe([
|
||||
"PI WEB diagnostics",
|
||||
"Package: @jmfederico/pi-web",
|
||||
"Web/UI: running 1.0.0 · installed 1.0.1 · restart needed · global npm package · /usr/lib/node_modules",
|
||||
"Session daemon: running 1.0.0 · installed 1.0.0 · current · local checkout · /srv/dev/pi-web",
|
||||
"Release: Update available: 1.1.0 (checked 2025-01-02T03:04:05Z)",
|
||||
"Status generated: 2025-01-02T03:04:06Z",
|
||||
"Machine: devbox (local machine)",
|
||||
"Workspace: pi-web — /srv/dev/pi-web (branch main, git worktree, main workspace)",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("degrades gracefully when the status and workspace are unavailable", () => {
|
||||
const summary = diagnosticsSummary({ status: undefined });
|
||||
|
||||
expect(summary).toBe([
|
||||
"PI WEB diagnostics",
|
||||
"Status: unavailable",
|
||||
"Workspace: none selected",
|
||||
].join("\n"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("small formatters", () => {
|
||||
it("formats missing versions as unknown", () => {
|
||||
expect(formatVersion(undefined)).toBe("unknown");
|
||||
expect(formatVersion("")).toBe("unknown");
|
||||
expect(formatVersion("1.0.0")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("labels installations", () => {
|
||||
expect(installationLabel(undefined)).toBe("installation unknown");
|
||||
expect(installationLabel({ kind: "pi-package", source: "Pi package", scope: "user" })).toBe("Pi package · user");
|
||||
expect(installationLabel({ kind: "docker", dockerMode: "dev" })).toBe("Docker development runtime");
|
||||
expect(installationLabel({ kind: "docker" })).toBe("Docker runtime");
|
||||
expect(installationLabel({ kind: "unknown" })).toBe("installation unknown");
|
||||
});
|
||||
|
||||
it("labels machine kinds", () => {
|
||||
expect(machineKindLabel("local")).toBe("local machine");
|
||||
expect(machineKindLabel("remote")).toBe("remote machine");
|
||||
});
|
||||
|
||||
it("describes workspaces without git metadata", () => {
|
||||
expect(workspaceFlags({
|
||||
id: "ws-1",
|
||||
projectId: "proj-1",
|
||||
path: "/srv/dev/plain",
|
||||
label: "plain",
|
||||
isMain: false,
|
||||
isGitRepo: false,
|
||||
isGitWorktree: false,
|
||||
})).toEqual(["not a git repo"]);
|
||||
});
|
||||
});
|
||||
|
||||
function componentStatus(patch: Partial<PiWebComponentStatus> = {}): PiWebComponentStatus {
|
||||
return {
|
||||
component: "web",
|
||||
label: "Web/UI",
|
||||
runtimeVersion: "1.0.0",
|
||||
installedVersion: "1.0.0",
|
||||
stale: false,
|
||||
available: true,
|
||||
installation: { kind: "npm-global", path: "/usr/lib/node_modules" },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function release(patch: Partial<PiWebReleaseStatus> = {}): PiWebReleaseStatus {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
updateAvailable: false,
|
||||
checkedAt: "2025-01-02T03:04:05Z",
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function statusResponse(): PiWebStatusResponse {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2025-01-02T03:04:06Z",
|
||||
components: {
|
||||
web: componentStatus({ installedVersion: "1.0.1", stale: true }),
|
||||
sessiond: componentStatus({ component: "sessiond", label: "Session daemon", installation: { kind: "local", path: "/srv/dev/pi-web" } }),
|
||||
},
|
||||
release: release({ updateAvailable: true, latestVersion: "1.1.0" }),
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function machineFixture(): PluginMachine {
|
||||
return { id: "local", name: "devbox", kind: "local" };
|
||||
}
|
||||
|
||||
function workspaceFixture(patch: Partial<Workspace> = {}): Workspace {
|
||||
return {
|
||||
id: "ws-1",
|
||||
projectId: "proj-1",
|
||||
path: "/srv/dev/pi-web",
|
||||
label: "pi-web",
|
||||
branch: "main",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Implementation details of the bundled Info plugin.
|
||||
//
|
||||
// This file is NOT part of the plugin skeleton. If you copied the Info plugin
|
||||
// as a starting point for your own plugin, replace everything here with your
|
||||
// own content — the plugin contract (metadata and contribution definitions)
|
||||
// lives in pi-web-plugin.ts.
|
||||
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { HtmlTemplateTag, MachineKind, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusResponse, PluginMachine, PluginRuntimeContext, Workspace, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
|
||||
|
||||
export type ComponentHealth = "current" | "restart needed" | "unavailable";
|
||||
|
||||
export function componentHealth(component: PiWebComponentStatus): ComponentHealth {
|
||||
if (!component.available) return "unavailable";
|
||||
if (component.stale) return "restart needed";
|
||||
return "current";
|
||||
}
|
||||
|
||||
export function formatVersion(version: string | undefined): string {
|
||||
return version === undefined || version === "" ? "unknown" : version;
|
||||
}
|
||||
|
||||
export function installationLabel(installation: PiWebInstallationInfo | undefined): string {
|
||||
if (installation === undefined) return "installation unknown";
|
||||
if (installation.kind === "pi-package") {
|
||||
const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
|
||||
const source = installation.source ?? "Pi package";
|
||||
return `${source}${scope}`;
|
||||
}
|
||||
if (installation.kind === "npm-global") return "global npm package";
|
||||
if (installation.kind === "local") return "local checkout";
|
||||
if (installation.kind === "docker") return installation.dockerMode === "dev" ? "Docker development runtime" : "Docker runtime";
|
||||
return "installation unknown";
|
||||
}
|
||||
|
||||
export function machineKindLabel(kind: MachineKind): string {
|
||||
return kind === "local" ? "local machine" : "remote machine";
|
||||
}
|
||||
|
||||
export function releaseSummary(release: PiWebReleaseStatus): string {
|
||||
if (release.updateAvailable) {
|
||||
return release.latestVersion === undefined || release.latestVersion === ""
|
||||
? "Update available"
|
||||
: `Update available: ${release.latestVersion}`;
|
||||
}
|
||||
if (release.error !== undefined && release.error !== "") return `Update check failed: ${release.error}`;
|
||||
if (release.skipped === true) return "Update check skipped";
|
||||
return "Up to date";
|
||||
}
|
||||
|
||||
/** One-line component summary used by the panel rows and the clipboard diagnostics. */
|
||||
export function componentDetails(component: PiWebComponentStatus): string {
|
||||
const parts = [
|
||||
`running ${formatVersion(component.runtimeVersion)}`,
|
||||
`installed ${formatVersion(component.installedVersion)}`,
|
||||
componentHealth(component),
|
||||
installationLabel(component.installation),
|
||||
];
|
||||
if (component.installation?.path !== undefined && component.installation.path !== "") parts.push(component.installation.path);
|
||||
if (component.error !== undefined && component.error !== "") parts.push(`error: ${component.error}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function workspaceFlags(workspace: Workspace): string[] {
|
||||
return [
|
||||
workspace.branch === undefined || workspace.branch === "" ? undefined : `branch ${workspace.branch}`,
|
||||
workspace.isGitWorktree ? "git worktree" : workspace.isGitRepo ? "git repo" : "not a git repo",
|
||||
workspace.isMain ? "main workspace" : undefined,
|
||||
].filter((flag): flag is string => flag !== undefined);
|
||||
}
|
||||
|
||||
export interface DiagnosticsInput {
|
||||
status: PiWebStatusResponse | undefined;
|
||||
machine?: PluginMachine | undefined;
|
||||
workspace?: Workspace | undefined;
|
||||
}
|
||||
|
||||
/** Plain-text status block suitable for pasting into a bug report. */
|
||||
export function diagnosticsSummary({ status, machine, workspace }: DiagnosticsInput): string {
|
||||
const lines: string[] = ["PI WEB diagnostics"];
|
||||
if (status === undefined) {
|
||||
lines.push("Status: unavailable");
|
||||
} else {
|
||||
lines.push(`Package: ${status.packageName}`);
|
||||
lines.push(`${status.components.web.label}: ${componentDetails(status.components.web)}`);
|
||||
lines.push(`${status.components.sessiond.label}: ${componentDetails(status.components.sessiond)}`);
|
||||
const checked = status.release.checkedAt === undefined || status.release.skipped === true ? "" : ` (checked ${status.release.checkedAt})`;
|
||||
lines.push(`Release: ${releaseSummary(status.release)}${checked}`);
|
||||
lines.push(`Status generated: ${status.generatedAt}`);
|
||||
}
|
||||
if (machine !== undefined) lines.push(`Machine: ${machine.name} (${machineKindLabel(machine.kind)})`);
|
||||
if (workspace === undefined) {
|
||||
lines.push("Workspace: none selected");
|
||||
} else {
|
||||
lines.push(`Workspace: ${workspace.label} — ${workspace.path} (${workspaceFlags(workspace).join(", ")})`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Action body: copy the diagnostics summary for the current runtime context. */
|
||||
export async function copyDiagnostics(context: PluginRuntimeContext): Promise<void> {
|
||||
const summary = diagnosticsSummary({
|
||||
status: context.state.piWebStatus,
|
||||
machine: context.state.selectedMachine,
|
||||
workspace: context.state.selectedWorkspace,
|
||||
});
|
||||
await navigator.clipboard.writeText(summary);
|
||||
}
|
||||
|
||||
function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult {
|
||||
const health = componentHealth(component);
|
||||
return html`
|
||||
<div class="info-component">
|
||||
<strong>${component.label}</strong>
|
||||
<span class=${health === "current" ? "info-health-ok" : "info-health-attention"}>${health}</span>
|
||||
<small>${componentDetails(component)}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStatusSection(html: HtmlTemplateTag, status: PiWebStatusResponse | undefined): TemplateResult {
|
||||
if (status === undefined) {
|
||||
return html`
|
||||
<section>
|
||||
<strong>PI WEB</strong>
|
||||
<p class="muted">PI WEB status is not available yet. It refreshes automatically in the background.</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
const web = status.components.web;
|
||||
const messageCount = status.messages.length;
|
||||
return html`
|
||||
<section>
|
||||
<strong>PI WEB</strong>
|
||||
<div class="info-row">
|
||||
<span>Version</span>
|
||||
<span>${formatVersion(web.runtimeVersion)}</span>
|
||||
${web.installedVersion === undefined || web.installedVersion === web.runtimeVersion ? null : html`<small>installed ${formatVersion(web.installedVersion)}</small>`}
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Package</span>
|
||||
<span>${status.packageName}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Installation</span>
|
||||
<span>${installationLabel(web.installation)}</span>
|
||||
${web.installation?.path === undefined || web.installation.path === "" ? null : html`<small>${web.installation.path}</small>`}
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Release</span>
|
||||
<span>${releaseSummary(status.release)}</span>
|
||||
${status.release.checkedAt === undefined || status.release.skipped === true ? null : html`<small>checked ${status.release.checkedAt}</small>`}
|
||||
</div>
|
||||
${messageCount === 0 ? null : html`<p class="muted">${String(messageCount)} status ${messageCount === 1 ? "message" : "messages"} — open the Updates tab for details.</p>`}
|
||||
<p class="muted">Status generated ${status.generatedAt}</p>
|
||||
</section>
|
||||
<section>
|
||||
<strong>Services</strong>
|
||||
${renderComponent(html, status.components.web)}
|
||||
${renderComponent(html, status.components.sessiond)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMachineSection(html: HtmlTemplateTag, machine: PluginMachine): TemplateResult {
|
||||
return html`
|
||||
<section>
|
||||
<strong>Machine</strong>
|
||||
<div class="info-row">
|
||||
<span>Name</span>
|
||||
<span>${machine.name}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Type</span>
|
||||
<span>${machineKindLabel(machine.kind)}</span>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWorkspaceSection(html: HtmlTemplateTag, workspace: Workspace): TemplateResult {
|
||||
return html`
|
||||
<section>
|
||||
<strong>Workspace</strong>
|
||||
<div class="info-row">
|
||||
<span>Name</span>
|
||||
<span>${workspace.label}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Path</span>
|
||||
<span class="info-path">${workspace.path}</span>
|
||||
${workspaceFlags(workspace).length === 0 ? null : html`<small>${workspaceFlags(workspace).join(" · ")}</small>`}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Panel body: render the Info tab for the current workspace panel context. */
|
||||
export function renderInfoPanel(html: HtmlTemplateTag, context: WorkspacePanelContext): TemplateResult {
|
||||
return html`
|
||||
<style>
|
||||
.viewer.info-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.info-status section { flex: 0 0 auto; min-width: 0; display: grid; gap: 8px; align-content: start; }
|
||||
.viewer.info-status p { margin: 0; }
|
||||
.info-row { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; overflow-wrap: anywhere; }
|
||||
.info-row small { grid-column: 1 / -1; color: var(--pi-muted); }
|
||||
.info-component { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; }
|
||||
.info-component small { grid-column: 1 / -1; color: var(--pi-muted); overflow-wrap: anywhere; }
|
||||
.info-health-ok { color: var(--pi-success); }
|
||||
.info-health-attention { color: var(--pi-warning); }
|
||||
</style>
|
||||
<section class="toolbar"><strong>Info</strong></section>
|
||||
<section class="viewer info-status">
|
||||
${renderStatusSection(html, context.state?.piWebStatus)}
|
||||
${renderMachineSection(html, context.machine)}
|
||||
${renderWorkspaceSection(html, context.workspace)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { html, svg } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntimeContext } from "@jmfederico/pi-web/plugin-api";
|
||||
import plugin from "./pi-web-plugin.js";
|
||||
|
||||
describe("Info plugin copy-diagnostics action", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("copies a diagnostics summary to the clipboard", async () => {
|
||||
const writeText = vi.fn((text: string) => { void text; return Promise.resolve(); });
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const action = findCopyDiagnosticsAction();
|
||||
const context = runtimeContext({
|
||||
state: {
|
||||
selectedMachine: { id: "local", name: "devbox", kind: "local" },
|
||||
selectedWorkspace: {
|
||||
id: "ws-1",
|
||||
projectId: "proj-1",
|
||||
path: "/srv/dev/pi-web",
|
||||
label: "pi-web",
|
||||
branch: "main",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await action.run(context);
|
||||
|
||||
expect(writeText).toHaveBeenCalledOnce();
|
||||
const summary = writeText.mock.calls[0]?.[0];
|
||||
expect(summary).toContain("PI WEB diagnostics");
|
||||
expect(summary).toContain("Status: unavailable");
|
||||
expect(summary).toContain("Machine: devbox (local machine)");
|
||||
expect(summary).toContain("Workspace: pi-web — /srv/dev/pi-web (branch main, git repo, main workspace)");
|
||||
});
|
||||
});
|
||||
|
||||
function findCopyDiagnosticsAction() {
|
||||
const action = plugin.activate({ apiVersion: 1, pluginId: "info", html, svg }).contributions.actions?.find((candidate) => candidate.id === "copy-diagnostics");
|
||||
if (action === undefined) throw new Error("Expected copy-diagnostics action");
|
||||
return action;
|
||||
}
|
||||
|
||||
function runtimeContext(patch: Partial<PluginRuntimeContext> = {}): PluginRuntimeContext {
|
||||
const noop = () => undefined;
|
||||
return {
|
||||
state: {},
|
||||
prompt: { insertText: noop, getText: () => "", getSelection: () => null },
|
||||
openActionPalette: noop,
|
||||
focusPrompt: noop,
|
||||
addProject: noop,
|
||||
configureAuth: noop,
|
||||
logoutAuth: noop,
|
||||
openThemePicker: noop,
|
||||
selectMainView: noop,
|
||||
selectWorkspaceTool: noop,
|
||||
openTerminal: noop,
|
||||
refreshFiles: noop,
|
||||
refreshGit: noop,
|
||||
refreshAppData: noop,
|
||||
reloadPage: noop,
|
||||
startSession: noop,
|
||||
archiveSession: noop,
|
||||
stopActiveWork: noop,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
// Skeleton of a PI WEB plugin: metadata plus contribution definitions.
|
||||
//
|
||||
// Everything the bundled Info panel and action actually render lives in
|
||||
// infoInternals.ts. That file is replaceable implementation detail — when
|
||||
// copying this plugin as a starting point, keep this file's shape and swap
|
||||
// the internals for your own.
|
||||
|
||||
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
|
||||
import { copyDiagnostics, renderInfoPanel } from "./infoInternals.js";
|
||||
|
||||
const plugin: PiWebPlugin = {
|
||||
apiVersion: 1,
|
||||
@@ -7,14 +15,11 @@ const plugin: PiWebPlugin = {
|
||||
contributions: {
|
||||
actions: [
|
||||
{
|
||||
id: "workspace.show-path",
|
||||
title: "Show Current Workspace Path",
|
||||
id: "copy-diagnostics",
|
||||
title: "Copy PI WEB Diagnostics",
|
||||
description: "Copy version, installation, and status details for this machine, ready to paste into a bug report",
|
||||
group: "Info",
|
||||
enabled: (context) => context.state.selectedWorkspace !== undefined,
|
||||
run: (context) => {
|
||||
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
|
||||
window.alert(path);
|
||||
},
|
||||
run: (context) => copyDiagnostics(context),
|
||||
},
|
||||
],
|
||||
workspaceLabels: [
|
||||
@@ -36,14 +41,7 @@ const plugin: PiWebPlugin = {
|
||||
</svg>
|
||||
`,
|
||||
order: 1000,
|
||||
render: (context) => html`
|
||||
<section class="toolbar"><strong>Info</strong></section>
|
||||
<section class="viewer">
|
||||
<p><strong>Workspace</strong></p>
|
||||
<p class="muted">${context.workspace.label}</p>
|
||||
<p class="muted">${context.workspace.path}</p>
|
||||
</section>
|
||||
`,
|
||||
render: (context) => renderInfoPanel(html, context),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface PluginMachine {
|
||||
}
|
||||
|
||||
export interface PluginRuntimeState {
|
||||
/** Identity of the currently selected machine. Undefined only on older hosts or before machines load. */
|
||||
selectedMachine?: PluginMachine;
|
||||
selectedWorkspace?: Workspace;
|
||||
selectedSession?: unknown;
|
||||
workspaceTool?: string;
|
||||
|
||||
Reference in New Issue
Block a user