Archived
feat(plugins): add files.listFiles directory listing to the plugin API
This commit is contained in:
@@ -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"))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user