Merge branch 'main' into chat-bidi-rtl-support

This commit is contained in:
Federico Jaramillo Martinez
2026-06-26 00:16:01 +02:00
committed by GitHub
83 changed files with 6594 additions and 705 deletions
+3 -1
View File
@@ -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, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, 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";
+63
View File
@@ -167,6 +167,69 @@ describe("machine-scoped terminal command-run API", () => {
});
});
describe("workspace file write API", () => {
it("sends text content with Content-Type text/plain", async () => {
const fetchMock = stubJsonFetch({ path: "hello.txt", size: 11, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "hello.txt", "hello world");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
expect(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
});
it("sends binary content with Content-Type application/octet-stream", async () => {
const fetchMock = stubJsonFetch({ path: "image.png", size: 4, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "image.png", binary);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
expect(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
});
it("sends createDirs and overwrite query parameters", async () => {
const fetchMock = stubJsonFetch({ path: "config/new.json", size: 10, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "config/new.json", "{\"a\":1}", { createDirs: false, overwrite: false });
expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchCall(fetchMock, 0);
expect(url).toContain("createDirs=false");
expect(url).toContain("overwrite=false");
});
it("parses WriteWorkspaceFileResponse correctly", async () => {
const fetchMock = stubJsonFetch({ path: "output/result.txt", size: 42, modifiedAt: "2026-06-10T12:00:00.000Z", created: true });
const result = await workspacesApi.writeWorkspaceFile("p 1", "w/1", "output/result.txt", "content");
expect(fetchMock).toHaveBeenCalledOnce();
expect(result).toEqual({
path: "output/result.txt",
size: 42,
modifiedAt: "2026-06-10T12:00:00.000Z",
created: true,
});
});
it("routes through machine prefix for remote machines", async () => {
const fetchMock = stubJsonFetch({ path: "file.txt", size: 5, modifiedAt: "2026-06-10T00:00:00.000Z", created: false });
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "file.txt", "data", undefined, "remote a");
expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchCall(fetchMock, 0);
expect(url).toContain("/api/machines/remote%20a/");
});
});
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
+30 -1
View File
@@ -1,4 +1,4 @@
import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -9,6 +9,7 @@ import {
parseClosed,
parseCommandResult,
parseDeleted,
parseDeleteWorkspaceFileResponse,
parseDetached,
parseFileContentResponse,
parseFileSuggestion,
@@ -21,6 +22,7 @@ import {
parseMachinesResponse,
parseMessagePage,
parseModelSelectionResponse,
parseMoveWorkspaceFileResponse,
parseOAuthFlowState,
parsePiWebConfigResponse,
parsePiWebPluginsResponse,
@@ -37,6 +39,7 @@ import {
parseTerminalCommandRun,
parseTerminalInfo,
parseThinkingLevelsResponse,
parseWriteWorkspaceFileResponse,
parseWorkspace,
parseWorkspaceActivityResponse,
} from "./parsers";
@@ -118,6 +121,32 @@ export const workspacesApi = {
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
writeWorkspaceFile: (projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId = "local") => {
const params = new URLSearchParams({ path });
if (options?.createDirs === false) params.set("createDirs", "false");
if (options?.overwrite === false) params.set("overwrite", "false");
const isBinary = content instanceof Uint8Array;
const body: BodyInit = isBinary ? new Uint8Array(content) : new TextEncoder().encode(content);
return request(
`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`,
parseWriteWorkspaceFileResponse,
{ method: "PUT", body, headers: { "Content-Type": isBinary ? "application/octet-stream" : "text/plain" } },
);
},
deleteWorkspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local"): Promise<DeleteWorkspaceFileResponse> => {
const params = new URLSearchParams({ path });
return request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, parseDeleteWorkspaceFileResponse, { method: "DELETE" });
},
moveWorkspaceFile: (projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId = "local") => {
const params = new URLSearchParams({ fromPath, toPath });
if (options?.createDirs === false) params.set("createDirs", "false");
if (options?.overwrite === true) params.set("overwrite", "true");
return request(
`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/move?${params.toString()}`,
parseMoveWorkspaceFileResponse,
{ method: "POST" },
);
},
};
export const sessionsApi = {
@@ -37,6 +37,10 @@ describe("federated route contract", () => {
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(workspacesApi.writeWorkspaceFile("p 1", "w 1", "README.md", "hello", { overwrite: false }, machineId)),
ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
+1 -1
View File
@@ -1,6 +1,6 @@
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
if (init?.body !== undefined) headers.set("content-type", "application/json");
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, { ...init, headers });
if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({}));
+49 -5
View File
@@ -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",
+47 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -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"])),
};
}
@@ -336,6 +345,34 @@ export function parseFileContentResponse(value: unknown): FileContentResponse {
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
}
export function parseWriteWorkspaceFileResponse(value: unknown): WriteWorkspaceFileResponse {
const record = requireRecord(value);
return {
path: requireString(record, "path"),
size: requireNumber(record, "size"),
modifiedAt: requireString(record, "modifiedAt"),
created: requireBoolean(record, "created"),
};
}
export function parseDeleteWorkspaceFileResponse(value: unknown): DeleteWorkspaceFileResponse {
const record = requireRecord(value);
return {
path: requireString(record, "path"),
existed: requireBoolean(record, "existed"),
};
}
export function parseMoveWorkspaceFileResponse(value: unknown): MoveWorkspaceFileResponse {
const record = requireRecord(value);
return {
fromPath: requireString(record, "fromPath"),
toPath: requireString(record, "toPath"),
size: requireNumber(record, "size"),
modifiedAt: requireString(record, "modifiedAt"),
};
}
function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined {
if (value === undefined) return undefined;
if (value !== "image") throw new Error("Invalid file media type");
@@ -446,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")),
@@ -474,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");
}
+8
View File
@@ -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);
+219
View File
@@ -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<string, string>();
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);
}
+355
View File
@@ -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<T> {
promise: Promise<T>;
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<WriteWorkspaceFileResponse> {
const xhr: WorkspaceUploadXhr = options.xhrFactory?.() ?? new XMLHttpRequest();
let settled = false;
let cancelled = false;
const promise = new Promise<WriteWorkspaceFileResponse>((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<WriteWorkspaceFileResponse[]> {
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<WriteWorkspaceFileResponse> | undefined;
let currentFileIndex = 0;
const cancellation = { requested: false };
const emit = () => {
options.onProgress?.(batchProgressSnapshot(progressFiles, currentFileIndex, progressFiles.every((file) => file.done)));
};
const promise = (async (): Promise<WriteWorkspaceFileResponse[]> => {
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<string, unknown> {
return typeof value === "object" && value !== null;
}
+4
View File
@@ -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<string, WorkspaceUploadBatchState>;
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,
+51 -3
View File
@@ -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";
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -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<void> {
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<void> {
@@ -1216,6 +1218,21 @@ export class PiWebApp extends LitElement {
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
return {
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
writeFile: async (path, content, options) => {
const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId);
void this.files.refreshFiles();
return result;
},
deleteFile: async (path) => {
const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId);
void this.files.refreshFiles();
return result;
},
moveFile: async (fromPath, toPath, options) => {
const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId);
void this.files.refreshFiles();
return result;
},
};
}
@@ -1235,6 +1252,7 @@ export class PiWebApp extends LitElement {
workspace,
state: this.state,
files: this.createWorkspaceFiles(workspace, machineId),
prompt: this.createPromptEditor(),
terminal: {
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
@@ -1255,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); },
@@ -1387,9 +1409,35 @@ export class PiWebApp extends LitElement {
}
}
private createPromptEditor(): PluginPromptEditor {
return {
insertText: (text: string) => {
const editor = this.promptEditor?.view;
if (!editor) return;
if (!editor.hasFocus) editor.focus();
const sel = editor.state.selection.main;
editor.dispatch({
changes: { from: sel.from, to: sel.to, insert: text },
selection: { anchor: sel.from + text.length },
});
},
getText: () => {
return this.promptEditor?.view?.state.doc.toString() ?? "";
},
getSelection: () => {
const editor = this.promptEditor?.view;
if (!editor) return null;
const sel = editor.state.selection.main;
if (sel.empty) return null;
return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) };
},
};
}
private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
prompt: this.createPromptEditor(),
piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
+103 -36
View File
@@ -7,25 +7,19 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { captureImageAttachments } from "../promptAttachmentCapture";
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft } from "../inputModes";
import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
import { promptEditorStyles, type CompletionItem } from "./shared";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu";
interface PendingAttachment {
id: string;
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
type PendingAttachment = CapturedAttachment & { id: string };
@customElement("prompt-editor")
export class PromptEditor extends LitElement {
@@ -59,6 +53,8 @@ export class PromptEditor extends LitElement {
private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment();
private readonly readOnlyCompartment = new Compartment();
private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia();
private explicitShiftKeyActive = false;
protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId") && !changed.has("machineId")) return;
@@ -96,8 +92,8 @@ export class PromptEditor extends LitElement {
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
<div class="editor-wrap">
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach images" aria-label="Attach images" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
<input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()}
@@ -117,6 +113,11 @@ export class PromptEditor extends LitElement {
this.editor?.focus();
}
/** Get the underlying CM6 EditorView, or undefined if not yet mounted. */
get view(): EditorView | undefined {
return this.editor;
}
private renderCompactStatus() {
const status = this.status;
if (status === undefined) return null;
@@ -132,18 +133,20 @@ export class PromptEditor extends LitElement {
private renderAttachments() {
if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments);
const delivery = this.effectiveAttachmentDelivery();
return html`
<div class="attachments" aria-label="Pending attachments">
${this.attachments.map((attachment) => html`
<div class="attachment-chip" title=${attachment.name}>
<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />
<div class=${`attachment-chip ${isInlinePromptAttachment(attachment) ? "attachment-chip-image" : "attachment-chip-file"}`} title=${attachment.name}>
${this.renderAttachmentPreview(attachment)}
<button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button>
</div>
`)}
${this.attachments.length > 0 ? html`
<label class="attachment-delivery" title="How attachments are delivered to the agent">
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option>
<label class="attachment-delivery" title=${canUseInlineDelivery ? "How attachments are delivered to the agent" : "General files are saved and mentioned from the workspace"}>
<select .value=${delivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline" ?disabled=${!canUseInlineDelivery}>Attach to message${canUseInlineDelivery ? "" : " (images only)"}</option>
<option value="folder">Save to .pi-web/attachments</option>
</select>
</label>
@@ -153,9 +156,24 @@ export class PromptEditor extends LitElement {
`;
}
private renderAttachmentPreview(attachment: PendingAttachment) {
if (isInlinePromptAttachment(attachment)) {
return html`<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />`;
}
return html`
<div class="attachment-file-preview" aria-hidden="true">${fileExtensionLabel(attachment.name)}</div>
<span class="attachment-file-name">${attachment.name}</span>
`;
}
private changeDelivery(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) return;
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline";
const requested = event.target.value === "folder" ? "folder" : "inline";
if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) {
event.target.value = "folder";
return;
}
this.attachmentDelivery = requested;
saveAttachmentDelivery(this.attachmentDelivery);
}
@@ -164,7 +182,7 @@ export class PromptEditor extends LitElement {
}
private async handlePaste(event: ClipboardEvent) {
const files = imageFilesFromDataTransfer(event.clipboardData);
const files = filesFromDataTransfer(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
@@ -172,13 +190,11 @@ export class PromptEditor extends LitElement {
private handleDragOver(event: DragEvent) {
if (event.dataTransfer === null) return;
if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) {
event.preventDefault();
}
if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault();
}
private async handleDrop(event: DragEvent) {
const files = imageFilesFromDataTransfer(event.dataTransfer);
const files = filesFromDataTransfer(event.dataTransfer);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
@@ -193,7 +209,7 @@ export class PromptEditor extends LitElement {
private async addAttachmentFiles(files: File[]) {
this.attachmentError = undefined;
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64);
const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64);
if (attachments.length > 0) {
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
}
@@ -201,12 +217,11 @@ export class PromptEditor extends LitElement {
}
private currentAttachments(): PromptAttachment[] {
return this.attachments.map((attachment) => ({
kind: "image",
mimeType: attachment.mimeType,
data: attachment.data,
name: attachment.name,
}));
return this.attachments.map((attachment) => pendingToPromptAttachment(attachment));
}
private effectiveAttachmentDelivery(): PromptAttachmentDelivery {
return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments);
}
private createEditor() {
@@ -223,6 +238,10 @@ export class PromptEditor extends LitElement {
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping,
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
EditorView.domEventHandlers({
keyup: (event) => this.handleEditorKeyUp(event),
blur: () => this.resetEditorModifierState(),
}),
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
@@ -230,11 +249,10 @@ export class PromptEditor extends LitElement {
if (update.docChanged) this.updateDraft(update.state.doc.toString());
}),
keymap.of([
{ any: (view, event) => this.handleEditorKeyDown(event, view) },
{ key: "ArrowDown", run: () => this.moveCompletion(1) },
{ key: "ArrowUp", run: () => this.moveCompletion(-1) },
{ key: "Escape", run: () => this.closeCompletions() },
{ key: "Enter", run: () => this.handleEditorEnter() },
{ key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) },
{ key: "Tab", run: (view) => this.handleEditorTab(view) },
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) },
@@ -330,12 +348,41 @@ export class PromptEditor extends LitElement {
return true;
}
private handleEditorEnter(): boolean {
if (this.completions.length) {
private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean {
if (event.key === "Shift") {
this.explicitShiftKeyActive = true;
return false;
}
if (event.key !== "Enter") {
this.explicitShiftKeyActive = false;
return false;
}
if (event.defaultPrevented || event.isComposing || view.composing) return false;
const shiftKey = shouldUsePromptEnterShiftShortcut(event.shiftKey, this.explicitShiftKeyActive, this.mobilePromptEnterMedia);
this.explicitShiftKeyActive = false;
return this.handleEditorEnter(view, shiftKey);
}
private handleEditorKeyUp(event: KeyboardEvent): boolean {
if (event.key === "Shift") this.explicitShiftKeyActive = false;
return false;
}
private resetEditorModifierState(): boolean {
this.explicitShiftKeyActive = false;
return false;
}
private handleEditorEnter(view: EditorView, shiftKey: boolean): boolean {
if (!shiftKey && this.completions.length) {
const completion = this.completions[this.selectedIndex];
if (completion !== undefined) this.pick(completion);
return true;
}
if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) {
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
}
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
return true;
}
@@ -375,7 +422,7 @@ export class PromptEditor extends LitElement {
if (text === "" && pending.length === 0) return;
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
const delivery = this.attachmentDelivery;
const delivery = this.effectiveAttachmentDelivery();
this.resetComposer();
// Sending is owned by the controller (it drives the chat activity dock and,
// for folder mode, orchestrates the upload + reference rewrite), so this is
@@ -409,9 +456,29 @@ function emptyFileSuggestions(): FileSuggestion[] {
return [];
}
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
function filesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return [];
return Array.from(data.files).filter((file) => file.type.startsWith("image/"));
return Array.from(data.files);
}
function dataTransferHasFiles(data: DataTransfer): boolean {
const items = Array.from(data.items);
if (items.length > 0) return items.some((item) => item.kind === "file");
return Array.from(data.types).includes("Files");
}
function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment {
if (attachment.kind === "image") {
return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
function fileExtensionLabel(name: string): string {
const trimmed = name.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase();
return "FILE";
}
function readFileAsBase64(file: File): Promise<string> {
+1 -1
View File
@@ -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)}`;
@@ -0,0 +1,59 @@
import { LitElement, css, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffTextSpan } from "../diff/unifiedDiff";
@customElement("unified-diff-viewer")
export class UnifiedDiffViewer extends LitElement {
@property() diff = "";
override render(): TemplateResult {
const lines = parseUnifiedDiff(this.diff);
if (lines.length === 0) return html`<p class="empty">No diff.</p>`;
return html`
<div class="scroller">
<div class="diff-grid" role="table" aria-label="Unified diff">
${lines.map((line) => this.renderLine(line))}
</div>
</div>
`;
}
private renderLine(line: UnifiedDiffLine): TemplateResult {
const kindClass = line.kind;
return html`
<div class="line" role="row">
<span class=${`cell line-number old ${kindClass}`} role="cell">${formatLineNumber(line.oldLineNumber)}</span>
<span class=${`cell line-number new ${kindClass}`} role="cell">${formatLineNumber(line.newLineNumber)}</span>
<span class=${`cell prefix ${kindClass}`} role="cell">${line.prefix}</span>
<span class=${`cell content ${kindClass}`} role="cell">${renderSpans(line.spans)}</span>
</div>
`;
}
static override styles = css`
:host { display: block; min-height: 0; height: 100%; color: var(--pi-text); background: var(--pi-bg); }
.empty { box-sizing: border-box; margin: 0; padding: 10px; color: var(--pi-muted); }
.scroller { height: 100%; min-height: 0; overflow: auto; background: var(--pi-bg); }
.diff-grid { display: grid; grid-template-columns: max-content max-content 2ch max-content; width: max-content; min-width: 100%; padding: 6px 0; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
.line { display: contents; }
.cell { min-height: 1.45em; white-space: pre; }
.line-number { min-width: 4ch; padding: 0 8px; border-right: 1px solid var(--pi-border-muted); color: var(--pi-dim); text-align: right; user-select: none; }
.prefix { padding: 0 4px; color: var(--pi-dim); text-align: center; user-select: none; }
.content { padding: 0 12px 0 4px; }
.meta { color: var(--pi-dim); }
.hunk { background: color-mix(in srgb, var(--pi-accent) 9%, transparent); color: var(--pi-accent); }
.add { background: color-mix(in srgb, var(--pi-success) 12%, transparent); }
.remove { background: color-mix(in srgb, var(--pi-danger) 12%, transparent); }
.marker { color: var(--pi-dim); }
.content.add .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-success) 36%, transparent); color: var(--pi-text); }
.content.remove .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-danger) 36%, transparent); color: var(--pi-text); }
`;
}
function renderSpans(spans: UnifiedDiffTextSpan[]): TemplateResult[] {
return spans.map((span) => html`<span class=${span.changed ? "inline-change" : ""}>${span.text}</span>`);
}
function formatLineNumber(lineNumber: number | undefined): string {
return lineNumber === undefined ? "" : String(lineNumber);
}
@@ -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> = {}): 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 }),
};
}
@@ -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<this>): 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`<p class="muted">Files unavailable.</p>`;
return html`
<section
class=${this.dragActive ? "files-panel dragging" : "files-panel"}
@dragenter=${this.handleDragEnter}
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
>
<section class="toolbar">
<strong>Files</strong>
${context.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<div class="toolbar-actions">
<button @click=${this.openFilePicker}>Upload</button>
<button @click=${context.onRefreshFiles}>Refresh</button>
</div>
<input id="workspace-upload-input" class="visually-hidden" type="file" multiple @change=${this.handleFileInputChange} />
</section>
${this.renderUploadProgress(context)}
<section class="split">
<div class="list tree">
${context.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : context.fileTree.map((entry) => this.renderTreeEntry(context, entry, 0))}
</div>
<div class="viewer">
${this.renderFileViewer(context)}
</div>
</section>
<div class="drop-overlay" aria-hidden=${this.dragActive ? "false" : "true"}>
<div>
<strong>Drop files to upload</strong>
<span>Uploads immediately to the default folder.</span>
</div>
</div>
${this.pendingUpload === undefined ? null : this.renderUploadDialog(context, this.pendingUpload)}
</section>
`;
}
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`
<button class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { this.selectTreeEntry(context, entry); }}>
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${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`<p class="muted">Select a file.</p>`;
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
if (file.mediaType === "image") return this.renderImageViewer(context, file);
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
loadCodeViewer();
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${file.content} .language=${file.language}></code-viewer>
`;
}
private renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult {
const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`;
if (file.size > MAX_IMAGE_PREVIEW_BYTES) {
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
`;
}
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id });
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<div class="image-preview">
<img src=${src} alt=${file.path} decoding="async" />
</div>
`;
}
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`
<section class="upload-progress" aria-label="Workspace uploads">
<div class="upload-progress-header">
<strong>Uploads</strong>
<small>${uploadSummaryLabel(batches)}</small>
</div>
${batches.map((batch) => this.renderUploadBatch(context, batch))}
</section>
`;
}
private renderUploadBatch(context: WorkspacePanelContext, batch: WorkspaceUploadBatchState): TemplateResult {
return html`
<article class=${`upload-batch ${batch.status}`}>
<div class="upload-batch-heading">
<div>
<strong>${uploadBatchTitle(batch)}</strong>
<small>${batch.destinationFolder === "" ? "workspace root" : batch.destinationFolder}</small>
</div>
<span>${uploadBatchStatusLabel(batch)}</span>
</div>
<progress max="1" .value=${uploadBatchProgressValue(batch)}></progress>
<div class="upload-file-list">
${batch.files.map((file) => this.renderUploadFile(file))}
</div>
<div class="upload-actions">
${batch.status === "uploading" ? html`<button @click=${() => { context.onCancelWorkspaceUpload(batch.id); }}>Cancel</button>` : html`<button @click=${() => { context.onClearWorkspaceUpload(batch.id); }}>Dismiss</button>`}
</div>
</article>
`;
}
private renderUploadFile(file: WorkspaceUploadFileState): TemplateResult {
const detail = uploadFileDetail(file);
return html`
<div class=${`upload-file ${file.status}`}>
<div class="upload-file-main">
<span>${file.name}</span>
<small>${detail}</small>
</div>
<span class="upload-file-status">${uploadFileStatusLabel(file)}</span>
</div>
`;
}
private renderUploadDialog(context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): TemplateResult {
const fileCount = review.files.length;
return html`
<div class="dialog-backdrop" @mousedown=${() => { this.closeUploadDialog(); }}>
<section class="upload-dialog" role="dialog" aria-modal="true" aria-label="Review file upload" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${this.handleDialogKeyDown}>
<header>
<div>
<span class="eyebrow">Upload</span>
<h2>Review ${fileCount === 1 ? "file" : `${String(fileCount)} files`}</h2>
</div>
<button class="close-button" title="Cancel upload" aria-label="Cancel upload" @click=${() => { this.closeUploadDialog(); }}>×</button>
</header>
<form @submit=${(event: SubmitEvent) => { this.submitUploadReview(event, context, review); }}>
<label>
<span>Destination folder</span>
<input .value=${this.destinationFolder} placeholder=${context.workspaceUploadDefaultFolder} @input=${this.handleDestinationInput} />
<small>Workspace-relative. Leave empty to upload at the workspace root.</small>
</label>
<div class="dialog-options">
<label>
<input type="checkbox" .checked=${this.createDirs} @change=${this.handleCreateDirsChange} />
<span>Create parent folders</span>
</label>
<label>
<input type="checkbox" .checked=${this.overwrite} @change=${this.handleOverwriteChange} />
<span>Overwrite existing files</span>
</label>
</div>
<section class="review-files" aria-label="Files to upload">
<strong>${fileCount === 1 ? "File" : "Files"}</strong>
${review.files.map((file) => html`
<div class="review-file">
<span>${file.name}</span>
<small>${formatFileSize(file.size)}</small>
</div>
`)}
</section>
${this.formError === "" ? null : html`<div class="dialog-error" role="alert">${this.formError}</div>`}
<footer>
<button type="button" @click=${() => { this.closeUploadDialog(); }}>Cancel</button>
<button type="submit">Upload</button>
</footer>
</form>
</section>
</div>
`;
}
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<string, WorkspaceUploadBatchState>, 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<WorkspacePanelContext, "workspaceUploadDefaultFolder" | "onStartWorkspaceUpload">,
files: readonly File[],
): ReturnType<WorkspacePanelContext["onStartWorkspaceUpload"]> {
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);
}
@@ -3,9 +3,28 @@ import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../../actions";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior";
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: string; description: string }[] = [
{
value: "auto",
label: "Auto/default",
description: "Desktop-like Enter sends; mobile, coarse pointer, or narrow screens insert a new line.",
},
{
value: "send",
label: "Enter sends message",
description: "Enter sends the chat message; Shift+Enter adds a new line when supported.",
},
{
value: "newline",
label: "Enter inserts new line",
description: "Enter adds a line break; Shift+Enter sends the chat message when supported.",
},
];
@customElement("settings-shortcuts-panel")
export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) actions: AppAction[] = [];
@@ -18,6 +37,7 @@ export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private drafts: Record<string, string> = {};
@state() private localError = "";
@state() private promptEnterPreference: PromptEnterPreference = readPromptEnterPreference();
@state() private recording: RecordingState | undefined;
private recordingTimer: number | undefined;
private recordingListenerActive = false;
@@ -75,6 +95,7 @@ export class SettingsShortcutsPanel extends LitElement {
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
${this.renderPromptEnterPreferenceCard()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
@@ -100,6 +121,40 @@ export class SettingsShortcutsPanel extends LitElement {
return null;
}
private renderPromptEnterPreferenceCard(): TemplateResult {
return html`
<section class="prompt-enter-card" aria-labelledby="prompt-enter-preference-title">
<div class="prompt-enter-copy">
<span class="card-eyebrow">Chat composer</span>
<h3 id="prompt-enter-preference-title">Enter key behavior</h3>
<p>Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.</p>
</div>
<div class="prompt-enter-options" role="radiogroup" aria-label="Enter and Shift Enter behavior in the chat composer">
${PROMPT_ENTER_OPTIONS.map((option) => html`
<label class="prompt-enter-option">
<input
type="radio"
name="prompt-enter-preference"
.value=${option.value}
.checked=${this.promptEnterPreference === option.value}
@change=${() => { this.updatePromptEnterPreference(option.value); }}
>
<span>
<strong>${option.label}</strong>
<small>${option.description}</small>
</span>
</label>
`)}
</div>
</section>
`;
}
private updatePromptEnterPreference(preference: PromptEnterPreference): void {
this.promptEnterPreference = preference;
writePromptEnterPreference(preference);
}
private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
const shortcuts = this.configResponse?.config.shortcuts;
const configured = shortcutPreference(action.id, shortcuts);
@@ -291,13 +346,22 @@ export class SettingsShortcutsPanel extends LitElement {
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card, .config-path-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; }
.prompt-enter-copy { display: grid; gap: 5px; min-width: 0; }
.prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; }
.prompt-enter-options { display: grid; gap: 7px; }
.prompt-enter-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; color: var(--pi-text); }
.prompt-enter-option input { box-sizing: border-box; width: 14px; min-width: 14px; height: 14px; margin: 3px 0 0; padding: 0; border: 0; background: transparent; accent-color: var(--pi-accent); font-family: inherit; }
.prompt-enter-option input:focus { border-color: transparent; box-shadow: none; outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
.prompt-enter-option span { display: grid; gap: 2px; }
.prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.shortcut-group { margin: 0 0 16px; }
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
@@ -330,6 +394,7 @@ export class SettingsShortcutsPanel extends LitElement {
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.prompt-enter-card { grid-template-columns: minmax(0, 1fr); }
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.shortcut-status, .shortcut-actions { justify-content: flex-start; }
}
@@ -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,
});
});
@@ -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 }),
+4 -1
View File
@@ -200,7 +200,7 @@ export const workspacePanelStyles = css`
.diff-section:last-child { border-bottom: 0; }
.viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
code-viewer { flex: 1 1 auto; min-height: 0; }
code-viewer, unified-diff-viewer { flex: 1 1 auto; min-height: 0; }
.image-preview { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; }
.image-preview img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid var(--pi-border-muted); border-radius: 8px; background-color: var(--pi-surface); background-image: linear-gradient(45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; box-shadow: 0 8px 24px var(--pi-shadow-soft); }
pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -476,6 +476,9 @@ export const promptEditorStyles = css`
.attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); }
.attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-chip-file { display: grid; place-items: center; }
.attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; }
.attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; }
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; }
.attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; }
@@ -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<FileExplorerControllerDependencies["uploadWorkspaceFiles"]>;
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[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<FileExplorerControllerDependencies["api"]> = deps.api ?? {
workspaceTree: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
workspaceFile: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["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<UploadWorkspaceFiles>((_projectId, _workspaceId, _files, sentOptions = {}) => {
uploadOptions = sentOptions;
const promise = new Promise<WriteWorkspaceFileResponse[]>((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 };
}
@@ -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<typeof defaultApi, "workspaceFile" | "workspaceTree">;
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<void>;
}
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<string, WorkspaceUploadTask<WriteWorkspaceFileResponse[]>>();
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<void> {
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<WriteWorkspaceFileResponse[]>;
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<void> {
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<void> {
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<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit));
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffLineKind } from "./unifiedDiff";
describe("parseUnifiedDiff", () => {
it("computes inline spans for paired removed and added lines", () => {
const diff = [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -10,2 +10,2 @@ export function demo() {",
"- const name = \"fooBar\";",
"+ const name = \"fooBaz\";",
" return name;",
].join("\n");
const lines = parseUnifiedDiff(diff);
const removed = firstLineOfKind(lines, "remove");
const added = firstLineOfKind(lines, "add");
const context = firstLineOfKind(lines, "context");
expect(removed.oldLineNumber).toBe(10);
expect(removed.newLineNumber).toBeUndefined();
expect(changedText(removed)).toEqual(["r"]);
expect(added.oldLineNumber).toBeUndefined();
expect(added.newLineNumber).toBe(10);
expect(changedText(added)).toEqual(["z"]);
expect(context.oldLineNumber).toBe(11);
expect(context.newLineNumber).toBe(11);
});
it("keeps file headers as metadata before a hunk starts", () => {
const diff = [
"diff --git a/README.md b/README.md",
"index 1111111..2222222 100644",
"--- a/README.md",
"+++ b/README.md",
"@@ -1 +1 @@",
"-old",
"+new",
].join("\n");
expect(parseUnifiedDiff(diff).slice(0, 5).map((line) => line.kind)).toEqual(["meta", "meta", "meta", "meta", "hunk"]);
});
it("parses changed content that starts with file header markers inside hunks", () => {
const diff = [
"diff --git a/README.md b/README.md",
"--- a/README.md",
"+++ b/README.md",
"@@ -1 +1 @@",
"---- removed heading",
"++++ added heading",
].join("\n");
const removed = firstLineOfKind(parseUnifiedDiff(diff), "remove");
const added = firstLineOfKind(parseUnifiedDiff(diff), "add");
expect(removed.text).toBe("--- removed heading");
expect(added.text).toBe("+++ added heading");
});
it("pairs a single removed line with the closest added line in uneven blocks", () => {
const diff = [
"diff --git a/src/app.ts b/src/app.ts",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1,2 @@",
"-const label = \"old\";",
"+const label = \"new\";",
"+const extra = true;",
].join("\n");
const addedLines = linesOfKind(parseUnifiedDiff(diff), "add");
const firstAdded = lineAt(addedLines, 0);
const secondAdded = lineAt(addedLines, 1);
expect(changedText(firstAdded)).toEqual(["new"]);
expect(secondAdded.spans.every((span) => !span.changed)).toBe(true);
});
it("leaves pure additions without inline change spans", () => {
const diff = [
"diff --git a/new.txt b/new.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1 @@",
"+brand new",
].join("\n");
const added = firstLineOfKind(parseUnifiedDiff(diff), "add");
expect(added.newLineNumber).toBe(1);
expect(added.spans).toEqual([{ text: "brand new", changed: false }]);
});
});
function firstLineOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine {
const found = lines.find((line) => line.kind === kind);
if (found === undefined) throw new Error(`Missing ${kind} line`);
return found;
}
function linesOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine[] {
return lines.filter((line) => line.kind === kind);
}
function lineAt(lines: UnifiedDiffLine[], index: number): UnifiedDiffLine {
const line = lines[index];
if (line === undefined) throw new Error(`Missing line at ${String(index)}`);
return line;
}
function changedText(line: UnifiedDiffLine): string[] {
return line.spans.filter((span) => span.changed).map((span) => span.text);
}
+224
View File
@@ -0,0 +1,224 @@
import { diffChars } from "diff";
export type UnifiedDiffLineKind = "meta" | "hunk" | "context" | "add" | "remove" | "marker";
export interface UnifiedDiffTextSpan {
text: string;
changed: boolean;
}
export interface UnifiedDiffLine {
kind: UnifiedDiffLineKind;
prefix: string;
text: string;
spans: UnifiedDiffTextSpan[];
oldLineNumber?: number;
newLineNumber?: number;
}
interface InlineDiffResult {
removed: UnifiedDiffTextSpan[];
added: UnifiedDiffTextSpan[];
}
interface DiffLinePair {
removed: UnifiedDiffLine;
added: UnifiedDiffLine;
}
const hunkHeaderPattern = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
const maxInlineLineLength = 5_000;
const maxInlineBlockLines = 20;
const minInlineSimilarity = 0.20;
const minPairSimilarity = 0.25;
export function parseUnifiedDiff(diff: string): UnifiedDiffLine[] {
const parsedLines = parseUnifiedDiffLines(diff);
applyInlineDiffs(parsedLines);
return parsedLines;
}
function parseUnifiedDiffLines(diff: string): UnifiedDiffLine[] {
const lines = splitDiffLines(diff);
const parsedLines: UnifiedDiffLine[] = [];
let oldLineNumber: number | undefined;
let newLineNumber: number | undefined;
for (const rawLine of lines) {
const hunkMatch = hunkHeaderPattern.exec(rawLine);
if (hunkMatch !== null) {
oldLineNumber = Number(hunkMatch[1]);
newLineNumber = Number(hunkMatch[2]);
parsedLines.push(line("hunk", "", rawLine));
continue;
}
if (oldLineNumber !== undefined && newLineNumber !== undefined) {
if (rawLine.startsWith("+")) {
parsedLines.push(line("add", "+", rawLine.slice(1), { newLineNumber }));
newLineNumber++;
continue;
}
if (rawLine.startsWith("-")) {
parsedLines.push(line("remove", "-", rawLine.slice(1), { oldLineNumber }));
oldLineNumber++;
continue;
}
if (rawLine.startsWith(" ")) {
parsedLines.push(line("context", " ", rawLine.slice(1), { oldLineNumber, newLineNumber }));
oldLineNumber++;
newLineNumber++;
continue;
}
if (rawLine.startsWith("\\")) {
parsedLines.push(line("marker", "", rawLine));
continue;
}
}
oldLineNumber = undefined;
newLineNumber = undefined;
parsedLines.push(line("meta", "", rawLine));
}
return parsedLines;
}
function splitDiffLines(diff: string): string[] {
if (diff === "") return [];
const lines = diff.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
if (lines.at(-1) === "") lines.pop();
return lines;
}
function line(kind: UnifiedDiffLineKind, prefix: string, text: string, numbers: { oldLineNumber?: number; newLineNumber?: number } = {}): UnifiedDiffLine {
return {
kind,
prefix,
text,
spans: text === "" ? [] : [{ text, changed: false }],
...numbers,
};
}
function applyInlineDiffs(lines: UnifiedDiffLine[]): void {
let index = 0;
while (index < lines.length) {
const current = lines[index];
if (current?.kind !== "remove") {
index++;
continue;
}
const removedStart = index;
while (lines[index]?.kind === "remove") index++;
const addedStart = index;
while (lines[index]?.kind === "add") index++;
if (addedStart === index) continue;
const removedLines = lines.slice(removedStart, addedStart);
const addedLines = lines.slice(addedStart, index);
applyInlineDiffBlock(removedLines, addedLines);
}
}
function applyInlineDiffBlock(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): void {
if (removedLines.length + addedLines.length > maxInlineBlockLines) return;
for (const pair of pairChangedLines(removedLines, addedLines)) {
const inlineDiff = computeInlineDiff(pair.removed.text, pair.added.text);
if (inlineDiff === undefined) continue;
pair.removed.spans = inlineDiff.removed;
pair.added.spans = inlineDiff.added;
}
}
function pairChangedLines(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): DiffLinePair[] {
if (removedLines.length === addedLines.length) return removedLines.map((removed, index) => ({ removed, added: addedLines[index] })).filter(isCompletePair);
if (removedLines.length === 1) return bestPairsForSingleRemovedLine(removedLines[0], addedLines);
if (addedLines.length === 1) return bestPairsForSingleAddedLine(removedLines, addedLines[0]);
const pairs: DiffLinePair[] = [];
const pairCount = Math.min(removedLines.length, addedLines.length);
for (let index = 0; index < pairCount; index++) {
const removed = removedLines[index];
const added = addedLines[index];
if (removed === undefined || added === undefined) continue;
if (lineSimilarity(removed.text, added.text) >= minPairSimilarity) pairs.push({ removed, added });
}
return pairs;
}
function isCompletePair(pair: { removed: UnifiedDiffLine; added: UnifiedDiffLine | undefined }): pair is DiffLinePair {
return pair.added !== undefined;
}
function bestPairsForSingleRemovedLine(removed: UnifiedDiffLine | undefined, addedLines: UnifiedDiffLine[]): DiffLinePair[] {
if (removed === undefined) return [];
const added = bestMatchingLine(removed.text, addedLines);
return added === undefined ? [] : [{ removed, added }];
}
function bestPairsForSingleAddedLine(removedLines: UnifiedDiffLine[], added: UnifiedDiffLine | undefined): DiffLinePair[] {
if (added === undefined) return [];
const removed = bestMatchingLine(added.text, removedLines);
return removed === undefined ? [] : [{ removed, added }];
}
function bestMatchingLine(text: string, candidates: UnifiedDiffLine[]): UnifiedDiffLine | undefined {
let bestCandidate: UnifiedDiffLine | undefined;
let bestScore = minPairSimilarity;
for (const candidate of candidates) {
const score = lineSimilarity(text, candidate.text);
if (score <= bestScore) continue;
bestCandidate = candidate;
bestScore = score;
}
return bestCandidate;
}
function computeInlineDiff(oldText: string, newText: string): InlineDiffResult | undefined {
if (oldText === newText) return undefined;
if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return undefined;
const changes = diffChars(oldText, newText);
const similarity = similarityFromChanges(changes, oldText, newText);
if (Math.max(oldText.length, newText.length) >= 20 && similarity < minInlineSimilarity) return undefined;
const removed: UnifiedDiffTextSpan[] = [];
const added: UnifiedDiffTextSpan[] = [];
for (const change of changes) {
if (change.value === "") continue;
if (change.added) added.push({ text: change.value, changed: true });
else if (change.removed) removed.push({ text: change.value, changed: true });
else {
removed.push({ text: change.value, changed: false });
added.push({ text: change.value, changed: false });
}
}
if (!removed.some((span) => span.changed) && !added.some((span) => span.changed)) return undefined;
return { removed: mergeAdjacentSpans(removed), added: mergeAdjacentSpans(added) };
}
function lineSimilarity(oldText: string, newText: string): number {
if (oldText === newText) return 1;
if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return 0;
return similarityFromChanges(diffChars(oldText, newText), oldText, newText);
}
function similarityFromChanges(changes: ReturnType<typeof diffChars>, oldText: string, newText: string): number {
const maxLength = Math.max(oldText.length, newText.length);
if (maxLength === 0) return 1;
const unchangedLength = changes.reduce((total, change) => change.added || change.removed ? total : total + change.value.length, 0);
return unchangedLength / maxLength;
}
function mergeAdjacentSpans(spans: UnifiedDiffTextSpan[]): UnifiedDiffTextSpan[] {
const merged: UnifiedDiffTextSpan[] = [];
for (const span of spans) {
const previous = merged[merged.length - 1];
if (previous?.changed === span.changed) previous.text += span.text;
else merged.push({ ...span });
}
return merged;
}
+7 -84
View File
@@ -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`
<section class="toolbar">
<strong>Files</strong>
${context.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<button @click=${context.onRefreshFiles}>Refresh</button>
</section>
<section class="split">
<div class="list tree">
${context.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : context.fileTree.map((entry) => renderTreeEntry(context, entry, 0))}
</div>
<div class="viewer">
${renderFileViewer(context)}
</div>
</section>
`;
}
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`
<button class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { selectTreeEntry(context, entry); }}>
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${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`<p class="muted">Select a file.</p>`;
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
if (file.mediaType === "image") return renderImageViewer(context, file);
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
loadCodeViewer();
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${file.content} .language=${file.language}></code-viewer>
`;
}
function renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult {
const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`;
if (file.size > MAX_IMAGE_PREVIEW_BYTES) {
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
`;
}
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id });
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<div class="image-preview">
<img src=${src} alt=${file.path} decoding="async" />
</div>
`;
return html`<workspace-files-panel .context=${context}></workspace-files-panel>`;
}
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
@@ -146,17 +83,17 @@ function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
}
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
loadCodeViewer();
loadUnifiedDiffViewer();
return html`
<section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${diff.diff} .language=${"diff"}></code-viewer>
<unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
</section>
`;
}
function loadCodeViewer(): void {
void import("../../components/CodeViewer");
function loadUnifiedDiffViewer(): void {
void import("../../components/UnifiedDiffViewer");
}
function loadTerminalPanel(): void {
@@ -174,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);
}
+71 -5
View File
@@ -1,6 +1,6 @@
import { html } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api";
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
@@ -14,6 +14,11 @@ function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
const context: PluginRuntimeContext = {
state: { ...initialAppState(), ...statePatch },
prompt: {
insertText: vi.fn(),
getText: vi.fn(() => ""),
getSelection: vi.fn(() => null),
},
piWebUnstable: {
terminalCommandRuns: {
runCommand: vi.fn(),
@@ -84,6 +89,37 @@ describe("PluginRegistry", () => {
expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined();
});
it("exposes the prompt helper to workspace panel callbacks", () => {
const registry = new PluginRegistry();
registry.register({
id: "example",
plugin: {
apiVersion: 1,
name: "Example",
activate: () => ({
contributions: {
workspacePanels: [
{
id: "workspace.prompt",
title: "Prompt",
render: (context) => {
context.prompt.insertText("@docs/example.md");
return html`<p>Prompt</p>`;
},
},
],
},
}),
},
});
const insertText = vi.fn();
const context = createWorkspacePanelContext("local", { insertText, getText: vi.fn(() => ""), getSelection: vi.fn(() => null) });
registry.getWorkspacePanels()[0]?.render(context);
expect(insertText).toHaveBeenCalledWith("@docs/example.md");
});
it("rejects duplicate ids within the same namespace", () => {
const registry = new PluginRegistry();
@@ -335,7 +371,7 @@ describe("PluginRegistry", () => {
context.host.requestRender();
return [{ type: "text", text: context.machine.id }];
});
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } });
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
registry.register({
id: "example",
@@ -545,7 +581,7 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
}
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) };
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
@@ -556,13 +592,14 @@ function createWorkspaceLabelContext(machineId: string, workspace = testWorkspac
};
}
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
function createWorkspacePanelContext(machineId: string, prompt: WorkspacePanelContext["prompt"] = { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }): WorkspacePanelContext {
const workspace = testWorkspace();
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn() },
files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() },
prompt,
terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() },
fileTree: [],
@@ -578,9 +615,13 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
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(),
@@ -613,6 +654,31 @@ function testStatus(patch: Partial<SessionStatus> = {}): SessionStatus {
};
}
function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse {
return {
path,
size: 0,
modifiedAt: "2026-05-20T00:00:00.000Z",
created: true,
};
}
function testDeleteFileResponse(path = "README.md"): DeleteWorkspaceFileResponse {
return {
path,
existed: true,
};
}
function testMoveFileResponse(fromPath = "old.txt", toPath = "new.txt"): MoveWorkspaceFileResponse {
return {
fromPath,
toPath,
size: 0,
modifiedAt: "2026-05-20T00:00:00.000Z",
};
}
function testMachine(id: string) {
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
}
+16 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { AppAction } from "../actions";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { AppState } from "../appState";
import type { SettingsSection } from "../settingsRoute";
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
@@ -50,6 +50,9 @@ export interface PluginMachine {
export interface WorkspaceFiles {
readFile(path: string): Promise<FileContentResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
}
export interface WorkspaceHost {
@@ -83,8 +86,15 @@ export interface TerminalCommandRunsInternalRuntime {
open(options?: { terminalId?: string | undefined }): void;
}
export interface PluginPromptEditor {
insertText(text: string): void;
getText(): string;
getSelection(): { start: number; end: number; text: string } | null;
}
export interface PluginRuntimeContext {
state: AppState;
prompt: PluginPromptEditor;
piWebUnstable?: PiWebUnstableRuntimeContext;
openActionPalette: () => void;
focusPrompt: () => void;
@@ -128,6 +138,7 @@ export interface QualifiedPluginAction extends AppAction {
}
export interface WorkspacePanelContext extends WorkspaceContext {
prompt: PluginPromptEditor;
terminal: WorkspacePanelTerminal;
/**
* @deprecated Runtime-only compatibility alias for pre-v2 plugins. Use `terminal.open()` instead.
@@ -148,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<void> } | undefined;
onCancelWorkspaceUpload: (batchId: string) => void;
onClearWorkspaceUpload: (batchId: string) => void;
onRefreshGit: () => void;
onSelectDiff: (path: string) => void;
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void;
+48 -18
View File
@@ -1,51 +1,81 @@
import { describe, expect, it } from "vitest";
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
function file(name: string, type: string, size = 10): CapturableFile {
return { name, type, size };
}
describe("captureImageAttachments", () => {
it("reads supported images as base64 attachments", async () => {
const result = await captureImageAttachments(
describe("capturePromptAttachments", () => {
it("reads supported images as native inline image attachments", async () => {
const result = await capturePromptAttachments(
[file("shot.png", "image/png"), file("pic.webp", "image/webp")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
{ kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
]);
});
it("derives a name from the mime type when the file is unnamed", async () => {
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x"));
expect(result.attachments[0]?.name).toBe("pasted-image.jpg");
it("captures generic files with their browser MIME type", async () => {
const result = await capturePromptAttachments(
[file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 },
{ kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 },
]);
});
it("skips unsupported types and reports a single error while keeping valid ones", async () => {
const result = await captureImageAttachments(
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
it("uses application/octet-stream when the browser does not provide a MIME type", async () => {
const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x"));
expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE });
});
it("derives fallback names for unnamed pasted attachments", async () => {
const result = await capturePromptAttachments(
[file("", "image/jpeg"), file("", "application/pdf")],
() => Promise.resolve("x"),
);
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]);
});
it("reports a read failure without dropping other attachments", async () => {
const result = await captureImageAttachments(
[file("bad.png", "image/png"), file("good.png", "image/png")],
const result = await capturePromptAttachments(
[file("bad.png", "image/png"), file("good.txt", "text/plain")],
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
);
expect(result.error).toBe(READ_FAILURE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]);
});
it("returns no attachments and no error for an empty batch", async () => {
const result = await captureImageAttachments([], () => Promise.resolve("x"));
const result = await capturePromptAttachments([], () => Promise.resolve("x"));
expect(result).toEqual({ attachments: [] });
});
});
describe("effectivePromptAttachmentDelivery", () => {
it("preserves inline delivery when all pending attachments are supported images", () => {
expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline");
});
it("preserves an explicit folder preference for supported images", () => {
expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder");
});
it("forces folder delivery when any attachment is a generic file", () => {
expect(effectivePromptAttachmentDelivery("inline", [
{ kind: "image", mimeType: "image/png" },
{ kind: "file", mimeType: "application/pdf" },
])).toBe("folder");
});
});
+55 -18
View File
@@ -1,3 +1,4 @@
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
/**
@@ -11,44 +12,51 @@ export interface CapturableFile {
size: number;
}
export interface CapturedAttachment {
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
export type CapturedAttachment =
| {
kind: "image";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
| {
kind: "file";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
};
export interface CaptureResult {
attachments: CapturedAttachment[];
error?: string;
}
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported.";
export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
/**
* Validate a batch of files and read the supported images as base64.
* Read a batch of browser files as prompt attachments.
*
* Pure orchestration: the actual byte reading is injected so the side effect
* (FileReader/Blob access) stays at the component boundary and tests can supply
* a fake reader. Unsupported types and read failures are collected into a single
* user-facing error while still returning every attachment that did succeed.
* a fake reader. Supported image MIME types stay marked as native inline images;
* every other file is captured as a generic file attachment that must be saved
* into the workspace before being mentioned in the prompt.
*/
export async function captureImageAttachments<T extends CapturableFile>(
export async function capturePromptAttachments<T extends CapturableFile>(
files: readonly T[],
readBase64: (file: T) => Promise<string>,
): Promise<CaptureResult> {
const attachments: CapturedAttachment[] = [];
let error: string | undefined;
for (const file of files) {
if (!isSupportedImageMimeType(file.type)) {
error = UNSUPPORTED_IMAGE_MESSAGE;
continue;
}
try {
const data = await readBase64(file);
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size });
attachments.push(capturedAttachment(file, data));
} catch {
error = READ_FAILURE_MESSAGE;
}
@@ -56,6 +64,35 @@ export async function captureImageAttachments<T extends CapturableFile>(
return { attachments, ...(error === undefined ? {} : { error }) };
}
export function isInlinePromptAttachment(attachment: Pick<CapturedAttachment, "kind" | "mimeType">): boolean {
return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType);
}
export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[]): boolean {
return attachments.every((attachment) => isInlinePromptAttachment(attachment));
}
export function effectivePromptAttachmentDelivery(
preferredDelivery: PromptAttachmentDelivery,
attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[],
): PromptAttachmentDelivery {
return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder";
}
function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment {
if (isSupportedImageMimeType(file.type)) {
return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size };
}
return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size };
}
function fileMimeType(file: CapturableFile): string {
const mimeType = file.type.trim();
return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType;
}
function attachmentName(file: CapturableFile): string {
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`;
if (file.name !== "") return file.name;
if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`;
return "pasted-file.bin";
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
MOBILE_PROMPT_ENTER_MEDIA_QUERY,
parsePromptEnterPreference,
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
readPromptEnterPreference,
shouldSendPromptOnEnter,
shouldSendPromptOnEnterShortcut,
shouldUsePromptEnterShiftShortcut,
writePromptEnterPreference,
type PromptEnterMedia,
} from "./promptEnterBehavior";
describe("promptEnterBehavior", () => {
it("uses the expected mobile media query", () => {
expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)");
});
it("uses the environment default when the preference is auto", () => {
expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnter(undefined, "auto")).toBe(true);
expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
});
it("lets explicit preferences override the environment", () => {
expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "send")).toBe(true);
expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "newline")).toBe(false);
expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false);
});
it("swaps Shift+Enter with the plain Enter behavior", () => {
expect(shouldSendPromptOnEnterShortcut(false, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnterShortcut(true, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(false, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(true, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnterShortcut(true, undefined, "send")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(true, undefined, "newline")).toBe(true);
});
it("ignores implicit Shift state on mobile-like keyboards", () => {
expect(shouldUsePromptEnterShiftShortcut(false, true, { matches: true } satisfies PromptEnterMedia)).toBe(false);
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: true } satisfies PromptEnterMedia)).toBe(false);
expect(shouldUsePromptEnterShiftShortcut(true, true, { matches: true } satisfies PromptEnterMedia)).toBe(true);
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: false } satisfies PromptEnterMedia)).toBe(true);
expect(shouldUsePromptEnterShiftShortcut(true, false, undefined)).toBe(true);
});
it("parses local storage preference values", () => {
expect(parsePromptEnterPreference("auto")).toBe("auto");
expect(parsePromptEnterPreference("send")).toBe("send");
expect(parsePromptEnterPreference("newline")).toBe("newline");
expect(parsePromptEnterPreference(null)).toBe("auto");
expect(parsePromptEnterPreference("return")).toBe("auto");
});
it("reads and writes the stored preference", () => {
const storage = new FakeStorage();
expect(readPromptEnterPreference(storage)).toBe("auto");
writePromptEnterPreference("send", storage);
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("send");
expect(readPromptEnterPreference(storage)).toBe("send");
writePromptEnterPreference("newline", storage);
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("newline");
expect(readPromptEnterPreference(storage)).toBe("newline");
writePromptEnterPreference("auto", storage);
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("auto");
expect(readPromptEnterPreference(storage)).toBe("auto");
});
it("ignores storage failures", () => {
const storage = new ThrowingStorage();
expect(readPromptEnterPreference(storage)).toBe("auto");
expect(() => { writePromptEnterPreference("send", storage); }).not.toThrow();
});
});
class FakeStorage {
private readonly values = new Map<string, string>();
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
value(key: string): string | undefined {
return this.values.get(key);
}
}
class ThrowingStorage {
getItem(): string | null {
throw new Error("blocked");
}
setItem(): void {
throw new Error("blocked");
}
}
+61
View File
@@ -0,0 +1,61 @@
export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)";
export const PROMPT_ENTER_PREFERENCE_STORAGE_KEY = "pi-web.promptEnterPreference";
export type PromptEnterPreference = "auto" | "send" | "newline";
export type PromptEnterMedia = Pick<MediaQueryList, "matches">;
export type PromptEnterPreferenceStorage = Pick<Storage, "getItem" | "setItem">;
export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined {
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined;
}
export function parsePromptEnterPreference(value: string | null): PromptEnterPreference {
if (value === "send" || value === "newline") return value;
return "auto";
}
export function readPromptEnterPreference(storage = browserStorage()): PromptEnterPreference {
if (storage === undefined) return "auto";
try {
return parsePromptEnterPreference(storage.getItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY));
} catch {
return "auto";
}
}
export function writePromptEnterPreference(preference: PromptEnterPreference, storage = browserStorage()): void {
if (storage === undefined) return;
try {
storage.setItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY, preference);
} catch {
// Ignore localStorage quota/privacy errors; Auto remains the safe fallback.
}
}
export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
if (preference === "send") return true;
if (preference === "newline") return false;
return media?.matches !== true;
}
export function shouldUsePromptEnterShiftShortcut(shiftKey: boolean, explicitShiftKeyActive: boolean, media = createMobilePromptEnterMedia()): boolean {
// Touch keyboards can report autocapitalization as Shift on Enter after a line break.
// On mobile-like screens, only trust Shift when the editor saw an explicit Shift keydown.
if (!shiftKey) return false;
if (media?.matches === true) return explicitShiftKeyActive;
return true;
}
export function shouldSendPromptOnEnterShortcut(shiftKey: boolean, media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
const plainEnterSends = shouldSendPromptOnEnter(media, preference);
return shiftKey ? !plainEnterSends : plainEnterSends;
}
function browserStorage(): PromptEnterPreferenceStorage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
+179
View File
@@ -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<WorkspaceUploadBatchState, "loaded" | "total" | "percent"> {
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));
}