Archived
Merge branch 'main' into chat-bidi-rtl-support
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js";
|
||||
|
||||
const originalShell = process.env["SHELL"];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) {
|
||||
delete process.env["SHELL"];
|
||||
} else {
|
||||
process.env["SHELL"] = originalShell;
|
||||
}
|
||||
});
|
||||
|
||||
describe("commandWithVersionCheck", () => {
|
||||
it("emits a POSIX subshell group for bash", () => {
|
||||
process.env["SHELL"] = "/bin/bash";
|
||||
expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)");
|
||||
});
|
||||
|
||||
it("emits a POSIX subshell group for zsh", () => {
|
||||
process.env["SHELL"] = "/bin/zsh";
|
||||
expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)");
|
||||
});
|
||||
|
||||
it("uses fish begin/end grouping instead of a POSIX subshell", () => {
|
||||
process.env["SHELL"] = "/usr/local/bin/fish";
|
||||
const command = commandWithVersionCheck("npm");
|
||||
expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end");
|
||||
expect(command).not.toContain("(");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCliEntrypoint", () => {
|
||||
it("matches direct execution paths", () => {
|
||||
expect(isCliEntrypoint("/tmp/pi-web-cli.js", "/tmp/pi-web-cli.js")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches npm-style symlinked bin entrypoints", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
|
||||
try {
|
||||
const target = join(dir, "dist", "cli.js");
|
||||
const symlink = join(dir, "bin", "pi-web");
|
||||
mkdirSync(join(dir, "dist"));
|
||||
mkdirSync(join(dir, "bin"));
|
||||
writeFileSync(target, "#!/usr/bin/env node\n", { mode: 0o755 });
|
||||
symlinkSync(target, symlink);
|
||||
|
||||
expect(isCliEntrypoint(symlink, target)).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match unrelated paths", () => {
|
||||
expect(isCliEntrypoint("/tmp/pi-web", "/tmp/other-pi-web")).toBe(false);
|
||||
});
|
||||
});
|
||||
+23
-7
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir, userInfo } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
@@ -902,8 +902,12 @@ function commandCheck(command: string): string {
|
||||
return `command -v ${command}`;
|
||||
}
|
||||
|
||||
function commandWithVersionCheck(command: string): string {
|
||||
return `${commandCheck(command)} && (${command} --version 2>&1 || true)`;
|
||||
export function commandWithVersionCheck(command: string): string {
|
||||
const found = commandCheck(command);
|
||||
if (detectServiceShell().name === "fish") {
|
||||
return `${found} && begin; ${command} --version 2>&1 || true; end`;
|
||||
}
|
||||
return `${found} && (${command} --version 2>&1 || true)`;
|
||||
}
|
||||
|
||||
function nodeVersionCheck(): string {
|
||||
@@ -1088,7 +1092,19 @@ async function main(): Promise<void> {
|
||||
else throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
export function isCliEntrypoint(entrypoint: string | undefined = process.argv[1], modulePath: string = fileURLToPath(import.meta.url)): boolean {
|
||||
if (entrypoint === undefined) return false;
|
||||
if (entrypoint === modulePath) return true;
|
||||
try {
|
||||
return realpathSync(entrypoint) === realpathSync(modulePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCliEntrypoint()) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
|
||||
@@ -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,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 => ({}));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
+16
-6
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@@ -18,18 +18,18 @@ afterEach(async () => {
|
||||
|
||||
describe("PI WEB config persistence", () => {
|
||||
it("writes and reads the configured PI WEB config path", () => {
|
||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, testOptions());
|
||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions());
|
||||
|
||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } });
|
||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } });
|
||||
expect(loadPiWebConfig(testOptions())).toEqual(saved);
|
||||
});
|
||||
|
||||
it("preserves unrelated config keys while replacing managed keys", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, future: { enabled: true } }, null, 2)}\n`, "utf8");
|
||||
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old" }, future: { enabled: true } }, null, 2)}\n`, "utf8");
|
||||
|
||||
savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }, testOptions());
|
||||
savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } }, testOptions());
|
||||
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } });
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } });
|
||||
});
|
||||
|
||||
it("rejects invalid plugin config", async () => {
|
||||
@@ -48,6 +48,16 @@ describe("PI WEB config persistence", () => {
|
||||
savePiWebConfig({ maxUploadBytes: 1234 }, testOptions());
|
||||
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
||||
});
|
||||
|
||||
it("exposes the default upload folder in the effective config", () => {
|
||||
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
|
||||
});
|
||||
|
||||
it("rejects upload defaults that are not workspace-relative", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config uploads.defaultFolder must not contain path traversal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxUploadBytes", () => {
|
||||
|
||||
+33
-1
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import type { PiWebConfigValues } from "./shared/apiTypes.js";
|
||||
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
|
||||
|
||||
@@ -33,6 +33,12 @@ export function defaultPiWebDataDir(): string {
|
||||
*/
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
|
||||
|
||||
export function effectiveUploadsConfig(config: Pick<PiWebConfig, "uploads"> = {}): NonNullable<PiWebConfig["uploads"]> {
|
||||
return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER };
|
||||
}
|
||||
|
||||
export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number {
|
||||
const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"];
|
||||
if (fromEnv !== undefined && fromEnv !== "") {
|
||||
@@ -82,6 +88,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
|
||||
...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}),
|
||||
...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}),
|
||||
...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}),
|
||||
uploads: effectiveUploadsConfig(loaded.config),
|
||||
// Always resolved (on by default) so the effective config is the single
|
||||
// source of truth for the runtime state and the settings UI toggle.
|
||||
spawnSessions: spawnSessionsEnabled(env, loaded.config),
|
||||
@@ -102,6 +109,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
||||
delete existing["shortcuts"];
|
||||
delete existing["plugins"];
|
||||
delete existing["pathAccess"];
|
||||
delete existing["uploads"];
|
||||
delete existing["maxUploadBytes"];
|
||||
delete existing["spawnSessions"];
|
||||
delete existing["subsessions"];
|
||||
@@ -126,6 +134,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
|
||||
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
@@ -140,6 +149,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
||||
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
|
||||
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
|
||||
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
||||
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
|
||||
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
|
||||
@@ -225,6 +235,28 @@ function parseAllowedPaths(value: unknown, path: string): string[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseUploadsConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["uploads"]> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config uploads must be an object: ${path}`);
|
||||
const defaultFolder = value["defaultFolder"];
|
||||
return {
|
||||
...(defaultFolder !== undefined ? { defaultFolder: parseWorkspaceRelativeFolder(defaultFolder, "uploads.defaultFolder", path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`);
|
||||
if (isAbsoluteLike(value)) throw new Error(`PI WEB config ${key} must be workspace-relative: ${path}`);
|
||||
const parts = value.split(/[\\/]+/).filter((part) => part !== "" && part !== ".");
|
||||
if (parts.length === 0) throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`);
|
||||
if (parts.some((part) => part === "..")) throw new Error(`PI WEB config ${key} must not contain path traversal: ${path}`);
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function isAbsoluteLike(value: string): boolean {
|
||||
const withForwardSlashes = value.replace(/\\/g, "/");
|
||||
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
|
||||
}
|
||||
|
||||
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
|
||||
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
|
||||
|
||||
+29
-1
@@ -1,5 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js";
|
||||
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js";
|
||||
|
||||
export type {
|
||||
FileContentMediaType,
|
||||
@@ -20,6 +20,11 @@ export type {
|
||||
TerminalCommandRunFilter,
|
||||
TerminalCommandRunHandle,
|
||||
TerminalCommandRunStatus,
|
||||
WriteWorkspaceFileOptions,
|
||||
WriteWorkspaceFileResponse,
|
||||
DeleteWorkspaceFileResponse,
|
||||
MoveWorkspaceFileOptions,
|
||||
MoveWorkspaceFileResponse,
|
||||
} from "./shared/apiTypes.js";
|
||||
|
||||
export type PluginId = string;
|
||||
@@ -67,8 +72,20 @@ export interface PluginRuntimeState {
|
||||
piWebStatus?: PiWebStatusResponse;
|
||||
}
|
||||
|
||||
export interface PluginPromptEditor {
|
||||
/** Insert text at the current cursor position. Replaces any selection.
|
||||
* If the editor is not focused, focuses it first.
|
||||
* No-op if the editor is not mounted. */
|
||||
insertText(text: string): void;
|
||||
/** Get the current prompt text content. Returns "" if the editor is not mounted. */
|
||||
getText(): string;
|
||||
/** Get the current selection range, or null if no selection or editor not mounted. */
|
||||
getSelection(): { start: number; end: number; text: string } | null;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
state: PluginRuntimeState;
|
||||
prompt: PluginPromptEditor;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
@@ -109,7 +126,17 @@ export interface Workspace {
|
||||
}
|
||||
|
||||
export interface WorkspaceFiles {
|
||||
/** Read a file from the workspace. Works for local and federated machines. */
|
||||
readFile(path: string): Promise<FileContentResponse>;
|
||||
/** Write content to a workspace file. Creates intermediate directories by default.
|
||||
* Works for local and federated machines. Auto-refreshes the file explorer after success. */
|
||||
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
|
||||
/** Delete a file from the workspace. Idempotent — returns { existed: false } if file doesn't exist.
|
||||
* Deletes the entry itself (for symlinks, removes the symlink not the target). */
|
||||
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
|
||||
/** Move or rename a file within the workspace. Unix mv semantics.
|
||||
* Default overwrite: false (safer than writeFile). Auto-refreshes the file explorer after success. */
|
||||
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
|
||||
}
|
||||
|
||||
export type WorkspacePanelFiles = WorkspaceFiles;
|
||||
@@ -141,6 +168,7 @@ export interface WorkspacePanelTerminal {
|
||||
}
|
||||
|
||||
export interface WorkspacePanelContext extends WorkspaceContext {
|
||||
prompt: PluginPromptEditor;
|
||||
terminal: WorkspacePanelTerminal;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,33 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const remoteWorkspaces = [{
|
||||
id: "w1",
|
||||
projectId: "p1",
|
||||
path: "/repo",
|
||||
label: "main",
|
||||
isMain: true,
|
||||
isGitRepo: false,
|
||||
isGitWorktree: false,
|
||||
effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } },
|
||||
}];
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify(remoteWorkspaces)]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(remoteWorkspaces);
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined);
|
||||
});
|
||||
|
||||
it("preserves remote file preview security headers while proxying safe response metadata", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -181,6 +208,29 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote workspace file writes as raw request bodies", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`,
|
||||
payload,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true });
|
||||
expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" });
|
||||
});
|
||||
|
||||
it("proxies remote terminal command-run and continue routes", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -465,6 +515,47 @@ describe("buildApp", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes the default upload config on workspace responses", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Upload Defaults", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
|
||||
expect(workspacesResponse.statusCode).toBe(200);
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: project.id,
|
||||
effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets project-local upload config override global upload config on workspace responses", async () => {
|
||||
piWebConfig = { uploads: { defaultFolder: "global-uploads" } };
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Project Upload Defaults", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`);
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
|
||||
expect(workspacesResponse.statusCode).toBe(200);
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: project.id,
|
||||
effectiveConfig: { uploads: { defaultFolder: "project-uploads" } },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves supported workspace images as previews", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -557,6 +648,235 @@ describe("buildApp", () => {
|
||||
expect(deniedResponse.statusCode).toBe(400);
|
||||
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
|
||||
});
|
||||
|
||||
it("writes workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "WriteTest", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
const writeTextResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
|
||||
payload: "hello world",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(writeTextResponse.statusCode).toBe(200);
|
||||
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
|
||||
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
|
||||
|
||||
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
|
||||
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
|
||||
|
||||
const writeBinaryResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
|
||||
payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
expect(writeBinaryResponse.statusCode).toBe(200);
|
||||
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
|
||||
|
||||
const writeDeepResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
|
||||
payload: "deep content",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(writeDeepResponse.statusCode).toBe(200);
|
||||
|
||||
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
|
||||
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
|
||||
|
||||
const overwriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
|
||||
payload: "updated",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
|
||||
|
||||
const noOverwriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
|
||||
payload: "should fail",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noOverwriteResponse.statusCode).toBe(400);
|
||||
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
|
||||
|
||||
const traversalResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
|
||||
payload: "evil",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(traversalResponse.statusCode).toBe(400);
|
||||
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
|
||||
|
||||
const noPathResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
payload: "no path",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
|
||||
|
||||
const noDirsResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
|
||||
payload: "should fail",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noDirsResponse.statusCode).toBe(400);
|
||||
|
||||
await mkdir(join(projectDir, "subdir"), { recursive: true });
|
||||
const dirWriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`,
|
||||
payload: "should fail",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(dirWriteResponse.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("deletes workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "DeleteTest", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
|
||||
payload: "delete me",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
|
||||
});
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
|
||||
|
||||
const deleteMissingResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
|
||||
});
|
||||
expect(deleteMissingResponse.statusCode).toBe(200);
|
||||
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
|
||||
|
||||
const traversalResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
|
||||
});
|
||||
expect(traversalResponse.statusCode).toBe(400);
|
||||
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
|
||||
|
||||
const noPathResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
|
||||
});
|
||||
|
||||
it("moves workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "MoveTest", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
|
||||
payload: "move me",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const moveResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
|
||||
});
|
||||
expect(moveResponse.statusCode).toBe(200);
|
||||
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
|
||||
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
|
||||
|
||||
const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
|
||||
expect(readSourceResponse.statusCode).toBe(400);
|
||||
|
||||
const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
|
||||
expect(readTargetResponse.statusCode).toBe(200);
|
||||
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
|
||||
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
|
||||
payload: "source",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`,
|
||||
payload: "target",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const overwriteResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
|
||||
});
|
||||
expect(overwriteResponse.statusCode).toBe(200);
|
||||
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
|
||||
payload: "s",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`,
|
||||
payload: "t",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const noOverwriteResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
|
||||
});
|
||||
expect(noOverwriteResponse.statusCode).toBe(400);
|
||||
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
|
||||
|
||||
const traversalFromResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
|
||||
});
|
||||
expect(traversalFromResponse.statusCode).toBe(400);
|
||||
|
||||
const noParamsResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
|
||||
});
|
||||
expect(noParamsResponse.statusCode).toBe(400);
|
||||
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
|
||||
});
|
||||
});
|
||||
|
||||
interface CapturedSessionDaemonRequest {
|
||||
|
||||
+23
-4
@@ -9,6 +9,7 @@ import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js";
|
||||
import { loadEffectiveProjectUploadsConfig } from "./workspaces/projectPiWebConfig.js";
|
||||
import { normalizeRequestCwd } from "./workingDirectory.js";
|
||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
@@ -25,6 +26,7 @@ import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
export interface AppDependencies {
|
||||
projects?: ProjectService;
|
||||
@@ -39,7 +41,11 @@ export interface AppDependencies {
|
||||
bodyLimit?: number;
|
||||
}
|
||||
|
||||
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
|
||||
interface LocalProjectRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalProjectRouteOptions = {}): void {
|
||||
app.get(`${prefix}/projects`, async () => projects.list());
|
||||
|
||||
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
|
||||
@@ -70,13 +76,26 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
|
||||
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
|
||||
try {
|
||||
const project = await projects.requireProject(request.params.projectId);
|
||||
return await workspaces.list(project);
|
||||
return await listWorkspacesWithEffectiveConfig(project, workspaces, options.config);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listWorkspacesWithEffectiveConfig(project: Project, workspaces: WorkspaceService, config?: Pick<PiWebConfigService, "read">): Promise<Workspace[]> {
|
||||
const [workspaceList, effectiveConfig] = await Promise.all([
|
||||
workspaces.list(project),
|
||||
workspaceEffectiveConfig(project.path, config),
|
||||
]);
|
||||
return workspaceList.map((workspace) => ({ ...workspace, effectiveConfig }));
|
||||
}
|
||||
|
||||
async function workspaceEffectiveConfig(projectPath: string, config?: Pick<PiWebConfigService, "read">): Promise<NonNullable<Workspace["effectiveConfig"]>> {
|
||||
const globalConfig = config === undefined ? {} : (await config.read()).effectiveConfig;
|
||||
return { uploads: await loadEffectiveProjectUploadsConfig(projectPath, globalConfig) };
|
||||
}
|
||||
|
||||
interface LocalFileSuggestionRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
@@ -131,8 +150,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api");
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api", { config: configService });
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
|
||||
|
||||
registerSessionProxyRoutes(app, sessionDaemon);
|
||||
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("config routes", () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } },
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 });
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,18 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid upload defaults before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { uploads: { defaultFolder: "/tmp" } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||
|
||||
@@ -59,6 +59,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
const shortcuts = value["shortcuts"];
|
||||
const plugins = value["plugins"];
|
||||
const pathAccess = value["pathAccess"];
|
||||
const uploads = value["uploads"];
|
||||
const maxUploadBytes = value["maxUploadBytes"];
|
||||
const spawnSessions = value["spawnSessions"];
|
||||
const subsessions = value["subsessions"];
|
||||
@@ -74,6 +75,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
|
||||
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
|
||||
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
|
||||
if (uploads !== undefined) config.uploads = parseUploadsConfig(uploads, "request");
|
||||
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
|
||||
if (spawnSessions !== undefined) {
|
||||
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RemoteMachineClient } from "./machineClient.js";
|
||||
|
||||
describe("RemoteMachineClient", () => {
|
||||
it("forwards raw binary request bodies with the provided content type", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
|
||||
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl);
|
||||
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
await client.request("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "image/png" });
|
||||
|
||||
const { input, init } = onlyFetchCall(fetchImpl);
|
||||
expect(fetchInputUrl(input)).toBe("https://remote.example.test/api/projects/p1/workspaces/w1/file?path=image.png");
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(new Headers(init.headers).get("content-type")).toBe("image/png");
|
||||
if (!(init.body instanceof ArrayBuffer)) throw new Error("Expected binary request body");
|
||||
expect(Array.from(new Uint8Array(init.body))).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
it("serializes structured request bodies as JSON by default", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
|
||||
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/base/", token: "secret" }, fetchImpl);
|
||||
|
||||
await client.request("POST", "/api/sessions", { cwd: "/repo" });
|
||||
|
||||
const { input, init } = onlyFetchCall(fetchImpl);
|
||||
expect(fetchInputUrl(input)).toBe("https://remote.example.test/base/api/sessions");
|
||||
expect(new Headers(init.headers).get("authorization")).toBe("Bearer secret");
|
||||
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
|
||||
expect(init.body).toBe(JSON.stringify({ cwd: "/repo" }));
|
||||
});
|
||||
});
|
||||
|
||||
function fetchInputUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function onlyFetchCall(fetchImpl: ReturnType<typeof vi.fn<typeof fetch>>): { input: RequestInfo | URL; init: RequestInit } {
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const call = fetchImpl.mock.calls[0];
|
||||
if (call === undefined) throw new Error("Expected fetch call");
|
||||
const [input, init] = call;
|
||||
if (init === undefined) throw new Error("Expected fetch init");
|
||||
return { input, init };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface MachineJsonResponse {
|
||||
|
||||
export interface MachineRequestOptions {
|
||||
timeoutMs?: number;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface MachineClient {
|
||||
@@ -82,13 +83,14 @@ export class RemoteMachineClient implements MachineClient {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const requestBody = serializeRequestBody(method, body);
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: this.requestHeaders(body),
|
||||
headers: this.requestHeaders(body, options),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
};
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body);
|
||||
if (requestBody !== undefined) init.body = requestBody;
|
||||
return await this.fetchImpl(this.remoteUrl(path), init);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504);
|
||||
@@ -98,11 +100,11 @@ export class RemoteMachineClient implements MachineClient {
|
||||
}
|
||||
}
|
||||
|
||||
private requestHeaders(body: unknown): HeadersInit {
|
||||
private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit {
|
||||
return {
|
||||
...this.remoteHeaders(),
|
||||
accept: "*/*",
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +149,34 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
function serializeRequestBody(method: string, body: unknown): NonNullable<RequestInit["body"]> | undefined {
|
||||
if (body === undefined || method === "GET" || method === "HEAD") return undefined;
|
||||
if (isRawRequestBody(body)) return body;
|
||||
if (ArrayBuffer.isView(body)) return copyArrayBufferView(body);
|
||||
const serialized: string = JSON.stringify(body);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
function defaultContentTypeForBody(body: unknown): string {
|
||||
return isRawRequestBody(body) || ArrayBuffer.isView(body) ? "application/octet-stream" : "application/json";
|
||||
}
|
||||
|
||||
function isRawRequestBody(body: unknown): body is NonNullable<RequestInit["body"]> {
|
||||
return typeof body === "string"
|
||||
|| body instanceof URLSearchParams
|
||||
|| body instanceof Blob
|
||||
|| body instanceof FormData
|
||||
|| body instanceof ReadableStream
|
||||
|| body instanceof ArrayBuffer;
|
||||
}
|
||||
|
||||
function copyArrayBufferView(view: ArrayBufferView): ArrayBuffer {
|
||||
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream {
|
||||
if (body === null) throw new Error("Response body is not readable");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config.
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError } from "./machineClient.js";
|
||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
|
||||
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||
method: spec.method,
|
||||
url: `/api/machines/:machineId${spec.path}`,
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply),
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (machineId === "local") {
|
||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
||||
}
|
||||
@@ -45,7 +45,10 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body);
|
||||
const requestOptions = proxyRequestOptions(body, contentType);
|
||||
const upstream = requestOptions === undefined
|
||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) return await reply.send();
|
||||
@@ -81,6 +84,20 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
|
||||
return `/api${compatPath}`;
|
||||
}
|
||||
|
||||
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
if (!isRawProxyBody(body)) return undefined;
|
||||
const value = firstHeaderValue(contentType);
|
||||
return value === undefined || value === "" ? undefined : { contentType: value };
|
||||
}
|
||||
|
||||
function isRawProxyBody(body: unknown): boolean {
|
||||
return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body);
|
||||
}
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
|
||||
let workspace: string;
|
||||
let externalDirectories: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
|
||||
externalDirectories = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
rm(workspace, { recursive: true, force: true }),
|
||||
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
]);
|
||||
});
|
||||
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(written.equals(pngBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves generic files with sanitized original filenames", async () => {
|
||||
const pdfBytes = Buffer.from("PDF bytes");
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[
|
||||
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
|
||||
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
|
||||
],
|
||||
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
|
||||
);
|
||||
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
|
||||
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
|
||||
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
|
||||
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
|
||||
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
|
||||
|
||||
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
|
||||
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not overwrite an existing attachment name", async () => {
|
||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||
const first = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
const second = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
|
||||
expect(second[0]?.path).not.toBe(first[0]?.path);
|
||||
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
|
||||
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
|
||||
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
|
||||
});
|
||||
|
||||
it("rejects unsafe custom folders", async () => {
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "/tmp/uploads" },
|
||||
)).rejects.toThrow(/Absolute paths/);
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "../uploads" },
|
||||
)).rejects.toThrow(/Path traversal/);
|
||||
});
|
||||
|
||||
it("honors a custom folder", async () => {
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects attachment folders that resolve outside the workspace", async () => {
|
||||
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
|
||||
externalDirectories.push(outside);
|
||||
await mkdir(join(workspace, ".pi-web"));
|
||||
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
|
||||
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
)).rejects.toThrow(/Path escapes workspace/);
|
||||
await expect(readdir(outside)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for no attachments", async () => {
|
||||
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
|
||||
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
|
||||
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
|
||||
/**
|
||||
* Default workspace-relative folder used when saving pasted/dropped
|
||||
@@ -26,7 +26,7 @@ export interface InlineImage {
|
||||
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
|
||||
* are dropped, matching pi's `[Image omitted]` behaviour.
|
||||
*/
|
||||
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> {
|
||||
export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
|
||||
const results: InlineImage[] = [];
|
||||
for (const attachment of attachments) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
|
||||
attachments: PromptAttachment[],
|
||||
options: SaveAttachmentsOptions = {},
|
||||
): Promise<SavedPromptAttachment[]> {
|
||||
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
|
||||
const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(folderTarget, { recursive: true });
|
||||
const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(requestedFolderTarget, { recursive: true });
|
||||
const folderTarget = await realpath(requestedFolderTarget);
|
||||
ensureInside(root, folderTarget);
|
||||
|
||||
const stamp = timestamp(now());
|
||||
const saved: SavedPromptAttachment[] = [];
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const relativePath = `${folder}/${filename}`;
|
||||
await writeFile(join(folderTarget, filename), bytes);
|
||||
const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
|
||||
const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
|
||||
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function normalizeFolder(folder: string): string {
|
||||
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
|
||||
async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
|
||||
try {
|
||||
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
|
||||
return candidate;
|
||||
} catch (error: unknown) {
|
||||
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to choose a unique attachment filename");
|
||||
}
|
||||
|
||||
function addCollisionSuffix(filename: string, suffix: number): string {
|
||||
const extension = extname(filename);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem}-${String(suffix)}${extension}`;
|
||||
}
|
||||
|
||||
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
|
||||
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
|
||||
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
|
||||
}
|
||||
|
||||
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
|
||||
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
return "file.bin";
|
||||
}
|
||||
|
||||
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
|
||||
|
||||
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
|
||||
const trimmed = name?.trim();
|
||||
if (trimmed === undefined || trimmed === "") return undefined;
|
||||
const leaf = basename(trimmed.replace(/\\/g, "/"));
|
||||
const sanitized = stripControlCharacters(leaf)
|
||||
.normalize("NFKC")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/-+\./g, ".")
|
||||
.replace(/^\.+/, "")
|
||||
.replace(/[.-]+$/, "");
|
||||
if (sanitized === "") return undefined;
|
||||
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
|
||||
}
|
||||
|
||||
function stripControlCharacters(value: string): string {
|
||||
return Array.from(value).filter((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function truncateFilename(filename: string, maxLength: number): string {
|
||||
if (filename.length <= maxLength) return filename;
|
||||
const extension = extname(filename);
|
||||
if (extension.length >= maxLength) return filename.slice(0, maxLength);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
|
||||
}
|
||||
|
||||
function timestamp(date: Date): string {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getProviders } from "@earendil-works/pi-ai";
|
||||
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
|
||||
|
||||
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
|
||||
const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders());
|
||||
|
||||
export interface AuthProviderModelRegistry {
|
||||
authStorage: {
|
||||
@@ -54,11 +52,10 @@ export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistr
|
||||
return filterAndSort(options);
|
||||
}
|
||||
|
||||
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>, builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS): boolean {
|
||||
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>): boolean {
|
||||
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
|
||||
if (providerId === "anthropic") return true;
|
||||
if (oauthProviderIds.has(providerId)) return false;
|
||||
if (builtInProviderIds.has(providerId)) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
@@ -32,11 +35,12 @@ interface TestSession extends PiAgentSession {
|
||||
getFollowUpMessages: () => readonly string[];
|
||||
}
|
||||
|
||||
function fakeSessionManager(cwd = "/workspace"): PiSessionManager {
|
||||
function fakeSessionManager(cwd = "/workspace", patch: Partial<PiSessionManager> = {}): PiSessionManager {
|
||||
return {
|
||||
getCwd: () => cwd,
|
||||
getBranch: () => [],
|
||||
getLeafId: () => "leaf-1",
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +145,16 @@ function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGat
|
||||
};
|
||||
}
|
||||
|
||||
function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveStore"]> {
|
||||
return {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: () => Promise.reject(new Error("archive should not be called")),
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
it("starts sessions through an injected runtime creator", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
@@ -887,6 +901,668 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("persists tracked child links in the parent and child sessions", async () => {
|
||||
const parentPersisted: { customType: string; data?: unknown }[] = [];
|
||||
const childPersisted: { customType: string; data?: unknown }[] = [];
|
||||
const parent = fakeRuntime("parent-1", {
|
||||
sessionFile: "/tmp/parent-1.jsonl",
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
appendCustomEntry: (customType, data) => {
|
||||
parentPersisted.push({ customType, data });
|
||||
return "parent-entry-1";
|
||||
},
|
||||
}),
|
||||
});
|
||||
const child = fakeRuntime("child-1", {
|
||||
sessionFile: "/tmp/child-1.jsonl",
|
||||
sessionManager: fakeSessionManager("/workspace-feature", {
|
||||
appendCustomEntry: (customType, data) => {
|
||||
childPersisted.push({ customType, data });
|
||||
return "child-entry-1";
|
||||
},
|
||||
}),
|
||||
});
|
||||
const runtimes = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore: emptyArchiveStore(),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
|
||||
|
||||
expect(parentPersisted).toEqual([
|
||||
{
|
||||
customType: "pi-web.subsession.link",
|
||||
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" },
|
||||
},
|
||||
]);
|
||||
expect(childPersisted).toEqual([
|
||||
{
|
||||
customType: "pi-web.subsession.spawned",
|
||||
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" },
|
||||
},
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("hydrates persisted child links after a service restart so the parent can inspect them", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }],
|
||||
});
|
||||
const parent = fakeRuntime("parent-1", {
|
||||
sessionFile: parentFile,
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
|
||||
}),
|
||||
});
|
||||
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const runtimes = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const open = vi.fn(() => childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({
|
||||
sessionId: "child-1",
|
||||
cwd: "/workspace-feature",
|
||||
status: "idle",
|
||||
finalText: "finished",
|
||||
messageCount: 1,
|
||||
});
|
||||
expect(open).toHaveBeenCalledWith(childFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores stale persisted child links when the child no longer records the parent", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const parent = fakeRuntime("parent-1", {
|
||||
sessionFile: parentFile,
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
|
||||
}),
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hydrate persisted links when the exact child file is unavailable", async () => {
|
||||
const parentFile = "/sessions/parent-1.jsonl";
|
||||
const parent = fakeRuntime("parent-1", {
|
||||
sessionFile: parentFile,
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }],
|
||||
}),
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("does not hydrate parent links without a child file", async () => {
|
||||
const parentFile = "/sessions/parent-1.jsonl";
|
||||
const parent = fakeRuntime("parent-1", {
|
||||
sessionFile: parentFile,
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }],
|
||||
}),
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("does not invent subsession links from existing child session headers", async () => {
|
||||
const parentFile = "/sessions/parent-1.jsonl";
|
||||
const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile };
|
||||
const parent = fakeRuntime("parent-1", {
|
||||
sessionFile: parentFile,
|
||||
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("does not hydrate copied parent links when the opened parent has a different id", async () => {
|
||||
const forkedParent = fakeRuntime("parent-fork-1", {
|
||||
sessionFile: "/sessions/parent-fork-1.jsonl",
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }],
|
||||
}),
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(forkedParent.runtime),
|
||||
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("relinks a spawned child when the child session is opened after restart", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getHeader: () => ({ parentSession: parentFile }),
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
});
|
||||
const parentManager = fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
|
||||
});
|
||||
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
|
||||
const runtimes = [child.runtime, parent.runtime];
|
||||
let index = 0;
|
||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => childManager,
|
||||
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(1);
|
||||
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
|
||||
expect(open).toHaveBeenCalledWith(parentFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies the validated parent file instead of an active prefix-matched parent id", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const forkParentFile = join(tempDir, "parent-fork.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getHeader: () => ({ parentSession: parentFile }),
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
});
|
||||
const parentManager = fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
|
||||
});
|
||||
const forkManager = fakeSessionManager("/workspace");
|
||||
const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager });
|
||||
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
|
||||
const runtimes = [fork.runtime, child.runtime, parent.runtime];
|
||||
let index = 0;
|
||||
const open = vi.fn((path: string) => {
|
||||
if (path === parentFile) return parentManager;
|
||||
if (path === forkParentFile) return forkManager;
|
||||
return childManager;
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => forkManager,
|
||||
list: (cwd: string) => Promise.resolve(cwd === "/workspace"
|
||||
? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }]
|
||||
: [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("parent-1-fork", "/workspace"));
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(fork.calls.sendCustomMessage).toHaveLength(0);
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(1);
|
||||
expect(open).toHaveBeenCalledWith(parentFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const originalChildFile = join(tempDir, "original-child.jsonl");
|
||||
const copiedChildFile = join(tempDir, "copied-child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getHeader: () => ({ parentSession: parentFile }),
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
});
|
||||
const parentManager = fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }],
|
||||
});
|
||||
const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager });
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
|
||||
const runtimes = [child.runtime, parent.runtime];
|
||||
let index = 0;
|
||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => childManager,
|
||||
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(0);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the verified child file instead of an active copied child with the same id", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const originalChildFile = join(tempDir, "original-child.jsonl");
|
||||
const copiedChildFile = join(tempDir, "copied-child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const copiedManager = fakeSessionManager("/workspace-feature", {
|
||||
getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }],
|
||||
});
|
||||
const originalManager = fakeSessionManager("/workspace-feature", {
|
||||
getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }],
|
||||
});
|
||||
const parentManager = fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }],
|
||||
});
|
||||
const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true });
|
||||
const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager });
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
|
||||
const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => {
|
||||
if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime);
|
||||
if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime);
|
||||
if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime);
|
||||
throw new Error("unexpected session manager");
|
||||
};
|
||||
const open = vi.fn((path: string) => {
|
||||
if (path === copiedChildFile) return copiedManager;
|
||||
if (path === originalChildFile) return originalManager;
|
||||
if (path === parentFile) return parentManager;
|
||||
throw new Error(`unexpected open path ${path}`);
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: {
|
||||
create: () => parentManager,
|
||||
list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([
|
||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
|
||||
]);
|
||||
|
||||
copiedChild.session.isStreaming = true;
|
||||
copiedChild.emit({ type: "agent_start" });
|
||||
copiedChild.session.isStreaming = false;
|
||||
copiedChild.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(0);
|
||||
|
||||
await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({
|
||||
sessionId: "child-1",
|
||||
cwd: "/workspace-feature",
|
||||
status: "idle",
|
||||
finalText: "original child result",
|
||||
messageCount: 1,
|
||||
});
|
||||
const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile);
|
||||
expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" });
|
||||
expect(open).toHaveBeenCalledWith(originalChildFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the verified parent file instead of an active copied parent with the same id", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const copiedParentFile = join(tempDir, "copied-parent.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }],
|
||||
});
|
||||
const parentManager = fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
|
||||
});
|
||||
const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] });
|
||||
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
|
||||
const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager });
|
||||
const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => {
|
||||
if (options.sessionManager === childManager) return Promise.resolve(child.runtime);
|
||||
if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime);
|
||||
if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime);
|
||||
throw new Error("unexpected session manager");
|
||||
};
|
||||
const open = vi.fn((path: string) => {
|
||||
if (path === childFile) return childManager;
|
||||
if (path === parentFile) return parentManager;
|
||||
if (path === copiedParentFile) return copiedParentManager;
|
||||
throw new Error(`unexpected open path ${path}`);
|
||||
});
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: {
|
||||
create: () => copiedParentManager,
|
||||
list: (cwd: string) => Promise.resolve(cwd === "/workspace"
|
||||
? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }]
|
||||
: [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
await service.status(sessionRef("parent-1", "/workspace"));
|
||||
|
||||
await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]);
|
||||
await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions");
|
||||
await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions");
|
||||
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(copiedParent.calls.sendCustomMessage).toHaveLength(0);
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(1);
|
||||
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
|
||||
expect(open).toHaveBeenCalledWith(parentFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not relink a child marker when the current child file header no longer records the parent", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-"));
|
||||
const parentFile = join(tempDir, "parent.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getHeader: () => ({ parentSession: parentFile }),
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
});
|
||||
const parentManager = fakeSessionManager("/workspace", {
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
|
||||
});
|
||||
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
|
||||
const runtimes = [child.runtime, parent.runtime];
|
||||
let index = 0;
|
||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => childManager,
|
||||
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: {
|
||||
...emptyArchiveStore(),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(0);
|
||||
expect(open).not.toHaveBeenCalledWith(parentFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not relink a child marker when the child header points at a different parent id", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-"));
|
||||
const mismatchedParentFile = join(tempDir, "other-parent.jsonl");
|
||||
const actualParentFile = join(tempDir, "parent.jsonl");
|
||||
const childFile = join(tempDir, "child.jsonl");
|
||||
await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
|
||||
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8");
|
||||
|
||||
try {
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getHeader: () => ({ parentSession: mismatchedParentFile }),
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
});
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") });
|
||||
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const runtimes = [child.runtime, parent.runtime];
|
||||
let index = 0;
|
||||
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
const runtime = runtimes[index] ?? parent.runtime;
|
||||
index += 1;
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => childManager,
|
||||
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]),
|
||||
listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-1", "/workspace-feature"));
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(0);
|
||||
expect(open).not.toHaveBeenCalledWith(actualParentFile);
|
||||
await service.dispose();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not relink copied child markers when the opened child has a different id", async () => {
|
||||
const parentFile = "/sessions/parent-1.jsonl";
|
||||
const childFile = "/sessions/child-fork-1.jsonl";
|
||||
const childManager = fakeSessionManager("/workspace-feature", {
|
||||
getHeader: () => ({ parentSession: parentFile }),
|
||||
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
|
||||
});
|
||||
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
|
||||
const open = vi.fn(() => childManager);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(child.runtime),
|
||||
sessionManager: {
|
||||
create: () => childManager,
|
||||
list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
|
||||
listAll: () => Promise.resolve([]),
|
||||
open,
|
||||
},
|
||||
archiveStore: emptyArchiveStore(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("child-fork-1", "/workspace-feature"));
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(open).not.toHaveBeenCalledWith(parentFile);
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("notifies the parent once when the tracked child stops working", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace");
|
||||
@@ -947,7 +1623,7 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports an archived child's status in the subsession list", async () => {
|
||||
it("reports a missing tracked child file as unknown in the subsession list", async () => {
|
||||
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||
@@ -955,7 +1631,7 @@ describe("PiSessionService", () => {
|
||||
await service.archive("child-1");
|
||||
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
|
||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" },
|
||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
AuthStorage,
|
||||
@@ -81,6 +81,26 @@ interface QueuedPrompt {
|
||||
echoUserMessage?: boolean;
|
||||
}
|
||||
|
||||
interface TrackedSubsessionLink {
|
||||
parentSessionId: string;
|
||||
childSessionId: string;
|
||||
childSessionFile?: string;
|
||||
parentSessionFile?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface PersistedParentSubsessionLink {
|
||||
spawnedBySessionId: string;
|
||||
spawnedSessionId: string;
|
||||
spawnedSessionFile?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface PersistedChildSubsessionLink {
|
||||
spawnedBySessionId: string;
|
||||
spawnedSessionId: string;
|
||||
}
|
||||
|
||||
function requirePromptText(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Prompt text is required");
|
||||
return value;
|
||||
@@ -123,8 +143,10 @@ type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||
export interface PiSessionManager {
|
||||
getCwd(): string;
|
||||
getBranch(): unknown[];
|
||||
getEntries?(): readonly unknown[];
|
||||
getLeafId(): string | null;
|
||||
getHeader?(): { parentSession?: string } | null | undefined;
|
||||
appendCustomEntry?(customType: string, data?: unknown): string;
|
||||
}
|
||||
|
||||
export interface PiSessionManagerGateway {
|
||||
@@ -290,6 +312,10 @@ export class PiSessionService {
|
||||
private readonly subsessionParents = new Map<string, string>();
|
||||
/** Parent session id -> the set of tracked subsession ids it spawned. */
|
||||
private readonly subsessionChildren = new Map<string, Set<string>>();
|
||||
/** Tracked subsession id -> persisted recovery details for the child. */
|
||||
private readonly subsessionLinks = new Map<string, TrackedSubsessionLink>();
|
||||
/** Parent id/file identities whose persisted links have already been loaded. */
|
||||
private readonly subsessionHydratedParents = new Set<string>();
|
||||
/**
|
||||
* Tracked subsession id -> whether a completion notification is armed.
|
||||
* Armed when the child starts working; firing on completion disarms it so a
|
||||
@@ -322,9 +348,9 @@ export class PiSessionService {
|
||||
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
|
||||
!subsessionsActive ? undefined : {
|
||||
spawn: (input) => this.spawnSubsession(input),
|
||||
list: (parentSessionId) => this.listSubsessions(parentSessionId),
|
||||
check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
|
||||
read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
|
||||
list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
|
||||
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
||||
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
||||
},
|
||||
);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
@@ -362,6 +388,8 @@ export class PiSessionService {
|
||||
this.authLossWarnings.clear();
|
||||
this.subsessionParents.clear();
|
||||
this.subsessionChildren.clear();
|
||||
this.subsessionLinks.clear();
|
||||
this.subsessionHydratedParents.clear();
|
||||
this.subsessionNotifyArmed.clear();
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
@@ -439,7 +467,17 @@ export class PiSessionService {
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd, input.parentSessionFile);
|
||||
this.registerSubsession(input.parentSessionId, created.id);
|
||||
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
||||
const link: TrackedSubsessionLink = {
|
||||
parentSessionId: input.parentSessionId,
|
||||
childSessionId: created.id,
|
||||
...(created.path === "" ? {} : { childSessionFile: created.path }),
|
||||
...(parentSessionFile === undefined ? {} : { parentSessionFile }),
|
||||
cwd: decision.cwd,
|
||||
};
|
||||
this.registerVerifiedSubsession(link);
|
||||
this.persistSubsessionLink(link);
|
||||
this.persistSubsessionChildMarker(input.parentSessionId, created.id);
|
||||
await this.prompt(created.id, input.prompt);
|
||||
this.logger.info(
|
||||
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
@@ -449,65 +487,272 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
||||
async listSubsessions(parentSessionId: string): Promise<SubsessionSummary[]> {
|
||||
async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]> {
|
||||
const parentFile = nonEmptyString(parentSessionFile);
|
||||
await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
|
||||
const childIds = this.subsessionChildren.get(parentSessionId);
|
||||
if (childIds === undefined) return [];
|
||||
return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
|
||||
const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
|
||||
return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
|
||||
}
|
||||
|
||||
/** Status and final result of a subsession, scoped to the caller's children. */
|
||||
async checkSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult> {
|
||||
const session = await this.openSubsession(parentSessionId, sessionId);
|
||||
async checkSubsession(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise<SubsessionCheckResult> {
|
||||
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
||||
const messages = historyMessages(session);
|
||||
return {
|
||||
sessionId,
|
||||
cwd: session.sessionManager.getCwd(),
|
||||
status: await this.subsessionStatus(session),
|
||||
status: this.subsessionStatus(session),
|
||||
finalText: finalAssistantText(messages),
|
||||
messageCount: messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
|
||||
async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult> {
|
||||
const session = await this.openSubsession(parentSessionId, sessionId);
|
||||
async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery, parentSessionFile?: string): Promise<SubsessionReadResult> {
|
||||
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
||||
const view = buildTranscriptView(historyMessages(session), query);
|
||||
return {
|
||||
sessionId,
|
||||
cwd: session.sessionManager.getCwd(),
|
||||
status: await this.subsessionStatus(session),
|
||||
status: this.subsessionStatus(session),
|
||||
...view,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open a session after verifying it is one of the caller's tracked children. */
|
||||
private async openSubsession(parentSessionId: string, sessionId: string): Promise<PiAgentSession> {
|
||||
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
|
||||
private async openSubsession(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise<PiAgentSession> {
|
||||
const parentFile = nonEmptyString(parentSessionFile);
|
||||
await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
|
||||
if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
|
||||
throw new Error(`Session ${sessionId} is not one of your subsessions`);
|
||||
}
|
||||
return this.getOrOpen(sessionId);
|
||||
return this.getOrOpenTrackedSubsession(sessionId);
|
||||
}
|
||||
|
||||
private registerSubsession(parentSessionId: string, childSessionId: string): void {
|
||||
private subsessionLinkBelongsToParent(parentSessionId: string, parentSessionFile: string | undefined, childSessionId: string): boolean {
|
||||
const link = this.subsessionLinks.get(childSessionId);
|
||||
if (link?.parentSessionId !== parentSessionId) return false;
|
||||
return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
|
||||
}
|
||||
|
||||
private activeChildForSubsessionLink(link: TrackedSubsessionLink): ActiveSession<PiSessionRuntime> | undefined {
|
||||
const active = this.active.get(link.childSessionId);
|
||||
if (active === undefined) return undefined;
|
||||
return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
|
||||
}
|
||||
|
||||
private activeParentForSubsessionLink(link: TrackedSubsessionLink): ActiveSession<PiSessionRuntime> | undefined {
|
||||
const active = this.active.get(link.parentSessionId);
|
||||
if (active === undefined) return undefined;
|
||||
return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
|
||||
}
|
||||
|
||||
private subsessionLinkForActiveChild(session: PiAgentSession): TrackedSubsessionLink | undefined {
|
||||
const childId = session.sessionId;
|
||||
const parentId = this.subsessionParents.get(childId);
|
||||
const link = this.subsessionLinks.get(childId);
|
||||
if (parentId === undefined || link?.parentSessionId !== parentId) return undefined;
|
||||
return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
|
||||
}
|
||||
|
||||
private registerVerifiedSubsession(link: TrackedSubsessionLink): void {
|
||||
const { childSessionId, parentSessionId } = link;
|
||||
const previousParentId = this.subsessionParents.get(childSessionId);
|
||||
if (previousParentId !== undefined && previousParentId !== parentSessionId) {
|
||||
const previousChildren = this.subsessionChildren.get(previousParentId);
|
||||
previousChildren?.delete(childSessionId);
|
||||
if (previousChildren?.size === 0) this.subsessionChildren.delete(previousParentId);
|
||||
}
|
||||
|
||||
this.subsessionParents.set(childSessionId, parentSessionId);
|
||||
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
|
||||
children.add(childSessionId);
|
||||
this.subsessionChildren.set(parentSessionId, children);
|
||||
this.subsessionNotifyArmed.set(childSessionId, false);
|
||||
|
||||
this.subsessionLinks.set(childSessionId, link);
|
||||
if (!this.subsessionNotifyArmed.has(childSessionId)) this.subsessionNotifyArmed.set(childSessionId, false);
|
||||
}
|
||||
|
||||
private unregisterSubsession(childSessionId: string): void {
|
||||
const parentSessionId = this.subsessionParents.get(childSessionId);
|
||||
this.subsessionParents.delete(childSessionId);
|
||||
this.subsessionLinks.delete(childSessionId);
|
||||
this.subsessionNotifyArmed.delete(childSessionId);
|
||||
if (parentSessionId === undefined) return;
|
||||
const children = this.subsessionChildren.get(parentSessionId);
|
||||
children?.delete(childSessionId);
|
||||
if (children?.size === 0) this.subsessionChildren.delete(parentSessionId);
|
||||
}
|
||||
|
||||
private persistSubsessionLink(link: TrackedSubsessionLink): void {
|
||||
const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
|
||||
if (parent === undefined) return;
|
||||
if (parent.sessionManager.appendCustomEntry === undefined) return;
|
||||
try {
|
||||
parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to persist subsession link",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private persistSubsessionChildMarker(parentSessionId: string, childSessionId: string): void {
|
||||
const child = this.active.get(childSessionId)?.runtime.session;
|
||||
if (child === undefined) return;
|
||||
if (child.sessionManager.appendCustomEntry === undefined) return;
|
||||
try {
|
||||
child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to persist subsession child marker",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateSubsessionsForParent(parentSessionId: string, parentSessionFile?: string): Promise<void> {
|
||||
const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
|
||||
if (this.subsessionHydratedParents.has(hydrationKey)) return;
|
||||
|
||||
const activeParent = this.active.get(parentSessionId);
|
||||
if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
|
||||
const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
|
||||
await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
|
||||
this.subsessionHydratedParents.add(hydrationKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentSessionFile === undefined) return;
|
||||
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
|
||||
this.subsessionHydratedParents.add(hydrationKey);
|
||||
return;
|
||||
}
|
||||
|
||||
let parentManager: PiSessionManager;
|
||||
try {
|
||||
parentManager = this.sessionManager.open(parentSessionFile);
|
||||
} catch {
|
||||
this.subsessionHydratedParents.add(hydrationKey);
|
||||
return;
|
||||
}
|
||||
await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
|
||||
this.subsessionHydratedParents.add(hydrationKey);
|
||||
}
|
||||
|
||||
private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise<void> {
|
||||
// Parent custom links are the authoritative recovery record: verify the
|
||||
// exact live child file/header before tracking.
|
||||
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
|
||||
for (const entry of entries) {
|
||||
const link = parsePersistedParentSubsessionLink(entry);
|
||||
if (link === undefined) continue;
|
||||
const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
|
||||
if (verified === undefined) continue;
|
||||
this.registerVerifiedSubsession(verified);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifiedSubsessionLinkFromParentLink(parentSessionId: string, parentSessionFile: string | undefined, link: PersistedParentSubsessionLink): Promise<TrackedSubsessionLink | undefined> {
|
||||
if (parentSessionFile === undefined) return undefined;
|
||||
if (link.spawnedBySessionId !== parentSessionId) return undefined;
|
||||
if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link))) return undefined;
|
||||
return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
|
||||
}
|
||||
|
||||
private async parentLinkHasValidChildTarget(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise<boolean> {
|
||||
return link.spawnedSessionFile !== undefined
|
||||
&& await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
|
||||
}
|
||||
|
||||
private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise<void> {
|
||||
const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
|
||||
if (link === undefined) return;
|
||||
this.registerVerifiedSubsession(link);
|
||||
}
|
||||
|
||||
private async verifiedSubsessionLinkFromOpenedChild(session: PiAgentSession): Promise<TrackedSubsessionLink | undefined> {
|
||||
// Child markers are only hints; the current child header and reciprocal
|
||||
// parent custom link must agree on the exact ids and files before relinking.
|
||||
const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
|
||||
let marker: PersistedChildSubsessionLink | undefined;
|
||||
for (const entry of entries) {
|
||||
const parsed = parsePersistedChildSubsessionLink(entry);
|
||||
if (parsed?.spawnedSessionId === session.sessionId) marker = parsed;
|
||||
}
|
||||
if (marker === undefined) return undefined;
|
||||
|
||||
const childSessionFile = nonEmptyString(session.sessionFile);
|
||||
if (childSessionFile === undefined) return undefined;
|
||||
const childHeader = await readSessionHeaderSummary(childSessionFile);
|
||||
if (childHeader?.id !== session.sessionId) return undefined;
|
||||
const parentSessionFile = nonEmptyString(childHeader.parentSession);
|
||||
if (parentSessionFile === undefined) return undefined;
|
||||
const parentHeader = await readSessionHeaderSummary(parentSessionFile);
|
||||
if (parentHeader?.id !== marker.spawnedBySessionId) return undefined;
|
||||
|
||||
const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
|
||||
if (parentLink === undefined) return undefined;
|
||||
return {
|
||||
parentSessionId: marker.spawnedBySessionId,
|
||||
childSessionId: session.sessionId,
|
||||
childSessionFile,
|
||||
parentSessionFile,
|
||||
cwd: parentLink.cwd ?? session.sessionManager.getCwd(),
|
||||
};
|
||||
}
|
||||
|
||||
private findReciprocalParentSubsessionLink(parentSessionFile: string, parentSessionId: string, childSessionId: string, childSessionFile: string): PersistedParentSubsessionLink | undefined {
|
||||
let parentManager: PiSessionManager;
|
||||
try {
|
||||
parentManager = this.sessionManager.open(parentSessionFile);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
|
||||
for (const entry of entries) {
|
||||
const link = parsePersistedParentSubsessionLink(entry);
|
||||
if (link === undefined) continue;
|
||||
if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId) continue;
|
||||
if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile)) continue;
|
||||
return link;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getOrOpenTrackedSubsession(sessionId: string): Promise<PiAgentSession> {
|
||||
const link = this.subsessionLinks.get(sessionId);
|
||||
if (link === undefined) throw new Error("Session not found");
|
||||
|
||||
const active = this.activeChildForSubsessionLink(link);
|
||||
if (active !== undefined) return active.runtime.session;
|
||||
|
||||
if (link.childSessionFile !== undefined) {
|
||||
if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found");
|
||||
const sessionManager = this.sessionManager.open(link.childSessionFile);
|
||||
return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
|
||||
}
|
||||
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> {
|
||||
const active = this.active.get(childSessionId);
|
||||
const link = this.subsessionLinks.get(childSessionId);
|
||||
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
|
||||
if (active !== undefined) {
|
||||
return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
|
||||
return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
|
||||
}
|
||||
const archived = await this.archiveStore.get(childSessionId);
|
||||
if (archived !== undefined) return { cwd: archived.cwd, status: "archived" };
|
||||
if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
|
||||
return { cwd: link.cwd ?? "", status: "idle" };
|
||||
}
|
||||
if (link?.cwd !== undefined) return { cwd: link.cwd, status: "unknown" };
|
||||
return { cwd: "", status: "unknown" };
|
||||
}
|
||||
|
||||
private async subsessionStatus(session: PiAgentSession): Promise<SubsessionStatus> {
|
||||
if (await this.archiveStore.isArchived(session.sessionId)) return "archived";
|
||||
private subsessionStatus(session: PiAgentSession): SubsessionStatus {
|
||||
if (this.hasActiveWork(session)) return "working";
|
||||
if (this.activities.get(session.sessionId)?.phase === "error") return "error";
|
||||
return "idle";
|
||||
@@ -520,9 +765,9 @@ export class PiSessionService {
|
||||
* parent is busy and delivers immediately when it is idle).
|
||||
*/
|
||||
private updateSubsessionTracking(session: PiAgentSession): void {
|
||||
const childId = session.sessionId;
|
||||
const parentId = this.subsessionParents.get(childId);
|
||||
if (parentId === undefined) return;
|
||||
const link = this.subsessionLinkForActiveChild(session);
|
||||
if (link === undefined) return;
|
||||
const childId = link.childSessionId;
|
||||
if (this.hasActiveWork(session)) {
|
||||
this.subsessionNotifyArmed.set(childId, true);
|
||||
return;
|
||||
@@ -533,7 +778,23 @@ export class PiSessionService {
|
||||
const finalText = finalAssistantText(historyMessages(session));
|
||||
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
|
||||
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
|
||||
void this.notifyParentOfSubsession(parentId, childId, text);
|
||||
void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
|
||||
}
|
||||
|
||||
private async getOrOpenParentForSubsession(parentSessionId: string, childSessionId: string): Promise<PiAgentSession> {
|
||||
const link = this.subsessionLinks.get(childSessionId);
|
||||
if (link?.parentSessionId !== parentSessionId) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
|
||||
|
||||
const active = this.activeParentForSubsessionLink(link);
|
||||
if (active !== undefined) return active.runtime.session;
|
||||
|
||||
const parentSessionFile = link.parentSessionFile;
|
||||
if (parentSessionFile === undefined) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
|
||||
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
|
||||
throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
|
||||
}
|
||||
const sessionManager = this.sessionManager.open(parentSessionFile);
|
||||
return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,7 +806,7 @@ export class PiSessionService {
|
||||
*/
|
||||
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
|
||||
try {
|
||||
const session = await this.getOrOpen(parentId);
|
||||
const session = await this.getOrOpenParentForSubsession(parentId, childId);
|
||||
await session.sendCustomMessage(
|
||||
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
@@ -696,7 +957,7 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
|
||||
if (parsed.length === 0) return [];
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
@@ -809,6 +1070,8 @@ export class PiSessionService {
|
||||
const sessionFile = session.sessionFile;
|
||||
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
|
||||
await clearParentSession(sessionFile);
|
||||
clearParentSessionHeader(session.sessionManager);
|
||||
this.unregisterSubsession(session.sessionId);
|
||||
}
|
||||
|
||||
async abort(ref: PiSessionLookup): Promise<void> {
|
||||
@@ -922,7 +1185,7 @@ export class PiSessionService {
|
||||
// Disarm subsession notification before teardown so the abort below cannot
|
||||
// emit a "stopped working" event that notifies the parent (e.g. on archive).
|
||||
// The parent/children link is kept so the parent can still see the child.
|
||||
this.subsessionNotifyArmed.delete(sessionId);
|
||||
if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined) this.subsessionNotifyArmed.delete(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
active.unsubscribe();
|
||||
try {
|
||||
@@ -979,8 +1242,10 @@ export class PiSessionService {
|
||||
runtime.setRebindSession(async (session) => {
|
||||
await this.bindSessionExtensions(session);
|
||||
this.bindRuntime(active);
|
||||
await this.recoverSubsessionTrackingForOpenedSession(session);
|
||||
});
|
||||
this.active.set(runtime.session.sessionId, active);
|
||||
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
||||
this.publishStatus(runtime.session);
|
||||
return active;
|
||||
}
|
||||
@@ -1411,6 +1676,117 @@ function isDefined<T>(value: T | undefined): value is T {
|
||||
return value !== undefined;
|
||||
}
|
||||
|
||||
function trackedSubsessionLinkFromParentLink(parentSessionId: string, link: PersistedParentSubsessionLink, parentSessionFile: string): TrackedSubsessionLink {
|
||||
return {
|
||||
parentSessionId,
|
||||
childSessionId: link.spawnedSessionId,
|
||||
...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
|
||||
parentSessionFile,
|
||||
...(link.cwd === undefined ? {} : { cwd: link.cwd }),
|
||||
};
|
||||
}
|
||||
|
||||
function persistedParentSubsessionLinkData(link: TrackedSubsessionLink): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
spawnedBySessionId: link.parentSessionId,
|
||||
spawnedSessionId: link.childSessionId,
|
||||
...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
|
||||
...(link.cwd === undefined ? {} : { cwd: link.cwd }),
|
||||
};
|
||||
}
|
||||
|
||||
function persistedChildSubsessionLinkData(parentSessionId: string, childSessionId: string): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
spawnedBySessionId: parentSessionId,
|
||||
spawnedSessionId: childSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePersistedParentSubsessionLink(entry: unknown): PersistedParentSubsessionLink | undefined {
|
||||
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE) return undefined;
|
||||
const data = entry["data"];
|
||||
if (!isRecord(data)) return undefined;
|
||||
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
||||
const spawnedSessionId = getString(data, "spawnedSessionId");
|
||||
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "") return undefined;
|
||||
const spawnedSessionFile = getString(data, "spawnedSessionFile");
|
||||
const cwd = getString(data, "cwd");
|
||||
return {
|
||||
spawnedBySessionId,
|
||||
spawnedSessionId,
|
||||
...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
|
||||
...(cwd === undefined || cwd === "" ? {} : { cwd }),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePersistedChildSubsessionLink(entry: unknown): PersistedChildSubsessionLink | undefined {
|
||||
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE) return undefined;
|
||||
const data = entry["data"];
|
||||
if (!isRecord(data)) return undefined;
|
||||
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
||||
const spawnedSessionId = getString(data, "spawnedSessionId");
|
||||
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "") return undefined;
|
||||
return { spawnedBySessionId, spawnedSessionId };
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | undefined): string | undefined {
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
function subsessionHydratedParentKey(parentSessionId: string, parentSessionFile: string | undefined): string {
|
||||
return `${parentSessionId}\0${parentSessionFile ?? ""}`;
|
||||
}
|
||||
|
||||
function sessionPathsEqual(a: string, b: string): boolean {
|
||||
return cwdPathsEqual(a, b);
|
||||
}
|
||||
|
||||
function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean {
|
||||
const sessionFile = nonEmptyString(session.sessionFile);
|
||||
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
|
||||
}
|
||||
|
||||
function activeSessionFileMatches(active: ActiveSession<PiSessionRuntime>, expectedSessionFile: string | undefined): boolean {
|
||||
return sessionFileMatches(active.runtime.session, expectedSessionFile);
|
||||
}
|
||||
|
||||
function trackedLinkParentFileMatches(link: TrackedSubsessionLink, parentSessionFile: string): boolean {
|
||||
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
|
||||
}
|
||||
|
||||
interface SessionHeaderSummary {
|
||||
id: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
async function readSessionHeaderSummary(sessionFile: string): Promise<SessionHeaderSummary | undefined> {
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
file = await open(sessionFile, "r");
|
||||
const buffer = Buffer.alloc(4096);
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
||||
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
||||
if (firstLine === undefined || firstLine === "") return undefined;
|
||||
const header: unknown = JSON.parse(firstLine);
|
||||
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined;
|
||||
const parentSession = getString(header, "parentSession");
|
||||
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionFileHeaderMatches(sessionFile: string, expected: { sessionId: string; parentSessionFile?: string | undefined }): Promise<boolean> {
|
||||
const header = await readSessionHeaderSummary(sessionFile);
|
||||
if (header?.id !== expected.sessionId) return false;
|
||||
if (expected.parentSessionFile === undefined) return true;
|
||||
return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
|
||||
}
|
||||
|
||||
async function clearParentSession(sessionFile: string): Promise<void> {
|
||||
const content = await readFile(sessionFile, "utf8");
|
||||
const newlineIndex = content.indexOf("\n");
|
||||
@@ -1423,6 +1799,11 @@ async function clearParentSession(sessionFile: string): Promise<void> {
|
||||
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
|
||||
}
|
||||
|
||||
function clearParentSessionHeader(sessionManager: PiSessionManager): void {
|
||||
const header = sessionManager.getHeader?.();
|
||||
if (header !== undefined && header !== null) delete header.parentSession;
|
||||
}
|
||||
|
||||
function clearSessionQueue(session: PiAgentSession): void {
|
||||
session.clearQueue();
|
||||
}
|
||||
@@ -1475,6 +1856,12 @@ function historyMessages(session: PiAgentSession): unknown[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** custom entry type used to persist parent -> child subsession links outside LLM context. */
|
||||
const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
|
||||
|
||||
/** custom entry type used to mark a child as created by spawn_subsession. */
|
||||
const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
|
||||
|
||||
/** customType marking a parent-facing subsession-completion notice. */
|
||||
const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { getApiProvider, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
||||
import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SESSION_NAME_TIMEOUT_MS = 10_000;
|
||||
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
|
||||
const SESSION_NAME_MAX_LENGTH = 60;
|
||||
const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
|
||||
const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/");
|
||||
|
||||
interface SessionNameApiProvider {
|
||||
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface PiAiProviderRegistryModule {
|
||||
getApiProvider?: (api: Api) => SessionNameApiProvider | undefined;
|
||||
}
|
||||
|
||||
type ModuleImporter = (specifier: string) => Promise<unknown>;
|
||||
|
||||
let piAiProviderRegistryModulePromise: Promise<PiAiProviderRegistryModule> | undefined;
|
||||
|
||||
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
|
||||
const provider = getApiProvider(model.api);
|
||||
const providerRegistry = await getPiAiProviderRegistryModule();
|
||||
const provider = providerRegistry.getApiProvider?.(model.api);
|
||||
if (provider === undefined) return undefined;
|
||||
|
||||
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
||||
@@ -67,6 +81,42 @@ export function cleanSessionName(value: string): string | undefined {
|
||||
return title === "" ? undefined : title;
|
||||
}
|
||||
|
||||
async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise<PiAiProviderRegistryModule> {
|
||||
piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer);
|
||||
return piAiProviderRegistryModulePromise;
|
||||
}
|
||||
|
||||
async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise<PiAiProviderRegistryModule> {
|
||||
const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer);
|
||||
if (hasGetApiProvider(compatModule)) return compatModule;
|
||||
|
||||
const rootModule = await importer("@earendil-works/pi-ai");
|
||||
if (hasGetApiProvider(rootModule)) return rootModule;
|
||||
return {};
|
||||
}
|
||||
|
||||
async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise<unknown> {
|
||||
try {
|
||||
return await importer(specifier);
|
||||
} catch (error) {
|
||||
if (isModuleUnavailableError(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule {
|
||||
return typeof moduleValue === "object"
|
||||
&& moduleValue !== null
|
||||
&& "getApiProvider" in moduleValue
|
||||
&& typeof moduleValue.getApiProvider === "function";
|
||||
}
|
||||
|
||||
function isModuleUnavailableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = "code" in error ? error.code : undefined;
|
||||
return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED";
|
||||
}
|
||||
|
||||
function textFromAssistant(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((part) => part.type === "text")
|
||||
|
||||
@@ -56,9 +56,9 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
]));
|
||||
const { list: listTool } = tools({ list });
|
||||
|
||||
const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(list).toHaveBeenCalledWith("parent-1");
|
||||
expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toEqual({ subsessions: [
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" },
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
|
||||
@@ -76,9 +76,9 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
|
||||
const { check: checkTool } = tools({ check });
|
||||
|
||||
const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(check).toHaveBeenCalledWith("parent-1", "child-1");
|
||||
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
||||
expect(firstText(result.content)).toContain("all done");
|
||||
});
|
||||
@@ -99,9 +99,9 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
}));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 });
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
|
||||
expect(firstText(result.content)).toContain("the answer");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
|
||||
|
||||
/** Lifecycle phase of a tracked subsession as seen by its parent. */
|
||||
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown";
|
||||
export type SubsessionStatus = "working" | "idle" | "error" | "unknown";
|
||||
|
||||
export interface SpawnSubsessionResult {
|
||||
sessionId: string;
|
||||
@@ -56,9 +56,9 @@ export interface SubsessionReadQuery {
|
||||
|
||||
export interface SubsessionToolDeps {
|
||||
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
|
||||
list(parentSessionId: string): Promise<SubsessionSummary[]>;
|
||||
check(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult>;
|
||||
read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult>;
|
||||
list(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]>;
|
||||
check(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise<SubsessionCheckResult>;
|
||||
read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery, parentSessionFile?: string): Promise<SubsessionReadResult>;
|
||||
}
|
||||
|
||||
const SpawnSubsessionParams = Type.Object({
|
||||
@@ -196,7 +196,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
parameters: ListSubsessionsParams,
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const subsessions = await deps.list(parentSessionId);
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const subsessions = await deps.list(parentSessionId, parentSessionFile);
|
||||
const text = subsessions.length === 0
|
||||
? "You have not spawned any subsessions."
|
||||
: `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`;
|
||||
@@ -212,7 +213,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
parameters: CheckSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const result = await deps.check(parentSessionId, params.sessionId);
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
|
||||
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
|
||||
return {
|
||||
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
|
||||
@@ -229,8 +231,9 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
parameters: ReadSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const { sessionId, ...query } = params;
|
||||
const result = await deps.read(parentSessionId, sessionId, query);
|
||||
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
|
||||
return {
|
||||
content: [{ type: "text", text: renderTranscript(result) }],
|
||||
details: result,
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
|
||||
import type { PiWebConfigService } from "./configRoutes.js";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
|
||||
export interface WorkspaceExplorerRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
|
||||
registerWorkspaceFileContentParsers(app);
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
@@ -32,6 +35,40 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const writeOptions: WriteWorkspaceFileOptions = {
|
||||
createDirs: request.query.createDirs !== "false",
|
||||
overwrite: request.query.overwrite !== "false",
|
||||
};
|
||||
return await writeWorkspaceFile(context.root, request.query.path, request.body, writeOptions);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await deleteWorkspaceFile(context.root, request.query.path);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { projectId: string; workspaceId: string }; Querystring: { fromPath?: string; toPath?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/move`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await moveWorkspaceFile(context.root, request.query.fromPath, request.query.toPath, {
|
||||
createDirs: request.query.createDirs !== "false",
|
||||
overwrite: request.query.overwrite === "true",
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
@@ -61,3 +98,12 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerWorkspaceFileContentParsers(app: FastifyInstance): void {
|
||||
// Fastify's default parser only handles JSON; workspace file writes need to
|
||||
// accept text and arbitrary binary payloads. This route module is registered
|
||||
// for both local aliases, so parser registration must tolerate repeats.
|
||||
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_request, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/u, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
|
||||
import { readWorkspaceFile } from "./fileContentService.js";
|
||||
import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js";
|
||||
import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js";
|
||||
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -113,3 +114,262 @@ describe("readWorkspaceFile", () => {
|
||||
expect(file.binary).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeWorkspaceFile", () => {
|
||||
it("writes text content to a new file with normalized paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
|
||||
|
||||
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
|
||||
expect(result.size).toBe(26);
|
||||
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
|
||||
|
||||
// Verify the file was actually written
|
||||
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
|
||||
expect(content).toBe("const greeting = 'hello';\n");
|
||||
});
|
||||
|
||||
it("writes binary content", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||
|
||||
const result = await writeWorkspaceFile(root, "image.png", binaryData);
|
||||
|
||||
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
|
||||
});
|
||||
|
||||
it("overwrites existing files by default", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "notes.txt"), "old content");
|
||||
|
||||
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
|
||||
|
||||
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
|
||||
const content = await readFile(join(root, "notes.txt"), "utf8");
|
||||
expect(content).toBe("new content");
|
||||
});
|
||||
|
||||
it("throws when overwrite is false and file exists", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "existing.txt"), "data");
|
||||
|
||||
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
|
||||
});
|
||||
|
||||
it("creates intermediate directories by default", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
|
||||
|
||||
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
|
||||
expect(content).toBe("deep content");
|
||||
});
|
||||
|
||||
it("fails when createDirs is false and parent directory does not exist", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing paths, traversal, and absolute paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
|
||||
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
|
||||
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("rejects writing to a directory path", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "mydir"), { recursive: true });
|
||||
|
||||
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
|
||||
});
|
||||
|
||||
it("prevents writing through symlinks that escape the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
// Create a symlink inside the workspace that points outside
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
|
||||
roots.push(outsideDir);
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
// Attempting to write through the symlink should be blocked
|
||||
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWorkspaceFile", () => {
|
||||
it("deletes an existing file and returns existed: true", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "notes.txt"), "hello");
|
||||
|
||||
const result = await deleteWorkspaceFile(root, "notes.txt");
|
||||
|
||||
expect(result).toMatchObject({ path: "notes.txt", existed: true });
|
||||
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
|
||||
});
|
||||
|
||||
it("returns existed: false when deleting a non-existent file", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
const result = await deleteWorkspaceFile(root, "missing.txt");
|
||||
|
||||
expect(result).toMatchObject({ path: "missing.txt", existed: false });
|
||||
});
|
||||
|
||||
it("rejects deleting a directory", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "mydir"), { recursive: true });
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
|
||||
});
|
||||
|
||||
it("rejects path traversal", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("rejects missing path", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
|
||||
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
|
||||
});
|
||||
|
||||
it("deletes a symlink itself, not its target", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-"));
|
||||
roots.push(outsideDir);
|
||||
await writeFile(join(outsideDir, "real.txt"), "real content");
|
||||
// Create a symlink inside the workspace pointing outside
|
||||
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
|
||||
|
||||
const result = await deleteWorkspaceFile(root, "link.txt");
|
||||
|
||||
expect(result).toMatchObject({ path: "link.txt", existed: true });
|
||||
// The symlink should be gone, but the target file should still exist
|
||||
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow();
|
||||
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
||||
expect(realContent).toBe("real content");
|
||||
});
|
||||
|
||||
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
// A real file living outside the workspace that must not be deletable.
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
|
||||
roots.push(outsideDir);
|
||||
await writeFile(join(outsideDir, "victim.txt"), "important");
|
||||
// A symlinked parent directory inside the workspace pointing outside.
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
|
||||
// The outside file must survive.
|
||||
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
|
||||
expect(realContent).toBe("important");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveWorkspaceFile", () => {
|
||||
it("moves a file to a new path", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "original.txt"), "content");
|
||||
|
||||
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
|
||||
|
||||
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
|
||||
expect(result.size).toBe(7);
|
||||
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
|
||||
// Source should no longer exist
|
||||
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
|
||||
// Target should exist
|
||||
const target = await readWorkspaceFile(root, "moved.txt");
|
||||
expect(target.content).toBe("content");
|
||||
});
|
||||
|
||||
it("creates intermediate directories by default", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "file.txt"), "data");
|
||||
|
||||
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
|
||||
|
||||
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
|
||||
expect(target.content).toBe("data");
|
||||
});
|
||||
|
||||
it("fails when createDirs is false and parent directory does not exist", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "file.txt"), "data");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("overwrites target when overwrite is true", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "source content");
|
||||
await writeFile(join(root, "target.txt"), "target content");
|
||||
|
||||
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
|
||||
|
||||
expect(result.toPath).toBe("target.txt");
|
||||
const target = await readWorkspaceFile(root, "target.txt");
|
||||
expect(target.content).toBe("source content");
|
||||
});
|
||||
|
||||
it("throws when target exists and overwrite is false (default)", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "source");
|
||||
await writeFile(join(root, "target.txt"), "target");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
|
||||
// Source should still exist
|
||||
const source = await readWorkspaceFile(root, "source.txt");
|
||||
expect(source.content).toBe("source");
|
||||
});
|
||||
|
||||
it("rejects source path traversal", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||
});
|
||||
|
||||
it("rejects target path traversal", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "data");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects moving a directory", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "mydir"), { recursive: true });
|
||||
|
||||
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
|
||||
});
|
||||
|
||||
it("rejects missing fromPath or toPath", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
|
||||
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
|
||||
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
|
||||
});
|
||||
|
||||
it("prevents moving through symlinks that escape the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
await writeFile(join(root, "subdir", "file.txt"), "data");
|
||||
// Create a symlink inside the workspace that points outside
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-"));
|
||||
roots.push(outsideDir);
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import type { FileContentResponse, PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, PiWebPathAccessConfig, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
|
||||
import { imageMimeTypeForPath } from "./imagePreviewService.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
@@ -39,6 +41,111 @@ async function readFilePrefix(target: string, bytesToRead: number): Promise<Buff
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorkspaceFile(rootPath: string, path: string | undefined, content: Buffer, options: WriteWorkspaceFileOptions = {}): Promise<WriteWorkspaceFileResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
|
||||
const createDirs = options.createDirs ?? true;
|
||||
const overwrite = options.overwrite ?? true;
|
||||
|
||||
let exists = false;
|
||||
try {
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
if (!overwrite) throw new Error(`File already exists: ${relativePath}`);
|
||||
exists = true;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message.startsWith("File already exists")) throw error;
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) { /* expected for creation — continue */ }
|
||||
else if (error instanceof Error && error.message === "Path does not exist") { /* expected for creation — continue */ }
|
||||
else throw error; // re-throw permission errors, "not a file", traversal errors, etc.
|
||||
}
|
||||
|
||||
// Use resolveParentInsideWorkspace for the actual write since the target may not exist yet
|
||||
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
||||
|
||||
if (createDirs) await mkdir(dirname(target), { recursive: true });
|
||||
|
||||
// Resolve symlinks in the parent path to prevent escape via symlink
|
||||
const realParent = await realpath(dirname(target));
|
||||
const realTarget = join(realParent, basename(target));
|
||||
ensureInside(root, realTarget);
|
||||
await writeFile(realTarget, content);
|
||||
|
||||
const s = await stat(realTarget);
|
||||
return {
|
||||
path: relativePath,
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
created: !exists,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceFile(rootPath: string, path: string | undefined): Promise<DeleteWorkspaceFileResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
// Use resolveParentInsideWorkspace + lstat so that deleting a symlink
|
||||
// deletes the symlink itself, not the target it points to.
|
||||
// resolveInsideWorkspace would call realpath on the target, following
|
||||
// symlinks and resolving the symlink's destination instead.
|
||||
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
||||
try {
|
||||
// Resolve symlinks in the parent path to prevent escape via a symlinked
|
||||
// parent directory. The final path component is intentionally NOT resolved
|
||||
// so that lstat/unlink act on the entry itself (deleting a symlink rather
|
||||
// than the file it points to).
|
||||
const realParent = await realpath(dirname(target));
|
||||
const realTarget = join(realParent, basename(target));
|
||||
ensureInside(root, realTarget);
|
||||
const s = await lstat(realTarget);
|
||||
// Allow deleting regular files and symlinks, but not directories
|
||||
if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead");
|
||||
await unlink(realTarget);
|
||||
return { path: relativePath, existed: true };
|
||||
} catch (error: unknown) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false };
|
||||
if (error instanceof Error && error.message === "Path does not exist") return { path: relativePath, existed: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveWorkspaceFile(rootPath: string, fromPath: string | undefined, toPath: string | undefined, options: MoveWorkspaceFileOptions = {}): Promise<MoveWorkspaceFileResponse> {
|
||||
if (fromPath === undefined || fromPath === "") throw new Error("fromPath query parameter is required");
|
||||
if (toPath === undefined || toPath === "") throw new Error("toPath query parameter is required");
|
||||
|
||||
const createDirs = options.createDirs ?? true;
|
||||
const overwrite = options.overwrite ?? false;
|
||||
|
||||
// Source: must exist and be a file (uses realpath via resolveInsideWorkspace)
|
||||
const { target: source, relativePath: fromRelative } = await resolveInsideWorkspace(rootPath, fromPath);
|
||||
const sourceStat = await stat(source);
|
||||
if (!sourceStat.isFile()) throw new Error("Source path is not a file");
|
||||
|
||||
// Target: uses resolveParentInsideWorkspace + realpath(dirname) pattern (same as writeFile)
|
||||
const { root, target: dest, relativePath: destRelative } = await resolveParentInsideWorkspace(rootPath, toPath);
|
||||
|
||||
if (createDirs) await mkdir(dirname(dest), { recursive: true });
|
||||
|
||||
// Resolve symlinks in the parent path to prevent escape via symlink
|
||||
const realParent = await realpath(dirname(dest));
|
||||
const realDest = join(realParent, basename(dest));
|
||||
ensureInside(root, realDest);
|
||||
|
||||
if (!overwrite) {
|
||||
try {
|
||||
const destStat = await stat(realDest);
|
||||
if (destStat.isFile()) throw new Error(`File already exists: ${destRelative}`);
|
||||
} catch (error: unknown) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) { /* expected — target doesn't exist */ }
|
||||
else if (error instanceof Error && error.message.startsWith("File already exists")) throw error;
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await rename(source, realDest);
|
||||
const finalStat = await stat(realDest);
|
||||
return { fromPath: fromRelative, toPath: destRelative, size: finalStat.size, modifiedAt: finalStat.mtime.toISOString() };
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer): boolean {
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
|
||||
return sample.includes(0);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
|
||||
export async function resolveInsideWorkspace(rootPath: string, relativePath: string | undefined): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
const joined = join(root, requested);
|
||||
const target = await realpath(joined).catch((error: unknown) => {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error("Path does not exist");
|
||||
throw error;
|
||||
});
|
||||
ensureInside(root, target);
|
||||
return { root, target, relativePath: requested };
|
||||
}
|
||||
|
||||
export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
@@ -18,7 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string {
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string): void {
|
||||
export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
export function ensureInside(root: string, target: string): void {
|
||||
const rel = relative(root, target);
|
||||
if (rel === "") return;
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
|
||||
import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
|
||||
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
@@ -26,13 +26,13 @@ describe("project PI WEB config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads project-local path access config", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } });
|
||||
it("loads project-local path access and upload config", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
|
||||
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
|
||||
exists: true,
|
||||
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } },
|
||||
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,12 @@ describe("project PI WEB config", () => {
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
});
|
||||
|
||||
it("reuses PI WEB upload schema validation", async () => {
|
||||
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "../outside" } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config uploads.defaultFolder must not contain path traversal");
|
||||
});
|
||||
|
||||
it("merges global and project path access in order", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
|
||||
|
||||
@@ -55,6 +61,14 @@ describe("project PI WEB config", () => {
|
||||
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
|
||||
});
|
||||
});
|
||||
|
||||
it("lets project upload defaults override global upload defaults", async () => {
|
||||
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "project-uploads" } });
|
||||
|
||||
await expect(loadEffectiveProjectUploadsConfig(projectPath, { uploads: { defaultFolder: "global-uploads" } })).resolves.toEqual({
|
||||
defaultFolder: "project-uploads",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePathAccessConfigs", () => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js";
|
||||
import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js";
|
||||
|
||||
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
|
||||
|
||||
export interface ProjectPiWebConfig {
|
||||
version?: 1;
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
uploads?: PiWebUploadsConfig;
|
||||
}
|
||||
|
||||
export interface LoadedProjectPiWebConfig {
|
||||
@@ -33,6 +34,11 @@ export async function loadEffectiveProjectPathAccess(projectPath: string, global
|
||||
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
|
||||
}
|
||||
|
||||
export async function loadEffectiveProjectUploadsConfig(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebUploadsConfig> {
|
||||
const projectConfig = await loadProjectPiWebConfig(projectPath);
|
||||
return effectiveUploadsConfig({ uploads: { ...(globalConfig.uploads ?? {}), ...(projectConfig.config.uploads ?? {}) } });
|
||||
}
|
||||
|
||||
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
|
||||
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
|
||||
return allowedPaths.length === 0 ? undefined : { allowedPaths };
|
||||
@@ -43,6 +49,7 @@ function parseProjectPiWebConfig(value: Record<string, unknown>, path: string):
|
||||
return {
|
||||
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+59
-6
@@ -56,6 +56,10 @@ export interface PiWebPathAccessConfig {
|
||||
allowedPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PiWebUploadsConfig {
|
||||
defaultFolder?: string;
|
||||
}
|
||||
|
||||
export interface PiWebConfigValues {
|
||||
host?: string;
|
||||
port?: number;
|
||||
@@ -64,6 +68,8 @@ export interface PiWebConfigValues {
|
||||
plugins?: PiWebPluginConfigMap;
|
||||
/** External filesystem roots PI WEB may expose outside a workspace. */
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
/** Workspace-relative defaults for manual file uploads. */
|
||||
uploads?: PiWebUploadsConfig;
|
||||
/** Maximum accepted HTTP request body size in bytes (uploads/attachments). */
|
||||
maxUploadBytes?: number;
|
||||
/** When true, LLMs can start new sessions via the spawn_session tool. */
|
||||
@@ -115,6 +121,10 @@ export interface Project {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceEffectiveConfig {
|
||||
uploads?: PiWebUploadsConfig;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -124,6 +134,8 @@ export interface Workspace {
|
||||
isMain: boolean;
|
||||
isGitRepo: boolean;
|
||||
isGitWorktree: boolean;
|
||||
/** Workspace-effective project/global settings needed by workspace UI features. */
|
||||
effectiveConfig?: WorkspaceEffectiveConfig;
|
||||
}
|
||||
|
||||
export interface SessionRef {
|
||||
@@ -164,14 +176,13 @@ export interface QueuedSessionMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* A binary attachment carried with a prompt. The wire format mirrors pi's own
|
||||
* `ImageContent` shape (`{ type: "image", data, mimeType }`) so attachments are
|
||||
* fully compatible with the underlying pi coding agent.
|
||||
* A pi-native image attachment carried with a prompt. The wire format mirrors
|
||||
* pi's own `ImageContent` shape (`{ type: "image", data, mimeType }`) so these
|
||||
* attachments are compatible with native multimodal delivery after validation.
|
||||
*/
|
||||
export interface PromptAttachment {
|
||||
/** Kind of attachment. Only images are supported by pi today. */
|
||||
export interface PromptImageAttachment {
|
||||
kind: "image";
|
||||
/** IANA mime type (for example "image/png"). */
|
||||
/** Supported image MIME type (image/png, image/jpeg, image/gif, or image/webp). */
|
||||
mimeType: string;
|
||||
/** Base64-encoded binary payload (no data: URL prefix). */
|
||||
data: string;
|
||||
@@ -179,6 +190,19 @@ export interface PromptAttachment {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** A general file attachment that must be saved into the workspace before use. */
|
||||
export interface PromptFileAttachment {
|
||||
kind: "file";
|
||||
/** Non-empty IANA MIME type (for example "application/pdf"). */
|
||||
mimeType: string;
|
||||
/** Base64-encoded binary payload (no data: URL prefix). Empty for zero-byte files. */
|
||||
data: string;
|
||||
/** Optional original filename, used for previews and folder-mode filenames. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type PromptAttachment = PromptImageAttachment | PromptFileAttachment;
|
||||
|
||||
/**
|
||||
* How prompt attachments should be delivered to the session.
|
||||
* - "inline": send the binary to pi as native image content (multimodal input).
|
||||
@@ -315,6 +339,35 @@ export interface FileContentResponse {
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export interface WriteWorkspaceFileOptions {
|
||||
createDirs?: boolean; // default: true — mkdir -p equivalent
|
||||
overwrite?: boolean; // default: true — throw if false and file exists
|
||||
}
|
||||
|
||||
export interface WriteWorkspaceFileResponse {
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
created: boolean; // true if file was created, false if overwritten
|
||||
}
|
||||
|
||||
export interface DeleteWorkspaceFileResponse {
|
||||
path: string;
|
||||
existed: boolean; // true if file existed and was deleted, false if file did not exist
|
||||
}
|
||||
|
||||
export interface MoveWorkspaceFileOptions {
|
||||
createDirs?: boolean; // default: true — mkdir -p equivalent for target parent directory
|
||||
overwrite?: boolean; // default: false — throw if target exists (safer default than writeFile)
|
||||
}
|
||||
|
||||
export interface MoveWorkspaceFileResponse {
|
||||
fromPath: string;
|
||||
toPath: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted";
|
||||
|
||||
export interface GitStatusFile {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FederatedHttpMethod = "GET" | "POST" | "DELETE";
|
||||
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export interface FederatedHttpRouteSpec {
|
||||
method: FederatedHttpMethod;
|
||||
@@ -15,6 +15,9 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "PUT", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/file/move" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/files" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
||||
|
||||
@@ -58,9 +58,34 @@ describe("parsePromptAttachments", () => {
|
||||
|
||||
it("rejects unsupported kinds and mime types", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
|
||||
});
|
||||
|
||||
it("accepts generic files only when file attachments are allowed", () => {
|
||||
const result = parsePromptAttachments(
|
||||
[{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }],
|
||||
{ allowFileAttachments: true },
|
||||
);
|
||||
expect(result).toEqual([{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }]);
|
||||
});
|
||||
|
||||
it("accepts zero-byte generic files", () => {
|
||||
const result = parsePromptAttachments(
|
||||
[{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }],
|
||||
{ allowFileAttachments: true },
|
||||
);
|
||||
expect(result).toEqual([{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }]);
|
||||
});
|
||||
|
||||
it("rejects generic files with empty mime types", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "", data: "QUJD" }], { allowFileAttachments: true })).toThrow(/invalid file type/);
|
||||
});
|
||||
|
||||
it("keeps image MIME validation when file attachments are allowed", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
|
||||
});
|
||||
|
||||
it("rejects invalid base64 data", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PromptAttachment } from "./apiTypes.js";
|
||||
import type { PromptAttachment, PromptFileAttachment, PromptImageAttachment } from "./apiTypes.js";
|
||||
|
||||
/**
|
||||
* Image mime types supported by the pi coding agent. Mirrors
|
||||
@@ -44,13 +44,20 @@ export function base64ByteLength(data: string): number {
|
||||
export interface AttachmentValidationOptions {
|
||||
/** When true, enforce the per-image base64 size cap (inline delivery). */
|
||||
enforceInlineSizeLimit?: boolean;
|
||||
/** When true, accept general file attachments for save-to-folder delivery. */
|
||||
allowFileAttachments?: boolean;
|
||||
maxAttachments?: number;
|
||||
}
|
||||
|
||||
type ImageOnlyAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments?: false | undefined };
|
||||
type SaveAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments: true };
|
||||
|
||||
/**
|
||||
* Validate and normalize untrusted prompt attachments. Throws on malformed,
|
||||
* unsupported, or oversized input so routes can return a 400.
|
||||
*/
|
||||
export function parsePromptAttachments(value: unknown, options?: ImageOnlyAttachmentValidationOptions): PromptImageAttachment[];
|
||||
export function parsePromptAttachments(value: unknown, options: SaveAttachmentValidationOptions): PromptAttachment[];
|
||||
export function parsePromptAttachments(value: unknown, options: AttachmentValidationOptions = {}): PromptAttachment[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error("attachments must be an array");
|
||||
@@ -67,19 +74,45 @@ function parsePromptAttachment(value: unknown, index: number, options: Attachmen
|
||||
if (!isRecord(value)) throw new Error(`attachment ${String(index)} must be an object`);
|
||||
const record = value;
|
||||
const kind = record["kind"];
|
||||
if (kind !== "image") throw new Error(`attachment ${String(index)} has unsupported kind`);
|
||||
if (kind === "image") return parseImageAttachment(record, index, options);
|
||||
if (kind === "file" && options.allowFileAttachments === true) return parseFileAttachment(record, index);
|
||||
throw new Error(`attachment ${String(index)} has unsupported kind`);
|
||||
}
|
||||
|
||||
function parseImageAttachment(record: Record<string, unknown>, index: number, options: AttachmentValidationOptions): PromptImageAttachment {
|
||||
const mimeType = record["mimeType"];
|
||||
if (!isSupportedImageMimeType(mimeType)) throw new Error(`attachment ${String(index)} has unsupported image type`);
|
||||
const data = record["data"];
|
||||
if (typeof data !== "string" || data === "" || !base64Pattern.test(data)) throw new Error(`attachment ${String(index)} has invalid base64 data`);
|
||||
const data = requireBase64Data(record["data"], index, { allowEmpty: false });
|
||||
if (options.enforceInlineSizeLimit === true && base64ByteLength(data) > MAX_INLINE_IMAGE_BASE64_BYTES) {
|
||||
throw new Error(`attachment ${String(index)} exceeds the inline image size limit`);
|
||||
}
|
||||
const name = record["name"];
|
||||
return {
|
||||
kind: "image",
|
||||
mimeType,
|
||||
data,
|
||||
...(typeof name === "string" && name !== "" ? { name } : {}),
|
||||
...attachmentName(record),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFileAttachment(record: Record<string, unknown>, index: number): PromptFileAttachment {
|
||||
const mimeType = record["mimeType"];
|
||||
if (typeof mimeType !== "string" || mimeType.trim() === "") throw new Error(`attachment ${String(index)} has invalid file type`);
|
||||
return {
|
||||
kind: "file",
|
||||
mimeType: mimeType.trim(),
|
||||
data: requireBase64Data(record["data"], index, { allowEmpty: true }),
|
||||
...attachmentName(record),
|
||||
};
|
||||
}
|
||||
|
||||
function requireBase64Data(value: unknown, index: number, options: { allowEmpty: boolean }): string {
|
||||
if (typeof value !== "string" || (!options.allowEmpty && value === "") || !base64Pattern.test(value)) {
|
||||
throw new Error(`attachment ${String(index)} has invalid base64 data`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function attachmentName(record: Record<string, unknown>): { name?: string } {
|
||||
const name = record["name"];
|
||||
return typeof name === "string" && name !== "" ? { name } : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user