Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] d86cb4cb2b fix(app): show the sidebar toggle on plugin screens
Plugin screens rendered only their icon and title on the left of the header,
so on wide web and Android layouts there was no way to collapse the sidebar
from them, or to reopen it after it was closed elsewhere.

Fixes #6315
2026-10-14 08:43:20 +02:00
25 changed files with 137 additions and 1111 deletions
@@ -223,7 +223,6 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await expect(addProjectFlowInput(page)).toBeFocused();
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
@@ -116,7 +116,6 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
@@ -129,7 +128,6 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
@@ -208,40 +206,21 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let created = Promise.withResolvers<void>();
let createRequestId: string | undefined;
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const message = readers.client(frame);
if (message?.type === "create_agent_request") createRequestId = message.requestId;
if (holding && message?.type === "create_agent_request") {
if (holding && readers.client(frame)?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => {
const message = readers.server(frame);
if (
message?.type === "status" &&
message.payload.status === "agent_created" &&
message.payload.requestId === createRequestId
) {
created.resolve();
}
browser.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
return {
waitForRequest: () => arrived.promise,
async waitForCreated() {
// Agent-list membership includes initialization, before the initial prompt starts.
await created.promise;
created = Promise.withResolvers<void>();
},
release() {
const send = release;
release = () => {};
@@ -1,29 +0,0 @@
import { test } from "../support/fixtures";
import {
addCustomModel,
expectCustomModelRetained,
openCustomModels,
removeCustomModel,
} from "../support/helpers/custom-models";
const longModelId = "custom-model-with-a-long-name-".repeat(6);
const shortModelId = "custom-short";
for (const width of [1200, 390]) {
test(`removes a long custom model at ${width}px without removing another model`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 1000 });
await openCustomModels(page);
await addCustomModel(page, shortModelId);
await addCustomModel(page, longModelId);
await test.step("remove the long model at the target width", async () => {
await page.setViewportSize({ width, height: 1000 });
await removeCustomModel(page, longModelId);
await expectCustomModelRetained(page, shortModelId);
});
await removeCustomModel(page, shortModelId);
});
}
@@ -241,6 +241,32 @@ test.describe("Plugin sidebar items", () => {
});
});
test("a plugin screen toggles the sidebar on a wide layout", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await visibleTestId(page, "menu-button").click();
await expect(row).not.toBeVisible();
await visibleTestId(page, "menu-button").click();
await expect(row).toBeVisible();
await closeScreen(page);
// With the sidebar closed elsewhere, the screen's own toggle is the way back to it.
await visibleTestId(page, "menu-button").click();
await expect(row).not.toBeVisible();
await page.keyboard.press("Control+k");
const panel = page.getByTestId("command-center-panel");
await panel.getByTestId("command-center-input").fill("Open deploys");
await panel.getByRole("button", { name: "Open deploys", exact: true }).click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await visibleTestId(page, "menu-button").click();
await expect(row).toBeVisible();
});
test("a header row's shortcut hint presses the row", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
@@ -1,74 +0,0 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(12);
return rows.evaluateAll(
(elements, prefix) =>
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
`sidebar-workspace-row-${getServerId()}:`,
);
}
async function navigateWorkspace(
page: Page,
direction: "next" | "previous",
workspaceId: string,
): Promise<void> {
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
}
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
page,
}) => {
test.setTimeout(120_000);
const project = await seedWorkspace({
repoPrefix: "relative-navigation-",
title: "Workspace 01",
});
try {
for (let index = 2; index <= 12; index += 1) {
const created = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
title: `Workspace ${String(index).padStart(2, "0")}`,
});
expect(created.error).toBeNull();
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
await navigateWorkspace(page, "next", order[9]);
await navigateWorkspace(page, "previous", order[8]);
});
await test.step("wrapping uses the true first and last visible workspace", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await navigateWorkspace(page, "previous", order[11]);
await navigateWorkspace(page, "next", order[0]);
});
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
await page.keyboard.press("Alt+9");
await expectSidebarWorkspaceSelected({
page,
serverId: getServerId(),
workspaceId: order[8],
});
});
} finally {
await project.cleanup();
}
});
@@ -1,37 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openSettingsHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openCustomModels(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
}
export async function addCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("button", { name: "Add model", exact: true }).click();
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
await page
.getByTestId("add-custom-model-sheet")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
await expect(remove).toHaveCount(0);
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
@@ -95,11 +95,11 @@ function CustomModelRow({
return (
<View style={sheetStyles.modelRow}>
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
@@ -762,7 +762,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButton: {
width: 28,
height: 28,
flexShrink: 0,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -808,10 +807,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
flexShrink: 0,
},
customModelText: {
flexShrink: 1,
minWidth: 0,
},
modelRowFiller: {
flex: 1,
},
@@ -8,6 +8,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { CompactHeader } from "@/components/headers/compact-header";
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
@@ -172,6 +173,7 @@ export function PluginSurfaceScreen() {
const headerLeft = useMemo(
() => (
<>
<SidebarMenuToggle />
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
@@ -5,7 +5,6 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import { routeKeyboardShortcut } from "@/keyboard/route-shortcut";
import {
buildSidebarShortcutModel,
@@ -66,46 +65,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
}
describe("buildSidebarShortcutModel", () => {
it.each([
[9, 1, 10],
[10, -1, 9],
[12, 1, 1],
[1, -1, 12],
] as const)(
"navigates from workspace %i by %i to workspace %i across the full visible sidebar",
(current, delta, next) => {
const workspaces = Array.from({ length: 12 }, (_, index) =>
workspace({
serverId: "s",
workspaceId: `ws-${index + 1}`,
workspaceDirectory: `/repo/w${index + 1}`,
name: `w${index + 1}`,
}),
);
const model = buildSidebarShortcutModel({
projects: [project("p", workspaces)],
collapsedProjectKeys: new Set<string>(),
});
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta } },
{
pathname: `/h/s/workspace/ws-${current}`,
isMobile: false,
sidebarShortcutTargets: model.shortcutTargets,
navigationActiveWorkspace: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
},
),
).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` });
expect(model.shortcutIndexByWorkspaceKey.size).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false);
},
);
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [
@@ -152,7 +111,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
});
it("keeps all traversal targets while numbering only the first 9", () => {
it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace({
serverId: "s",
@@ -168,11 +127,9 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(),
});
expect(model.shortcutTargets).toHaveLength(20);
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" });
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined();
});
it("excludes a collapsed project's workspaces regardless of project kind", () => {
+6 -3
View File
@@ -70,10 +70,13 @@ export function buildSidebarShortcutSections(input: {
}
for (const workspace of section.workspaces) {
shortcutTargets.push(createShortcutTarget(workspace));
if (shortcutTargets.length <= maxShortcuts) {
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length);
if (shortcutTargets.length >= maxShortcuts) {
break;
}
const shortcutNumber = shortcutTargets.length + 1;
shortcutTargets.push(createShortcutTarget(workspace));
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
}
}
@@ -1,88 +0,0 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
type Output = "stdout" | "stderr";
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"-e",
`
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
protectConsoleStreams();
process.send("ready");
process.once("message", () => {
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
console.info("desktop info");
console.warn("desktop warning");
setImmediate(() => { process.send("alive"); process.disconnect(); });
});
`,
],
{
cwd: fileURLToPath(new URL(".", import.meta.url)),
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
},
);
let stdout = "";
let stderr = "";
let alive = false;
child.stdout.setEncoding("utf8").on("data", (text: string) => {
stdout += text;
});
child.stderr.setEncoding("utf8").on("data", (text: string) => {
stderr += text;
});
child.on("message", (message) => {
if (message === "ready") {
if (input.closed) child[input.closed].destroy();
child.send("write");
}
if (message === "alive") alive = true;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
return { code, stdout, stderr, alive };
}
describe("desktop console streams", () => {
for (const stream of ["stdout", "stderr"] as const) {
it(`keeps running when the launcher closes ${stream}`, async () => {
const result = await writeToConsole({ closed: stream });
expect(result.code, result.stderr).toBe(0);
expect(result.alive).toBe(true);
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
);
});
}
// Windows stdout uses a native handle rather than the POSIX fd closed here.
it.skipIf(process.platform === "win32")(
"does not suppress a console failure other than a closed pipe",
async () => {
const result = await writeToConsole({ invalidStdout: true });
expect(result.code).toBe(1);
expect(result.alive).toBe(false);
expect(result.stderr).toContain("EBADF");
},
);
it("preserves logging while the console has readers", async () => {
expect(await writeToConsole()).toEqual({
code: 0,
stdout: "desktop info\n",
stderr: "desktop warning\n",
alive: true,
});
});
});
@@ -1,11 +0,0 @@
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
/** Keep desktop logging independent of the program that launched its console. */
export function protectConsoleStreams(): void {
process.stdout.on("error", ignoreClosedConsole);
process.stderr.on("error", ignoreClosedConsole);
}
-3
View File
@@ -1,6 +1,3 @@
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
protectConsoleStreams();
process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -1,62 +0,0 @@
import { test, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
test.skipIf(process.platform === "win32")(
"checkout and commit diffs preserve filenames that Git quotes",
async () => {
const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const names = [
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"normal.txt",
"with spaces.txt",
];
const editLines = expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]);
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.quotepath", "false");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
await client.connect();
const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(checkout.error).toBeNull();
expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort());
for (const file of checkout.files) {
expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 });
expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
}
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const sha = git("rev-parse", "HEAD");
for (const name of names) {
const commit = await client.getCommitFileDiff(cwd, sha, name);
expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 });
expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
const file = await client.readFile(cwd, name);
expect(file.kind).toBe("text");
expect(new TextDecoder().decode(file.bytes)).toBe("after\n");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
);
@@ -773,12 +773,8 @@ describe("Hub relationship", () => {
const second = await relationship.ownedCreateResult("second-create");
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(first).toMatchObject({
payload: { success: true, executionId: "first-execution", error: null },
});
expect(second).toMatchObject({
payload: { success: true, executionId: "second-execution", error: null },
});
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
expect(await relationship.durableOwnedAgentIds()).toHaveLength(2);
});
@@ -218,85 +218,6 @@ index 1234567..abcdefg 100644
},
);
it("decodes quoted Git patch paths before matching files", () => {
const files = parseDiff(
[
'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"',
"index 1234567..abcdefg 100644",
'--- "a/report \\"draft\\".txt"',
'+++ "b/report \\"draft\\".txt"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toEqual([
{
path: 'report "draft".txt',
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
]);
});
it.each([
["tab\\tname.txt", "tab\tname.txt"],
["line\\nname.txt", "line\nname.txt"],
["back\\\\slash.txt", "back\\slash.txt"],
["bell\\aname.txt", "bell\u0007name.txt"],
["vertical\\vname.txt", "vertical\u000bname.txt"],
["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"],
['测试 \\"draft\\".txt', '测试 "draft".txt'],
])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => {
const files = parseDiff(
[
'diff --git "a/' + encoded + '" "b/' + encoded + '"',
"index 1234567..abcdefg 100644",
'--- "a/' + encoded + '"',
'+++ "b/' + encoded + '"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 });
expect(files[0].hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
]);
});
it.each([
['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"],
['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'],
['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'],
])("reads quoted paths from header-only patches: %s", (header, path) => {
expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([
{ path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] },
]);
});
it("parses a simple diff with one hunk", () => {
const files = parseDiff(SIMPLE_DIFF);
@@ -1,7 +1,6 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
import { unquoteGitPath } from "../../utils/git-quoted-path.js";
const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000;
@@ -54,7 +53,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
return null;
}
const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, ""));
const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
return path === "/dev/null" ? null : path;
}
@@ -65,17 +64,6 @@ function extractPathFromDiffHeader(lines: string[]): string {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
const oldPath = unquoteGitPath(quotedPathMatch[1]);
const newPath = unquoteGitPath(quotedPathMatch[2]);
if (usesDiffPathPrefixes(oldPath, newPath)) {
return newPath.slice(2);
}
return newPath;
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
@@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
expect(allocatedServices).toEqual(["api", "web", "worker"]);
});
it("extends the existing plan with newly declared services without reallocating peers", async () => {
it("returns the existing plan without calling the allocator on later calls", async () => {
let allocationCount = 0;
const firstPlan = await ensureWorkspaceServicePortPlan({
@@ -54,11 +54,9 @@ describe("ensureWorkspaceServicePortPlan", () => {
},
});
expect(Array.from(secondPlan.entries())).toEqual([
...firstPlan.entries(),
["new-service", 4300],
]);
expect(allocationCount).toBe(3);
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
expect(secondPlan.has("new-service")).toBe(false);
expect(allocationCount).toBe(2);
});
it("shares one first-plan build across concurrent callers", async () => {
@@ -103,103 +101,6 @@ describe("ensureWorkspaceServicePortPlan", () => {
expect(allocationCount).toBe(2);
});
it("extends a pending plan for a concurrent caller with another service", async () => {
const allocation = createDeferredPort();
const workspaceId = "registry-concurrent-extension-workspace";
const firstPlan = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => allocation.promise,
});
let allocations = 0;
const secondPlan = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }],
allocatePort: async () => {
allocations += 1;
return 6101;
},
});
allocation.resolve(6100);
expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]);
expect(Array.from((await secondPlan).entries())).toEqual([
["backend-dev", 6100],
["web", 6101],
]);
expect(allocations).toBe(1);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("keeps the previous plan and reservations when an extension fails", async () => {
const workspaceId = "registry-failed-extension-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6200,
});
let allocations = 0;
await expect(
ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }],
allocatePort: async () => {
allocations += 1;
if (allocations === 1) return 6201;
throw new Error("Allocation failed");
},
}),
).rejects.toThrow("Allocation failed");
const priorPlan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => {
throw new Error("Existing port must survive");
},
});
expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]);
const otherWorkspace = "registry-failed-extension-other-workspace";
const reusedPlan = await ensureWorkspaceServicePortPlan({
workspaceId: otherWorkspace,
services: [{ scriptName: "web" }],
allocatePort: async ({ reservedPorts }) => {
expect(reservedPorts.has(6200)).toBe(true);
expect(reservedPorts.has(6201)).toBe(false);
return 6201;
},
});
expect(reusedPlan.get("web")).toBe(6201);
releaseWorkspaceServicePortPlan(workspaceId);
releaseWorkspaceServicePortPlan(otherWorkspace);
});
it("rejects a new explicit port that collides with an existing service", async () => {
const workspaceId = "registry-extension-explicit-collision-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6300,
});
await expect(
ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }],
allocatePort: async () => 6301,
}),
).rejects.toThrow("Service 'web' has a duplicate port 6300");
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["backend-dev", 6300],
["web", 6301],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("uses explicit configured ports without calling the allocator", async () => {
let allocationCount = 0;
@@ -411,123 +312,6 @@ describe("refreshWorkspaceServicePort", () => {
]);
});
it("preserves a concurrent service addition while refreshing a stopped service", async () => {
const workspaceId = "registry-refresh-and-extend-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6400,
});
const allocation = createDeferredPort();
const refreshing = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "backend-dev" },
allocatePort: async () => allocation.promise,
});
const extending = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }],
allocatePort: async () => {
throw new Error("Explicit addition needs no allocation");
},
});
allocation.resolve(6401);
expect(await refreshing).toBe(6401);
await extending;
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["backend-dev", 6401],
["web", 6402],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it.each(["reused port", "allocation failure"])(
"cancels a pending refresh and its waiter after workspace release (%s)",
async (outcome) => {
const workspaceId = "registry-cancelled-refresh-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "api" }],
allocatePort: async () => 6500,
});
const allocation = createDeferredPort();
const refreshing = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "api" },
allocatePort: async () => {
const port = await allocation.promise;
if (outcome === "allocation failure") throw new Error("Allocation failed");
return port;
},
});
const waiting = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "web", port: 6501 },
allocatePort: async () => 6501,
});
const assertions = Promise.all([
expect(refreshing).rejects.toThrow("Workspace service port plan was released"),
expect(waiting).rejects.toThrow("Workspace service port plan was released"),
]);
releaseWorkspaceServicePortPlan(workspaceId);
allocation.resolve(6500);
await assertions;
const reusedPlan = await ensureWorkspaceServicePortPlan({
workspaceId: "registry-after-cancelled-refresh-workspace",
services: [{ scriptName: "api" }],
allocatePort: async () => 6500,
});
expect(reusedPlan.get("api")).toBe(6500);
releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace");
},
);
it("lets a waiting service restart allocate after another service's restart fails", async () => {
const workspaceId = "registry-independent-refresh-failure-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "api" }, { scriptName: "web" }],
allocatePort: createSequentialPortAllocator(6600),
});
const allocation = createDeferredPort();
const failedRefresh = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "api" },
allocatePort: async () => {
await allocation.promise;
throw new Error("API port script failed");
},
});
const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed");
const waitingRefresh = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "web" },
allocatePort: async () => 6602,
});
allocation.resolve(0);
await failedAssertion;
expect(await waitingRefresh).toBe(6602);
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["api", 6600],
["web", 6602],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("uses an explicit configured port without calling the allocator", async () => {
let allocationCount = 0;
@@ -24,24 +24,6 @@ interface PendingWorkspaceServicePortPlanToken {
isReleased: boolean;
}
class WorkspaceServicePortPlanReleasedError extends Error {
constructor(workspaceId: string) {
super(`Workspace service port plan was released while being created for '${workspaceId}'`);
}
}
async function waitForWorkspaceServicePortPlanUpdate(
pendingPlan: Promise<Map<string, number>>,
): Promise<void> {
try {
await pendingPlan;
} catch (error) {
// Allocation failure belongs to the original request. A waiting request
// retries against the rolled-back plan; workspace archival cancels both.
if (error instanceof WorkspaceServicePortPlanReleasedError) throw error;
}
}
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
const pendingWorkspaceServicePortPlanTokens = new Map<
@@ -55,19 +37,25 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10;
export async function ensureWorkspaceServicePortPlan(
options: EnsureWorkspaceServicePortPlanOptions,
): Promise<ReadonlyMap<string, number>> {
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (pendingPlan) {
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
return ensureWorkspaceServicePortPlan(options);
}
const existingPlan =
workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
if (options.services.every((service) => existingPlan.has(service.scriptName))) {
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
if (existingPlan) {
return new Map(existingPlan);
}
return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan }));
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (!pendingPlan) {
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
pendingPlan = createPendingWorkspaceServicePortPlan({
workspaceId: options.workspaceId,
services: options.services,
allocatePort: options.allocatePort,
token,
});
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
}
return new Map(await pendingPlan);
}
export function requirePlannedWorkspaceServicePort(
@@ -94,62 +82,27 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void {
dynamicPortsByWorkspace.delete(workspaceId);
}
interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions {
existingPlan: ReadonlyMap<string, number>;
refreshScriptName?: string;
}
function startWorkspaceServicePortPlanUpdate(
options: WorkspaceServicePortPlanUpdateOptions,
): Promise<Map<string, number>> {
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
const plan = createPendingWorkspaceServicePortPlan({ ...options, token });
pendingWorkspaceServicePortPlans.set(options.workspaceId, plan);
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
return plan;
}
async function createPendingWorkspaceServicePortPlan(
options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken },
): Promise<Map<string, number>> {
const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId));
const retainedPlan = new Map(options.existingPlan);
if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName);
async function createPendingWorkspaceServicePortPlan(options: {
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
token: PendingWorkspaceServicePortPlanToken;
}): Promise<Map<string, number>> {
try {
const updatedPlan = await buildWorkspaceServicePortPlan({
const plan = await buildWorkspaceServicePortPlan({
workspaceId: options.workspaceId,
services: options.services,
allocatePort: options.allocatePort,
existingPlan: retainedPlan,
});
const plan = new Map(options.existingPlan);
for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port);
if (options.token.isReleased) {
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
}
for (const [scriptName, port] of previousDynamicPorts) {
if (plan.get(scriptName) !== port) {
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
throw new Error(
`Workspace service port plan was released while being created for '${options.workspaceId}'`,
);
}
workspaceServicePortPlans.set(options.workspaceId, plan);
return plan;
} catch (error) {
const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId);
if (dynamicPorts) {
for (const [scriptName, port] of dynamicPorts) {
if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) {
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
}
if (!options.token.isReleased) {
for (const [scriptName, port] of previousDynamicPorts) {
reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
if (options.token.isReleased)
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
releaseWorkspaceServicePortPlan(options.workspaceId);
throw error;
} finally {
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
@@ -158,16 +111,12 @@ async function createPendingWorkspaceServicePortPlan(
}
async function buildWorkspaceServicePortPlan(options: {
existingPlan: ReadonlyMap<string, number>;
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
}): Promise<Map<string, number>> {
const explicitPortOwners = new Map<number, string>(
Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]),
);
const explicitPortOwners = new Map<number, string>();
for (const service of options.services) {
if (options.existingPlan.has(service.scriptName)) continue;
if (service.port === undefined) continue;
if (explicitPortOwners.has(service.port)) {
throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`);
@@ -175,9 +124,8 @@ async function buildWorkspaceServicePortPlan(options: {
explicitPortOwners.set(service.port, service.scriptName);
}
const plan = new Map(options.existingPlan);
const plan = new Map<string, number>();
for (const service of options.services) {
if (plan.has(service.scriptName)) continue;
if (service.port !== undefined) {
plan.set(service.scriptName, service.port);
continue;
@@ -200,20 +148,32 @@ async function buildWorkspaceServicePortPlan(options: {
export async function refreshWorkspaceServicePort(
options: RefreshWorkspaceServicePortOptions,
): Promise<number> {
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (pendingPlan) {
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
return refreshWorkspaceServicePort(options);
}
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
const plan = await startWorkspaceServicePortPlanUpdate({
const reservedPorts = new Set(plan.values());
const previousPort = plan.get(options.service.scriptName);
if (previousPort !== undefined) {
reservedPorts.delete(previousPort);
}
const oldDynamicPort = dynamicPortsByWorkspace
.get(options.workspaceId)
?.get(options.service.scriptName);
const port = await resolveServicePort({
service: options.service,
workspaceId: options.workspaceId,
services: [options.service],
allocatePort: options.allocatePort,
existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(),
refreshScriptName: options.service.scriptName,
reservedPorts,
});
return requirePlannedWorkspaceServicePort(plan, options.service.scriptName);
if (oldDynamicPort !== undefined && oldDynamicPort !== port) {
releaseDynamicPort({
workspaceId: options.workspaceId,
scriptName: options.service.scriptName,
port: oldDynamicPort,
});
}
plan.set(options.service.scriptName, port);
workspaceServicePortPlans.set(options.workspaceId, plan);
return port;
}
async function resolveServicePort(options: {
@@ -837,54 +837,6 @@ describe("runAsyncWorktreeBootstrap", () => {
});
});
it("starts a service added to paseo.json without restarting the daemon", async () => {
const backend = {
type: "service" as const,
command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT",
};
commitPaseoScripts({ "backend-dev": backend });
const serviceProxy = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const options = {
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-added-service",
daemonPort: 6767,
serviceProxy,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
};
const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" });
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
"backend-dev": backend,
web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" },
},
}),
);
const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" });
expect(webResult.port).toEqual(expect.any(Number));
expect(webResult.port).not.toBe(backendResult.port);
expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]);
expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port));
expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe(
String(backendResult.port),
);
expect(serviceProxy.listRoutes()).toHaveLength(2);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({
lifecycle: "running",
terminalId: backendResult.terminalId,
});
});
it("spawns services with public aliases and public service URLs", async () => {
commitPaseoScripts(
{
@@ -3,218 +3,15 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getCheckoutDiff,
getCheckoutShortstat,
getCommitFileDiff,
listCheckoutCommits,
} from "./checkout-git.js";
import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js";
const repos: string[] = [];
const EDITED_TEXT_DIFF = {
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
};
afterEach(() => {
vi.unstubAllEnvs();
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")(
"returns a quoted filename and its uncommitted diff using its actual path",
async () => {
const cwd = createChangedRepo("core.quotepath=false");
const name = 'report "draft".txt';
writeFileSync(join(cwd, "file.txt"), "before\n");
writeFileSync(join(cwd, name), "before\n");
git(cwd, "add", "--", name);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename");
writeFileSync(join(cwd, name), "after\n");
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured).toHaveLength(1);
expect(result.structured?.[0]).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
},
);
describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => {
const names = [
"normal.txt",
"with spaces.txt",
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"line\nname.txt",
" leading and trailing .txt ",
'report "old => draft".txt',
];
function createQuotedPathsRepo(): string {
const cwd = createChangedRepo("core.quotepath=false");
writeFileSync(join(cwd, "file.txt"), "before\n");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git(cwd, "add", "--", ...names);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames");
git(cwd, "checkout", "-b", "feature");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
return cwd;
}
it("preserves tracked and untracked paths, diff lines and summary counts", async () => {
const cwd = createQuotedPathsRepo();
const untrackedNames = names.map((name) => "untracked-" + name);
for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n");
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured?.map((file) => file.path).sort()).toEqual(
[...names, ...untrackedNames].sort(),
);
for (const name of names) {
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
}
for (const name of untrackedNames) {
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
isNew: true,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -0,0 +1 @@" },
{ type: "add", content: "new file" },
],
},
],
});
}
expect(await getCheckoutShortstat(cwd)).toEqual({
additions: names.length + untrackedNames.length,
deletions: names.length,
});
});
it("opens base and commit diffs using the actual paths from the commit list", async () => {
const cwd = createQuotedPathsRepo();
git(cwd, "add", "--", ...names);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const base = await getCheckoutDiff(cwd, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort());
for (const name of names) {
expect(base.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
}
const history = await listCheckoutCommits({ cwd });
const commit = history.commits[0];
expect(commit.sha).toBe(sha);
expect(commit.files).toEqual(
[...names].sort().map((name) => ({
path: name,
additions: 1,
deletions: 1,
status: "modified",
})),
);
for (const file of commit.files) {
expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({
path: file.path,
...EDITED_TEXT_DIFF,
});
}
});
it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => {
const cwd = createChangedRepo("core.quotepath=false");
const oldPath = 'old "draft".txt';
const newPath = 'new "draft".txt';
const binaryPath = "image\tdata.bin";
writeFileSync(join(cwd, "file.txt"), "before\n");
writeFileSync(join(cwd, oldPath), "unchanged\n");
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2]));
git(cwd, "add", "--", oldPath, binaryPath);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files");
git(cwd, "checkout", "-b", "feature");
git(cwd, "mv", "--", oldPath, newPath);
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4]));
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(
result.structured?.map(({ path, oldPath: sourcePath, status }) => ({
path,
oldPath: sourcePath,
status,
})),
).toEqual([
{ path: binaryPath, oldPath: undefined, status: "binary" },
{
path: newPath,
oldPath,
status: "ok",
},
]);
expect(result.structured?.[0]).toEqual({
path: binaryPath,
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
status: "binary",
});
git(cwd, "add", "--", binaryPath);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary");
const history = await listCheckoutCommits({ cwd });
expect(history.commits[0].files).toEqual([
{ path: binaryPath, additions: 0, deletions: 0, status: "modified" },
{ path: newPath, additions: 0, deletions: 0, status: "renamed" },
]);
});
});
it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])(
"preserves batched paths, spaces and literal prefix directories with %s",
async (config) => {
@@ -1628,7 +1628,7 @@ const x = 1;
expect(diff.diff).toContain(`-export const value = "old";`);
expect(diff.diff).toContain(`+export const value = "new";`);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD",
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD",
);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
+35 -30
View File
@@ -32,7 +32,6 @@ import {
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
import { readGitFileContents } from "./git-file-contents.js";
import { unquoteGitPath } from "./git-quoted-path.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
branchNameFromRef,
@@ -516,18 +515,22 @@ async function listCheckoutFileChanges(
const { stdout: nameStatusOut } = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)],
extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
}),
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const nameStatusFields = nameStatusOut.split("\0");
for (let index = 0; index < nameStatusFields.length; ) {
const rawStatus = nameStatusFields[index++];
for (const line of nameStatusOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
// `--name-status` uses TAB separators, which preserves filenames with spaces.
const tabParts = line.split("\t");
const rawStatus = (tabParts[0] ?? "").trim();
if (!rawStatus) continue;
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
const oldPath = nameStatusFields[index++];
const newPath = nameStatusFields[index++];
const oldPath = tabParts[1];
const newPath = tabParts[2];
if (newPath) {
changes.push({
path: newPath,
@@ -540,7 +543,7 @@ async function listCheckoutFileChanges(
continue;
}
const path = nameStatusFields[index++];
const path = tabParts[1];
if (!path) continue;
const code = rawStatus[0];
changes.push({
@@ -553,13 +556,16 @@ async function listCheckoutFileChanges(
if (refs.includeUntracked) {
const { stdout: untrackedOut } = await runGitCommand(
["ls-files", "-z", "--others", "--exclude-standard"],
["ls-files", "--others", "--exclude-standard"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
for (const file of untrackedOut.split("\0").filter(Boolean)) {
for (const file of untrackedOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
changes.push({
path: file,
status: "U",
@@ -617,22 +623,18 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
function normalizeNumstatPath(pathField: string): string {
if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) {
return unquoteGitPath(pathField);
}
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceRenameMatch) {
const [, prefix, , renamed, suffix] = braceRenameMatch;
return unquoteGitPath(`${prefix}${renamed}${suffix}`);
return `${prefix}${renamed}${suffix}`;
}
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
if (inlineRenameMatch) {
return unquoteGitPath(inlineRenameMatch[2] ?? pathField);
return inlineRenameMatch[2] ?? pathField;
}
return unquoteGitPath(pathField);
return pathField;
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
@@ -667,7 +669,7 @@ async function getTrackedNumstatByPath(
const result = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)],
extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
}),
{
cwd,
@@ -678,21 +680,21 @@ async function getTrackedNumstatByPath(
);
const stats = new Map<string, FileStat>();
const records = result.stdout.split("\0");
for (let index = 0; index < records.length; index += 1) {
const parts = records[index].split("\t");
const lines = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const parts = line.split("\t");
if (parts.length < 3) {
continue;
}
const additionsField = parts[0] ?? "";
const deletionsField = parts[1] ?? "";
let path = parts.slice(2).join("\t");
if (!path) {
// A rename has an empty path here, followed by separate old/new NUL fields.
path = records[index + 2];
index += 2;
}
const rawPath = parts.slice(2).join("\t");
const path = normalizeNumstatPath(rawPath);
if (!path) {
continue;
@@ -2422,7 +2424,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi
if (!path) {
return;
}
statuses.set(unquoteGitPath(path), status);
statuses.set(path, status);
}
// A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary
@@ -2736,13 +2738,16 @@ async function countUntrackedAdditions(
): Promise<number> {
try {
const { stdout } = await getRunGitCommand(context)(
["ls-files", "-z", "--others", "--exclude-standard"],
["ls-files", "--others", "--exclude-standard"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
const files = stdout.split("\0").filter(Boolean);
const files = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
let additions = 0;
for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) {
@@ -1,34 +0,0 @@
const GIT_PATH_ESCAPE_BYTES: Record<string, number> = {
a: 7,
b: 8,
t: 9,
n: 10,
v: 11,
f: 12,
r: 13,
'"': 34,
"\\": 92,
};
// core.quotepath=false still quotes control characters, double quotes and backslashes.
export function unquoteGitPath(value: string): string {
if (!value.startsWith('"') || !value.endsWith('"')) return value;
const encoded = value.slice(1, -1);
const parts: Buffer[] = [];
let offset = 0;
for (const match of encoded.matchAll(/\\([0-7]{1,3}|[abfnrtv"\\])/g)) {
parts.push(Buffer.from(encoded.slice(offset, match.index)));
const escape = match[1];
let byte: number;
if (/^[0-7]/.test(escape)) {
byte = Number.parseInt(escape, 8);
} else {
byte = GIT_PATH_ESCAPE_BYTES[escape];
}
parts.push(Buffer.from([byte]));
offset = match.index + match[0].length;
}
parts.push(Buffer.from(encoded.slice(offset)));
return Buffer.concat(parts).toString("utf8");
}