diff --git a/.changeset/manual-workspace-uploads.md b/.changeset/manual-workspace-uploads.md new file mode 100644 index 0000000..033fb63 --- /dev/null +++ b/.changeset/manual-workspace-uploads.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. diff --git a/README.md b/README.md index 801f4b5..2afd031 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ This maps naturally to real development work: - Add and list local or remote PI WEB machines from the action palette. - Proxy remote projects, workspaces, files, git state, sessions, and terminals through the currently opened PI WEB server. +- Upload files from the Files panel with direct drag/drop, configurable default destinations, and per-file progress. - Add and list server-side projects. - Discover git worktrees automatically with `git worktree list --porcelain`. - Support non-git folders as single-workspace projects. @@ -280,6 +281,7 @@ Common config keys: - `host` / `port` — web/API bind address. Environment overrides: `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`. - `pathAccess.allowedPaths` — external filesystem roots that PI WEB may list/read through the file explorer and absolute `@` path completions. Absolute paths are denied by default. +- `uploads.defaultFolder` — workspace-relative default destination for manual Files-panel uploads. Set it globally or in `/.pi-web/config.json`; the project-local value wins for that project's workspaces. Defaults to `.pi-web/uploads`. - `maxUploadBytes` — maximum accepted request body size. Defaults to 64 MB. Environment override: `PI_WEB_MAX_UPLOAD_BYTES`. - `spawnSessions` — enable the `spawn_session` tool. Defaults to `true`. Environment override: `PI_WEB_SPAWN_SESSIONS`. - `subsessions` — beta tracked-subsession tools (`spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`). Defaults to `false`, requires `spawnSessions`, and requires a session daemon restart after changes. Environment override: `PI_WEB_SUBSESSIONS`. diff --git a/docs/config.md b/docs/config.md index d437ca2..2707a9c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,6 +1,6 @@ # PI WEB configuration reference -PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload limits, and session-daemon tools. +PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools. This file is the markdown reference for agents and package consumers. The website page is . @@ -17,12 +17,14 @@ If you installed services with a custom config path, rerun `pi-web install --con ## Precedence and reloads -Runtime values are resolved as: +Machine-global runtime values are resolved as: ```text -defaults → config file → environment overrides +defaults → global config file → environment overrides ``` +Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value. + Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: @@ -31,6 +33,7 @@ Process restarts depend on the key: - `maxUploadBytes`: restart both the web/API process and the session daemon. - `spawnSessions` / `subsessions`: restart the session daemon. - `pathAccess`: applies on the next request; existing file views may need a browser refresh. +- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing plugin enablement. - `shortcuts`: saved settings apply in the browser after config refresh/save. @@ -43,6 +46,9 @@ Process restarts depend on the key: "pathAccess": { "allowedPaths": ["~/SDKs", "/opt/reference"] }, + "uploads": { + "defaultFolder": ".pi-web/uploads" + }, "maxUploadBytes": 67108864, "spawnSessions": true, "subsessions": false, @@ -67,12 +73,17 @@ Project-local config lives at `/.pi-web/config.json`. Use it for settin "version": 1, "pathAccess": { "allowedPaths": ["~/SDKs", "/opt/reference"] + }, + "uploads": { + "defaultFolder": "manual/uploads" } } ``` Project-local `pathAccess.allowedPaths` entries are merged after the global list and deduplicated. Paths must still be host-absolute or `~`-prefixed; relative roots are not supported. +Project-local `uploads.defaultFolder` overrides the global upload destination for workspaces in that project. Current PI WEB servers include this workspace-effective value on the existing workspace responses used locally and through machine federation. Older remote servers may omit the optional field; the browser falls back to the global/default upload folder. + Plugins may own separate project files, such as `.pi-web/tasks.json` for the built-in Workspace Tasks plugin. ## Configuration matrix @@ -86,6 +97,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Web/API port | `port` | `PI_WEB_PORT`, `PORT` | Global | Not supported locally | Restart web/API | | Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI | | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | +| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon | @@ -123,6 +135,31 @@ When an absolute request is served, PI WEB expands `~`, canonicalizes the config This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace. +### Manual upload defaults + +The Files panel can upload one or more files in two ways: + +- Drop files onto the Files panel to upload immediately to the workspace-effective default folder. +- Use the toolbar **Upload** button to open the review dialog, edit the destination, and opt into upload options. + +`uploads.defaultFolder` sets the workspace-effective default destination. The built-in default is `.pi-web/uploads`; a global config value applies to every project unless `/.pi-web/config.json` sets a project-local override. + +```json +{ + "uploads": { + "defaultFolder": "manual/uploads" + } +} +``` + +The value must be a non-empty workspace-relative folder. PI WEB normalizes repeated separators and backslashes to `/`, and rejects absolute paths or `..` traversal. In the upload dialog only, clearing the destination field uploads that batch to the workspace root. + +Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth. + +For machine federation, current remote PI WEB servers return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder. + +The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`. + ### Session daemon tools `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index c6cbeb7..11bdca8 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,5 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; +export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 375fe77..69e39bc 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }); }); @@ -74,6 +74,50 @@ describe("API parsers", () => { }); }); + it("parses workspace effective upload config when present", () => { + expect(parseWorkspace({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + branch: "main", + isMain: true, + isGitRepo: true, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "manual/uploads" } }, + })).toEqual({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + branch: "main", + isMain: true, + isGitRepo: true, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "manual/uploads" } }, + }); + }); + + it("accepts legacy workspace responses without effective config", () => { + expect(parseWorkspace({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + })).toEqual({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + }); + }); + it("parses workspace activity snapshots", () => { expect(parseWorkspaceActivityResponse({ generatedAt: "now", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 891bad3..dd8dae2 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -141,6 +141,15 @@ export function parseWorkspace(value: unknown): Workspace { isMain: requireBoolean(record, "isMain"), isGitRepo: requireBoolean(record, "isGitRepo"), isGitWorktree: requireBoolean(record, "isGitWorktree"), + ...optionalField("effectiveConfig", optionalWorkspaceEffectiveConfig(record["effectiveConfig"])), + }; +} + +function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveConfig"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid workspace effectiveConfig field"); + return { + ...optionalField("uploads", optionalUploads(value["uploads"])), }; } @@ -474,6 +483,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("plugins", optionalPlugins(record["plugins"])), ...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])), + ...optionalField("uploads", optionalUploads(record["uploads"])), ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), @@ -502,6 +512,14 @@ function optionalStringArray(value: unknown, field: string): string[] | undefine throw new Error(`Invalid PI WEB ${field} field`); } +function optionalUploads(value: unknown): PiWebConfigValues["uploads"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB uploads field"); + return { + ...optionalField("defaultFolder", optionalString(value, "defaultFolder")), + }; +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index 744c82d..b532924 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -28,6 +28,14 @@ export function messageUrl(session: SessionLookup, options?: { limit?: number; b return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; } +export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string { + const params = new URLSearchParams({ path }); + if (options?.createDirs === false) params.set("createDirs", "false"); + if (options?.overwrite === false) params.set("overwrite", "false"); + const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; + return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; +} + export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string { const params = new URLSearchParams(); params.set("path", path); diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts new file mode 100644 index 0000000..e36e855 --- /dev/null +++ b/src/client/src/api/workspaceUploads.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + effectiveWorkspaceUploadFolder, + uploadWorkspaceFile, + uploadWorkspaceFiles, + workspaceEffectiveUploadFolder, + workspaceUploadPath, + WorkspaceUploadBatchError, + WorkspaceUploadCancelledError, + type WorkspaceUploadBatchProgress, + type WorkspaceFileUploadProgress, + type WorkspaceUploadXhr, +} from "./workspaceUploads"; + +describe("workspace upload helpers", () => { + it("resolves effective upload defaults and workspace-relative paths", () => { + expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads"); + expect(effectiveWorkspaceUploadFolder({ uploads: { defaultFolder: "manual/uploads" } })).toBe("manual/uploads"); + expect(workspaceEffectiveUploadFolder({ uploads: { defaultFolder: "project/uploads" } }, "global/uploads")).toBe("project/uploads"); + expect(workspaceEffectiveUploadFolder(undefined, "global/uploads")).toBe("global/uploads"); + expect(workspaceUploadPath(" uploads\\manual// ", "./report.txt")).toBe("uploads/manual/report.txt"); + expect(workspaceUploadPath("", "report.txt")).toBe("report.txt"); + + expect(() => workspaceUploadPath("/tmp", "report.txt")).toThrow("workspace-relative"); + expect(() => workspaceUploadPath("uploads", "../secret.txt")).toThrow("path traversal"); + expect(() => workspaceUploadPath("uploads", " ")).toThrow("must not be empty"); + }); + + it("uploads one workspace file through XHR with progress and parses the final response", async () => { + const xhrs = new FakeXhrQueue(); + const progress: WorkspaceFileUploadProgress[] = []; + const file = new File(["hello"], "hello.txt", { type: "text/plain" }); + + const task = uploadWorkspaceFile("p 1", "w/1", { path: "manual/hello.txt", file }, { + machineId: "remote a", + overwrite: false, + xhrFactory: xhrs.factory, + onProgress: (event) => { progress.push(event); }, + }); + + const xhr = xhrs.only(); + expect(xhr.method).toBe("PUT"); + expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); + expect(xhr.headers.get("content-type")).toBe("text/plain"); + expect(xhr.body).toBe(file); + + xhr.emitUploadProgress(2, 5); + xhr.respondJson(200, { path: "manual/hello.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + + await expect(task.promise).resolves.toEqual({ path: "manual/hello.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + expect(progress).toEqual([ + { loaded: 2, total: 5, percent: 0.4, lengthComputable: true }, + { loaded: 5, total: 5, percent: 1, lengthComputable: true }, + ]); + }); + + it("cancels an in-flight workspace file upload", async () => { + const xhrs = new FakeXhrQueue(); + const file = new File(["hello"], "hello.txt"); + + const task = uploadWorkspaceFile("p1", "w1", { path: "uploads/hello.txt", file }, { xhrFactory: xhrs.factory }); + task.cancel(); + + await expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError); + expect(xhrs.only().aborted).toBe(true); + }); + + it("uploads a batch sequentially and reports aggregate progress", async () => { + const xhrs = new FakeXhrQueue(); + const progress: WorkspaceUploadBatchProgress[] = []; + const files = [new File(["ab"], "a.txt", { type: "text/plain" }), new File(["cde"], "b.txt")]; + + const task = uploadWorkspaceFiles("p 1", "w/1", files, { + destinationFolder: "uploads//manual", + machineId: "remote a", + xhrFactory: xhrs.factory, + onProgress: (event) => { progress.push(event); }, + }); + + const first = xhrs.at(0); + expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); + first.emitUploadProgress(1, 2); + first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + await Promise.resolve(); + + const second = xhrs.at(1); + expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); + second.emitUploadProgress(3, 3); + second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); + + await expect(task.promise).resolves.toEqual([ + { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }, + { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }, + ]); + expect(progress[0]).toMatchObject({ currentFileIndex: 0, loaded: 1, total: 5, percent: 0.2, done: false }); + expect(progress.at(-1)).toMatchObject({ currentFileIndex: 1, loaded: 5, total: 5, percent: 1, done: true }); + expect(progress.at(-1)?.files.map((file) => ({ path: file.path, loaded: file.loaded, total: file.total, done: file.done }))).toEqual([ + { path: "uploads/manual/a.txt", loaded: 2, total: 2, done: true }, + { path: "uploads/manual/b.txt", loaded: 3, total: 3, done: true }, + ]); + }); + + it("continues batch uploads after per-file failures and reports the failed file only", async () => { + const xhrs = new FakeXhrQueue(); + const progress: WorkspaceUploadBatchProgress[] = []; + const files = [new File(["ab"], "duplicate.txt"), new File(["cde"], "new.txt")]; + + const task = uploadWorkspaceFiles("p1", "w1", files, { + destinationFolder: "uploads", + overwrite: false, + xhrFactory: xhrs.factory, + onProgress: (event) => { progress.push(event); }, + }); + + xhrs.at(0).respondJson(409, { error: "File already exists: uploads/duplicate.txt" }, "Conflict"); + await Promise.resolve(); + xhrs.at(1).respondJson(200, { path: "uploads/new.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); + + await expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadBatchError); + await task.promise.catch((error: unknown) => { + if (!(error instanceof WorkspaceUploadBatchError)) throw error; + expect(error.failures).toEqual([{ index: 0, name: "duplicate.txt", path: "uploads/duplicate.txt", error: "File already exists: uploads/duplicate.txt" }]); + expect(error.responses).toEqual([{ path: "uploads/new.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }]); + }); + expect(progress.at(-1)?.files.map((file) => ({ path: file.path, done: file.done, error: file.error }))).toEqual([ + { path: "uploads/duplicate.txt", done: true, error: "File already exists: uploads/duplicate.txt" }, + { path: "uploads/new.txt", done: true, error: undefined }, + ]); + }); +}); + +class FakeXhrQueue { + private readonly instances: FakeXMLHttpRequest[] = []; + + readonly factory = (): WorkspaceUploadXhr => { + const xhr = new FakeXMLHttpRequest(); + this.instances.push(xhr); + return xhr; + }; + + only(): FakeXMLHttpRequest { + expect(this.instances).toHaveLength(1); + return this.instances[0] ?? failTest("missing XHR instance"); + } + + at(index: number): FakeXMLHttpRequest { + return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`); + } +} + +class FakeXMLHttpRequest implements WorkspaceUploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null }; + readonly headers = new Map(); + method = ""; + url = ""; + async = true; + body: XMLHttpRequestBodyInit | Document | null = null; + responseType: XMLHttpRequestResponseType = ""; + response: unknown; + responseText = ""; + status = 0; + statusText = ""; + aborted = false; + onload: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + onabort: ((event: ProgressEvent) => void) | null = null; + + open(method: string, url: string, async = true): void { + this.method = method; + this.url = url; + this.async = async; + } + + setRequestHeader(name: string, value: string): void { + this.headers.set(name.toLowerCase(), value); + } + + send(body?: XMLHttpRequestBodyInit | Document | null): void { + this.body = body ?? null; + } + + abort(): void { + this.aborted = true; + this.onabort?.(fakeProgressEvent()); + } + + emitUploadProgress(loaded: number, total: number, lengthComputable = true): void { + this.upload.onprogress?.(fakeProgressEvent(loaded, total, lengthComputable)); + } + + respondJson(status: number, body: unknown, statusText = "OK"): void { + this.status = status; + this.statusText = statusText; + this.response = body; + this.responseText = JSON.stringify(body); + this.onload?.(fakeProgressEvent()); + } +} + +function fakeProgressEvent(loaded = 0, total = 0, lengthComputable = false): ProgressEvent { + return new FakeProgressEvent(loaded, total, lengthComputable); +} + +class FakeProgressEvent extends Event implements ProgressEvent { + readonly loaded: number; + readonly total: number; + readonly lengthComputable: boolean; + + constructor(loaded: number, total: number, lengthComputable: boolean) { + super("progress"); + this.loaded = loaded; + this.total = total; + this.lengthComputable = lengthComputable; + } +} + +function failTest(message: string): never { + throw new Error(message); +} diff --git a/src/client/src/api/workspaceUploads.ts b/src/client/src/api/workspaceUploads.ts new file mode 100644 index 0000000..7ad1897 --- /dev/null +++ b/src/client/src/api/workspaceUploads.ts @@ -0,0 +1,355 @@ +import type { WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../../shared/apiTypes"; +import { parseWriteWorkspaceFileResponse } from "./parsers"; +import { workspaceFileWriteUrl } from "./urls"; + +export const DEFAULT_WORKSPACE_UPLOADS_FOLDER = ".pi-web/uploads"; + +export interface WorkspaceUploadFileInput { + path: string; + file: Blob; + contentType?: string; +} + +export interface WorkspaceFileUploadProgress { + loaded: number; + total: number; + percent: number; + lengthComputable: boolean; +} + +export interface WorkspaceUploadBatchFileProgress extends WorkspaceFileUploadProgress { + index: number; + name: string; + path: string; + done: boolean; + error?: string; +} + +export interface WorkspaceUploadFileFailure { + index: number; + name: string; + path: string; + error: string; +} + +export interface WorkspaceUploadBatchProgress { + currentFileIndex: number; + files: WorkspaceUploadBatchFileProgress[]; + loaded: number; + total: number; + percent: number; + done: boolean; +} + +export interface WorkspaceUploadTask { + promise: Promise; + cancel(): void; +} + +export interface WorkspaceUploadXhr { + upload: { onprogress: ((event: ProgressEvent) => void) | null }; + responseType: XMLHttpRequestResponseType; + response: unknown; + responseText: string; + status: number; + statusText: string; + onload: ((event: ProgressEvent) => void) | null; + onerror: ((event: ProgressEvent) => void) | null; + onabort: ((event: ProgressEvent) => void) | null; + open(method: string, url: string, async?: boolean): void; + setRequestHeader(name: string, value: string): void; + send(body?: XMLHttpRequestBodyInit | Document | null): void; + abort(): void; +} + +export type WorkspaceUploadXhrFactory = () => WorkspaceUploadXhr; + +export interface UploadWorkspaceFileOptions extends WriteWorkspaceFileOptions { + machineId?: string; + xhrFactory?: WorkspaceUploadXhrFactory; + onProgress?: (progress: WorkspaceFileUploadProgress) => void; +} + +export interface UploadWorkspaceFilesOptions extends WriteWorkspaceFileOptions { + destinationFolder?: string; + machineId?: string; + xhrFactory?: WorkspaceUploadXhrFactory; + onProgress?: (progress: WorkspaceUploadBatchProgress) => void; +} + +export class WorkspaceUploadCancelledError extends Error { + constructor(message = "Workspace upload cancelled") { + super(message); + this.name = "WorkspaceUploadCancelledError"; + } +} + +export class WorkspaceUploadBatchError extends Error { + readonly failures: WorkspaceUploadFileFailure[]; + readonly responses: WriteWorkspaceFileResponse[]; + + constructor(failures: readonly WorkspaceUploadFileFailure[], responses: readonly WriteWorkspaceFileResponse[]) { + super(uploadBatchErrorMessage(failures)); + this.name = "WorkspaceUploadBatchError"; + this.failures = failures.map((failure) => ({ ...failure })); + this.responses = responses.map((response) => ({ ...response })); + } +} + +export interface WorkspaceUploadFolderConfig { + uploads?: { + defaultFolder?: string; + }; +} + +export function effectiveWorkspaceUploadFolder(config: WorkspaceUploadFolderConfig | undefined): string { + return config?.uploads?.defaultFolder ?? DEFAULT_WORKSPACE_UPLOADS_FOLDER; +} + +export function workspaceEffectiveUploadFolder(config: WorkspaceUploadFolderConfig | undefined, fallbackFolder: string): string { + return config?.uploads?.defaultFolder ?? fallbackFolder; +} + +export function workspaceUploadPath(destinationFolder: string, fileName: string): string { + const folder = normalizeWorkspaceUploadPath(destinationFolder, "upload destination", { allowEmpty: true }); + const name = normalizeWorkspaceUploadPath(fileName, "upload file name", { allowEmpty: false }); + return folder === "" ? name : `${folder}/${name}`; +} + +export function uploadWorkspaceFile( + projectId: string, + workspaceId: string, + input: WorkspaceUploadFileInput, + options: UploadWorkspaceFileOptions = {}, +): WorkspaceUploadTask { + const xhr: WorkspaceUploadXhr = options.xhrFactory?.() ?? new XMLHttpRequest(); + let settled = false; + let cancelled = false; + + const promise = new Promise((resolve, reject) => { + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + const succeed = (response: WriteWorkspaceFileResponse) => { + if (settled) return; + settled = true; + resolve(response); + }; + + xhr.open("PUT", workspaceFileWriteUrl(projectId, workspaceId, input.path, uploadWriteUrlOptions(options)), true); + xhr.responseType = "json"; + xhr.setRequestHeader("Content-Type", (input.contentType ?? input.file.type) || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + options.onProgress?.(progressFromEvent(event, input.file.size)); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + options.onProgress?.({ loaded: input.file.size, total: input.file.size, percent: 1, lengthComputable: true }); + succeed(parseWriteWorkspaceFileResponse(readXhrJson(xhr))); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + return; + } + fail(new Error(readXhrErrorMessage(xhr))); + }; + xhr.onerror = () => { fail(new Error("Workspace upload failed")); }; + xhr.onabort = () => { fail(new WorkspaceUploadCancelledError(cancelled ? undefined : "Workspace upload aborted")); }; + xhr.send(input.file); + }); + + return { + promise, + cancel: () => { + if (settled) return; + cancelled = true; + xhr.abort(); + }, + }; +} + +export function uploadWorkspaceFiles( + projectId: string, + workspaceId: string, + files: readonly File[], + options: UploadWorkspaceFilesOptions = {}, +): WorkspaceUploadTask { + const destinationFolder = options.destinationFolder ?? DEFAULT_WORKSPACE_UPLOADS_FOLDER; + const progressFiles = files.map((file, index): WorkspaceUploadBatchFileProgress => ({ + index, + name: file.name, + path: workspaceUploadPath(destinationFolder, file.name), + loaded: 0, + total: file.size, + percent: percentFor(0, file.size), + lengthComputable: true, + done: false, + })); + let currentTask: WorkspaceUploadTask | undefined; + let currentFileIndex = 0; + const cancellation = { requested: false }; + + const emit = () => { + options.onProgress?.(batchProgressSnapshot(progressFiles, currentFileIndex, progressFiles.every((file) => file.done))); + }; + + const promise = (async (): Promise => { + const responses: WriteWorkspaceFileResponse[] = []; + const failures: WorkspaceUploadFileFailure[] = []; + for (let index = 0; index < files.length; index += 1) { + if (cancellation.requested) throw new WorkspaceUploadCancelledError(); + currentFileIndex = index; + const file = files[index]; + const progressFile = progressFiles[index]; + if (file === undefined || progressFile === undefined) continue; + currentTask = uploadWorkspaceFile(projectId, workspaceId, { path: progressFile.path, file }, { + ...uploadWriteOptions(options), + onProgress: (progress) => { + progressFile.total = progress.total; + progressFile.loaded = Math.min(progress.loaded, progressFile.total); + progressFile.percent = progress.percent; + progressFile.lengthComputable = progress.lengthComputable; + emit(); + }, + }); + try { + const response = await currentTask.promise; + progressFile.loaded = progressFile.total; + progressFile.percent = 1; + progressFile.lengthComputable = true; + progressFile.done = true; + responses.push(response); + emit(); + } catch (error) { + if (isUploadCancellation(error, cancellation)) throw error; + const message = errorMessage(error); + progressFile.loaded = progressFile.total; + progressFile.percent = 1; + progressFile.lengthComputable = true; + progressFile.done = true; + progressFile.error = message; + failures.push({ index, name: file.name, path: progressFile.path, error: message }); + emit(); + } finally { + currentTask = undefined; + } + } + if (failures.length > 0) throw new WorkspaceUploadBatchError(failures, responses); + return responses; + })(); + + return { + promise, + cancel: () => { + cancellation.requested = true; + currentTask?.cancel(); + }, + }; +} + +function uploadWriteOptions(options: UploadWorkspaceFilesOptions): UploadWorkspaceFileOptions { + return { + ...(options.createDirs === undefined ? {} : { createDirs: options.createDirs }), + ...(options.overwrite === undefined ? {} : { overwrite: options.overwrite }), + ...(options.machineId === undefined ? {} : { machineId: options.machineId }), + ...(options.xhrFactory === undefined ? {} : { xhrFactory: options.xhrFactory }), + }; +} + +function uploadWriteUrlOptions(options: UploadWorkspaceFileOptions): { createDirs?: boolean; overwrite?: boolean; machineId?: string } { + return { + ...(options.createDirs === undefined ? {} : { createDirs: options.createDirs }), + ...(options.overwrite === undefined ? {} : { overwrite: options.overwrite }), + ...(options.machineId === undefined ? {} : { machineId: options.machineId }), + }; +} + +function progressFromEvent(event: ProgressEvent, fallbackTotal: number): WorkspaceFileUploadProgress { + const total = event.lengthComputable ? event.total : fallbackTotal; + return { + loaded: event.loaded, + total, + percent: percentFor(event.loaded, total), + lengthComputable: event.lengthComputable, + }; +} + +function batchProgressSnapshot(files: WorkspaceUploadBatchFileProgress[], currentFileIndex: number, done: boolean): WorkspaceUploadBatchProgress { + const total = files.reduce((sum, file) => sum + file.total, 0); + const loaded = files.reduce((sum, file) => sum + file.loaded, 0); + return { + currentFileIndex, + files: files.map((file) => ({ ...file })), + loaded, + total, + percent: percentFor(loaded, total), + done, + }; +} + +function percentFor(loaded: number, total: number): number { + if (total <= 0) return loaded <= 0 ? 0 : 1; + return Math.max(0, Math.min(1, loaded / total)); +} + +function uploadBatchErrorMessage(failures: readonly WorkspaceUploadFileFailure[]): string { + if (failures.length === 1) return failures[0]?.error ?? "Workspace upload failed"; + return `${String(failures.length)} files failed to upload`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isUploadCancellation(error: unknown, cancellation: { requested: boolean }): boolean { + return cancellation.requested || error instanceof WorkspaceUploadCancelledError; +} + +function normalizeWorkspaceUploadPath(value: string, label: string, options: { allowEmpty: boolean }): string { + const trimmed = value.trim(); + if (trimmed === "") { + if (options.allowEmpty) return ""; + throw new Error(`${label} must not be empty`); + } + if (isAbsoluteLike(trimmed)) throw new Error(`${label} must be workspace-relative`); + const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== "."); + if (parts.length === 0) { + if (options.allowEmpty) return ""; + throw new Error(`${label} must not be empty`); + } + if (parts.some((part) => part === "..")) throw new Error(`${label} must not contain path traversal`); + return parts.join("/"); +} + +function isAbsoluteLike(value: string): boolean { + const withForwardSlashes = value.replace(/\\/g, "/"); + return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes); +} + +function readXhrJson(xhr: WorkspaceUploadXhr): unknown { + if (xhr.response !== undefined && xhr.response !== null && xhr.response !== "") return xhr.response; + if (xhr.responseText === "") return {}; + const parsed: unknown = JSON.parse(xhr.responseText); + return parsed; +} + +function readXhrErrorMessage(xhr: WorkspaceUploadXhr): string { + const body = safeReadXhrJson(xhr); + if (isRecord(body) && typeof body["error"] === "string") return body["error"]; + return xhr.statusText || `HTTP ${String(xhr.status)}`; +} + +function safeReadXhrJson(xhr: WorkspaceUploadXhr): unknown { + try { + return readXhrJson(xhr); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index dc59f43..83ac086 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,6 +1,7 @@ import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; import type { ChatLine } from "./components/shared"; import type { QualifiedContributionId } from "./plugins/ids"; +import type { WorkspaceUploadBatchState } from "./workspaceUploadState"; export interface AppState { machines: Machine[]; @@ -49,6 +50,8 @@ export interface AppState { selectedFilePath: string | undefined; selectedFileContent: FileContentResponse | undefined; fileTreeStale: boolean; + /** Manual workspace file upload batches, keyed by client-owned batch id. */ + workspaceUploadBatches: Record; gitStatus: GitStatusResponse | undefined; selectedDiffPath: string | undefined; selectedDiff: GitDiffResponse | undefined; @@ -147,6 +150,7 @@ export function initialAppState(): AppState { selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, + workspaceUploadBatches: {}, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index f520115..5a438e2 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; +import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -169,6 +169,7 @@ export class PiWebApp extends LitElement { @state() private isRefreshingApp = false; @state() private settingsSection: SettingsSection | undefined = readSettingsSection(); @state() private shortcutConfig: PiWebShortcutConfig = {}; + @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined); private readonly onPopState = () => void this.withChatScrollTransition(async () => { this.restoreSettingsRoute(); await this.restoreRoute(false); @@ -324,7 +325,7 @@ export class PiWebApp extends LitElement { private async loadClientConfig(): Promise { try { - this.applyClientConfig((await configApi.config()).config); + this.applyClientConfig((await configApi.config()).effectiveConfig); } catch (error) { console.warn("Failed to load PI WEB config", error); } @@ -332,6 +333,7 @@ export class PiWebApp extends LitElement { private applyClientConfig(config: PiWebConfigValues): void { this.shortcutConfig = config.shortcuts ?? {}; + this.workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(config); } private async refreshAppData(): Promise { @@ -1271,9 +1273,13 @@ export class PiWebApp extends LitElement { activeTerminalCount: this.state.activeTerminalCount, selectedTerminalId: this.state.selectedTerminalId, terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id, + workspaceUploadDefaultFolder: workspaceEffectiveUploadFolder(workspace.effectiveConfig, this.workspaceUploadDefaultFolder), onRefreshFiles: () => { void this.files.refreshFiles(); }, onExpandDir: (path: string) => { void this.files.expandDir(path); }, onSelectFile: (path: string) => { void this.files.selectFile(path); }, + onStartWorkspaceUpload: (files, options) => this.files.startWorkspaceUpload(files, options), + onCancelWorkspaceUpload: (batchId) => { this.files.cancelWorkspaceUpload(batchId); }, + onClearWorkspaceUpload: (batchId) => { this.files.clearWorkspaceUpload(batchId); }, onRefreshGit: () => { void this.git.refreshGit(); }, onSelectDiff: (path: string) => { void this.git.selectDiff(path); }, onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); }, diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 65076ad..86eaa7d 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -166,7 +166,7 @@ export class SettingsDialog extends LitElement { try { const response = await configApi.saveConfig(config); this.configResponse = response; - this.onConfigSaved?.(response.config); + this.onConfigSaved?.(response.effectiveConfig); this.showSavedMessage(); } catch (error) { this.error = `Failed to save config: ${errorMessage(error)}`; diff --git a/src/client/src/components/WorkspaceFilesPanel.test.ts b/src/client/src/components/WorkspaceFilesPanel.test.ts new file mode 100644 index 0000000..923fd83 --- /dev/null +++ b/src/client/src/components/WorkspaceFilesPanel.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceUploadBatchState } from "../workspaceUploadState"; +import { startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel"; + +describe("workspaceUploadBatchesForScope", () => { + it("filters upload batches to the selected project, workspace, and machine", () => { + const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" }); + const matchingNewer = uploadBatch({ id: "newer", startedAt: "2026-06-25T00:01:00.000Z" }); + const batches = { + older: matchingOlder, + otherProject: uploadBatch({ id: "otherProject", projectId: "project-2" }), + otherWorkspace: uploadBatch({ id: "otherWorkspace", workspaceId: "workspace-2" }), + otherMachine: uploadBatch({ id: "otherMachine", machineId: "remote-1" }), + newer: matchingNewer, + }; + + expect(workspaceUploadBatchesForScope(batches, { projectId: "project-1", workspaceId: "workspace-1", machineId: "local" })).toEqual([matchingNewer, matchingOlder]); + }); +}); + +describe("workspace upload terminal display", () => { + it("uses terminal labels and full progress for failed batches instead of stale partial percentages", () => { + const failed = uploadBatch({ status: "error", percent: 0.31 }); + + expect(uploadBatchStatusLabel(failed)).toBe("Failed"); + expect(uploadBatchProgressValue(failed)).toBe(1); + }); + + it("keeps live percentages while a batch is uploading", () => { + const uploading = uploadBatch({ status: "uploading", percent: 0.31 }); + + expect(uploadBatchStatusLabel(uploading)).toBe("31%"); + expect(uploadBatchProgressValue(uploading)).toBe(0.31); + }); +}); + +describe("workspace upload defaults", () => { + it("uses safe defaults for the review dialog", () => { + expect(workspaceUploadReviewDefaults("project/uploads")).toEqual({ + destinationFolder: "project/uploads", + createDirs: true, + overwrite: false, + }); + }); + + it("starts drag/drop uploads directly with safe defaults", () => { + const files = [new File(["a"], "a.txt")]; + const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() })); + + const run = startDirectWorkspaceUpload({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }, files); + + expect(run?.batchId).toBe("batch-1"); + expect(onStartWorkspaceUpload).toHaveBeenCalledWith(files, { + destinationFolder: "project/uploads", + createDirs: true, + overwrite: false, + selectUploadedFile: true, + }); + }); + + it("ignores empty drag/drop uploads", () => { + const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() })); + + expect(startDirectWorkspaceUpload({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }, [])).toBeUndefined(); + expect(onStartWorkspaceUpload).not.toHaveBeenCalled(); + }); +}); + +describe("workspaceUploadReviewError", () => { + it("accepts one or more files with a workspace-relative destination", () => { + expect(workspaceUploadReviewError([ + new File(["a"], "a.txt"), + new File(["b"], "b.txt"), + ], ".pi-web/uploads")).toBeUndefined(); + }); + + it("rejects empty selections and unsafe destinations before starting an upload", () => { + expect(workspaceUploadReviewError([], ".pi-web/uploads")).toBe("Choose at least one file to upload."); + expect(workspaceUploadReviewError([new File(["a"], "a.txt")], "../outside")).toContain("path traversal"); + }); +}); + +function uploadBatch(patch: Partial = {}): WorkspaceUploadBatchState { + return { + id: patch.id ?? "batch-1", + projectId: patch.projectId ?? "project-1", + workspaceId: patch.workspaceId ?? "workspace-1", + machineId: patch.machineId ?? "local", + destinationFolder: patch.destinationFolder ?? ".pi-web/uploads", + overwrite: patch.overwrite ?? true, + createDirs: patch.createDirs ?? true, + files: patch.files ?? [], + currentFileIndex: patch.currentFileIndex ?? -1, + loaded: patch.loaded ?? 0, + total: patch.total ?? 0, + percent: patch.percent ?? 0, + status: patch.status ?? "uploading", + startedAt: patch.startedAt ?? "2026-06-25T00:00:00.000Z", + ...(patch.completedAt === undefined ? {} : { completedAt: patch.completedAt }), + ...(patch.error === undefined ? {} : { error: patch.error }), + }; +} diff --git a/src/client/src/components/WorkspaceFilesPanel.ts b/src/client/src/components/WorkspaceFilesPanel.ts new file mode 100644 index 0000000..29f080e --- /dev/null +++ b/src/client/src/components/WorkspaceFilesPanel.ts @@ -0,0 +1,492 @@ +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { FileContentResponse, FileTreeEntry } from "../api"; +import { workspaceImagePreviewUrl } from "../api/urls"; +import { workspaceUploadPath } from "../api/workspaceUploads"; +import type { WorkspaceUploadBatchState, WorkspaceUploadFileState } from "../workspaceUploadState"; +import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../shared/workspaceFiles"; +import type { WorkspacePanelContext } from "../plugins/types"; +import { workspacePanelStyles } from "./shared"; + +interface PendingWorkspaceUploadReview { + files: File[]; +} + +export interface WorkspaceUploadScope { + projectId: string; + workspaceId: string; + machineId: string; +} + +@customElement("workspace-files-panel") +export class WorkspaceFilesPanel extends LitElement { + @property({ attribute: false }) context: WorkspacePanelContext | undefined; + @query("#workspace-upload-input") private uploadInput?: HTMLInputElement; + @state() private pendingUpload: PendingWorkspaceUploadReview | undefined; + @state() private destinationFolder = ""; + @state() private overwrite = false; + @state() private createDirs = true; + @state() private formError = ""; + @state() private dragActive = false; + private dragDepth = 0; + + protected override willUpdate(changedProperties: PropertyValues): void { + if (!changedProperties.has("context")) return; + const previous = changedProperties.get("context"); + if (previous !== undefined && this.context !== undefined && workspaceContextKey(previous) !== workspaceContextKey(this.context)) this.resetPendingUpload(); + } + + override render(): TemplateResult { + const context = this.context; + if (context === undefined) return html`

