This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/pi-web-plugins/updates/pi-web-plugin.ts
T
Federico Jaramillo Martinez 59a13f5058 test(updates): extract panel logic into a tested module
Move the Updates plugin's pure decision logic (recommended/additional
commands, panel visibility, installation labels, version formatting)
into a sibling updatesLogic.ts module so it can be unit tested directly,
matching the workspace-tasks multi-file plugin layout. The plugin file
is now thin rendering glue that imports those helpers.

Add unit tests for the extracted logic and render smoke tests that
exercise the panel through its public contribution API, including that
Run actions wire to the terminal with the "pi.plugin": "updates"
metadata. No behavior change; the beta label stays.
2026-06-14 17:32:34 +02:00

173 lines
8.4 KiB
TypeScript

import type { TemplateResult } from "lit";
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebPlugin, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor } from "./updatesLogic.js";
function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void {
void terminal.runCommand({
title: label,
command,
open: true,
metadata: { "pi.plugin": "updates" },
}).catch((error: unknown) => {
console.error(`Updates plugin failed to run "${label}"`, error);
});
}
function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult {
const status = !component.available
? "unavailable"
: component.stale
? "restart needed"
: "current";
return html`
<div class="updates-version-row">
<strong>${component.label}</strong>
<span>${status}</span>
<small>running ${formatVersion(component.runtimeVersion)} · installed ${formatVersion(component.installedVersion)}</small>
<small>${installationLabel(component.installation)}${component.installation?.path === undefined ? "" : ` · ${component.installation.path}`}</small>
</div>
`;
}
function renderCommandActions(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, label: string, command: string): TemplateResult {
return html`
<span class="updates-command-actions">
<button @click=${() => { void navigator.clipboard.writeText(command); }}>Copy</button>
${terminal === undefined ? null : html`<button class="primary" @click=${() => { runCommandInTerminal(terminal, label, command); }}>Run</button>`}
</span>
`;
}
function renderCommand(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, label: string, command: string): TemplateResult {
return html`
<div class="updates-command">
<span>${label}</span>
<code>${command}</code>
${renderCommandActions(html, terminal, label, command)}
</div>
`;
}
function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined {
const recommended = recommendedCommand(status);
const additional = additionalCommands(status, recommended);
if (recommended === undefined && additional.length === 0) return undefined;
return html`
${recommended === undefined ? null : html`
<section class="updates-recommended">
<strong>Recommended</strong>
<p class="muted">Run this one command to bring this installation fully up to date. Nothing else is required.</p>
${renderCommand(html, terminal, recommended.label, recommended.command)}
</section>
`}
${additional.length === 0 ? null : html`
<section>
<strong>${recommended === undefined ? "Suggested commands" : "Additional commands (optional)"}</strong>
${recommended === undefined ? null : html`<p class="muted">Only needed for finer control, such as restarting a single service.</p>`}
${additional.map((entry) => renderCommand(html, terminal, entry.label, entry.command))}
</section>
`}
`;
}
function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
const status = statusFor(state);
if (status === undefined) {
return html`
<section class="toolbar"><strong>Updates</strong></section>
<section class="viewer"><p class="muted">Checking PI WEB update status…</p></section>
`;
}
const messages = status.messages;
return html`
<style>
.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-command-inline { grid-template-columns: minmax(0, 1fr) auto; }
.updates-command-actions { display: inline-flex; gap: 6px; }
.updates-command-actions button.primary { border-color: var(--pi-accent-border); color: var(--pi-text-bright); }
.updates-recommended { border: 1px solid var(--pi-accent-border); border-radius: 8px; padding: 10px; background: var(--pi-surface); }
.updates-recommended > strong { color: var(--pi-text-bright); }
.updates-meta { display: grid; gap: 2px; color: var(--pi-muted); font-size: 12px; }
@media (max-width: 520px) {
.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 updates-status">
<section>
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
<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`
<div class="updates-command updates-command-inline">
<code>${message.command}</code>
${renderCommandActions(html, terminal, message.title, message.command)}
</div>
`}
</article>
`)}
</section>
<section>
<strong>Installed services</strong>
${renderComponent(html, status.components.web)}
${renderComponent(html, status.components.sessiond)}
</section>
${renderCommands(html, terminal, status)}
<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}
${status.release.error === undefined ? null : html`<span>Remote version check failed: ${status.release.error}</span>`}
</section>
</section>
`;
}
const plugin: PiWebPlugin = {
apiVersion: 1,
name: "Updates",
activate: ({ html, svg }) => ({
contributions: {
workspacePanels: [
{
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) => shouldShowUpdatesPanel(context.state),
badge: (context) => {
const count = messageCount(context.state);
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
},
render: (context) => renderUpdatesPanel(html, context.terminal, context.state),
},
],
},
}),
};
export default plugin;