feat(plugins): add files.listFiles directory listing to the plugin API

This commit is contained in:
Federico Jaramillo Martinez
2026-07-28 23:14:21 +02:00
parent 12f25282fc
commit d19fca4090
10 changed files with 199 additions and 26 deletions
+2 -18
View File
@@ -37,6 +37,7 @@ import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
import { loadExternalPlugins } from "../plugins/external";
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
import { createWorkspaceFiles as createPluginWorkspaceFiles } from "../plugins/workspaceFiles";
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
import { AppShellController } from "../appShell/appShellController";
import { BrowserResumeController } from "../appShell/browserResumeController";
@@ -1596,24 +1597,7 @@ export class PiWebApp extends LitElement {
}
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
return {
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
writeFile: async (path, content, options) => {
const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId);
void this.files.refreshFiles();
return result;
},
deleteFile: async (path) => {
const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId);
void this.files.refreshFiles();
return result;
},
moveFile: async (fromPath, toPath, options) => {
const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId);
void this.files.refreshFiles();
return result;
},
};
return createPluginWorkspaceFiles(workspacesApi, workspace, machineId, () => { void this.files.refreshFiles(); });
}
private createWorkspaceHost(): WorkspaceHost {
@@ -243,6 +243,7 @@ function workspacePanelContext(patch: Partial<WorkspacePanelContext> = {}): Work
state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} },
files: patch.files ?? {
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
listFiles: vi.fn<WorkspacePanelContext["files"]["listFiles"]>(() => Promise.reject(new Error("not implemented"))),
writeFile: vi.fn<WorkspacePanelContext["files"]["writeFile"]>(() => Promise.reject(new Error("not implemented"))),
deleteFile: vi.fn<WorkspacePanelContext["files"]["deleteFile"]>(() => Promise.reject(new Error("not implemented"))),
moveFile: vi.fn<WorkspacePanelContext["files"]["moveFile"]>(() => Promise.reject(new Error("not implemented"))),
@@ -358,6 +358,7 @@ function workspacePanelContext(patch: Partial<WorkspacePanelContext> = {}): Work
state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} },
files: patch.files ?? {
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
listFiles: vi.fn<WorkspacePanelContext["files"]["listFiles"]>(() => Promise.reject(new Error("not implemented"))),
writeFile: vi.fn<WorkspacePanelContext["files"]["writeFile"]>(() => Promise.reject(new Error("not implemented"))),
deleteFile: vi.fn<WorkspacePanelContext["files"]["deleteFile"]>(() => Promise.reject(new Error("not implemented"))),
moveFile: vi.fn<WorkspacePanelContext["files"]["moveFile"]>(() => Promise.reject(new Error("not implemented"))),
+13 -4
View File
@@ -1,6 +1,6 @@
import { html } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
@@ -464,7 +464,7 @@ describe("PluginRegistry", () => {
context.host.requestRender();
return [{ type: "text", text: context.machine.id }];
});
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, listFiles: vi.fn<WorkspaceFiles["listFiles"]>(() => Promise.resolve(testFileTreeResponse())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
registry.register({
id: "example",
@@ -674,7 +674,7 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
}
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), listFiles: vi.fn<WorkspaceFiles["listFiles"]>(() => Promise.resolve(testFileTreeResponse())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
@@ -691,7 +691,7 @@ function createWorkspacePanelContext(machineId: string, prompt: WorkspacePanelCo
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() },
files: { readFile: vi.fn(), listFiles: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() },
prompt,
terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() },
@@ -747,6 +747,15 @@ function testStatus(patch: Partial<SessionStatus> = {}): SessionStatus {
};
}
function testFileTreeResponse(path = ".pi-web/relays"): FileTreeResponse {
return {
path,
entries: [],
scannedAt: "2026-05-20T00:00:00.000Z",
truncated: false,
};
}
function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse {
return {
path,
+2 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { AppAction } from "../actions";
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { AppState } from "../appState";
import type { SettingsSection } from "../settingsRoute";
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
@@ -50,6 +50,7 @@ export interface PluginMachine {
export interface WorkspaceFiles {
readFile(path: string): Promise<FileContentResponse>;
listFiles(path: string): Promise<FileTreeResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import type { FileContentResponse, FileTreeResponse } from "../api";
import { createWorkspaceFiles, type WorkspaceFilesApi } from "./workspaceFiles";
const workspace = { id: "w-1", projectId: "p-1" };
describe("createWorkspaceFiles", () => {
it("listFiles resolves with the directory listing for the bound workspace and machine", async () => {
const tree = testFileTreeResponse();
const workspaceTree = vi.fn<WorkspaceFilesApi["workspaceTree"]>(() => Promise.resolve(tree));
const files = createWorkspaceFiles(fakeApi({ workspaceTree }), workspace, "remote-1");
await expect(files.listFiles(".pi-web/relays")).resolves.toBe(tree);
expect(workspaceTree).toHaveBeenCalledWith("p-1", "w-1", ".pi-web/relays", "remote-1");
});
it("listFiles rejects when the directory is missing, matching readFile error behavior", async () => {
const workspaceTree = vi.fn<WorkspaceFilesApi["workspaceTree"]>(() => Promise.reject(new Error("Path not found: .pi-web/relays")));
const files = createWorkspaceFiles(fakeApi({ workspaceTree }), workspace, "local");
await expect(files.listFiles(".pi-web/relays")).rejects.toThrow("Path not found: .pi-web/relays");
});
it("readFile reads through the bound workspace and machine", async () => {
const content = testFileContent("README.md");
const workspaceFile = vi.fn<WorkspaceFilesApi["workspaceFile"]>(() => Promise.resolve(content));
const files = createWorkspaceFiles(fakeApi({ workspaceFile }), workspace, "remote-1");
await expect(files.readFile("README.md")).resolves.toBe(content);
expect(workspaceFile).toHaveBeenCalledWith("p-1", "w-1", "README.md", "remote-1");
});
it("writeFile reports the change after a successful write", async () => {
const writeWorkspaceFile = vi.fn<WorkspaceFilesApi["writeWorkspaceFile"]>(() => Promise.resolve({ path: "out.txt", size: 2, modifiedAt: "2026-06-14T10:00:00.000Z", created: true }));
const onFilesChanged = vi.fn();
const files = createWorkspaceFiles(fakeApi({ writeWorkspaceFile }), workspace, "local", onFilesChanged);
const result = await files.writeFile("out.txt", "hi");
expect(result.created).toBe(true);
expect(writeWorkspaceFile).toHaveBeenCalledWith("p-1", "w-1", "out.txt", "hi", undefined, "local");
expect(onFilesChanged).toHaveBeenCalledOnce();
});
it("deleteFile and moveFile report the change after success", async () => {
const deleteWorkspaceFile = vi.fn<WorkspaceFilesApi["deleteWorkspaceFile"]>(() => Promise.resolve({ path: "old.txt", existed: true }));
const moveWorkspaceFile = vi.fn<WorkspaceFilesApi["moveWorkspaceFile"]>(() => Promise.resolve({ fromPath: "old.txt", toPath: "new.txt", size: 0, modifiedAt: "2026-06-14T10:00:00.000Z" }));
const onFilesChanged = vi.fn();
const files = createWorkspaceFiles(fakeApi({ deleteWorkspaceFile, moveWorkspaceFile }), workspace, "local", onFilesChanged);
await files.deleteFile("old.txt");
await files.moveFile("old.txt", "new.txt");
expect(deleteWorkspaceFile).toHaveBeenCalledWith("p-1", "w-1", "old.txt", "local");
expect(moveWorkspaceFile).toHaveBeenCalledWith("p-1", "w-1", "old.txt", "new.txt", undefined, "local");
expect(onFilesChanged).toHaveBeenCalledTimes(2);
});
it("does not report a change when a mutation fails", async () => {
const writeWorkspaceFile = vi.fn<WorkspaceFilesApi["writeWorkspaceFile"]>(() => Promise.reject(new Error("File exists: out.txt")));
const onFilesChanged = vi.fn();
const files = createWorkspaceFiles(fakeApi({ writeWorkspaceFile }), workspace, "local", onFilesChanged);
await expect(files.writeFile("out.txt", "hi", { overwrite: false })).rejects.toThrow("File exists: out.txt");
expect(onFilesChanged).not.toHaveBeenCalled();
});
});
function fakeApi(overrides: Partial<WorkspaceFilesApi> = {}): WorkspaceFilesApi {
const unexpected = (name: string) => () => Promise.reject(new Error(`Unexpected ${name} call`));
return {
workspaceFile: vi.fn<WorkspaceFilesApi["workspaceFile"]>(unexpected("workspaceFile")),
workspaceTree: vi.fn<WorkspaceFilesApi["workspaceTree"]>(unexpected("workspaceTree")),
writeWorkspaceFile: vi.fn<WorkspaceFilesApi["writeWorkspaceFile"]>(unexpected("writeWorkspaceFile")),
deleteWorkspaceFile: vi.fn<WorkspaceFilesApi["deleteWorkspaceFile"]>(unexpected("deleteWorkspaceFile")),
moveWorkspaceFile: vi.fn<WorkspaceFilesApi["moveWorkspaceFile"]>(unexpected("moveWorkspaceFile")),
...overrides,
};
}
function testFileTreeResponse(path = ".pi-web/relays"): FileTreeResponse {
return {
path,
entries: [{ name: "relays-panel-plugin", path: `${path}/relays-panel-plugin`, type: "directory", modifiedAt: "2026-06-14T10:00:00.000Z" }],
scannedAt: "2026-06-14T10:00:01.000Z",
truncated: false,
};
}
function testFileContent(path: string): FileContentResponse {
return {
path,
encoding: "utf8",
size: 0,
modifiedAt: "2026-06-14T10:00:00.000Z",
content: "",
truncated: false,
binary: false,
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { WorkspaceFiles } from "./types";
/**
* API surface the workspace files helper needs. Structurally satisfied by
* `workspacesApi`; declared here so the helper stays testable with fakes.
*/
export interface WorkspaceFilesApi {
workspaceFile(projectId: string, workspaceId: string, path: string, machineId?: string): Promise<FileContentResponse>;
workspaceTree(projectId: string, workspaceId: string, path?: string, machineId?: string): Promise<FileTreeResponse>;
writeWorkspaceFile(projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId?: string): Promise<WriteWorkspaceFileResponse>;
deleteWorkspaceFile(projectId: string, workspaceId: string, path: string, machineId?: string): Promise<DeleteWorkspaceFileResponse>;
moveWorkspaceFile(projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId?: string): Promise<MoveWorkspaceFileResponse>;
}
/**
* Build the `files` helper exposed to workspace panel and label callbacks.
* Every call is bound to the callback's workspace and machine, so local and
* federated machines behave the same. `onFilesChanged` runs after a mutation
* succeeds so the host can refresh its file explorer.
*/
export function createWorkspaceFiles(api: WorkspaceFilesApi, workspace: Pick<Workspace, "id" | "projectId">, machineId: string, onFilesChanged?: () => void): WorkspaceFiles {
return {
readFile: (path) => api.workspaceFile(workspace.projectId, workspace.id, path, machineId),
listFiles: (path) => api.workspaceTree(workspace.projectId, workspace.id, path, machineId),
writeFile: async (path, content, options) => {
const result = await api.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId);
onFilesChanged?.();
return result;
},
deleteFile: async (path) => {
const result = await api.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId);
onFilesChanged?.();
return result;
},
moveFile: async (fromPath, toPath, options) => {
const result = await api.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId);
onFilesChanged?.();
return result;
},
};
}
+5 -1
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js";
import type { FileContentResponse, FileTreeResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js";
export type {
FileContentMediaType,
@@ -135,6 +135,10 @@ export interface Workspace {
export interface WorkspaceFiles {
/** Read a file from the workspace. Works for local and federated machines. */
readFile(path: string): Promise<FileContentResponse>;
/** List the entries of a workspace directory. Pass "" for the workspace root.
* Works for local and federated machines. Rejects when the directory does not
* exist or cannot be read, matching readFile error behavior. */
listFiles(path: string): Promise<FileTreeResponse>;
/** Write content to a workspace file. Creates intermediate directories by default.
* Works for local and federated machines. Auto-refreshes the file explorer after success. */
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;