Files unavailable.

`; + return html` +
+
+ Files + ${context.fileTreeStale ? html`stale` : null} +
+ + +
+ +
+ ${this.renderUploadProgress(context)} +
+
+ ${context.fileTree.length === 0 ? html`

No files loaded.

` : context.fileTree.map((entry) => this.renderTreeEntry(context, entry, 0))} +
+
+ ${this.renderFileViewer(context)} +
+
+
+
+ Drop files to upload + Uploads immediately to the default folder. +
+
+ ${this.pendingUpload === undefined ? null : this.renderUploadDialog(context, this.pendingUpload)} +
+ `; + } + + private renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult { + const children = context.expandedDirs[entry.path]; + const hasChildren = children !== undefined; + const selected = entry.type !== "directory" && context.selectedFilePath === entry.path; + return html` + + ${hasChildren ? children.map((child) => this.renderTreeEntry(context, child, depth + 1)) : null} + `; + } + + private selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void { + if (entry.type === "directory") context.onExpandDir(entry.path); + else context.onSelectFile(entry.path); + } + + private renderFileViewer(context: WorkspacePanelContext): TemplateResult { + const file = context.selectedFileContent; + if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; + if (file === undefined) return html`

Loading ${context.selectedFilePath}…

`; + if (file.mediaType === "image") return this.renderImageViewer(context, file); + if (file.binary) return html`

