feat: add public plugin workspace capabilities

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 13:00:25 +02:00
parent 93b50e61af
commit e3533ebf1f
28 changed files with 584 additions and 531 deletions
@@ -1,14 +1,10 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
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 { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js";
export const tasksPanelTagName = "pi-web-workspace-tasks-panel";
export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void;
const configChangedEvent = "pi-web-workspace-tasks-config-changed";
type ConfigState =
@@ -27,17 +23,15 @@ export function defineTasksPanelElement(): void {
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
}
export function tasksPanelBadge(workspace: Workspace): string | number | undefined {
const state = getCachedWorkspaceConfig(workspace);
export function tasksPanelBadge(context: WorkspacePanelContext): string | number | undefined {
const state = getCachedWorkspaceConfig(context);
if (state?.kind === "unavailable") return "!";
if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length;
return undefined;
}
class PiWebTasksPanel extends HTMLElement {
private workspaceValue: Workspace | undefined;
private openTerminalValue: OpenTerminal | undefined;
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
private contextValue: WorkspacePanelContext | undefined;
private runningTaskId: string | undefined;
private status: TaskStatus | undefined;
private readonly root: ShadowRoot;
@@ -50,10 +44,10 @@ class PiWebTasksPanel extends HTMLElement {
this.root = this.attachShadow({ mode: "open" });
}
set workspace(value: Workspace | undefined) {
const previousKey = this.workspaceValue === undefined ? undefined : cacheKeyForWorkspace(this.workspaceValue);
const nextKey = value === undefined ? undefined : cacheKeyForWorkspace(value);
this.workspaceValue = value;
set context(value: WorkspacePanelContext | undefined) {
const previousKey = this.contextValue === undefined ? undefined : cacheKeyForContext(this.contextValue);
const nextKey = value === undefined ? undefined : cacheKeyForContext(value);
this.contextValue = value;
// 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;
@@ -62,14 +56,6 @@ class PiWebTasksPanel extends HTMLElement {
this.render();
}
set openTerminal(value: OpenTerminal | undefined) {
this.openTerminalValue = value;
}
set terminalCommandRuns(value: InternalTerminalCommandRunsRuntime | undefined) {
this.terminalCommandRunsValue = value;
}
connectedCallback(): void {
window.addEventListener(configChangedEvent, this.onConfigChanged);
this.render();
@@ -80,13 +66,13 @@ class PiWebTasksPanel extends HTMLElement {
}
private render(): void {
const workspace = this.workspaceValue;
if (workspace === undefined) {
const context = this.contextValue;
if (context === undefined) {
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
return;
}
const state = getOrLoadWorkspaceConfig(workspace);
const state = getOrLoadWorkspaceConfig(context);
this.root.innerHTML = `
${taskStyles()}
<section class="toolbar">
@@ -103,12 +89,12 @@ class PiWebTasksPanel extends HTMLElement {
`;
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
void this.refreshConfig(workspace);
void this.refreshConfig(context);
});
for (const button of this.root.querySelectorAll("button[data-task-id]")) {
button.addEventListener("click", () => {
void this.dispatchTaskById(workspace, button.getAttribute("data-task-id"));
void this.dispatchTaskById(context, button.getAttribute("data-task-id"));
});
}
@@ -117,19 +103,19 @@ class PiWebTasksPanel extends HTMLElement {
});
}
private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise<void> {
if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId);
private dispatchTaskById(context: WorkspacePanelContext, taskId: string | null): Promise<void> {
if (!this.isCurrentContext(context)) return Promise.resolve();
const task = taskFromConfigState(getCachedWorkspaceConfig(context), 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.dispatchTask(workspace, task);
return this.dispatchTask(context, task);
}
private isCurrentWorkspace(workspace: Workspace): boolean {
return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
private isCurrentContext(context: WorkspacePanelContext): boolean {
return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
}
private renderConfigState(state: ConfigState): string {
@@ -150,20 +136,20 @@ class PiWebTasksPanel extends HTMLElement {
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
}
private async refreshConfig(workspace: Workspace): Promise<void> {
private async refreshConfig(context: WorkspacePanelContext): Promise<void> {
this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}` };
configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" });
configCache.set(cacheKeyForContext(context), { kind: "loading" });
this.render();
const state = await refreshWorkspaceConfig(workspace);
if (!this.isCurrentWorkspace(workspace)) return;
const state = await refreshWorkspaceConfig(context);
if (!this.isCurrentContext(context)) return;
this.status = state.kind === "loaded"
? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` }
: undefined;
this.render();
}
private async dispatchTask(workspace: Workspace, task: WorkspaceTask): Promise<void> {
private async dispatchTask(context: WorkspacePanelContext, 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();
@@ -175,20 +161,13 @@ class PiWebTasksPanel extends HTMLElement {
return;
}
const terminal = this.terminalCommandRunsValue;
if (terminal === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal command helpers to plugins." };
this.render();
return;
}
this.runningTaskId = task.id;
this.status = { kind: "info", message: `Starting ${task.title}` };
this.render();
try {
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
if (!this.isCurrentWorkspace(workspace)) return;
const handle = await runWorkspaceTaskInTerminal(context.terminal, task);
if (!this.isCurrentContext(context)) return;
this.status = {
kind: "success",
message: `Started terminal command “${handle.run.title}”.`,
@@ -197,7 +176,7 @@ class PiWebTasksPanel extends HTMLElement {
this.runningTaskId = undefined;
this.render();
} catch (error) {
if (!this.isCurrentWorkspace(workspace)) return;
if (!this.isCurrentContext(context)) return;
this.runningTaskId = undefined;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
@@ -205,50 +184,47 @@ class PiWebTasksPanel extends HTMLElement {
}
private openWorkspaceTerminal(terminalId?: string): void {
if (this.terminalCommandRunsValue !== undefined) {
this.terminalCommandRunsValue.open(terminalId === undefined ? undefined : { terminalId });
return;
}
if (this.openTerminalValue === undefined) {
this.status = { kind: "error", message: "This PI WEB version does not provide terminal navigation to plugins." };
const context = this.contextValue;
if (context === undefined) {
this.status = { kind: "error", message: "Select a workspace before opening a terminal." };
this.render();
return;
}
if (terminalId === undefined) this.openTerminalValue();
else this.openTerminalValue({ terminalId });
if (terminalId === undefined) context.terminal.open();
else context.terminal.open({ terminalId });
}
}
function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined {
return configCache.get(cacheKeyForWorkspace(workspace));
function getCachedWorkspaceConfig(context: WorkspacePanelContext): ConfigState | undefined {
return configCache.get(cacheKeyForContext(context));
}
function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState {
const cached = getCachedWorkspaceConfig(workspace);
function getOrLoadWorkspaceConfig(context: WorkspacePanelContext): ConfigState {
const cached = getCachedWorkspaceConfig(context);
if (cached !== undefined) return cached;
const loading: ConfigState = { kind: "loading" };
configCache.set(cacheKeyForWorkspace(workspace), loading);
void refreshWorkspaceConfig(workspace);
configCache.set(cacheKeyForContext(context), loading);
void refreshWorkspaceConfig(context);
return loading;
}
async function refreshWorkspaceConfig(workspace: Workspace): Promise<ConfigState> {
const key = cacheKeyForWorkspace(workspace);
const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({
async function refreshWorkspaceConfig(context: WorkspacePanelContext): Promise<ConfigState> {
const key = cacheKeyForContext(context);
const state = await loadWorkspaceTasksConfig(context.files).catch((error: unknown): ConfigState => ({
kind: "unavailable",
message: tasksConfigUnavailableMessage,
hint: tasksConfigRefreshHint,
detail: error instanceof Error ? error.message : String(error),
}));
configCache.set(key, state);
requestPiWebRender();
context.requestRender();
window.dispatchEvent(new Event(configChangedEvent));
return state;
}
function cacheKeyForWorkspace(workspace: Workspace): string {
return `${workspace.projectId}:${workspace.id}`;
function cacheKeyForContext(context: WorkspacePanelContext): string {
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
}
function renderMissingState(state: Extract<ConfigState, { kind: "missing" }>): string {