diff --git a/.changeset/friendly-missing-actions-config.md b/.changeset/friendly-missing-actions-config.md new file mode 100644 index 0000000..ee86a1a --- /dev/null +++ b/.changeset/friendly-missing-actions-config.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web-actions": patch +--- + +Treat missing workspace actions configuration as an empty optional state instead of an error, with clearer guidance for invalid configs. diff --git a/plugins/actions/README.md b/plugins/actions/README.md index c74a30a..8f12131 100644 --- a/plugins/actions/README.md +++ b/plugins/actions/README.md @@ -6,7 +6,7 @@ The plugin adds an **Actions** workspace tab. Actions create a new Pi Web termin ## Configuration -Create `.pi-web/actions.json` in the workspace root: +Create `.pi-web/actions.json` in the workspace root where you want actions. The file is optional per workspace; workspaces without it simply show no actions. ```json { diff --git a/plugins/actions/src/actionsPanelElement.ts b/plugins/actions/src/actionsPanelElement.ts index 3419951..8a0db10 100644 --- a/plugins/actions/src/actionsPanelElement.ts +++ b/plugins/actions/src/actionsPanelElement.ts @@ -2,7 +2,7 @@ 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 { loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; +import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; export const actionsPanelTagName = "pi-web-actions-panel"; @@ -100,8 +100,9 @@ class PiWebActionsPanel extends HTMLElement { 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 configured in ${escapeHtml(ACTIONS_CONFIG_PATH)}.

${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)} @@ -185,8 +186,8 @@ async function refreshWorkspaceConfig(workspace: Workspace): Promise ({ kind: "unavailable", - message: `No valid ${ACTIONS_CONFIG_PATH} found.`, - hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, + message: actionsConfigUnavailableMessage, + hint: actionsConfigRefreshHint, detail: error instanceof Error ? error.message : String(error), })); configCache.set(key, state); @@ -199,6 +200,10 @@ 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}
`; @@ -268,6 +273,8 @@ function actionStyles(): string { button { border: 1px solid var(--pi-accent-border); border-radius: 7px; background: var(--pi-accent); color: var(--pi-bg); cursor: pointer; padding: 6px 10px; font: inherit; } button.secondary { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); } button:disabled { cursor: wait; opacity: 0.65; } + .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; } + .empty-state p { margin: 6px 0 0; } .status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; } .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); } .status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); } diff --git a/plugins/actions/src/workspaceActionsClient.test.ts b/plugins/actions/src/workspaceActionsClient.test.ts index dbe1bac..f6057c9 100644 --- a/plugins/actions/src/workspaceActionsClient.test.ts +++ b/plugins/actions/src/workspaceActionsClient.test.ts @@ -34,14 +34,24 @@ describe("workspace actions client", () => { }); }); + it("treats a missing optional actions config as unconfigured", async () => { + const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 })); + + await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + kind: "missing", + message: "No workspace actions configured here.", + hint: `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`, + }); + }); + it("returns a visible unavailable state instead of throwing on request failures", async () => { const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ kind: "unavailable", - message: `No valid ${ACTIONS_CONFIG_PATH} found.`, - hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, - detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400`, + message: "Could not load workspace actions.", + hint: `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, + detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400: nope`, }); }); diff --git a/plugins/actions/src/workspaceActionsClient.ts b/plugins/actions/src/workspaceActionsClient.ts index 008552f..3075433 100644 --- a/plugins/actions/src/workspaceActionsClient.ts +++ b/plugins/actions/src/workspaceActionsClient.ts @@ -1,13 +1,18 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; import { ACTIONS_CONFIG_PATH, parseActionsConfigText, type WorkspaceActionsConfig } from "./config.js"; -export const actionsConfigUnavailableMessage = `No valid ${ACTIONS_CONFIG_PATH} found.`; -export const actionsConfigRefreshHint = `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`; +export const actionsConfigMissingMessage = "No workspace actions configured here."; +export const actionsConfigMissingHint = `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`; +export const actionsConfigUnavailableMessage = "Could not load workspace actions."; +export const actionsConfigRefreshHint = `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`; + +const missingWorkspaceFileError = "Path does not exist"; export type FetchLike = (input: string, init?: RequestInit) => Promise; export type WorkspaceActionsConfigLoadResult = | { kind: "loaded"; config: WorkspaceActionsConfig } + | { kind: "missing"; message: string; hint: string } | { kind: "unavailable"; message: string; hint: string; detail?: string }; interface WorkspaceFileResponse { @@ -27,7 +32,12 @@ export async function loadWorkspaceActionsConfig( return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); } - if (!response.ok) return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: HTTP ${String(response.status)}`); + if (!response.ok) { + const errorMessage = await readResponseErrorMessage(response); + if (errorMessage === missingWorkspaceFileError) return missing(); + const responseSummary = errorMessage === undefined ? `HTTP ${String(response.status)}` : `HTTP ${String(response.status)}: ${errorMessage}`; + return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${responseSummary}`); + } let body: unknown; try { @@ -59,6 +69,14 @@ export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileRespons return { content, truncated, binary }; } +function missing(): WorkspaceActionsConfigLoadResult { + return { + kind: "missing", + message: actionsConfigMissingMessage, + hint: actionsConfigMissingHint, + }; +} + function unavailable(detail: string): WorkspaceActionsConfigLoadResult { return { kind: "unavailable", @@ -68,6 +86,17 @@ function unavailable(detail: string): WorkspaceActionsConfigLoadResult { }; } +async function readResponseErrorMessage(response: Response): Promise { + try { + const body: unknown = await response.json(); + if (!isRecord(body)) return undefined; + const error = body["error"]; + return typeof error === "string" ? error : undefined; + } catch { + return undefined; + } +} + function formatUnknownError(error: unknown): string { return error instanceof Error ? error.message : String(error); }