Binary file: ${file.path} · ${formatFileSize(file.size)}

`; + loadCodeViewer(); + return html` +
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
+ + `; + } + + private renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult { + const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`; + if (file.size > MAX_IMAGE_PREVIEW_BYTES) { + return html` +
${file.path}${metadata}
+

Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

+ `; + } + const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id }); + return html` +
${file.path}${metadata}
+
+ ${file.path} +
+ `; + } + + private renderUploadProgress(context: WorkspacePanelContext): TemplateResult | null { + const batches = workspaceUploadBatchesForScope(context.state.workspaceUploadBatches, { + projectId: context.workspace.projectId, + workspaceId: context.workspace.id, + machineId: context.machine.id, + }); + if (batches.length === 0) return null; + return html` +
+
+ Uploads + ${uploadSummaryLabel(batches)} +
+ ${batches.map((batch) => this.renderUploadBatch(context, batch))} +
+ `; + } + + private renderUploadBatch(context: WorkspacePanelContext, batch: WorkspaceUploadBatchState): TemplateResult { + return html` +
+
+
+ ${uploadBatchTitle(batch)} + ${batch.destinationFolder === "" ? "workspace root" : batch.destinationFolder} +
+ ${uploadBatchStatusLabel(batch)} +
+ +
+ ${batch.files.map((file) => this.renderUploadFile(file))} +
+
+ ${batch.status === "uploading" ? html`` : html``} +
+
+ `; + } + + private renderUploadFile(file: WorkspaceUploadFileState): TemplateResult { + const detail = uploadFileDetail(file); + return html` +
+
+ ${file.name} + ${detail} +
+ ${uploadFileStatusLabel(file)} +
+ `; + } + + private renderUploadDialog(context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): TemplateResult { + const fileCount = review.files.length; + return html` +
{ this.closeUploadDialog(); }}> + +
+ `; + } + + private readonly openFilePicker = (): void => { + this.uploadInput?.click(); + }; + + private readonly handleFileInputChange = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + const files = fileListToArray(input?.files); + if (input !== undefined) input.value = ""; + if (files.length > 0) this.openUploadReview(files); + }; + + private readonly handleDragEnter = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + this.dragDepth += 1; + this.dragActive = true; + }; + + private readonly handleDragOver = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "copy"; + this.dragActive = true; + }; + + private readonly handleDragLeave = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + this.dragDepth = Math.max(0, this.dragDepth - 1); + if (this.dragDepth === 0) this.dragActive = false; + }; + + private readonly handleDrop = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + this.dragDepth = 0; + this.dragActive = false; + const files = fileListToArray(event.dataTransfer?.files); + const context = this.context; + if (files.length > 0 && context !== undefined) startDirectWorkspaceUpload(context, files); + }; + + private readonly handleDestinationInput = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + this.destinationFolder = input?.value ?? ""; + this.formError = ""; + }; + + private readonly handleCreateDirsChange = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + this.createDirs = input?.checked ?? true; + }; + + private readonly handleOverwriteChange = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + this.overwrite = input?.checked ?? false; + }; + + private readonly handleDialogKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + this.closeUploadDialog(); + }; + + private openUploadReview(files: File[]): void { + const context = this.context; + const defaults = workspaceUploadReviewDefaults(context?.workspaceUploadDefaultFolder ?? ""); + this.pendingUpload = { files }; + this.destinationFolder = defaults.destinationFolder; + this.overwrite = defaults.overwrite; + this.createDirs = defaults.createDirs; + this.formError = ""; + } + + private submitUploadReview(event: SubmitEvent, context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): void { + event.preventDefault(); + const validationError = workspaceUploadReviewError(review.files, this.destinationFolder); + if (validationError !== undefined) { + this.formError = validationError; + return; + } + const run = context.onStartWorkspaceUpload(review.files, { + destinationFolder: this.destinationFolder, + createDirs: this.createDirs, + overwrite: this.overwrite, + selectUploadedFile: true, + }); + if (run !== undefined) this.closeUploadDialog(); + } + + private closeUploadDialog(): void { + this.pendingUpload = undefined; + this.formError = ""; + } + + private resetPendingUpload(): void { + this.closeUploadDialog(); + this.dragDepth = 0; + this.dragActive = false; + } + + static override styles = [ + workspacePanelStyles, + css` + :host { flex: 1 1 auto; } + .files-panel { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + .toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } + .toolbar .toolbar-actions button { margin-left: 0; } + .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } + .drop-overlay { position: absolute; inset: 52px 10px 10px; z-index: 15; display: grid; place-items: center; border: 2px dashed var(--pi-accent); border-radius: 12px; background: color-mix(in srgb, var(--pi-bg-overlay) 90%, var(--pi-accent) 10%); color: var(--pi-text); opacity: 0; pointer-events: none; transition: opacity .12s ease; } + .files-panel.dragging .drop-overlay { opacity: 1; } + .drop-overlay div { display: grid; gap: 4px; justify-items: center; padding: 18px; border-radius: 10px; background: var(--pi-bg-overlay); box-shadow: 0 8px 24px var(--pi-shadow); } + .drop-overlay span { color: var(--pi-muted); } + .upload-progress { flex: 0 0 auto; display: grid; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: color-mix(in srgb, var(--pi-surface) 55%, transparent); } + .upload-progress-header, .upload-batch-heading, .upload-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .upload-batch { display: grid; gap: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: var(--pi-bg); padding: 8px; } + .upload-batch.error { border-color: var(--pi-danger); } + .upload-batch.cancelled { border-color: var(--pi-warning-border); } + .upload-batch.completed { border-color: var(--pi-success-border); } + .upload-batch-heading > div { min-width: 0; display: grid; gap: 2px; } + .upload-batch-heading strong, .upload-batch-heading small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + progress { width: 100%; accent-color: var(--pi-accent); } + .upload-file-list { display: grid; gap: 4px; max-height: 180px; overflow: auto; padding-right: 2px; } + .upload-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; color: var(--pi-muted); } + .upload-file.completed .upload-file-status { color: var(--pi-success); } + .upload-file.error { color: var(--pi-danger); } + .upload-file.cancelled .upload-file-status { color: var(--pi-warning); } + .upload-file-main { min-width: 0; display: grid; gap: 1px; } + .upload-file-main span, .upload-file-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .upload-file-status { font-size: 12px; white-space: nowrap; } + .upload-actions { justify-content: end; } + .dialog-backdrop { position: fixed; inset: 0; z-index: 100; box-sizing: border-box; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); } + .upload-dialog { box-sizing: border-box; width: min(560px, 100%); max-height: min(720px, 100%); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 18px 70px var(--pi-shadow-strong); } + .upload-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border-muted); } + .upload-dialog h2 { margin: 2px 0 0; font-size: 18px; line-height: 1.2; } + .eyebrow { color: var(--pi-muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } + .close-button { font-size: 20px; line-height: 1; padding: 4px 9px; } + form { min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: auto; padding: 16px; } + form > label { display: grid; gap: 6px; } + form > label > span, .review-files > strong { font-weight: 600; } + input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: inherit; } + input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; } + .dialog-options { display: grid; gap: 8px; } + .dialog-options label { display: flex; align-items: center; gap: 8px; color: var(--pi-text); } + .review-files { display: grid; gap: 6px; min-height: 0; max-height: 180px; overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 8px; padding: 8px; } + .review-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; } + .review-file span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dialog-error { border: 1px solid var(--pi-danger); border-radius: 8px; background: color-mix(in srgb, var(--pi-danger) 10%, transparent); color: var(--pi-danger); padding: 9px; line-height: 1.35; overflow-wrap: anywhere; } + footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; } + `, + ]; +} + +export function workspaceUploadBatchesForScope(batches: Record, scope: WorkspaceUploadScope): WorkspaceUploadBatchState[] { + return Object.values(batches) + .filter((batch) => batch.projectId === scope.projectId && batch.workspaceId === scope.workspaceId && batch.machineId === scope.machineId) + .sort((left, right) => right.startedAt.localeCompare(left.startedAt)); +} + +export function workspaceUploadReviewError(files: readonly File[], destinationFolder: string): string | undefined { + if (files.length === 0) return "Choose at least one file to upload."; + for (const file of files) { + try { + workspaceUploadPath(destinationFolder, file.name); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + return undefined; +} + +export function workspaceUploadReviewDefaults(destinationFolder: string): { destinationFolder: string; createDirs: boolean; overwrite: boolean } { + return { destinationFolder, createDirs: true, overwrite: false }; +} + +export function startDirectWorkspaceUpload( + context: Pick, + files: readonly File[], +): ReturnType { + if (files.length === 0) return undefined; + return context.onStartWorkspaceUpload(files, { + destinationFolder: context.workspaceUploadDefaultFolder, + createDirs: true, + overwrite: false, + selectUploadedFile: true, + }); +} + +function workspaceContextKey(context: WorkspacePanelContext): string { + return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`; +} + +function fileListToArray(files: FileList | null | undefined): File[] { + return files === null || files === undefined ? [] : Array.from(files); +} + +function isFileDrag(event: DragEvent): boolean { + return Array.from(event.dataTransfer?.types ?? []).includes("Files"); +} + +function uploadSummaryLabel(batches: readonly WorkspaceUploadBatchState[]): string { + const uploading = batches.filter((batch) => batch.status === "uploading").length; + return uploading === 0 ? `${String(batches.length)} recent` : `${String(uploading)} uploading`; +} + +function uploadBatchTitle(batch: WorkspaceUploadBatchState): string { + const count = batch.files.length; + const files = count === 1 ? "file" : "files"; + switch (batch.status) { + case "completed": return `Uploaded ${String(count)} ${files}`; + case "error": return `Upload failed for ${String(count)} ${files}`; + case "cancelled": return `Upload cancelled for ${String(count)} ${files}`; + case "uploading": return `Uploading ${String(count)} ${files}`; + } +} + +export function uploadBatchStatusLabel(batch: WorkspaceUploadBatchState): string { + switch (batch.status) { + case "completed": return "Done"; + case "error": return "Failed"; + case "cancelled": return "Cancelled"; + case "uploading": return formatPercent(batch.percent); + } +} + +export function uploadBatchProgressValue(batch: WorkspaceUploadBatchState): number { + return batch.status === "uploading" ? batch.percent : 1; +} + +function uploadFileStatusLabel(file: WorkspaceUploadFileState): string { + switch (file.status) { + case "pending": return "Pending"; + case "uploading": return formatPercent(file.percent); + case "completed": return "Done"; + case "error": return "Error"; + case "cancelled": return "Cancelled"; + } +} + +function uploadFileDetail(file: WorkspaceUploadFileState): string { + if (file.error !== undefined) return file.error; + if (file.response !== undefined) return `Wrote ${file.response.path}`; + return `${file.path} · ${formatFileSize(file.loaded)} / ${formatFileSize(file.total)}`; +} + +function formatPercent(value: number): string { + return `${String(Math.round(Math.max(0, Math.min(1, value)) * 100))}%`; +} + +function loadCodeViewer(): void { + void import("./CodeViewer"); +} + +function formatFileSize(size: number): string { + if (!Number.isFinite(size) || size < 0) return "0 B"; + if (size < 1024) return `${String(size)} B`; + const kib = size / 1024; + if (kib < 1024) return `${formatScaledFileSize(kib)} KB`; + const mib = kib / 1024; + if (mib < 1024) return `${formatScaledFileSize(mib)} MB`; + return `${formatScaledFileSize(mib / 1024)} GB`; +} + +function formatScaledFileSize(value: number): string { + return value >= 10 ? String(Math.round(value)) : value.toFixed(1); +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 2ff81c2..69ebb53 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -20,13 +20,14 @@ describe("settings config drafts", () => { allowedHostsMode: "list", allowedHostsText: "example.local, 192.168.1.20\n", allowedPathsText: "/tmp\n~/SDKs\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({ + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({ host: "127.0.0.1", port: 9000, allowedHosts: ["example.local", "192.168.1.20"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, + uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 2df0e7c..414ef16 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -26,6 +26,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue const config: PiWebConfigValues = { ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), + ...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }), ...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), diff --git a/src/client/src/controllers/fileExplorerController.test.ts b/src/client/src/controllers/fileExplorerController.test.ts new file mode 100644 index 0000000..4cf87e5 --- /dev/null +++ b/src/client/src/controllers/fileExplorerController.test.ts @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { initialAppState, type AppState } from "../appState"; +import { + WorkspaceUploadBatchError, + WorkspaceUploadCancelledError, + type FileContentResponse, + type FileTreeResponse, + type Machine, + type Project, + type Workspace, + type WorkspaceUploadBatchProgress, + type WriteWorkspaceFileResponse, +} from "../api"; +import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController"; + +type UploadWorkspaceFiles = NonNullable; +type UploadWorkspaceFilesOptions = NonNullable[3]>; + +const originalWindow = globalThis.window; + +afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true }); +}); + +const machine: Machine = { + id: "remote-1", + name: "Remote", + kind: "remote", + createdAt: "2026-06-25T00:00:00.000Z", + updatedAt: "2026-06-25T00:00:00.000Z", +}; + +const project: Project = { + id: "project-1", + name: "Project", + path: "/repo", + createdAt: "2026-06-25T00:00:00.000Z", +}; + +const workspace: Workspace = { + id: "workspace-1", + projectId: project.id, + path: "/repo", + label: "repo", + isMain: true, + isGitRepo: true, + isGitWorktree: false, +}; + +describe("FileExplorerController workspace uploads", () => { + it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "complete") }); + const files = [new File(["aa"], "a.txt", { type: "text/plain" }), new File(["bbb"], "b.txt")]; + + const run = harness.controller.startWorkspaceUpload(files, { destinationFolder: "uploads/manual", overwrite: false }); + + expect(run?.batchId).toBe("batch-1"); + expect(upload.fn).toHaveBeenCalledWith("project-1", "workspace-1", files, expect.objectContaining({ + destinationFolder: "uploads/manual", + machineId: "remote-1", + overwrite: false, + createDirs: true, + })); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + destinationFolder: "uploads/manual", + overwrite: false, + createDirs: true, + status: "uploading", + startedAt: "start", + total: 5, + files: [ + { name: "a.txt", path: "uploads/manual/a.txt", status: "uploading", total: 2 }, + { name: "b.txt", path: "uploads/manual/b.txt", status: "pending", total: 3 }, + ], + }); + + upload.emitProgress({ + currentFileIndex: 0, + files: [ + { index: 0, name: "a.txt", path: "uploads/manual/a.txt", loaded: 1, total: 2, percent: 0.5, lengthComputable: true, done: false }, + { index: 1, name: "b.txt", path: "uploads/manual/b.txt", loaded: 0, total: 3, percent: 0, lengthComputable: true, done: false }, + ], + loaded: 1, + total: 5, + percent: 0.2, + done: false, + }); + + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + loaded: 1, + percent: 0.2, + files: [ + { path: "uploads/manual/a.txt", loaded: 1, percent: 0.5, status: "uploading" }, + { path: "uploads/manual/b.txt", loaded: 0, status: "pending" }, + ], + }); + + upload.resolve([ + writeResponse("uploads/manual/a.txt", 2), + writeResponse("uploads/manual/b.txt", 3), + ]); + await run?.done; + + expect(harness.api.workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1"); + expect(harness.api.workspaceFile).toHaveBeenCalledWith("project-1", "workspace-1", "uploads/manual/a.txt", "remote-1"); + expect(harness.updateUrl).toHaveBeenCalledWith({ replace: true }); + expect(harness.state.selectedFilePath).toBe("uploads/manual/a.txt"); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + status: "completed", + completedAt: "complete", + loaded: 5, + percent: 1, + files: [ + { status: "completed", response: { path: "uploads/manual/a.txt", size: 2 } }, + { status: "completed", response: { path: "uploads/manual/b.txt", size: 3 } }, + ], + }); + }); + + it("defaults uploads to create parent folders without overwriting existing files", () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn }); + const files = [new File(["aa"], "a.txt")]; + + const run = harness.controller.startWorkspaceUpload(files, { destinationFolder: "uploads" }); + + expect(run?.batchId).toBe("batch-1"); + expect(upload.fn).toHaveBeenCalledWith("project-1", "workspace-1", files, expect.objectContaining({ + destinationFolder: "uploads", + machineId: "remote-1", + overwrite: false, + createDirs: true, + })); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + destinationFolder: "uploads", + overwrite: false, + createDirs: true, + }); + }); + + it("cancels an in-flight upload without setting the global error", async () => { + const upload = controllableUpload({ rejectOnCancel: true }); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "cancel") }); + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "uploads" }); + + harness.controller.cancelWorkspaceUpload(run?.batchId ?? "missing"); + await run?.done; + + expect(upload.cancel).toHaveBeenCalledTimes(1); + expect(harness.state.error).toBe(""); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + status: "cancelled", + completedAt: "cancel", + error: "Upload cancelled", + files: [{ status: "cancelled", error: "Upload cancelled" }], + }); + }); + + it("keeps per-file errors accurate and refreshes after partial batch success", async () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "fail") }); + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt"), new File(["bbbb"], "b.txt")], { destinationFolder: "uploads" }); + + upload.emitProgress({ + currentFileIndex: 1, + files: [ + { index: 0, name: "a.txt", path: "uploads/a.txt", loaded: 2, total: 2, percent: 1, lengthComputable: true, done: true, error: "File already exists: uploads/a.txt" }, + { index: 1, name: "b.txt", path: "uploads/b.txt", loaded: 4, total: 4, percent: 1, lengthComputable: true, done: true }, + ], + loaded: 6, + total: 6, + percent: 1, + done: true, + }); + upload.reject(new WorkspaceUploadBatchError( + [{ index: 0, name: "a.txt", path: "uploads/a.txt", error: "File already exists: uploads/a.txt" }], + [writeResponse("uploads/b.txt", 4)], + )); + await run?.done; + + expect(harness.api.workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1"); + expect(harness.api.workspaceFile).toHaveBeenCalledWith("project-1", "workspace-1", "uploads/b.txt", "remote-1"); + expect(harness.state.error).toBe(""); + expect(harness.state.selectedFilePath).toBe("uploads/b.txt"); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + status: "error", + completedAt: "fail", + error: "File already exists: uploads/a.txt", + loaded: 6, + total: 6, + percent: 1, + files: [ + { path: "uploads/a.txt", status: "error", error: "File already exists: uploads/a.txt" }, + { path: "uploads/b.txt", status: "completed" }, + ], + }); + }); + + it("rejects unsafe upload destinations before starting a batch", () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn }); + + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "../outside" }); + + expect(run).toBeUndefined(); + expect(upload.fn).not.toHaveBeenCalled(); + expect(harness.state.workspaceUploadBatches).toEqual({}); + expect(harness.state.error).toContain("upload destination must not contain path traversal"); + }); +}); + +function createHarness(deps: FileExplorerControllerDependencies = {}) { + installWindow("http://localhost/app"); + let state: AppState = { + ...initialAppState(), + selectedMachine: machine, + selectedProject: project, + selectedWorkspace: workspace, + }; + const api: NonNullable = deps.api ?? { + workspaceTree: vi.fn["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))), + workspaceFile: vi.fn["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))), + }; + const updateUrl = vi.fn(); + let batchSequence = 0; + const controller = new FileExplorerController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + updateUrl, + { + ...deps, + api, + createUploadBatchId: deps.createUploadBatchId ?? (() => { + batchSequence += 1; + return `batch-${String(batchSequence)}`; + }), + }, + ); + return { + controller, + api, + updateUrl, + get state(): AppState { return state; }, + }; +} + +function installWindow(href: string): void { + const url = new URL(href); + const fakeWindow = { + location: { + href: url.href, + pathname: url.pathname, + search: url.search, + hash: url.hash, + }, + history: { + pushState: vi.fn(), + replaceState: vi.fn(), + }, + }; + Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true }); +} + +function controllableUpload(options: { rejectOnCancel?: boolean } = {}) { + let resolveUpload: ((responses: WriteWorkspaceFileResponse[]) => void) | undefined; + let rejectUpload: ((error: unknown) => void) | undefined; + let uploadOptions: UploadWorkspaceFilesOptions | undefined; + const cancel = vi.fn(() => { + if (options.rejectOnCancel === true) rejectUpload?.(new WorkspaceUploadCancelledError()); + }); + const fn = vi.fn((_projectId, _workspaceId, _files, sentOptions = {}) => { + uploadOptions = sentOptions; + const promise = new Promise((resolve, reject) => { + resolveUpload = resolve; + rejectUpload = reject; + }); + return { promise, cancel }; + }); + return { + fn, + cancel, + emitProgress: (progress: WorkspaceUploadBatchProgress) => { uploadOptions?.onProgress?.(progress); }, + resolve: (responses: WriteWorkspaceFileResponse[]) => { resolveUpload?.(responses); }, + reject: (error: unknown) => { rejectUpload?.(error); }, + }; +} + +function sequenceNow(...values: string[]): () => string { + let index = 0; + return () => values[index++] ?? values.at(-1) ?? "now"; +} + +function treeResponse(path: string): FileTreeResponse { + return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false }; +} + +function fileResponse(path: string): FileContentResponse { + return { path, encoding: "utf8", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", content: "aa", truncated: false, binary: false }; +} + +function writeResponse(path: string, size: number): WriteWorkspaceFileResponse { + return { path, size, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }; +} diff --git a/src/client/src/controllers/fileExplorerController.ts b/src/client/src/controllers/fileExplorerController.ts index e9dd073..12d6fa9 100644 --- a/src/client/src/controllers/fileExplorerController.ts +++ b/src/client/src/controllers/fileExplorerController.ts @@ -1,11 +1,69 @@ -import { api } from "../api"; +import { + api as defaultApi, + uploadWorkspaceFiles as defaultUploadWorkspaceFiles, + WorkspaceUploadBatchError, + WorkspaceUploadCancelledError, + type WorkspaceUploadBatchProgress, + type WorkspaceUploadTask, + type WriteWorkspaceFileResponse, +} from "../api"; import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; +import { + cancelWorkspaceUploadBatch, + completeWorkspaceUploadBatch, + createWorkspaceUploadBatchState, + failWorkspaceUploadBatch, + updateWorkspaceUploadBatchProgress, + type WorkspaceUploadBatchState, +} from "../workspaceUploadState"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files"); +type FileExplorerApi = Pick; +type UploadWorkspaceFiles = typeof defaultUploadWorkspaceFiles; + +export interface FileExplorerControllerDependencies { + api?: FileExplorerApi; + uploadWorkspaceFiles?: UploadWorkspaceFiles; + createUploadBatchId?: () => string; + now?: () => string; +} + +export interface StartWorkspaceUploadOptions { + destinationFolder: string; + createDirs?: boolean; + overwrite?: boolean; + selectUploadedFile?: boolean; +} + +export interface WorkspaceUploadRun { + batchId: string; + done: Promise; +} + export class FileExplorerController { - constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} + private readonly api: FileExplorerApi; + private readonly uploadWorkspaceFiles: UploadWorkspaceFiles; + private readonly createUploadBatchId: () => string; + private readonly now: () => string; + private readonly uploadTasks = new Map>(); + private uploadBatchSequence = 0; + + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + private readonly updateUrl: UpdateUrl, + deps: FileExplorerControllerDependencies = {}, + ) { + this.api = deps.api ?? defaultApi; + this.uploadWorkspaceFiles = deps.uploadWorkspaceFiles ?? defaultUploadWorkspaceFiles; + this.createUploadBatchId = deps.createUploadBatchId ?? (() => { + this.uploadBatchSequence += 1; + return `workspace-upload-${String(this.uploadBatchSequence)}`; + }); + this.now = deps.now ?? (() => new Date().toISOString()); + } async refreshFiles(): Promise { const project = this.getState().selectedProject; @@ -13,9 +71,9 @@ export class FileExplorerController { if (project === undefined || workspace === undefined) return; try { const machineId = selectedMachineId(this.getState()); - const root = await api.workspaceTree(project.id, workspace.id, "", machineId); + const root = await this.api.workspaceTree(project.id, workspace.id, "", machineId); const expanded = { ...this.getState().expandedDirs }; - await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; })); + await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await this.api.workspaceTree(project.id, workspace.id, path, machineId)).entries; })); this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" }); } catch (error) { this.setState({ error: String(error) }); @@ -31,7 +89,7 @@ export class FileExplorerController { return; } try { - const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState())); + const response = await this.api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState())); this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" }); } catch (error) { this.setState({ error: String(error) }); @@ -51,7 +109,7 @@ export class FileExplorerController { if (project === undefined || workspace === undefined) return; this.setState({ selectedFilePath: path, selectedFileContent: undefined }); try { - const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState())); + const content = await this.api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState())); if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" }); } catch (error) { if (this.getState().selectedFilePath !== path) return; @@ -64,6 +122,123 @@ export class FileExplorerController { this.setState({ error: String(error) }); } } + + startWorkspaceUpload(files: readonly File[], options: StartWorkspaceUploadOptions): WorkspaceUploadRun | undefined { + const project = this.getState().selectedProject; + const workspace = this.getState().selectedWorkspace; + if (project === undefined || workspace === undefined) { + this.setState({ error: "Select a workspace before uploading files." }); + return undefined; + } + if (files.length === 0) return undefined; + + const machineId = selectedMachineId(this.getState()); + const overwrite = options.overwrite ?? false; + const createDirs = options.createDirs ?? true; + let batch: WorkspaceUploadBatchState; + try { + batch = createWorkspaceUploadBatchState({ + id: this.createUploadBatchId(), + projectId: project.id, + workspaceId: workspace.id, + machineId, + destinationFolder: options.destinationFolder, + overwrite, + createDirs, + files, + startedAt: this.now(), + }); + } catch (error) { + this.setState({ error: String(error) }); + return undefined; + } + + this.setUploadBatch(batch); + let task: WorkspaceUploadTask; + try { + task = this.uploadWorkspaceFiles(project.id, workspace.id, files, { + destinationFolder: options.destinationFolder, + machineId, + overwrite, + createDirs, + onProgress: (progress) => { this.updateUploadProgress(batch.id, progress); }, + }); + } catch (error) { + this.failUploadBatch(batch.id, error); + return { batchId: batch.id, done: Promise.resolve() }; + } + + this.uploadTasks.set(batch.id, task); + const done = task.promise + .then(async (responses) => { await this.completeUploadBatch(batch.id, responses, options); }) + .catch(async (error: unknown) => { await this.handleUploadFailure(batch.id, error, options); }) + .finally(() => { this.uploadTasks.delete(batch.id); }); + return { batchId: batch.id, done }; + } + + cancelWorkspaceUpload(batchId: string): void { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return; + this.setUploadBatch(cancelWorkspaceUploadBatch(batch, this.now())); + this.uploadTasks.get(batchId)?.cancel(); + } + + clearWorkspaceUpload(batchId: string): void { + this.uploadTasks.get(batchId)?.cancel(); + this.uploadTasks.delete(batchId); + this.setState({ workspaceUploadBatches: omitKey(this.getState().workspaceUploadBatches, batchId) }); + } + + private updateUploadProgress(batchId: string, progress: WorkspaceUploadBatchProgress): void { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return; + this.setUploadBatch(updateWorkspaceUploadBatchProgress(batch, progress)); + } + + private async completeUploadBatch(batchId: string, responses: WriteWorkspaceFileResponse[], options: StartWorkspaceUploadOptions): Promise { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return; + this.setUploadBatch(completeWorkspaceUploadBatch(batch, responses, this.now()), { error: "" }); + if (!this.isCurrentWorkspaceBatch(batch)) return; + await this.refreshFiles(); + const uploadedPath = responses[0]?.path; + if (options.selectUploadedFile !== false && uploadedPath !== undefined && this.isCurrentWorkspaceBatch(batch)) await this.selectFile(uploadedPath); + } + + private async handleUploadFailure(batchId: string, error: unknown, options: StartWorkspaceUploadOptions): Promise { + const batch = this.failUploadBatch(batchId, error); + if (!(error instanceof WorkspaceUploadBatchError) || error.responses.length === 0 || batch === undefined || !this.isCurrentWorkspaceBatch(batch)) return; + await this.refreshFiles(); + const uploadedPath = error.responses[0]?.path; + if (options.selectUploadedFile !== false && uploadedPath !== undefined && this.isCurrentWorkspaceBatch(batch)) await this.selectFile(uploadedPath); + } + + private failUploadBatch(batchId: string, error: unknown): WorkspaceUploadBatchState | undefined { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return undefined; + if (isWorkspaceUploadCancelled(error)) { + const cancelled = cancelWorkspaceUploadBatch(batch, this.now()); + this.setUploadBatch(cancelled); + return cancelled; + } + const message = errorMessage(error); + const failed = failWorkspaceUploadBatch(batch, message, this.now()); + this.setUploadBatch(failed, { error: message }); + return failed; + } + + private getUploadBatch(batchId: string): WorkspaceUploadBatchState | undefined { + return this.getState().workspaceUploadBatches[batchId]; + } + + private setUploadBatch(batch: WorkspaceUploadBatchState, patch: { error?: string } = {}): void { + this.setState({ workspaceUploadBatches: { ...this.getState().workspaceUploadBatches, [batch.id]: batch }, ...patch }); + } + + private isCurrentWorkspaceBatch(batch: WorkspaceUploadBatchState): boolean { + const state = this.getState(); + return state.selectedProject?.id === batch.projectId && state.selectedWorkspace?.id === batch.workspaceId && selectedMachineId(state) === batch.machineId; + } } function isUnavailableFileError(error: unknown): boolean { @@ -71,6 +246,14 @@ function isUnavailableFileError(error: unknown): boolean { return message.includes("Path does not exist") || message.includes("ENOENT") || message.includes("no such file or directory"); } +function isWorkspaceUploadCancelled(error: unknown): boolean { + return error instanceof WorkspaceUploadCancelledError; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function omitKey(record: Record, keyToOmit: string): Record { return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); } diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 0f0d2a3..4c2dca6 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -1,8 +1,7 @@ import { html, type TemplateResult } from "lit"; -import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api"; -import { workspaceImagePreviewUrl } from "../../api/urls"; -import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../../shared/workspaceFiles"; +import type { GitDiffResponse, GitStatusResponse } from "../../api"; import { renderBuiltinTabIcon } from "../../components/tabIcons"; +import "../../components/WorkspaceFilesPanel"; import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types"; export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { @@ -34,69 +33,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { } function renderFiles(context: WorkspacePanelContext): TemplateResult { - return html` -
- Files - ${context.fileTreeStale ? html`stale` : null} - -
-
-
- ${context.fileTree.length === 0 ? html`

No files loaded.

` : context.fileTree.map((entry) => renderTreeEntry(context, entry, 0))} -
-
- ${renderFileViewer(context)} -
-
- `; -} - -function renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult { - const children = context.expandedDirs[entry.path]; - const hasChildren = children !== undefined; - const selected = entry.type !== "directory" && context.selectedFilePath === entry.path; - return html` - - ${hasChildren ? children.map((child) => renderTreeEntry(context, child, depth + 1)) : null} - `; -} - -function selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void { - if (entry.type === "directory") context.onExpandDir(entry.path); - else context.onSelectFile(entry.path); -} - -function renderFileViewer(context: WorkspacePanelContext): TemplateResult { - const file = context.selectedFileContent; - if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; - if (file === undefined) return html`

Loading ${context.selectedFilePath}…

`; - if (file.mediaType === "image") return renderImageViewer(context, file); - if (file.binary) return html`

Binary file: ${file.path} · ${formatFileSize(file.size)}

`; - loadCodeViewer(); - return html` -
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
- - `; -} - -function renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult { - const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`; - if (file.size > MAX_IMAGE_PREVIEW_BYTES) { - return html` -
${file.path}${metadata}
-

Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

- `; - } - const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id }); - return html` -
${file.path}${metadata}
-
- ${file.path} -
- `; + return html``; } function renderTerminal(context: WorkspacePanelContext): TemplateResult { @@ -155,10 +92,6 @@ function renderDiffSection(diff: GitDiffResponse): TemplateResult { `; } -function loadCodeViewer(): void { - void import("../../components/CodeViewer"); -} - function loadUnifiedDiffViewer(): void { void import("../../components/UnifiedDiffViewer"); } @@ -178,17 +111,3 @@ function stateLabel(index: string, workingTree: string): string { const label = workingTree !== "unmodified" ? workingTree : index; return label.slice(0, 1).toUpperCase(); } - -function formatFileSize(size: number): string { - if (!Number.isFinite(size) || size < 0) return "0 B"; - if (size < 1024) return `${String(size)} B`; - const kib = size / 1024; - if (kib < 1024) return `${formatScaledFileSize(kib)} KB`; - const mib = kib / 1024; - if (mib < 1024) return `${formatScaledFileSize(mib)} MB`; - return `${formatScaledFileSize(mib / 1024)} GB`; -} - -function formatScaledFileSize(value: number): string { - return value >= 10 ? String(Math.round(value)) : value.toFixed(1); -} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 5356926..a5b535f 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -615,9 +615,13 @@ function createWorkspacePanelContext(machineId: string, prompt: WorkspacePanelCo activeTerminalCount: 0, selectedTerminalId: undefined, terminalAutoStart: false, + workspaceUploadDefaultFolder: ".pi-web/uploads", onRefreshFiles: vi.fn(), onExpandDir: vi.fn(), onSelectFile: vi.fn(), + onStartWorkspaceUpload: vi.fn(), + onCancelWorkspaceUpload: vi.fn(), + onClearWorkspaceUpload: vi.fn(), onRefreshGit: vi.fn(), onSelectDiff: vi.fn(), onSelectTerminal: vi.fn(), diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 083db9a..13915a4 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -159,9 +159,13 @@ export interface WorkspacePanelContext extends WorkspaceContext { activeTerminalCount: number; selectedTerminalId: string | undefined; terminalAutoStart: boolean; + workspaceUploadDefaultFolder: string; onRefreshFiles: () => void; onExpandDir: (path: string) => void; onSelectFile: (path: string) => void; + onStartWorkspaceUpload: (files: readonly File[], options: { destinationFolder: string; createDirs?: boolean; overwrite?: boolean; selectUploadedFile?: boolean }) => { batchId: string; done: Promise } | undefined; + onCancelWorkspaceUpload: (batchId: string) => void; + onClearWorkspaceUpload: (batchId: string) => void; onRefreshGit: () => void; onSelectDiff: (path: string) => void; onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; diff --git a/src/client/src/workspaceUploadState.ts b/src/client/src/workspaceUploadState.ts new file mode 100644 index 0000000..7a837fa --- /dev/null +++ b/src/client/src/workspaceUploadState.ts @@ -0,0 +1,179 @@ +import type { WriteWorkspaceFileResponse } from "../../shared/apiTypes"; +import { workspaceUploadPath, type WorkspaceUploadBatchProgress } from "./api/workspaceUploads"; + +export type WorkspaceUploadFileStatus = "pending" | "uploading" | "completed" | "error" | "cancelled"; +export type WorkspaceUploadBatchStatus = "uploading" | "completed" | "error" | "cancelled"; + +export interface WorkspaceUploadFileState { + index: number; + name: string; + path: string; + size: number; + loaded: number; + total: number; + percent: number; + lengthComputable: boolean; + status: WorkspaceUploadFileStatus; + error?: string; + response?: WriteWorkspaceFileResponse; +} + +export interface WorkspaceUploadBatchState { + id: string; + projectId: string; + workspaceId: string; + machineId: string; + destinationFolder: string; + overwrite: boolean; + createDirs: boolean; + files: WorkspaceUploadFileState[]; + currentFileIndex: number; + loaded: number; + total: number; + percent: number; + status: WorkspaceUploadBatchStatus; + startedAt: string; + completedAt?: string; + error?: string; +} + +export interface WorkspaceUploadFileLike { + name: string; + size: number; +} + +export interface CreateWorkspaceUploadBatchStateInput { + id: string; + projectId: string; + workspaceId: string; + machineId: string; + destinationFolder: string; + overwrite: boolean; + createDirs: boolean; + files: readonly WorkspaceUploadFileLike[]; + startedAt: string; +} + +export function createWorkspaceUploadBatchState(input: CreateWorkspaceUploadBatchStateInput): WorkspaceUploadBatchState { + const files = input.files.map((file, index): WorkspaceUploadFileState => { + const total = file.size; + return { + index, + name: file.name, + path: workspaceUploadPath(input.destinationFolder, file.name), + size: file.size, + loaded: 0, + total, + percent: percentFor(0, total), + lengthComputable: true, + status: index === 0 ? "uploading" : "pending", + }; + }); + const total = files.reduce((sum, file) => sum + file.total, 0); + return { + id: input.id, + projectId: input.projectId, + workspaceId: input.workspaceId, + machineId: input.machineId, + destinationFolder: input.destinationFolder, + overwrite: input.overwrite, + createDirs: input.createDirs, + files, + currentFileIndex: files.length === 0 ? -1 : 0, + loaded: 0, + total, + percent: percentFor(0, total), + status: "uploading", + startedAt: input.startedAt, + }; +} + +export function updateWorkspaceUploadBatchProgress(batch: WorkspaceUploadBatchState, progress: WorkspaceUploadBatchProgress): WorkspaceUploadBatchState { + const progressByIndex = new Map(progress.files.map((file) => [file.index, file])); + const files = batch.files.map((file): WorkspaceUploadFileState => { + const progressFile = progressByIndex.get(file.index); + if (progressFile === undefined) return file; + const next: WorkspaceUploadFileState = { + ...file, + path: progressFile.path, + loaded: progressFile.loaded, + total: progressFile.total, + percent: progressFile.percent, + lengthComputable: progressFile.lengthComputable, + status: progressFile.error !== undefined ? "error" : progressFile.done ? "completed" : progress.currentFileIndex === file.index ? "uploading" : file.status, + }; + if (progressFile.error === undefined) delete next.error; + else next.error = progressFile.error; + return next; + }); + return { + ...batch, + files, + currentFileIndex: progress.currentFileIndex, + loaded: progress.loaded, + total: progress.total, + percent: progress.percent, + }; +} + +export function completeWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, responses: readonly WriteWorkspaceFileResponse[], completedAt: string): WorkspaceUploadBatchState { + const files = batch.files.map((file, index): WorkspaceUploadFileState => { + const response = responses[index]; + return { + ...file, + ...(response === undefined ? {} : { path: response.path, response }), + loaded: file.total, + percent: 1, + lengthComputable: true, + status: "completed", + }; + }); + const progress = terminalBatchProgress(files); + return { + ...batch, + files, + currentFileIndex: files.length === 0 ? -1 : files.length - 1, + ...progress, + status: "completed", + completedAt, + }; +} + +export function failWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, error: string, completedAt: string): WorkspaceUploadBatchState { + const files = batch.files.map((file): WorkspaceUploadFileState => { + if (file.status === "completed" || file.status === "error") return file; + if (file.status === "uploading" || file.index === batch.currentFileIndex) return { ...file, status: "error", error }; + return { ...file, status: "cancelled", error: "Not uploaded because an earlier file failed." }; + }); + return { + ...batch, + files, + ...terminalBatchProgress(files), + status: "error", + error, + completedAt, + }; +} + +export function cancelWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, completedAt: string): WorkspaceUploadBatchState { + const error = "Upload cancelled"; + const files = batch.files.map((file): WorkspaceUploadFileState => file.status === "completed" || file.status === "error" ? file : { ...file, status: "cancelled", error }); + return { + ...batch, + files, + ...terminalBatchProgress(files), + status: "cancelled", + error, + completedAt, + }; +} + +function terminalBatchProgress(files: readonly WorkspaceUploadFileState[]): Pick { + const total = files.reduce((sum, file) => sum + file.total, 0); + return { loaded: total, total, percent: files.length === 0 ? 0 : 1 }; +} + +function percentFor(loaded: number, total: number): number { + if (total <= 0) return loaded <= 0 ? 0 : 1; + return Math.max(0, Math.min(1, loaded / total)); +} diff --git a/src/config.test.ts b/src/config.test.ts index 88abfc3..c23c0c2 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -18,18 +18,18 @@ afterEach(async () => { describe("PI WEB config persistence", () => { it("writes and reads the configured PI WEB config path", () => { - const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, testOptions()); + const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions()); - expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } }); + expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } }); expect(loadPiWebConfig(testOptions())).toEqual(saved); }); it("preserves unrelated config keys while replacing managed keys", async () => { - await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, future: { enabled: true } }, null, 2)}\n`, "utf8"); + await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old" }, future: { enabled: true } }, null, 2)}\n`, "utf8"); - savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }, testOptions()); + savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } }, testOptions()); - expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } }); }); it("rejects invalid plugin config", async () => { @@ -48,6 +48,16 @@ describe("PI WEB config persistence", () => { savePiWebConfig({ maxUploadBytes: 1234 }, testOptions()); expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); }); + + it("exposes the default upload folder in the effective config", () => { + expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER }); + }); + + it("rejects upload defaults that are not workspace-relative", async () => { + await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8"); + + expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config uploads.defaultFolder must not contain path traversal"); + }); }); describe("maxUploadBytes", () => { diff --git a/src/config.ts b/src/config.ts index 02555ff..b770fb1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import type { PiWebConfigValues } from "./shared/apiTypes.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; @@ -33,6 +33,12 @@ export function defaultPiWebDataDir(): string { */ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; +export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads"; + +export function effectiveUploadsConfig(config: Pick = {}): NonNullable { + return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER }; +} + export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number { const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"]; if (fromEnv !== undefined && fromEnv !== "") { @@ -82,6 +88,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), ...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}), + uploads: effectiveUploadsConfig(loaded.config), // Always resolved (on by default) so the effective config is the single // source of truth for the runtime state and the settings UI toggle. spawnSessions: spawnSessionsEnabled(env, loaded.config), @@ -102,6 +109,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["shortcuts"]; delete existing["plugins"]; delete existing["pathAccess"]; + delete existing["uploads"]; delete existing["maxUploadBytes"]; delete existing["spawnSessions"]; delete existing["subsessions"]; @@ -126,6 +134,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}), + ...(config.uploads !== undefined ? { uploads: config.uploads } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), @@ -140,6 +149,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), ...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}), + ...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), @@ -225,6 +235,28 @@ function parseAllowedPaths(value: unknown, path: string): string[] { return value; } +export function parseUploadsConfig(value: unknown, path: string): NonNullable { + if (!isRecord(value)) throw new Error(`PI WEB config uploads must be an object: ${path}`); + const defaultFolder = value["defaultFolder"]; + return { + ...(defaultFolder !== undefined ? { defaultFolder: parseWorkspaceRelativeFolder(defaultFolder, "uploads.defaultFolder", path) } : {}), + }; +} + +function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string): string { + if (typeof value !== "string" || value.trim() === "") throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`); + if (isAbsoluteLike(value)) throw new Error(`PI WEB config ${key} must be workspace-relative: ${path}`); + const parts = value.split(/[\\/]+/).filter((part) => part !== "" && part !== "."); + if (parts.length === 0) throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`); + if (parts.some((part) => part === "..")) throw new Error(`PI WEB config ${key} must not contain path traversal: ${path}`); + return parts.join("/"); +} + +function isAbsoluteLike(value: string): boolean { + const withForwardSlashes = value.replace(/\\/g, "/"); + return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); +} + function parseShortcuts(value: unknown, path: string): Record { if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`); return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 7241884..955c5f5 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -155,6 +155,33 @@ describe("buildApp", () => { expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); }); + it("proxies remote workspace effective upload config through the existing federated workspace route", async () => { + const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const remoteWorkspaces = [{ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } }, + }]; + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify(remoteWorkspaces)]), + })); + remoteClient = fakeRemoteClient({ request }); + + const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual(remoteWorkspaces); + expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined); + }); + it("preserves remote file preview security headers while proxying safe response metadata", async () => { const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); @@ -181,6 +208,29 @@ describe("buildApp", () => { expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined); }); + it("proxies remote workspace file writes as raw request bodies", async () => { + const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]), + })); + remoteClient = fakeRemoteClient({ request }); + + const response = await app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`, + payload, + headers: { "content-type": "application/octet-stream" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true }); + expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" }); + }); + it("proxies remote terminal command-run and continue routes", async () => { const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); @@ -465,6 +515,47 @@ describe("buildApp", () => { ]); }); + it("exposes the default upload config on workspace responses", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Upload Defaults", path: projectDir, create: true }, + }); + const project = addResponse.json(); + + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } }, + }), + ]); + }); + + it("lets project-local upload config override global upload config on workspace responses", async () => { + piWebConfig = { uploads: { defaultFolder: "global-uploads" } }; + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Project Upload Defaults", path: projectDir, create: true }, + }); + const project = addResponse.json(); + await mkdir(join(projectDir, ".pi-web"), { recursive: true }); + await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`); + + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + effectiveConfig: { uploads: { defaultFolder: "project-uploads" } }, + }), + ]); + }); + it("serves supported workspace images as previews", async () => { const addResponse = await app.inject({ method: "POST", diff --git a/src/server/app.ts b/src/server/app.ts index cff2a3c..c0bcf5d 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,6 +9,7 @@ import { ProjectService } from "./projects/projectService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js"; import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js"; +import { loadEffectiveProjectUploadsConfig } from "./workspaces/projectPiWebConfig.js"; import { normalizeRequestCwd } from "./workingDirectory.js"; import { listDirectorySuggestions } from "./projects/directorySuggestions.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; @@ -25,6 +26,7 @@ import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js"; +import type { Project, Workspace } from "./types.js"; export interface AppDependencies { projects?: ProjectService; @@ -39,7 +41,11 @@ export interface AppDependencies { bodyLimit?: number; } -function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void { +interface LocalProjectRouteOptions { + config?: Pick; +} + +function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalProjectRouteOptions = {}): void { app.get(`${prefix}/projects`, async () => projects.list()); app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => { @@ -70,13 +76,26 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => { try { const project = await projects.requireProject(request.params.projectId); - return await workspaces.list(project); + return await listWorkspacesWithEffectiveConfig(project, workspaces, options.config); } catch (error) { return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); } }); } +async function listWorkspacesWithEffectiveConfig(project: Project, workspaces: WorkspaceService, config?: Pick): Promise { + const [workspaceList, effectiveConfig] = await Promise.all([ + workspaces.list(project), + workspaceEffectiveConfig(project.path, config), + ]); + return workspaceList.map((workspace) => ({ ...workspace, effectiveConfig })); +} + +async function workspaceEffectiveConfig(projectPath: string, config?: Pick): Promise> { + const globalConfig = config === undefined ? {} : (await config.read()).effectiveConfig; + return { uploads: await loadEffectiveProjectUploadsConfig(projectPath, globalConfig) }; +} + interface LocalFileSuggestionRouteOptions { config?: Pick; } @@ -131,8 +150,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise { const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } }, + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); expect(response.json().config).toEqual(savedConfig); }); @@ -80,6 +80,18 @@ describe("config routes", () => { expect(response.json()).toHaveProperty("error"); expect(service.write).not.toHaveBeenCalled(); }); + + it("rejects invalid upload defaults before writing", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { uploads: { defaultFolder: "/tmp" } } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toHaveProperty("error"); + expect(service.write).not.toHaveBeenCalled(); + }); }); function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse { diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index eec326e..1585efa 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -59,6 +59,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { const shortcuts = value["shortcuts"]; const plugins = value["plugins"]; const pathAccess = value["pathAccess"]; + const uploads = value["uploads"]; const maxUploadBytes = value["maxUploadBytes"]; const spawnSessions = value["spawnSessions"]; const subsessions = value["subsessions"]; @@ -74,6 +75,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts); if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins); if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess); + if (uploads !== undefined) config.uploads = parseUploadsConfig(uploads, "request"); if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes); if (spawnSessions !== undefined) { if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean"); diff --git a/src/server/machines/machineClient.test.ts b/src/server/machines/machineClient.test.ts new file mode 100644 index 0000000..ed15e92 --- /dev/null +++ b/src/server/machines/machineClient.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { RemoteMachineClient } from "./machineClient.js"; + +describe("RemoteMachineClient", () => { + it("forwards raw binary request bodies with the provided content type", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response("ok", { status: 200 }))); + const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl); + const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + + await client.request("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "image/png" }); + + const { input, init } = onlyFetchCall(fetchImpl); + expect(fetchInputUrl(input)).toBe("https://remote.example.test/api/projects/p1/workspaces/w1/file?path=image.png"); + expect(init.method).toBe("PUT"); + expect(new Headers(init.headers).get("content-type")).toBe("image/png"); + if (!(init.body instanceof ArrayBuffer)) throw new Error("Expected binary request body"); + expect(Array.from(new Uint8Array(init.body))).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); + + it("serializes structured request bodies as JSON by default", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response("ok", { status: 200 }))); + const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/base/", token: "secret" }, fetchImpl); + + await client.request("POST", "/api/sessions", { cwd: "/repo" }); + + const { input, init } = onlyFetchCall(fetchImpl); + expect(fetchInputUrl(input)).toBe("https://remote.example.test/base/api/sessions"); + expect(new Headers(init.headers).get("authorization")).toBe("Bearer secret"); + expect(new Headers(init.headers).get("content-type")).toBe("application/json"); + expect(init.body).toBe(JSON.stringify({ cwd: "/repo" })); + }); +}); + +function fetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +function onlyFetchCall(fetchImpl: ReturnType>): { input: RequestInfo | URL; init: RequestInit } { + expect(fetchImpl).toHaveBeenCalledTimes(1); + const call = fetchImpl.mock.calls[0]; + if (call === undefined) throw new Error("Expected fetch call"); + const [input, init] = call; + if (init === undefined) throw new Error("Expected fetch init"); + return { input, init }; +} diff --git a/src/server/machines/machineClient.ts b/src/server/machines/machineClient.ts index d1658ff..57b2b96 100644 --- a/src/server/machines/machineClient.ts +++ b/src/server/machines/machineClient.ts @@ -16,6 +16,7 @@ export interface MachineJsonResponse { export interface MachineRequestOptions { timeoutMs?: number; + contentType?: string; } export interface MachineClient { @@ -82,13 +83,14 @@ export class RemoteMachineClient implements MachineClient { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS); try { + const requestBody = serializeRequestBody(method, body); const init: RequestInit = { method, - headers: this.requestHeaders(body), + headers: this.requestHeaders(body, options), signal: controller.signal, redirect: "manual", }; - if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body); + if (requestBody !== undefined) init.body = requestBody; return await this.fetchImpl(this.remoteUrl(path), init); } catch (error) { if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504); @@ -98,11 +100,11 @@ export class RemoteMachineClient implements MachineClient { } } - private requestHeaders(body: unknown): HeadersInit { + private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit { return { ...this.remoteHeaders(), accept: "*/*", - ...(body === undefined ? {} : { "content-type": "application/json" }), + ...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }), }; } @@ -147,6 +149,34 @@ function headersToRecord(headers: Headers): Record { return Object.fromEntries(headers.entries()); } +function serializeRequestBody(method: string, body: unknown): NonNullable | undefined { + if (body === undefined || method === "GET" || method === "HEAD") return undefined; + if (isRawRequestBody(body)) return body; + if (ArrayBuffer.isView(body)) return copyArrayBufferView(body); + const serialized: string = JSON.stringify(body); + return serialized; +} + +function defaultContentTypeForBody(body: unknown): string { + return isRawRequestBody(body) || ArrayBuffer.isView(body) ? "application/octet-stream" : "application/json"; +} + +function isRawRequestBody(body: unknown): body is NonNullable { + return typeof body === "string" + || body instanceof URLSearchParams + || body instanceof Blob + || body instanceof FormData + || body instanceof ReadableStream + || body instanceof ArrayBuffer; +} + +function copyArrayBufferView(view: ArrayBufferView): ArrayBuffer { + const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream { if (body === null) throw new Error("Response body is not readable"); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config. diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index bdd710f..14a99d5 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { WebSocket } from "ws"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js"; import { bridgeSockets } from "../webSocketBridge.js"; -import { RemoteMachineRequestError } from "./machineClient.js"; +import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js"; import { MachineService } from "./machineService.js"; export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES; @@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new app.route<{ Params: { machineId: string }; Body: unknown }>({ method: spec.method, url: `/api/machines/:machineId${spec.path}`, - handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply), + handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply), }); } @@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new } } -async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise { +async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise { if (machineId === "local") { return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" }); } @@ -45,7 +45,10 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met } try { - const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body); + const requestOptions = proxyRequestOptions(body, contentType); + const upstream = requestOptions === undefined + ? await client.request(method, remoteApiPath(machineId, requestUrl), body) + : await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions); reply.code(upstream.statusCode); applySafeHeaders(reply, upstream.headers); if (upstream.body === undefined) return await reply.send(); @@ -81,6 +84,20 @@ function remoteApiPath(machineId: string, requestUrl: string): string { return `/api${compatPath}`; } +function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined { + if (!isRawProxyBody(body)) return undefined; + const value = firstHeaderValue(contentType); + return value === undefined || value === "" ? undefined : { contentType: value }; +} + +function isRawProxyBody(body: unknown): boolean { + return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body); +} + +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + function applySafeHeaders(reply: FastifyReply, headers: Record): void { for (const [name, value] of Object.entries(headers)) { if (value === undefined) continue; diff --git a/src/server/workspaces/projectPiWebConfig.test.ts b/src/server/workspaces/projectPiWebConfig.test.ts index c160c6f..51f6aa6 100644 --- a/src/server/workspaces/projectPiWebConfig.test.ts +++ b/src/server/workspaces/projectPiWebConfig.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js"; +import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js"; let tempDir: string; let projectPath: string; @@ -26,13 +26,13 @@ describe("project PI WEB config", () => { }); }); - it("loads project-local path access config", async () => { - await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }); + it("loads project-local path access and upload config", async () => { + await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }); await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({ path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH), exists: true, - config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, + config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } }, }); }); @@ -48,6 +48,12 @@ describe("project PI WEB config", () => { await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings"); }); + it("reuses PI WEB upload schema validation", async () => { + await writeProjectConfig({ version: 1, uploads: { defaultFolder: "../outside" } }); + + await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config uploads.defaultFolder must not contain path traversal"); + }); + it("merges global and project path access in order", async () => { await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } }); @@ -55,6 +61,14 @@ describe("project PI WEB config", () => { allowedPaths: ["/global-sdk", "/shared", "/project-sdk"], }); }); + + it("lets project upload defaults override global upload defaults", async () => { + await writeProjectConfig({ version: 1, uploads: { defaultFolder: "project-uploads" } }); + + await expect(loadEffectiveProjectUploadsConfig(projectPath, { uploads: { defaultFolder: "global-uploads" } })).resolves.toEqual({ + defaultFolder: "project-uploads", + }); + }); }); describe("mergePathAccessConfigs", () => { diff --git a/src/server/workspaces/projectPiWebConfig.ts b/src/server/workspaces/projectPiWebConfig.ts index 7684d4e..a1213d5 100644 --- a/src/server/workspaces/projectPiWebConfig.ts +++ b/src/server/workspaces/projectPiWebConfig.ts @@ -1,13 +1,14 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { parsePathAccessConfig, type PiWebConfig } from "../../config.js"; -import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js"; +import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js"; +import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js"; export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json"; export interface ProjectPiWebConfig { version?: 1; pathAccess?: PiWebPathAccessConfig; + uploads?: PiWebUploadsConfig; } export interface LoadedProjectPiWebConfig { @@ -33,6 +34,11 @@ export async function loadEffectiveProjectPathAccess(projectPath: string, global return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess); } +export async function loadEffectiveProjectUploadsConfig(projectPath: string, globalConfig: PiWebConfig): Promise { + const projectConfig = await loadProjectPiWebConfig(projectPath); + return effectiveUploadsConfig({ uploads: { ...(globalConfig.uploads ?? {}), ...(projectConfig.config.uploads ?? {}) } }); +} + export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined { const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? [])); return allowedPaths.length === 0 ? undefined : { allowedPaths }; @@ -43,6 +49,7 @@ function parseProjectPiWebConfig(value: Record, path: string): return { ...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}), ...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}), + ...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}), }; } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 9a5dcb4..6d7847e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -56,6 +56,10 @@ export interface PiWebPathAccessConfig { allowedPaths?: string[]; } +export interface PiWebUploadsConfig { + defaultFolder?: string; +} + export interface PiWebConfigValues { host?: string; port?: number; @@ -64,6 +68,8 @@ export interface PiWebConfigValues { plugins?: PiWebPluginConfigMap; /** External filesystem roots PI WEB may expose outside a workspace. */ pathAccess?: PiWebPathAccessConfig; + /** Workspace-relative defaults for manual file uploads. */ + uploads?: PiWebUploadsConfig; /** Maximum accepted HTTP request body size in bytes (uploads/attachments). */ maxUploadBytes?: number; /** When true, LLMs can start new sessions via the spawn_session tool. */ @@ -115,6 +121,10 @@ export interface Project { createdAt: string; } +export interface WorkspaceEffectiveConfig { + uploads?: PiWebUploadsConfig; +} + export interface Workspace { id: string; projectId: string; @@ -124,6 +134,8 @@ export interface Workspace { isMain: boolean; isGitRepo: boolean; isGitWorktree: boolean; + /** Workspace-effective project/global settings needed by workspace UI features. */ + effectiveConfig?: WorkspaceEffectiveConfig; } export interface SessionRef {