feat: add public plugin workspace capabilities

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 13:00:25 +02:00
parent 93b50e61af
commit e3533ebf1f
28 changed files with 584 additions and 531 deletions
@@ -1,7 +1,6 @@
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,
@@ -39,8 +38,8 @@ const plugin: PiWebPlugin = {
</svg>
`,
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>`,
badge: (context) => tasksPanelBadge(context),
render: (context) => html`<pi-web-workspace-tasks-panel .context=${context}></pi-web-workspace-tasks-panel>`,
},
],
},
@@ -1,62 +0,0 @@
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;
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,24 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { TerminalCommandRun, WorkspacePanelTerminal } 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 = {
const run: TerminalCommandRun = {
id: "run1",
origin: "workspace-tasks",
projectId: workspace.projectId,
workspaceId: workspace.id,
projectId: "project/1",
workspaceId: "workspace 1",
terminalId: "term1",
title: "Build",
command: "npm run build",
@@ -28,20 +17,19 @@ const run: InternalTerminalCommandRun = {
};
describe("task runner", () => {
it("starts workspace tasks through the internal terminal command-run helper", async () => {
it("starts workspace tasks through the public workspace terminal 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 = {
const runCommand = vi.fn<WorkspacePanelTerminal["runCommand"]>(() => Promise.resolve({ run, completed: Promise.resolve(run) }));
const terminal: WorkspacePanelTerminal = {
runCommand,
open: vi.fn(),
};
const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task);
const handle = await runWorkspaceTaskInTerminal(terminal, 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,
+2 -4
View File
@@ -1,10 +1,8 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspacePanelTerminal } 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"]> {
export function runWorkspaceTaskInTerminal(terminal: WorkspacePanelTerminal, task: WorkspaceTask): ReturnType<WorkspacePanelTerminal["runCommand"]> {
return terminal.runCommand({
workspace,
title: task.title,
command: task.command,
open: true,
@@ -1,14 +1,10 @@
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import type { WorkspacePanelContext } 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 =
@@ -27,17 +23,15 @@ export function defineTasksPanelElement(): void {
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
}
export function tasksPanelBadge(workspace: Workspace): string | number | undefined {
const state = getCachedWorkspaceConfig(workspace);
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 workspaceValue: Workspace | undefined;
private openTerminalValue: OpenTerminal | undefined;
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
private contextValue: WorkspacePanelContext | undefined;
private runningTaskId: string | undefined;
private status: TaskStatus | undefined;
private readonly root: ShadowRoot;
@@ -50,10 +44,10 @@ class PiWebTasksPanel extends HTMLElement {
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;
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;
@@ -62,14 +56,6 @@ class PiWebTasksPanel extends HTMLElement {
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();
@@ -80,13 +66,13 @@ class PiWebTasksPanel extends HTMLElement {
}
private render(): void {
const workspace = this.workspaceValue;
if (workspace === undefined) {
const context = this.contextValue;
if (context === undefined) {
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
return;
}
const state = getOrLoadWorkspaceConfig(workspace);
const state = getOrLoadWorkspaceConfig(context);
this.root.innerHTML = `
${taskStyles()}
<section class="toolbar">
@@ -103,12 +89,12 @@ class PiWebTasksPanel extends HTMLElement {
`;
this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
void this.refreshConfig(workspace);
void this.refreshConfig(context);
});
for (const button of this.root.querySelectorAll("button[data-task-id]")) {
button.addEventListener("click", () => {
void this.dispatchTaskById(workspace, button.getAttribute("data-task-id"));
void this.dispatchTaskById(context, button.getAttribute("data-task-id"));
});
}
@@ -117,19 +103,19 @@ class PiWebTasksPanel extends HTMLElement {
});
}
private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise<void> {
if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId);
private dispatchTaskById(context: WorkspacePanelContext, taskId: string | null): Promise<void> {
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(workspace, task);
return this.dispatchTask(context, task);
}
private isCurrentWorkspace(workspace: Workspace): boolean {
return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
private isCurrentContext(context: WorkspacePanelContext): boolean {
return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
}
private renderConfigState(state: ConfigState): string {
@@ -150,20 +136,20 @@ class PiWebTasksPanel extends HTMLElement {
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
}
private async refreshConfig(workspace: Workspace): Promise<void> {
private async refreshConfig(context: WorkspacePanelContext): Promise<void> {
this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}` };
configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" });
configCache.set(cacheKeyForContext(context), { kind: "loading" });
this.render();
const state = await refreshWorkspaceConfig(workspace);
if (!this.isCurrentWorkspace(workspace)) return;
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(workspace: Workspace, task: WorkspaceTask): Promise<void> {
private async dispatchTask(context: WorkspacePanelContext, 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();
@@ -175,20 +161,13 @@ class PiWebTasksPanel extends HTMLElement {
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;
const handle = await runWorkspaceTaskInTerminal(context.terminal, task);
if (!this.isCurrentContext(context)) return;
this.status = {
kind: "success",
message: `Started terminal command “${handle.run.title}”.`,
@@ -197,7 +176,7 @@ class PiWebTasksPanel extends HTMLElement {
this.runningTaskId = undefined;
this.render();
} catch (error) {
if (!this.isCurrentWorkspace(workspace)) return;
if (!this.isCurrentContext(context)) return;
this.runningTaskId = undefined;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
@@ -205,50 +184,47 @@ class PiWebTasksPanel extends HTMLElement {
}
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." };
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) this.openTerminalValue();
else this.openTerminalValue({ terminalId });
if (terminalId === undefined) context.terminal.open();
else context.terminal.open({ terminalId });
}
}
function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined {
return configCache.get(cacheKeyForWorkspace(workspace));
function getCachedWorkspaceConfig(context: WorkspacePanelContext): ConfigState | undefined {
return configCache.get(cacheKeyForContext(context));
}
function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState {
const cached = getCachedWorkspaceConfig(workspace);
function getOrLoadWorkspaceConfig(context: WorkspacePanelContext): ConfigState {
const cached = getCachedWorkspaceConfig(context);
if (cached !== undefined) return cached;
const loading: ConfigState = { kind: "loading" };
configCache.set(cacheKeyForWorkspace(workspace), loading);
void refreshWorkspaceConfig(workspace);
configCache.set(cacheKeyForContext(context), loading);
void refreshWorkspaceConfig(context);
return loading;
}
async function refreshWorkspaceConfig(workspace: Workspace): Promise<ConfigState> {
const key = cacheKeyForWorkspace(workspace);
const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({
async function refreshWorkspaceConfig(context: WorkspacePanelContext): Promise<ConfigState> {
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);
requestPiWebRender();
context.requestRender();
window.dispatchEvent(new Event(configChangedEvent));
return state;
}
function cacheKeyForWorkspace(workspace: Workspace): string {
return `${workspace.projectId}:${workspace.id}`;
function cacheKeyForContext(context: WorkspacePanelContext): string {
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
}
function renderMissingState(state: Extract<ConfigState, { kind: "missing" }>): string {
@@ -1,31 +1,24 @@
import { describe, expect, it } from "vitest";
import type { Workspace } from "@jmfederico/pi-web/plugin-api";
import { describe, expect, it, vi } from "vitest";
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,
};
import { loadWorkspaceTasksConfig, type WorkspaceTasksFileReader } from "./workspaceTasksClient";
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 the configured path through the public workspace file helper", async () => {
const readFile = vi.fn<WorkspaceTasksFileReader["readFile"]>(() => Promise.resolve({ content: JSON.stringify({ version: 1, tasks: [] }), truncated: false, binary: false }));
await loadWorkspaceTasksConfig({ readFile });
expect(readFile).toHaveBeenCalledWith(TASKS_CONFIG_PATH);
});
it("loads and parses a valid tasks config", async () => {
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
it("loads and parses a valid tasks config through the public workspace file helper", async () => {
const files = reader({
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({
await expect(loadWorkspaceTasksConfig(files)).resolves.toEqual({
kind: "loaded",
path: TASKS_CONFIG_PATH,
config: {
@@ -36,49 +29,40 @@ describe("workspace tasks client", () => {
});
it("treats a missing optional tasks config as unconfigured", async () => {
const fetcher: FetchLike = () => Promise.resolve(missingResponse());
const files: WorkspaceTasksFileReader = { readFile: () => Promise.reject(new Error("Path does not exist")) };
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({
await expect(loadWorkspaceTasksConfig(files)).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 }));
it("returns a visible unavailable state instead of throwing on read failures", async () => {
const files: WorkspaceTasksFileReader = { readFile: () => Promise.reject(new Error("nope")) };
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
await expect(loadWorkspaceTasksConfig(files)).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`,
detail: `Unable to read ${TASKS_CONFIG_PATH}: nope`,
});
});
it("returns parser details for invalid config files", async () => {
const fetcher: FetchLike = () => Promise.resolve(jsonResponse({
const files = reader({
content: JSON.stringify({ version: 2, tasks: [] }),
truncated: false,
binary: false,
}));
});
await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({
await expect(loadWorkspaceTasksConfig(files)).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 });
function reader(file: Awaited<ReturnType<WorkspaceTasksFileReader["readFile"]>>): WorkspaceTasksFileReader {
return { readFile: () => Promise.resolve(file) };
}
@@ -1,4 +1,3 @@
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.";
@@ -8,46 +7,30 @@ export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refr
const missingWorkspaceFileError = "Path does not exist";
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export interface WorkspaceTasksFileReader {
readFile(path: string): Promise<WorkspaceTasksFileContent>;
}
interface WorkspaceTasksFileContent {
content: string;
truncated: boolean;
binary: boolean;
}
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;
export async function loadWorkspaceTasksConfig(files: WorkspaceTasksFileReader): Promise<WorkspaceTasksConfigLoadResult> {
let file: WorkspaceTasksFileContent;
try {
response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" });
file = await files.readFile(TASKS_CONFIG_PATH);
} catch (error) {
if (errorMessage(error) === missingWorkspaceFileError) return missing();
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`);
@@ -56,19 +39,6 @@ export async function loadWorkspaceTasksConfig(
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",
@@ -86,21 +56,10 @@ function unavailable(detail: string): WorkspaceTasksConfigLoadResult {
};
}
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 errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : 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);
}