Archived
feat(plugins): add files.listFiles directory listing to the plugin API
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add `files.listFiles(path)` to the stable plugin API so workspace panel and label plugins can list workspace directory entries on local and federated machines.
|
||||
+30
-2
@@ -585,6 +585,7 @@ interface WorkspacePanelContext {
|
||||
state?: PluginRuntimeState;
|
||||
files: {
|
||||
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>;
|
||||
@@ -607,7 +608,7 @@ interface WorkspacePanelContext {
|
||||
|
||||
`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide.
|
||||
|
||||
`machine`, `workspace`, `files`, `prompt`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). The `prompt` helper supports panel interactions that insert workspace context into the current prompt — see [Prompt editor API](#prompt-editor-api). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
|
||||
`machine`, `workspace`, `files`, `prompt`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `listFiles`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files), [Listing workspace files](#listing-workspace-files), and [Writing workspace files](#writing-workspace-files). The `prompt` helper supports panel interactions that insert workspace context into the current prompt — see [Prompt editor API](#prompt-editor-api). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
|
||||
|
||||
For compatibility, PI WEB still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`.
|
||||
|
||||
@@ -675,6 +676,7 @@ interface WorkspaceLabelContext {
|
||||
state?: PluginRuntimeState;
|
||||
files: {
|
||||
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>;
|
||||
@@ -685,7 +687,7 @@ interface WorkspaceLabelContext {
|
||||
}
|
||||
```
|
||||
|
||||
`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
|
||||
`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `listFiles`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files), [Listing workspace files](#listing-workspace-files), and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
|
||||
|
||||
Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes.
|
||||
|
||||
@@ -824,6 +826,32 @@ workspaceLabels: [
|
||||
|
||||
The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
|
||||
|
||||
## Listing workspace files
|
||||
|
||||
`files.listFiles(path)` lists the entries of a workspace directory. Pass `""` for the workspace root. Like `readFile`, PI WEB binds the call to the callback's machine and workspace, so it works the same for local and federated machines.
|
||||
|
||||
```js
|
||||
const listing = await context.files.listFiles("src");
|
||||
for (const entry of listing.entries) {
|
||||
// entry: { name, path, type: "file" | "directory" | "symlink", size?, modifiedAt? }
|
||||
}
|
||||
```
|
||||
|
||||
The listing response includes `path`, `entries`, `scannedAt`, and `truncated`. When `truncated` is true, the server cut the listing short, so treat the entries as partial.
|
||||
|
||||
`listFiles` rejects when the directory does not exist or cannot be read, matching `readFile` error behavior. When a directory is optional, catch the error and treat it as an empty listing:
|
||||
|
||||
```js
|
||||
async function listSubdirectoryNames(context, path) {
|
||||
try {
|
||||
const listing = await context.files.listFiles(path);
|
||||
return listing.entries.filter((entry) => entry.type === "directory").map((entry) => entry.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Writing, deleting, and moving workspace files
|
||||
|
||||
Workspace panels and workspace labels can write, delete, and move files through the documented `files` helper. Like `readFile`, PI WEB binds these helpers to the callback's machine and workspace, so they work the same for local and federated machines.
|
||||
|
||||
@@ -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