import type { Workspace } from "@jmfederico/pi-web/plugin-api"; import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js"; import { runWorkspaceActionInTerminal } from "./actionRunner.js"; import { requestPiWebRender } from "./piWebPrivateUi.js"; import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; export const actionsPanelTagName = "pi-web-actions-panel"; export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void; const configChangedEvent = "pi-web-actions-config-changed"; type ConfigState = | { kind: "loading" } | WorkspaceActionsConfigLoadResult; interface ActionStatus { kind: "info" | "success" | "error"; message: string; detail?: string; } const configCache = new Map(); export function defineActionsPanelElement(): void { if (!customElements.get(actionsPanelTagName)) customElements.define(actionsPanelTagName, PiWebActionsPanel); } export function actionsPanelBadge(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; return undefined; } class PiWebActionsPanel extends HTMLElement { private workspaceValue: Workspace | undefined; private openTerminalValue: OpenTerminal | undefined; private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined; private runningActionId: string | undefined; private status: ActionStatus | undefined; private readonly root: ShadowRoot; private readonly onConfigChanged = () => { this.render(); }; constructor() { super(); 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; // 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.status = undefined; 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(); } disconnectedCallback(): void { window.removeEventListener(configChangedEvent, this.onConfigChanged); } private render(): void { const workspace = this.workspaceValue; if (workspace === undefined) { this.root.innerHTML = `${actionStyles()}
Select a workspace.
`; return; } const state = getOrLoadWorkspaceConfig(workspace); this.root.innerHTML = ` ${actionStyles()}
Workspace Actions
${this.renderStatus()}
${this.renderConfigState(state)}
`; this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => { void this.refreshConfig(workspace); }); for (const button of this.root.querySelectorAll("button[data-action-id]")) { button.addEventListener("click", () => { void this.dispatchActionById(workspace, button.getAttribute("data-action-id")); }); } this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => { this.openWorkspaceTerminal(); }); } private dispatchActionById(workspace: Workspace, actionId: string | null): Promise { 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." }; this.render(); return Promise.resolve(); } return this.dispatchAction(workspace, action); } private isCurrentWorkspace(workspace: Workspace): boolean { return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace); } private renderConfigState(state: ConfigState): string { if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

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

No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.

`; return `

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

${renderActionGroups(state.config.actions, this.runningActionId)} `; } 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(workspace: Workspace): Promise { this.status = { kind: "info", message: `Refreshing ${ACTIONS_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"}.` } : undefined; this.render(); } private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise { if (this.runningActionId !== undefined) { this.status = { kind: "info", message: "Another action 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}.` }; this.render(); 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.runningActionId = action.id; this.status = { kind: "info", message: `Starting ${action.title}…` }; this.render(); try { const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); if (!this.isCurrentWorkspace(workspace)) return; this.status = { kind: "success", message: `Started terminal command “${handle.run.title}”.`, detail: action.command, }; this.runningActionId = undefined; this.render(); } catch (error) { if (!this.isCurrentWorkspace(workspace)) return; this.runningActionId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); } } 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." }; this.render(); return; } if (terminalId === undefined) this.openTerminalValue(); else this.openTerminalValue({ terminalId }); } } function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined { return configCache.get(cacheKeyForWorkspace(workspace)); } function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState { const cached = getCachedWorkspaceConfig(workspace); if (cached !== undefined) return cached; const loading: ConfigState = { kind: "loading" }; configCache.set(cacheKeyForWorkspace(workspace), loading); void refreshWorkspaceConfig(workspace); return loading; } async function refreshWorkspaceConfig(workspace: Workspace): Promise { const key = cacheKeyForWorkspace(workspace); const state = await loadWorkspaceActionsConfig(workspace).catch((error: unknown): ConfigState => ({ kind: "unavailable", message: actionsConfigUnavailableMessage, hint: actionsConfigRefreshHint, detail: error instanceof Error ? error.message : String(error), })); configCache.set(key, state); requestPiWebRender(); window.dispatchEvent(new Event(configChangedEvent)); return state; } function cacheKeyForWorkspace(workspace: Workspace): string { return `${workspace.projectId}:${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 renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string { return `
${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}
`; } 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; let group = groups.find((candidate) => candidate.title === title); if (group === undefined) { group = { title, actions: [] }; groups.push(group); } group.actions.push(action); } return groups; } function renderActionGroup(group: { title: string | undefined; actions: WorkspaceAction[] }, runningActionId: string | undefined): string { const title = group.title === undefined ? "" : `

${escapeHtml(group.title)}

`; return `
${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}
`; } function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string { const running = runningActionId === action.id; const disabled = runningActionId !== undefined; const description = action.description === undefined ? "" : `${escapeHtml(action.description)}`; return `
${escapeHtml(action.title)} ${description} ${escapeHtml(action.command)}
`; } 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 actionStyles(): string { return ` `; } function escapeHtml(value: unknown): string { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function escapeAttr(value: unknown): string { return escapeHtml(value).replaceAll('"', """); }