feat: bundle workspace tasks plugin

This commit is contained in:
Federico Jaramillo Martinez
2026-06-03 22:31:36 +02:00
parent fda6fb0eca
commit 08f69d09c0
52 changed files with 879 additions and 681 deletions
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { parseTasksConfigText } from "./config";
describe("workspace tasks config", () => {
it("parses a minimal version 1 config", () => {
expect(parseTasksConfigText(JSON.stringify({
version: 1,
tasks: [
{ id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset" },
],
}))).toEqual({
ok: true,
config: {
version: 1,
tasks: [
{ id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset", confirm: false },
],
},
});
});
it("parses optional group, description, and confirm fields", () => {
expect(parseTasksConfigText(JSON.stringify({
version: 1,
tasks: [
{
id: "docker.start",
title: "Start Docker",
description: "Start the dev stack.",
group: "Docker",
command: "./docker/scripts/docker-compose-dev up -d",
confirm: true,
},
],
}))).toEqual({
ok: true,
config: {
version: 1,
tasks: [
{
id: "docker.start",
title: "Start Docker",
description: "Start the dev stack.",
group: "Docker",
command: "./docker/scripts/docker-compose-dev up -d",
confirm: true,
},
],
},
});
});
it("accepts an empty tasks array", () => {
expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [] }))).toEqual({
ok: true,
config: { version: 1, tasks: [] },
});
});
it("rejects invalid JSON and unsupported versions", () => {
expect(parseTasksConfigText("{")).toMatchObject({ ok: false });
expect(parseTasksConfigText(JSON.stringify({ version: 2, tasks: [] }))).toEqual({
ok: false,
error: "Config version must be 1",
});
});
it("rejects missing, empty, or duplicate required fields", () => {
expect(parseTasksConfigText(JSON.stringify({ version: 1 }))).toEqual({
ok: false,
error: "Config tasks must be an array",
});
expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({
ok: false,
error: "Task 1 id must be a non-empty string",
});
expect(parseTasksConfigText(JSON.stringify({
version: 1,
tasks: [
{ id: "one", title: "One", command: "cmd" },
{ id: "one", title: "Again", command: "cmd" },
],
}))).toEqual({
ok: false,
error: "Duplicate task id: one",
});
});
it("rejects invalid optional field types", () => {
expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({
ok: false,
error: "Task 1 confirm must be a boolean",
});
expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({
ok: false,
error: "Task 1 group must be a non-empty string when provided",
});
});
});
+121
View File
@@ -0,0 +1,121 @@
export const TASKS_CONFIG_PATH = ".pi-web/tasks.json";
export const TASKS_CONFIG_VERSION = 1;
const taskIdPattern = /^[a-z][a-z0-9.-]*$/u;
export interface WorkspaceTasksConfig {
version: typeof TASKS_CONFIG_VERSION;
tasks: WorkspaceTask[];
}
export interface WorkspaceTask {
id: string;
title: string;
command: string;
description?: string;
group?: string;
confirm: boolean;
}
export type ParseTasksConfigResult =
| { ok: true; config: WorkspaceTasksConfig }
| { ok: false; error: string };
export function parseTasksConfigText(text: string): ParseTasksConfigResult {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
return { ok: false, error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
}
return parseTasksConfig(parsed);
}
export function parseTasksConfig(value: unknown): ParseTasksConfigResult {
if (!isRecord(value)) return invalid("Config must be an object");
if (value["version"] !== TASKS_CONFIG_VERSION) return invalid("Config version must be 1");
const tasks = value["tasks"];
if (!Array.isArray(tasks)) return invalid("Config tasks must be an array");
const ids = new Set<string>();
const parsedTasks: WorkspaceTask[] = [];
for (const [index, task] of tasks.entries()) {
const parsedTask = parseTask(task, index);
if (!parsedTask.ok) return parsedTask;
if (ids.has(parsedTask.task.id)) return invalid(`Duplicate task id: ${parsedTask.task.id}`);
ids.add(parsedTask.task.id);
parsedTasks.push(parsedTask.task);
}
return { ok: true, config: { version: TASKS_CONFIG_VERSION, tasks: parsedTasks } };
}
type ParseTaskResult =
| { ok: true; task: WorkspaceTask }
| { ok: false; error: string };
function parseTask(value: unknown, index: number): ParseTaskResult {
const label = `Task ${String(index + 1)}`;
if (!isRecord(value)) return invalid(`${label} must be an object`);
const id = requireNonEmptyString(value, "id", label);
if (!id.ok) return id;
if (!taskIdPattern.test(id.value)) return invalid(`${label} id must match ${taskIdPattern.source}`);
const title = requireNonEmptyString(value, "title", label);
if (!title.ok) return title;
const command = requireNonEmptyString(value, "command", label);
if (!command.ok) return command;
const description = optionalNonEmptyString(value, "description", label);
if (!description.ok) return description;
const group = optionalNonEmptyString(value, "group", label);
if (!group.ok) return group;
const confirm = value["confirm"];
if (confirm !== undefined && typeof confirm !== "boolean") return invalid(`${label} confirm must be a boolean`);
return {
ok: true,
task: {
id: id.value,
title: title.value,
command: command.value,
...(description.value === undefined ? {} : { description: description.value }),
...(group.value === undefined ? {} : { group: group.value }),
confirm: confirm ?? false,
},
};
}
type StringFieldResult =
| { ok: true; value: string }
| { ok: false; error: string };
type OptionalStringFieldResult =
| { ok: true; value: string | undefined }
| { ok: false; error: string };
function requireNonEmptyString(record: Record<string, unknown>, key: string, label: string): StringFieldResult {
const value = record[key];
if (typeof value !== "string" || value.trim() === "") return invalid(`${label} ${key} must be a non-empty string`);
return { ok: true, value };
}
function optionalNonEmptyString(record: Record<string, unknown>, key: string, label: string): OptionalStringFieldResult {
const value = record[key];
if (value === undefined) return { ok: true, value: undefined };
if (typeof value !== "string" || value.trim() === "") return invalid(`${label} ${key} must be a non-empty string when provided`);
return { ok: true, value };
}
function invalid(error: string): { ok: false; error: string } {
return { ok: false, error };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,9 @@
{
"name": "@pi-web/workspace-tasks-plugin",
"private": true,
"piWeb": {
"plugins": [
{ "id": "workspace-tasks", "module": "pi-web-plugin.js" }
]
}
}
@@ -0,0 +1,41 @@
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH } from "./config.js";
import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js";
import { terminalCommandRunsFromContext } from "./piWebInternal.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
name: "Workspace Tasks",
activate: ({ pluginId, html }) => {
defineTasksPanelElement();
return {
contributions: {
actions: [
{
id: "workspace.open-tasks",
title: "Open Workspace Tasks",
description: `Open the workspace Tasks tab. Configure tasks in ${TASKS_CONFIG_PATH}.`,
group: "Workspace",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
if (context.state.selectedWorkspace === undefined) return;
context.selectWorkspaceTool(`${pluginId}:workspace.tasks`);
},
},
],
workspacePanels: [
{
id: "workspace.tasks",
title: "Tasks",
order: 40,
badge: ({ workspace }) => tasksPanelBadge(workspace),
render: (context) => html`<pi-web-workspace-tasks-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-workspace-tasks-panel>`,
},
],
},
};
},
};
export default plugin;
@@ -0,0 +1,62 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
export interface InternalRunTerminalCommandInput {
workspace: Workspace;
title: string;
command: string;
metadata?: Record<string, string>;
open?: boolean;
}
export interface InternalTerminalCommandRun {
id: string;
origin: string;
projectId: string;
workspaceId: string;
terminalId: string;
title: string;
command: string;
status: "queued" | "running" | "succeeded" | "failed";
exitCode?: number;
createdAt: string;
startedAt?: string;
completedAt?: string;
metadata: Record<string, string>;
}
export interface InternalTerminalCommandRunHandle {
run: InternalTerminalCommandRun;
completed: Promise<InternalTerminalCommandRun>;
}
export interface InternalTerminalCommandRunsRuntime {
runCommand(input: InternalRunTerminalCommandInput): Promise<InternalTerminalCommandRunHandle>;
open(options?: { terminalId?: string | undefined }): void;
}
export function terminalCommandRunsFromContext(context: unknown): InternalTerminalCommandRunsRuntime | undefined {
if (!isRecord(context)) return undefined;
const internal = context["piWebInternal"];
if (!isRecord(internal)) return undefined;
const terminalCommandRuns = internal["terminalCommandRuns"];
if (!isRecord(terminalCommandRuns)) return undefined;
const runCommand = terminalCommandRuns["runCommand"];
const open = terminalCommandRuns["open"];
if (!isRunCommand(runCommand) || !isOpen(open)) return undefined;
return {
runCommand: (input) => runCommand(input),
open: (options) => { open(options); },
};
}
function isRunCommand(value: unknown): value is InternalTerminalCommandRunsRuntime["runCommand"] {
return typeof value === "function";
}
function isOpen(value: unknown): value is InternalTerminalCommandRunsRuntime["open"] {
return typeof value === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,16 @@
interface Updatable {
requestUpdate: () => void;
}
export function requestPiWebRender(): void {
const app = document.querySelector("pi-web-app");
if (isUpdatable(app)) app.requestUpdate();
}
function isUpdatable(value: unknown): value is Updatable {
return isRecord(value) && typeof value["requestUpdate"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { runWorkspaceTaskInTerminal } from "./taskRunner";
import type { WorkspaceTask } from "./config";
import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
const run: InternalTerminalCommandRun = {
id: "run1",
origin: "workspace-tasks",
projectId: workspace.projectId,
workspaceId: workspace.id,
terminalId: "term1",
title: "Build",
command: "npm run build",
status: "running",
createdAt: "2026-05-25T00:00:00.000Z",
metadata: { "pi.plugin": "workspace-tasks", "task.id": "build" },
};
describe("task runner", () => {
it("starts workspace tasks through the internal terminal command-run helper", async () => {
const task: WorkspaceTask = { id: "build", title: "Build", command: "npm run build", confirm: false };
const runCommand = vi.fn<InternalTerminalCommandRunsRuntime["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
const terminal: InternalTerminalCommandRunsRuntime = {
runCommand,
open: vi.fn(),
};
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
expect(handle.run).toEqual(run);
await expect(handle.completed).resolves.toEqual(run);
expect(runCommand).toHaveBeenCalledWith({
workspace,
title: "Build",
command: "npm run build",
open: true,
metadata: { "pi.plugin": "workspace-tasks", "task.id": "build" },
});
});
});
@@ -0,0 +1,16 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspaceTask } from "./config.js";
import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js";
export function runWorkspaceTaskInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, task: WorkspaceTask): ReturnType<InternalTerminalCommandRunsRuntime["runCommand"]> {
return terminal.runCommand({
workspace,
title: task.title,
command: task.command,
open: true,
metadata: {
"pi.plugin": "workspace-tasks",
"task.id": task.id,
},
});
}
@@ -0,0 +1,349 @@
import type { Workspace } 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 =
| { kind: "loading" }
| WorkspaceTasksConfigLoadResult;
interface TaskStatus {
kind: "info" | "success" | "error";
message: string;
detail?: string;
}
const configCache = new Map<string, ConfigState>();
export function defineTasksPanelElement(): void {
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
}
export function tasksPanelBadge(workspace: Workspace): string | number | undefined {
const state = getCachedWorkspaceConfig(workspace);
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 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 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.runningTaskId = 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 = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
return;
}
const state = getOrLoadWorkspaceConfig(workspace);
this.root.innerHTML = `
${taskStyles()}
<section class="toolbar">
<strong>Workspace Tasks</strong>
<span class="toolbar-tasks">
<button class="secondary" data-refresh-config ${state.kind === "loading" ? "disabled" : ""}>Refresh</button>
<button class="secondary" data-open-terminal>Open Terminal</button>
</span>
</section>
${this.renderStatus()}
<section class="viewer tasks-viewer">
${this.renderConfigState(state)}
</section>
`;
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
void this.refreshConfig(workspace);
});
for (const button of this.root.querySelectorAll("button[data-task-id]")) {
button.addEventListener("click", () => {
void this.dispatchTaskById(workspace, button.getAttribute("data-task-id"));
});
}
this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => {
this.openWorkspaceTerminal();
});
}
private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise<void> {
if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), 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);
}
private isCurrentWorkspace(workspace: Workspace): boolean {
return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
}
private renderConfigState(state: ConfigState): string {
if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(TASKS_CONFIG_PATH)}…</p>`;
if (state.kind === "missing") return renderMissingState(state);
if (state.kind === "unavailable") return renderUnavailableState(state);
if (state.config.tasks.length === 0) return `<p class="muted">No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.</p>`;
return `
<p class="muted">Tasks run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(state.path)} and click Refresh to reload.</p>
${renderTaskGroups(state.config.tasks, this.runningTaskId)}
`;
}
private renderStatus(): string {
if (this.status === undefined) return "";
const detail = this.status.detail === undefined ? "" : `<pre>${escapeHtml(this.status.detail)}</pre>`;
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
}
private async refreshConfig(workspace: Workspace): Promise<void> {
this.status = { kind: "info", message: `Refreshing ${TASKS_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.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` }
: undefined;
this.render();
}
private async dispatchTask(workspace: Workspace, 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();
return;
}
if (task.confirm && !window.confirm(`Run ${task.title}?\n\n${task.command}`)) {
this.status = { kind: "info", message: `Cancelled ${task.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.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;
this.status = {
kind: "success",
message: `Started terminal command “${handle.run.title}”.`,
detail: task.command,
};
this.runningTaskId = undefined;
this.render();
} catch (error) {
if (!this.isCurrentWorkspace(workspace)) return;
this.runningTaskId = 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<ConfigState> {
const key = cacheKeyForWorkspace(workspace);
const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({
kind: "unavailable",
message: tasksConfigUnavailableMessage,
hint: tasksConfigRefreshHint,
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<ConfigState, { kind: "missing" }>): string {
return `<div class="empty-state"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p></div>`;
}
function renderUnavailableState(state: Extract<ConfigState, { kind: "unavailable" }>): string {
const detail = state.detail === undefined ? "" : `<pre>${escapeHtml(state.detail)}</pre>`;
return `<div class="status error"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p>${detail}</div>`;
}
function renderTaskGroups(tasks: WorkspaceTask[], runningTaskId: string | undefined): string {
return `<div class="tasks">${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}</div>`;
}
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 ? "" : `<h3>${escapeHtml(group.title)}</h3>`;
return `<section class="task-group">${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}</section>`;
}
function renderTask(task: WorkspaceTask, runningTaskId: string | undefined): string {
const running = runningTaskId === task.id;
const disabled = runningTaskId !== undefined;
const description = task.description === undefined ? "" : `<span>${escapeHtml(task.description)}</span>`;
return `
<article class="task-card">
<div class="task-copy">
<strong>${escapeHtml(task.title)}</strong>
${description}
<code>${escapeHtml(task.command)}</code>
</div>
<button data-task-id="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
</article>
`;
}
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 `
<style>
:host { display: contents; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
.toolbar-tasks { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.viewer { box-sizing: border-box; min-height: 0; overflow: auto; padding: 12px; }
.tasks-viewer { display: grid; align-content: start; gap: 12px; }
.tasks { display: grid; gap: 14px; }
.task-group { display: grid; gap: 10px; }
.task-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
.task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.task-copy { display: grid; min-width: 0; gap: 5px; }
.task-copy span, .muted { color: var(--pi-muted); }
code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { overflow: auto; padding: 5px 7px; white-space: nowrap; }
pre { margin: 8px 0 0; overflow: auto; padding: 8px; white-space: pre-wrap; }
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; }
.panel-status { margin: 12px 12px 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); }
.status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
.empty { padding: 16px; color: var(--pi-muted); }
@media (max-width: 760px) {
.task-card { grid-template-columns: 1fr; }
.task-card button { justify-self: start; }
}
</style>
`;
}
function escapeHtml(value: unknown): string {
return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function escapeAttr(value: unknown): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH } from "./config";
import { loadWorkspaceTasksConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceTasksClient";
const workspace: Workspace = {
id: "workspace 1",
projectId: "project/1",
path: "/repo",
label: "repo",
isMain: false,
isGitRepo: true,
isGitWorktree: true,
};
describe("workspace tasks client", () => {
it("builds the private workspace file URL", () => {
expect(workspaceFileUrl(workspace, TASKS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Ftasks.json");
});
it("loads and parses a valid tasks config", async () => {
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }),
truncated: false,
binary: false,
}));
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({
kind: "loaded",
path: TASKS_CONFIG_PATH,
config: {
version: 1,
tasks: [{ id: "build", title: "Build", command: "npm run build", confirm: false }],
},
});
});
it("treats a missing optional tasks config as unconfigured", async () => {
const fetcher: FetchLike = () => Promise.resolve(missingResponse());
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({
kind: "missing",
message: "No workspace tasks configured here.",
hint: `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`,
});
});
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(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
kind: "unavailable",
message: "Could not load workspace tasks.",
hint: `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`,
detail: `Unable to read ${TASKS_CONFIG_PATH}: HTTP 400: nope`,
});
});
it("returns parser details for invalid config files", async () => {
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
content: JSON.stringify({ version: 2, tasks: [] }),
truncated: false,
binary: false,
}));
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
kind: "unavailable",
detail: "Config version must be 1",
});
});
it("validates workspace file responses", () => {
expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false });
expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined();
});
});
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
}
function missingResponse(): Response {
return new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 });
}
@@ -0,0 +1,106 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js";
export const tasksConfigMissingMessage = "No workspace tasks configured here.";
export const tasksConfigMissingHint = `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`;
export const tasksConfigUnavailableMessage = "Could not load workspace tasks.";
export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`;
const missingWorkspaceFileError = "Path does not exist";
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export type WorkspaceTasksConfigLoadResult =
| { kind: "loaded"; config: WorkspaceTasksConfig; path: string }
| { kind: "missing"; message: string; hint: string }
| { kind: "unavailable"; message: string; hint: string; detail?: string };
interface WorkspaceFileResponse {
content: string;
truncated: boolean;
binary: boolean;
}
export async function loadWorkspaceTasksConfig(
workspace: Workspace,
deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) },
): Promise<WorkspaceTasksConfigLoadResult> {
let response: Response;
try {
response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" });
} catch (error) {
return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
}
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 ${TASKS_CONFIG_PATH}: ${responseSummary}`);
}
let body: unknown;
try {
body = await response.json();
} catch (error) {
return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
}
const file = parseWorkspaceFileResponse(body);
if (file === undefined) return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}`);
if (file.binary) return unavailable(`${TASKS_CONFIG_PATH} must be a text file`);
if (file.truncated) return unavailable(`${TASKS_CONFIG_PATH} is too large and was truncated`);
const result = parseTasksConfigText(file.content);
if (!result.ok) return unavailable(result.error);
return { kind: "loaded", config: result.config, path: TASKS_CONFIG_PATH };
}
export function workspaceFileUrl(workspace: Workspace, path: string): string {
return `/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/file?path=${encodeURIComponent(path)}`;
}
export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileResponse | undefined {
if (!isRecord(value)) return undefined;
const content = value["content"];
const truncated = value["truncated"];
const binary = value["binary"];
if (typeof content !== "string" || typeof truncated !== "boolean" || typeof binary !== "boolean") return undefined;
return { content, truncated, binary };
}
function missing(): WorkspaceTasksConfigLoadResult {
return {
kind: "missing",
message: tasksConfigMissingMessage,
hint: tasksConfigMissingHint,
};
}
function unavailable(detail: string): WorkspaceTasksConfigLoadResult {
return {
kind: "unavailable",
message: tasksConfigUnavailableMessage,
hint: tasksConfigRefreshHint,
detail,
};
}
async function readResponseErrorMessage(response: Response): Promise<string | undefined> {
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);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}