import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api"; import { TASKS_CONFIG_PATH, type WorkspaceTask } from "./config.js"; import { runWorkspaceTaskInTerminal } from "./taskRunner.js"; import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js"; export const tasksPanelTagName = "pi-web-workspace-tasks-panel"; const configChangedEvent = "pi-web-workspace-tasks-config-changed"; type ConfigState = | { kind: "loading" } | WorkspaceTasksConfigLoadResult; interface TaskStatus { kind: "info" | "success" | "error"; message: string; detail?: string; } const configCache = new Map(); export function defineTasksPanelElement(): void { if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel); } 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 contextValue: WorkspacePanelContext | undefined; private runningTaskId: string | undefined; private status: TaskStatus | undefined; private readonly root: ShadowRoot; private readonly onConfigChanged = () => { this.render(); }; constructor() { super(); this.root = this.attachShadow({ mode: "open" }); } 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; this.runningTaskId = undefined; this.status = undefined; this.render(); } connectedCallback(): void { window.addEventListener(configChangedEvent, this.onConfigChanged); this.render(); } disconnectedCallback(): void { window.removeEventListener(configChangedEvent, this.onConfigChanged); } private render(): void { const context = this.contextValue; if (context === undefined) { this.root.innerHTML = `${taskStyles()}
Select a workspace.
`; return; } const state = getOrLoadWorkspaceConfig(context); this.root.innerHTML = ` ${taskStyles()}
Workspace Tasks
${this.renderStatus()}
${this.renderConfigState(state)}
`; this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => { void this.refreshConfig(context); }); for (const button of this.root.querySelectorAll("button[data-task-id]")) { button.addEventListener("click", () => { void this.dispatchTaskById(context, button.getAttribute("data-task-id")); }); } this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => { this.openWorkspaceTerminal(); }); } private dispatchTaskById(context: WorkspacePanelContext, taskId: string | null): Promise { 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(context, task); } private isCurrentContext(context: WorkspacePanelContext): boolean { return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context); } private renderConfigState(state: ConfigState): string { if (state.kind === "loading") return `

Loading ${escapeHtml(TASKS_CONFIG_PATH)}…

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

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

`; return `

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

${renderTaskGroups(state.config.tasks, this.runningTaskId)} `; } private renderStatus(): string { if (this.status === undefined) return ""; const detail = this.status.detail === undefined ? "" : `
${escapeHtml(this.status.detail)}
`; return `
${escapeHtml(this.status.message)}${detail}
`; } private async refreshConfig(context: WorkspacePanelContext): Promise { this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` }; configCache.set(cacheKeyForContext(context), { kind: "loading" }); this.render(); 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(context: WorkspacePanelContext, task: WorkspaceTask): Promise { if (this.runningTaskId !== undefined) { this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." }; this.render(); return; } if (task.confirm && !window.confirm(`Run ${task.title}?\n\n${task.command}`)) { this.status = { kind: "info", message: `Cancelled ${task.title}.` }; this.render(); return; } this.runningTaskId = task.id; this.status = { kind: "info", message: `Starting ${task.title}…` }; this.render(); try { const handle = await runWorkspaceTaskInTerminal(context.terminal, task); if (!this.isCurrentContext(context)) return; this.status = { kind: "success", message: `Started terminal command “${handle.run.title}”.`, detail: task.command, }; this.runningTaskId = undefined; this.render(); } catch (error) { if (!this.isCurrentContext(context)) return; this.runningTaskId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); } } private openWorkspaceTerminal(terminalId?: string): void { 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) context.terminal.open(); else context.terminal.open({ terminalId }); } } function getCachedWorkspaceConfig(context: WorkspacePanelContext): ConfigState | undefined { return configCache.get(cacheKeyForContext(context)); } function getOrLoadWorkspaceConfig(context: WorkspacePanelContext): ConfigState { const cached = getCachedWorkspaceConfig(context); if (cached !== undefined) return cached; const loading: ConfigState = { kind: "loading" }; configCache.set(cacheKeyForContext(context), loading); void refreshWorkspaceConfig(context); return loading; } async function refreshWorkspaceConfig(context: WorkspacePanelContext): Promise { 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); context.requestRender(); window.dispatchEvent(new Event(configChangedEvent)); return state; } function cacheKeyForContext(context: WorkspacePanelContext): string { return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`; } function renderMissingState(state: Extract): string { return `
${escapeHtml(state.message)}

${escapeHtml(state.hint)}

`; } function renderUnavailableState(state: Extract): string { const detail = state.detail === undefined ? "" : `
${escapeHtml(state.detail)}
`; return `
${escapeHtml(state.message)}

${escapeHtml(state.hint)}

${detail}
`; } function renderTaskGroups(tasks: WorkspaceTask[], runningTaskId: string | undefined): string { return `
${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}
`; } 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, tasks: [] }; groups.push(group); } group.tasks.push(task); } return groups; } function renderTaskGroup(group: { title: string | undefined; tasks: WorkspaceTask[] }, runningTaskId: string | undefined): string { const title = group.title === undefined ? "" : `

${escapeHtml(group.title)}

`; return `
${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}
`; } function renderTask(task: WorkspaceTask, runningTaskId: string | undefined): string { const running = runningTaskId === task.id; const disabled = runningTaskId !== undefined; const description = task.description === undefined ? "" : `${escapeHtml(task.description)}`; return `
${escapeHtml(task.title)} ${description} ${escapeHtml(task.command)}
`; } 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 taskStyles(): string { return ` `; } function escapeHtml(value: unknown): string { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function escapeAttr(value: unknown): string { return escapeHtml(value).replaceAll('"', """); }