import type { Workspace } from "@jmfederico/pi-web/plugin-api"; import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js"; import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js"; import { requestPiWebRender } from "./piWebPrivateUi.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; 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 runningActionId: string | undefined; private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined; private readonly root: ShadowRoot; private readonly onConfigChanged = () => { this.render(); }; constructor() { super(); this.root = this.attachShadow({ mode: "open" }); } set workspace(value: Workspace | undefined) { this.workspaceValue = value; this.render(); } set openTerminal(value: OpenTerminal | undefined) { this.openTerminalValue = 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.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", () => { const action = actionFromConfigState(state, button.getAttribute("data-action-id")); if (action !== undefined) void this.dispatchAction(workspace, action); }); } this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => { this.openWorkspaceTerminal(); }); } private renderConfigState(state: ConfigState): string { if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

${this.renderStatus()}`; if (state.kind === "missing") return `${renderMissingState(state)}${this.renderStatus()}`; if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`; if (state.config.actions.length === 0) return `

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

${this.renderStatus()}`; return `

Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

${renderActionGroups(state.config.actions, this.runningActionId)} ${this.renderStatus()} `; } 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); 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) return; if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return; this.runningActionId = action.id; this.status = { kind: "info", message: `Creating terminal for ${action.title}…` }; this.render(); try { const terminal = await createWorkspaceTerminal(workspace, action.title); this.status = { kind: "info", message: `Dispatching command to ${terminal.name}…` }; this.render(); await sendTerminalCommand(workspace, terminal.id, action.command); this.status = { kind: "success", message: `Dispatched to terminal “${terminal.name}”.`, detail: action.command, }; this.runningActionId = undefined; this.render(); this.openWorkspaceTerminal(terminal.id); } catch (error) { this.runningActionId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); } } private openWorkspaceTerminal(terminalId?: string): void { 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, 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('"', """); }