Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] bf3ac32532 test(server): reproduce deletion when archiving a local import 2026-11-06 04:00:34 +00:00
Mohamed Boudra 6355b339c8 fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891)
Under touch sizing the composer's send and stop buttons filled their whole
44pt hit frame with color, so they outweighed every unpainted control in the
toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc
(TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so
desktop is unchanged. The voice button's recording fill follows the same rule.
2026-11-06 10:28:42 +07:00
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00
Okan YILDIRIM a68dc55490 fix(app): refresh visible usage summaries periodically (#7806) 2026-11-06 03:03:57 +00:00
96 changed files with 2399 additions and 5989 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
+3 -2
View File
@@ -226,7 +226,8 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+3 -162
View File
@@ -8,11 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
openChangesPanel,
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
@@ -266,126 +262,6 @@ test.afterEach(async () => {
}
});
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
await openWorkspaceChangesTree(page, workspace);
await expect(
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
).toBeVisible();
await selectChangesComparison(page, "Uncommitted");
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
});
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesTreePanel(page);
}
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await createLargeStreamedWorkspace();
await addOversizedSibling(workspace);
const stream = await holdDiffCompletion(page);
await openWorkspaceChangesSurface(page, workspace, 90_000);
await expectAnnouncedTreeAndLoader(page);
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
stream.finish();
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
const connections = stream.connections;
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
await addOneLineToFirstFile(workspace);
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
expect(stream.connections).toBe(connections);
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
});
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithManyTinyDiffs(252);
await Promise.all(
Array.from({ length: 252 }, (_, file) =>
writeFile(
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
Array.from(
{ length: 90 },
(_unused, line) =>
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
).join("\n") + "\n",
),
),
);
return workspace;
}
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
}
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
const file = path.join(workspace.repoPath, "src/file-0000.ts");
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
}
async function holdDiffCompletion(page: Page) {
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
let holding = true;
let connections = 0;
let completed = 0;
const completions: Array<() => void> = [];
const changedBodies: number[] = [];
const bodyCounts = new Map<number, number>();
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
connections++;
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => serverSocket.send(message));
serverSocket.onMessage((message) => {
if (
typeof message !== "string" &&
message[0] >= DiffOpcode.Begin &&
message[0] <= DiffOpcode.End
) {
const frame = decodeDiffFrame(message);
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
else if (frame.opcode === DiffOpcode.Bodies)
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
else {
completed++;
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
}
}
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
completions.push(() => browserSocket.send(message));
return;
}
browserSocket.send(message);
});
});
return {
changedBodies,
get connections() {
return connections;
},
get completed() {
return completed;
},
finish: () => {
holding = false;
completions.splice(0).forEach((send) => send());
},
};
}
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
}
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
await expect(changesTree(page).first()).toBeVisible();
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
}
test("Changes opens All changes for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
@@ -614,35 +490,6 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
await useLegacyDiffHost(page);
const workspace = await createWorkspaceWithScrollableComparisons();
await openWorkspaceChangesSurface(page, workspace);
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
await selectChangesComparison(page, "Committed");
await expectWorkingComparisonFiles(page, "committed");
await selectChangesComparison(page, "Uncommitted");
await expectWorkingComparisonFiles(page, "uncommitted");
});
async function useLegacyDiffHost(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => serverSocket.send(message));
serverSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message);
if (envelope.message?.payload?.status === "server_info") {
delete envelope.message.payload.features.diffSubscription;
browserSocket.send(JSON.stringify(envelope));
return;
}
}
browserSocket.send(message);
});
});
}
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
page,
}) => {
@@ -1455,13 +1302,11 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
page,
}) => {
test.setTimeout(300_000);
test.setTimeout(180_000);
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
await useUnwrappedDiffLines(page);
await openWorkspaceChangesSurface(page, workspace, 90_000);
// Measure document bounds after delivery, independently of the cold Git read.
await expectWorkingDiffLoaded(page, 240_000);
const root = page.getByTestId("git-diff-canvas-root");
const scroller = page.getByTestId("git-diff-scroll");
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
@@ -2040,16 +1885,12 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(
fileCount: number,
branch = "main",
): Promise<DirtyWorkspace> {
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
content: "export const value = 0;\n",
}));
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
await Promise.all(
files.map((file, index) =>
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
@@ -79,12 +79,6 @@ async function previewExplorerResize(page: Page, distance: number) {
return { ...measurement, width: after };
}
async function expectCleanChangesLoaded(page: Page): Promise<void> {
await expect(
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
).toBeVisible();
}
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
await page.reload();
const explorer = page.getByTestId("workspace-explorer-sidebar");
@@ -106,7 +100,6 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectCleanChangesLoaded(page);
// Verify that direct pane renders are being counted before resetting them.
expect(
await page.evaluate(() =>
@@ -0,0 +1,127 @@
import { expect, test } from "../support/fixtures";
import { addProjectFlow } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
chooseFooterOverflowAction,
expectFooterLine,
expectFooterMenusInsideViewport,
expectFooterNeverClipped,
expectFooterOverflowActions,
footerOverflowMenu,
openFooterOverflow,
seedDockedSidebarWidth,
watchFooterClipping,
} from "../support/helpers/sidebar-footer-overflow";
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
const TOUCH_CONTROL = 44;
const MIN_SIDEBAR_WIDTH = 200;
const ALL_INLINE = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
const NARROW_TOUCH_LINE = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-footer-overflow",
"sidebar-settings",
];
test.describe("Sidebar footer on a touch screen", () => {
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
page,
}) => {
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
await watchFooterClipping(page);
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await test.step("Help opens as a page of the overflow menu", async () => {
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await chooseFooterOverflowAction(page, "Help and support");
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(footerOverflowMenu(page)).toBeHidden();
});
await test.step("Usage opens from the overflow menu", async () => {
await openFooterOverflow(page);
await chooseFooterOverflowAction(page, "Usage");
await expect(footerOverflowMenu(page)).toBeHidden();
await expect(page.getByTestId("usage-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("usage-modal")).toBeHidden();
});
await test.step("Add project and Settings shortcuts still work", async () => {
await page.keyboard.press("Control+o");
await expect(addProjectFlow(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.keyboard.press("Control+,");
await expect(page).toHaveURL(/\/settings\/general$/);
});
});
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
});
});
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
test("the overflow menu reaches the hidden actions", async ({ page }) => {
await watchFooterClipping(page);
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await expectFooterMenusInsideViewport(page);
});
});
test.describe("Sidebar footer on a short, narrow phone", () => {
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostsAndReload(
page,
Array.from({ length: 10 }, (_, index) => ({
serverId: `overflow-host-${index}`,
label: `Overflow host ${index}`,
})),
);
await openCompactSidebar(page);
await expectFooterLine(page, {
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
controlSize: TOUCH_CONTROL,
});
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
await expectFooterMenusInsideViewport(page);
await chooseFooterOverflowAction(page, "Hosts");
await expectFooterMenusInsideViewport(page);
const addHost = page.getByRole("menuitem", { name: "Add host" });
await addHost.scrollIntoViewIfNeeded();
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
await expect(addHost).toBeInViewport();
await addHost.click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
});
});
@@ -8,7 +8,7 @@ import {
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,6 +42,7 @@ import {
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildOpenProjectRoute } from "@/utils/host-routes";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -70,7 +71,112 @@ async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
test.describe("Usage item", () => {
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
let usedPct = 31;
const usage = await installUsageReportsFixture(page, {
lists: [
() => [
{
...claudeAndCodexReports()[0]!,
report: {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
],
},
},
],
],
});
await page.clock.install();
await page.setViewportSize(WIDE);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h"]);
const startingUrl = page.url();
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
const requestsBeforeRefresh = usage.listRequests();
usedPct = 42;
await page.clock.fastForward(5 * 60_000);
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
await expectPinnedUsage(page, ["42% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(startingUrl);
expect(usage.listRequests()).toEqual([
...requestsBeforeRefresh,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await setPageVisibility(page, "hidden");
const requestsBeforeBackgrounding = usage.listRequests();
usedPct = 47;
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
await expectPinnedUsage(page, ["42% 5h"]);
await setPageVisibility(page, "visible");
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["47% 5h"]);
expect(usage.listRequests()).toEqual([
...requestsBeforeBackgrounding,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await page.setViewportSize(COMPACT);
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeHiding = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["47% 5h"]);
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
usedPct = 53;
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["53% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await page.setViewportSize(WIDE);
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectNoUsageItem(page);
const requestsBeforeDisabling = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
});
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.clock.install();
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsHostSection(page, getServerId(), "usage");
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeWaiting = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
});
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
+25 -11
View File
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
export async function addOfflineHostsAndReload(
page: Page,
hosts: Array<{ serverId: string; label: string }>,
options: { primaryLabel?: string } = {},
): Promise<void> {
const nowIso = new Date().toISOString();
const offlineHosts = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: "127.0.0.1:59999",
nowIso,
}),
);
await page.evaluate(
({ host, keys, primaryLabel }) => {
({ hosts: added, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
hosts: offlineHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
primaryLabel: options.primaryLabel,
},
);
@@ -0,0 +1,127 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Every footer control that can sit in the icon line, caret included. */
const FOOTER_LINE_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-footer-overflow",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const GEOMETRY_TOLERANCE = 0.01;
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
await page.addInitScript((sidebarWidth) => {
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
}, width);
}
/**
* From the first frame, record any frame in which a footer button reaches past its row, so a row
* laid out before it measured shows up even if it lasted one frame.
*/
export async function watchFooterClipping(page: Page): Promise<void> {
await page.addInitScript((testIDs) => {
const clippedFrames: string[] = [];
Object.assign(window, { __footerClippedFrames: clippedFrames });
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width === 0) continue;
for (const testID of testIDs) {
const button = row.querySelector(`[data-testid="${testID}"]`);
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
clippedFrames.push(testID);
}
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, FOOTER_LINE_TEST_IDS);
}
export async function expectFooterNeverClipped(page: Page): Promise<void> {
const clipped = await page.evaluate(
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
);
expect(clipped).toEqual([]);
}
function footerLine(page: Page): Locator {
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
}
export function footerOverflowMenu(page: Page): Locator {
return page.getByTestId("sidebar-footer-overflow-menu");
}
/**
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
* line's box, so no button is cut off by the sidebar's edge.
*/
export async function expectFooterLine(
page: Page,
input: { testIDs: string[]; controlSize: number },
): Promise<void> {
const line = footerLine(page);
await expect(line).toHaveCount(1);
await expect
.poll(async () =>
line
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
)
.toEqual(input.testIDs);
const { row, buttons } = await line.evaluate((element, testIDs) => {
const rect = (node: Element) => {
const { x, width, height } = node.getBoundingClientRect();
return { x, width, height };
};
return {
row: rect(element),
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
};
}, input.testIDs);
for (const button of buttons) {
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
}
}
export async function openFooterOverflow(page: Page): Promise<void> {
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
await expect(footerOverflowMenu(page)).toBeVisible();
}
/** The overflow menu lists exactly these actions, in row order. */
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
}
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
}
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
const viewport = page.viewportSize()!;
const rows = page.getByRole("menuitem");
await expect(rows.first()).toBeVisible();
const boxes = await rows.evaluateAll((elements) =>
elements.map((element) => {
const { x, width } = element.getBoundingClientRect();
return { x, width };
}),
);
for (const box of boxes) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
}
+79 -24
View File
@@ -17,6 +17,7 @@ import {
Text,
useWindowDimensions,
View,
type LayoutChangeEvent,
} from "react-native";
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
@@ -40,6 +41,17 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
type SidebarFooterAction,
} from "@/components/sidebar-footer-layout";
import {
controlSizingForLayout,
mapLayout,
TOOLBAR_CONTROL_SIZES,
} from "@/components/ui/control-geometry";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
@@ -55,6 +67,7 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { SPACING } from "@/styles/theme";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -79,6 +92,9 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */
const FOOTER_ROW_PADDING = SPACING[2];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
interface SidebarSharedProps {
@@ -410,35 +426,72 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const isCompactLayout = useIsCompactFormFactor();
const [rowWidth, setRowWidth] = useState(0);
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
setRowWidth(event.nativeEvent.layout.width);
}, []);
const { inline, overflow } = useMemo(
() =>
resolveSidebarFooterLayout({
rowWidth,
controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)],
rowPadding: FOOTER_ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
}),
[rowWidth, isCompactLayout],
);
const isInline = (action: SidebarFooterAction) => inline.includes(action);
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that
// don't fit move behind a caret before Settings.
return (
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View
style={styles.sidebarFooter}
onLayout={handleRowLayout}
testID="sidebar-footer-bottom-line"
>
{isInline("add-project") ? (
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
) : null}
{isInline("usage") ? <SidebarUsageIcon label={labels.usage} /> : null}
{isInline("hosts") ? (
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
{isInline("help") ? <SidebarHelpMenu /> : null}
{overflow.length > 0 ? (
<SidebarFooterOverflowMenu
actions={overflow}
labels={labels}
onAddProject={handleOpenProject}
onSettings={handleSettings}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
{isInline("settings") ? (
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
) : null}
</View>
</View>
</UsageSidebarRoot>
@@ -937,10 +990,12 @@ const styles = StyleSheet.create((theme) => ({
borderTopColor: theme.colors.border,
},
// Buttons sit edge to edge; their own inset spaces the glyphs.
// As tall as its buttons from the first frame, which places none until the row has measured.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
paddingHorizontal: FOOTER_ROW_PADDING,
paddingVertical: theme.spacing[1.5],
},
// Pushes Help and Settings to the end of the icon line.
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
} from "@/components/sidebar-footer-layout";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
const TOUCH = 44;
const POINTER = 28;
const ROW_PADDING = 8;
function layout(rowWidth: number, controlSize: number) {
return resolveSidebarFooterLayout({
rowWidth,
controlSize,
rowPadding: ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
});
}
describe("sidebar footer layout", () => {
it("keeps every action inline at the default width with either sizing", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({
inline: ["add-project", "usage", "hosts", "help", "settings"],
overflow: [],
});
}
});
it("keeps every action inline at the minimum width with pointer sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]);
});
// The iPhone Duo report: the folded sidebar column is about the minimum width, and touch
// sizing makes five 44pt buttons plus padding 236pt wide.
it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({
inline: ["add-project", "hosts", "settings"],
overflow: ["usage", "help"],
});
});
it("keeps Settings and Add project inline longest", () => {
// Three slots: two actions and the caret.
expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["add-project", "settings"],
overflow: ["usage", "hosts", "help"],
});
// Two slots: Settings and the caret.
expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["settings"],
overflow: ["add-project", "usage", "hosts", "help"],
});
});
it("puts everything behind the caret when only the caret fits", () => {
expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({
inline: [],
overflow: ["add-project", "usage", "hosts", "help", "settings"],
});
});
it("fits all five actions exactly at their combined width", () => {
const exact = 5 * TOUCH + 2 * ROW_PADDING;
expect(layout(exact, TOUCH).overflow).toEqual([]);
// Browser layout rounds a little below the exact width.
expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]);
expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]);
});
it("only places the actions it is given", () => {
expect(
resolveSidebarFooterLayout({
rowWidth: 3 * TOUCH + 2 * ROW_PADDING,
controlSize: TOUCH,
rowPadding: ROW_PADDING,
actions: ["add-project", "hosts", "settings"],
}),
).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] });
});
// Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars.
it("places nothing before the row has been measured", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] });
}
});
});
@@ -0,0 +1,62 @@
/** The sidebar footer's icon line, in row order. */
export const SIDEBAR_FOOTER_ACTIONS = [
"add-project",
"usage",
"hosts",
"help",
"settings",
] as const;
export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number];
/** The order actions keep their place in a row too narrow for all of them. */
const KEEP_ORDER: readonly SidebarFooterAction[] = [
"settings",
"add-project",
"hosts",
"usage",
"help",
];
/** Measured widths land a hair under the exact sum of their parts. */
const MEASUREMENT_TOLERANCE = 0.5;
export interface SidebarFooterLayout {
/** Actions drawn in the row, in row order. */
inline: SidebarFooterAction[];
/** Actions behind the overflow caret, in row order. Empty when everything fits. */
overflow: SidebarFooterAction[];
}
/**
* Splits the footer's actions between the row and the overflow menu. Buttons keep their full size;
* when they don't all fit, the caret takes one slot and the actions that lose their place move
* behind it, Help first and Settings last. Nothing is placed until the row has a measured width.
*/
export function resolveSidebarFooterLayout(input: {
rowWidth: number;
controlSize: number;
rowPadding: number;
actions: readonly SidebarFooterAction[];
}): SidebarFooterLayout {
const { rowWidth, controlSize, rowPadding, actions } = input;
// An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow
// sidebar before the real width arrives.
if (rowWidth <= 0) {
return { inline: [], overflow: [] };
}
const slots = Math.floor(
(rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1),
);
if (slots >= actions.length) {
return { inline: [...actions], overflow: [] };
}
const inlineCount = Math.max(slots - 1, 0);
const kept = new Set(
KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount),
);
return {
inline: actions.filter((action) => kept.has(action)),
overflow: actions.filter((action) => !kept.has(action)),
};
}
@@ -0,0 +1,69 @@
import type { ReactElement } from "react";
import { Text, View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { density } from "@/styles/density";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
/**
* A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the
* tooltip stays out of the way while the menu is open.
*/
export function SidebarFooterMenuTrigger({
icon,
label,
menuOpen,
testID,
}: {
icon: LucideIcon;
label: string;
menuOpen: boolean;
testID: string;
}): ReactElement {
return (
<Tooltip delayDuration={300} enabledOnDesktop={!menuOpen}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
>
{({ hovered, pressed, open }) => (
<>
<SidebarFooterIconHighlight visible={pressed || open} />
<DensityIcon
icon={icon}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -0,0 +1,245 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronUp,
CircleGauge,
CircleHelp,
FolderPlus,
Plus,
Server,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import type { SidebarFooterAction } from "@/components/sidebar-footer-layout";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useHosts } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { useOpenSidebarUsage } from "@/usage";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { SidebarHelpMenuItems } from "./sidebar-help-menu";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedCircleGauge = withUnistyles(CircleGauge);
const ThemedServer = withUnistyles(Server);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedSettings = withUnistyles(Settings);
const ThemedPlus = withUnistyles(Plus);
const addProjectLeading = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const usageLeading = <ThemedCircleGauge size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const hostsLeading = <ThemedServer size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const helpLeading = <ThemedCircleHelp size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const settingsLeading = <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const addHostLeading = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const HOSTS_PAGE_ID = "hosts";
const HELP_PAGE_ID = "help";
export interface SidebarFooterOverflowMenuProps {
/** The footer actions that did not fit in the row, in row order. */
actions: readonly SidebarFooterAction[];
labels: { addProject: string; hosts: string; settings: string; usage: string };
onAddProject: () => void;
onSettings: () => void;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}
/**
* The caret at the end of a footer row too narrow for every action. It lists the actions that
* lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their
* own in the row, open as pages of this menu.
*/
export function SidebarFooterOverflowMenu({
actions,
labels,
onAddProject,
onSettings,
onAddHost,
onOpenHostSettings,
}: SidebarFooterOverflowMenuProps): ReactElement {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const openUsage = useOpenSidebarUsage();
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const helpLabel = t("sidebar.help.trigger");
const addProjectTrailing = useMemo(
() => (newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null),
[newAgentKeys],
);
const settingsTrailing = useMemo(
() => (settingsKeys ? <Shortcut chord={settingsKeys} /> : null),
[settingsKeys],
);
const pages = useMemo((): MenuPageDefinition[] => {
const result: MenuPageDefinition[] = [];
if (actions.includes("hosts")) {
result.push({
id: HOSTS_PAGE_ID,
title: labels.hosts,
content: <HostsPage onAddHost={onAddHost} onOpenHostSettings={onOpenHostSettings} />,
});
}
if (actions.includes("help")) {
result.push({ id: HELP_PAGE_ID, title: helpLabel, content: <SidebarHelpMenuItems /> });
}
return result;
}, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]);
function renderAction(action: SidebarFooterAction): ReactElement {
switch (action) {
case "add-project":
return (
<DropdownMenuItem
key={action}
testID="sidebar-add-project"
leading={addProjectLeading}
trailing={addProjectTrailing}
onSelect={onAddProject}
>
{labels.addProject}
</DropdownMenuItem>
);
case "usage":
return (
<DropdownMenuItem
key={action}
testID="sidebar-usage-icon"
leading={usageLeading}
onSelect={openUsage}
>
{labels.usage}
</DropdownMenuItem>
);
case "hosts":
return (
<DropdownMenuSubTrigger
key={action}
id={HOSTS_PAGE_ID}
testID="sidebar-hosts-trigger"
leading={hostsLeading}
>
{labels.hosts}
</DropdownMenuSubTrigger>
);
case "help":
return (
<DropdownMenuSubTrigger
key={action}
id={HELP_PAGE_ID}
testID="sidebar-help"
leading={helpLeading}
>
{helpLabel}
</DropdownMenuSubTrigger>
);
case "settings":
return (
<DropdownMenuItem
key={action}
testID="sidebar-settings"
leading={settingsLeading}
trailing={settingsTrailing}
onSelect={onSettings}
>
{labels.settings}
</DropdownMenuItem>
);
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={ChevronUp}
label={t("sidebar.actions.moreActions")}
menuOpen={open}
testID="sidebar-footer-overflow"
/>
<DropdownMenuContent
side="top"
align="end"
offset={8}
width={240}
pages={pages}
scrollable
testID="sidebar-footer-overflow-menu"
>
{actions.map(renderAction)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The host picker's choices as a menu page: each host opens its settings, then Add host. */
function HostsPage({
onAddHost,
onOpenHostSettings,
}: {
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const { t } = useTranslation();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
return (
<>
{orderedHosts.map((host) => (
<HostRow
key={host.serverId}
serverId={host.serverId}
label={host.label}
onOpenHostSettings={onOpenHostSettings}
/>
))}
{orderedHosts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem testID="sidebar-host-add" leading={addHostLeading} onSelect={onAddHost}>
{t("settings.addHost")}
</DropdownMenuItem>
</>
);
}
function HostRow({
serverId,
label,
onOpenHostSettings,
}: {
serverId: string;
label: string;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const handleSelect = useCallback(
() => onOpenHostSettings(serverId),
[onOpenHostSettings, serverId],
);
const leading = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
return (
<DropdownMenuItem
testID={`sidebar-host-row-${serverId}`}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
import { View } from "react-native";
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -12,17 +12,13 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -77,12 +73,32 @@ function HostVersionHint({ host }: { host: HostProfile }) {
);
}
/** The Help menu's trigger, and the menu it opens. */
export function SidebarHelpMenu() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={CircleHelp}
label={t("sidebar.help.trigger")}
menuOpen={open}
testID="sidebar-help"
/>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<SidebarHelpMenuItems />
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */
export function SidebarHelpMenuItems() {
const { t } = useTranslation();
const shortcutsAvailable = useHardwareKeyboardPresent();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false);
const version = formatVersionWithPrefix(resolveAppVersion());
const hosts = useHosts();
@@ -99,105 +115,65 @@ export function SidebarHelpMenu() {
}, []);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered, pressed, open: menuOpen }) => (
<>
<SidebarFooterIconHighlight visible={pressed || menuOpen} />
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("sidebar.help.trigger")}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
) : null}
<>
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.whatsNew")}
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
) : null}
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
>
{t("sidebar.help.whatsNew")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</DropdownMenuContent>
</DropdownMenu>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
versionList: {
gap: theme.spacing[1],
paddingVertical: theme.spacing[2],
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -97,3 +97,26 @@ describe("getTransformOrigin", () => {
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
});
});
describe("fitSurfaceWidth", () => {
it("leaves a surface that fits the display alone, capped at the display", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
width: 240,
minWidth: 180,
maxWidth: 984,
});
});
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
width: 204,
minWidth: 180,
maxWidth: 204,
});
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
width: undefined,
minWidth: 134,
maxWidth: 134,
});
});
});
+21
View File
@@ -120,6 +120,27 @@ function clampToDisplayArea(input: {
};
}
export interface SurfaceWidth {
width?: number;
minWidth?: number;
maxWidth?: number;
}
/**
* Caps a surface's requested width to the display. Position clamping can move a surface but not
* shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge.
*/
export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth {
const available = Math.max(displayWidth - EDGE_PADDING * 2, 0);
const cap = (value: number | undefined) =>
typeof value === "number" ? Math.min(value, available) : undefined;
return {
width: cap(requested.width),
minWidth: cap(requested.minWidth),
maxWidth: cap(requested.maxWidth) ?? available,
};
}
export function computePosition({
triggerRect,
contentSize,
@@ -14,6 +14,8 @@ import {
getControlInteractionPhase,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZE,
TOOLBAR_DISC_SIZES,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -90,6 +92,11 @@ describe("control sizing gate", () => {
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
// A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its
// 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph.
expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer });
expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 });
expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
@@ -93,6 +93,14 @@ export const ICON_BUTTON_SIZES = {
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* The painted disc of a filled toolbar control, such as the composer's send and stop. The
* control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so
* touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame.
*/
export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
@@ -29,6 +29,7 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
measureElement,
type Alignment,
@@ -283,11 +284,7 @@ export function AnchoredSurface({
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
return [
resolvedWidthStyle,
{
+10 -2
View File
@@ -35,6 +35,7 @@ import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
@@ -1117,7 +1118,7 @@ function ComposerCancelButton({
accessibilityRole="button"
style={cancelButtonStyle}
>
{icon}
<View style={styles.cancelButtonDisc}>{icon}</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -2595,14 +2596,21 @@ const styles = StyleSheet.create((theme: Theme) => ({
width: "100%",
gap: theme.spacing[3],
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
cancelButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
cancelButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
+41 -24
View File
@@ -33,6 +33,7 @@ import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_SIZE,
} from "@/components/ui/control-geometry";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
@@ -349,7 +350,11 @@ function VoiceButtonIcon({
buttonIconSize: number;
}) {
if (isDictating) {
return <Square size={buttonIconSize} color="white" fill="white" />;
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={buttonIconSize} color="white" fill="white" />
</View>
);
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
@@ -837,12 +842,14 @@ function SendButtonTooltip({
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
@@ -1925,7 +1932,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.voiceButton,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
],
[isDictating, isDictationStartEnabled],
);
@@ -1974,12 +1980,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useMemo(
() => [
styles.sendButton,
submitLabel ? styles.sendButtonLabeled : undefined,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isPresentedSendDisabled, submitLabel],
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
[isPresentedSendDisabled],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -2416,23 +2418,38 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
backgroundColor: theme.colors.destructive,
alignItems: "center",
justifyContent: "center",
},
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
sendButton: {
minWidth: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
sendButtonLabeled: {
width: "auto",
minWidth: TOOLBAR_CONTROL_HEIGHT,
sendButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonDiscLabeled: {
minWidth: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonLabel: {
fontSize: theme.fontSize.base,
+8 -53
View File
@@ -1,4 +1,4 @@
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
@@ -144,21 +144,10 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
["status", "providers_snapshot_update"],
() => {},
),
observeDiff(input) {
const cwd = input.cwd;
let mode: "uncommitted" | "base" | "all" = "uncommitted";
if (input.head.kind === "rev") mode = "base";
else if (input.mergeBase) mode = "all";
const compare = {
mode,
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
? { baseRef: input.base.rev }
: {}),
ignoreWhitespace: input.ignoreWhitespace,
};
observeCheckoutDiff(cwd, compare) {
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
const handle = observe(
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
["checkout_diff_update", "subscribe_checkout_diff_response"],
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
@@ -166,43 +155,9 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
"subscriptionId" in message.payload &&
message.payload.subscriptionId === subscriptionId,
);
const modern: OwnedSubscription<DiffState> = {
...handle,
ready: Promise.resolve({
phase: "ready",
revision: 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: [],
diffTooLarge: false,
subscriptionId,
}),
subscribe: (observer) =>
handle.subscribe({
snapshot: (payload) =>
observer.snapshot({
phase: "ready",
revision: 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: payload.files,
diffTooLarge: payload.diffTooLarge === true,
subscriptionId,
}),
update: (message) => {
if (message.type === "checkout_diff_update")
observer.snapshot({
phase: "ready",
revision: 2,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
files: message.payload.files,
diffTooLarge: message.payload.diffTooLarge === true,
subscriptionId,
});
},
}),
};
if (!config.rejectCheckoutDiffSubscribe) return modern;
if (!config.rejectCheckoutDiffSubscribe) return handle;
return {
...modern,
...handle,
subscribe: (observer) => {
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
return () => {};
@@ -373,7 +328,7 @@ describe("server data push router", () => {
const first = publish(files, "first");
const same = publish(structuredClone(files), "reopen");
expect(same.files).toBe(first.files);
expect(same.requestId).toBe("diff:1:ready");
expect(same.requestId).toBe("reopen");
const changed = structuredClone(files);
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
const next = publish(changed, "edit");
@@ -475,7 +430,7 @@ describe("server data push router", () => {
files: [],
error: null,
diffTooLarge: true,
requestId: "diff:1:ready",
requestId: "diff-1",
});
fake.emit({
@@ -494,7 +449,7 @@ describe("server data push router", () => {
files: [],
error: null,
diffTooLarge: true,
requestId: "diff:2:ready",
requestId: `subscription:${serverSubscriptionId}`,
});
unsubscribeObserver();
+30 -27
View File
@@ -1,5 +1,3 @@
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
import type { DiffState } from "@getpaseo/client";
import type { OwnedSubscription } from "@getpaseo/client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
@@ -25,9 +23,14 @@ type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>;
type SubscribeCheckoutDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
@@ -46,7 +49,7 @@ interface CheckoutDiffRoute {
}
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
subscription: OwnedSubscription<DiffState>;
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
}
interface WorkspaceTerminalsRoute {
@@ -72,7 +75,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
interface ServerDataPushClient {
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
}
@@ -336,34 +339,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
}
for (const [key, desired] of input.desired) {
if (input.active.has(key)) continue;
const { mode, baseRef, ignoreWhitespace } = desired.compare;
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
if (!revision) continue;
const subscription = input.client.observeDiff({
cwd: desired.cwd,
base: { kind: "rev", rev: revision },
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
mergeBase: mode !== "uncommitted",
ignoreWhitespace,
});
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
const registration = { ...desired, subscription };
input.active.set(key, registration);
const apply = (
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
) => {
if (input.active.get(key) !== registration) return;
const { subscriptionId, ...snapshot } = payload;
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.active,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: key,
payload: {
...snapshot,
files: orderCheckoutDiffFiles(payload.files),
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
},
});
};
subscription.subscribe({
snapshot: (state) => {
if (input.active.get(key) !== registration) return;
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.active,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: key,
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
});
snapshot: apply,
update: (message) => {
if (message.type === "checkout_diff_update") apply(message.payload);
},
update: () => {},
error: (error) => {
if (input.active.get(key) === registration) input.active.delete(key);
console.error("[server-data] observeDiff failed", {
console.error("[server-data] observeCheckoutDiff failed", {
serverId: input.serverId,
cwd: desired.cwd,
error,
+2 -2
View File
@@ -13,10 +13,10 @@ import {
type DiffTreeRow,
} from "@/git/diff-tree";
import { FileHeader } from "@/git/file-header";
import type { DiffFileHeader } from "@/git/diff-snapshot";
import type { ParsedDiffFile } from "@/git/use-diff-query";
export interface ChangedFilesTreeProps {
files: DiffFileHeader[];
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
+1 -1
View File
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
// chains, so it is skipped; the collapsed set is empty because the flat list
// always carries every file, whatever the rail has collapsed.
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
row.kind === "file" ? [files[row.fileIndex]] : [],
row.kind === "file" ? [row.file] : [],
);
}
+6 -19
View File
@@ -1,4 +1,3 @@
import type { DiffFileHeader } from "@/git/diff-snapshot";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
@@ -1096,7 +1095,6 @@ interface DiffBodyContentProps {
emptyMessage: string;
emptyAction: ChangesEmptyAction | null;
children: ReactElement;
loadingLabel: string;
checkingRepositoryLabel: string;
notRepositoryLabel: string;
}
@@ -1113,16 +1111,11 @@ function DiffBodyContent({
emptyAction,
children,
checkingRepositoryLabel,
loadingLabel,
notRepositoryLabel,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View
style={styles.loadingContainer}
accessibilityRole="progressbar"
accessibilityLabel={loadingLabel}
>
<View style={styles.loadingContainer}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
</View>
@@ -1144,16 +1137,12 @@ function DiffBodyContent({
}
if (isDiffLoading) {
return (
<View
style={styles.loadingContainer}
accessibilityRole="progressbar"
accessibilityLabel={loadingLabel}
>
<View style={styles.loadingContainer}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
if (diffTooLarge && !hasChanges) {
if (diffTooLarge) {
return <DiffTooLargeState />;
}
if (diffErrorMessage) {
@@ -1332,7 +1321,7 @@ function ChangesTreeRail({
}: {
shown: boolean;
children: ReactElement;
files: DiffFileHeader[];
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
@@ -1372,7 +1361,7 @@ function ChangesBody({
children: ReactElement;
desktopTreeVisible: boolean;
isMobile: boolean;
files: DiffFileHeader[];
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
@@ -1597,7 +1586,6 @@ export function ChangesSurface({
selectBase: handleSelectBase,
selectAll: handleSelectAll,
files,
treeFiles,
diffPayloadError,
diffTooLarge,
isDiffLoading,
@@ -1847,7 +1835,6 @@ export function ChangesSurface({
hasChanges={hasChanges}
emptyMessage={emptyMessage}
emptyAction={emptyAction}
loadingLabel={t("common.loading")}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
@@ -1878,7 +1865,7 @@ export function ChangesSurface({
presentation={presentation}
desktopTreeVisible={desktopTreeVisible}
isMobile={isMobile}
files={treeFiles}
files={files}
mode={workingMode}
onSelectFile={handleSelectTreeFile}
treeWidth={instanceState.treeWidth}
+2 -4
View File
@@ -1,8 +1,7 @@
import { replaceEqualDeep } from "@tanstack/react-query";
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
type DiffPayload = CheckoutDiffSnapshot;
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
export function retainDiffFiles(
@@ -31,7 +30,6 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
error === old.error &&
old.cwd === next.cwd &&
old.requestId === next.requestId &&
old.entries === next.entries &&
old.diffTooLarge === next.diffTooLarge
? old
: { ...next, files, error };
-74
View File
@@ -1,74 +0,0 @@
import type { DiffState, DiffEntry } from "@getpaseo/client";
import type {
DiffError,
ParsedDiffFile,
SubscribeCheckoutDiffRequest,
} from "@getpaseo/protocol/messages";
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
export interface CheckoutDiffSnapshot {
requestId: string;
cwd: string;
files: ParsedDiffFile[];
entries?: DiffEntry[];
error: DiffError | null;
diffTooLarge?: boolean;
}
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
if (state.phase === "loading")
return { ...common, files: [], entries: state.entries, error: null };
if (state.phase === "error") return { ...common, files: [], error: state.error };
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
}
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
return entries.map((entry) => ({
path: entry.path,
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
isNew: entry.change === "added",
isDeleted: entry.change === "deleted",
additions: entry.additions,
deletions: entry.deletions,
status: entry.content === "binary" ? "binary" : "ok",
}));
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
entries: DiffEntry[] | null;
payloadError: CheckoutDiffSnapshot["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
entries: payload?.entries ?? null,
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null || payload.entries !== undefined,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}
export function checkoutDiffComparisonError(
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
): DiffError | null {
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
return {
code: "INVALID_COMPARE",
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
};
}
+5 -5
View File
@@ -1,6 +1,6 @@
import type { DiffFileHeader } from "@/git/diff-snapshot";
import type { ParsedDiffFile } from "@/git/use-diff-query";
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
// renders. The tree renders on every form factor, consistent with the Files
// explorer.
//
@@ -17,7 +17,7 @@ import type { DiffFileHeader } from "@/git/diff-snapshot";
export interface DiffTreeFileNode {
kind: "file";
file: DiffFileHeader;
file: ParsedDiffFile;
fileIndex: number;
/** basename, e.g. "diff-pane.tsx" */
name: string;
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
export interface DiffTreeFileRow {
kind: "file";
file: DiffFileHeader;
file: ParsedDiffFile;
fileIndex: number;
depth: number;
}
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
}
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
+4 -4
View File
@@ -28,11 +28,11 @@ import {
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import type { DiffFileHeader } from "@/git/diff-snapshot";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
export interface FileHeaderProps {
file: DiffFileHeader;
file: ParsedDiffFile;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
bodyVisible: boolean;
showsBodyState?: boolean;
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
return showsBodyState ? bodyVisible : undefined;
}
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
if (!canvasRendered) return undefined;
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
}
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
];
}
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
return diffFileChangeKind(file);
}
+1 -26
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult } from "./diff-snapshot";
import { deriveCheckoutDiffResult } from "./use-diff-query";
function createParsedDiffFile(path: string): ParsedDiffFile {
return {
@@ -59,7 +59,6 @@ describe("deriveCheckoutDiffResult", () => {
expect(result).toEqual({
files: [],
entries: null,
payloadError: error,
diffTooLarge: true,
isLoading: false,
@@ -69,27 +68,3 @@ describe("deriveCheckoutDiffResult", () => {
});
});
});
it("shows the announced tree while file bodies are still loading", () => {
const entries = [
{
path: "src/app.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 3,
deletions: 1,
},
] as const;
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
expect(result.isLoading).toBe(true);
expect(result.entries).toEqual(entries);
expect(result.files).toEqual([]);
});
it("settles a comparison without a base and leaves Uncommitted available", async () => {
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
});
+42 -19
View File
@@ -5,13 +5,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
import { useReplicaQuery } from "@/data/query";
import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
checkoutDiffComparisonError,
deriveCheckoutDiffResult,
type CheckoutDiffSnapshot,
} from "./diff-snapshot";
export { deriveCheckoutDiffResult } from "./diff-snapshot";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
@@ -24,7 +18,7 @@ interface UseCheckoutDiffQueryOptions {
queryScope?: string;
}
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
// Re-export the canonical protocol type so all consumers share one definition.
export type { ParsedDiffFile };
@@ -83,11 +77,7 @@ export function useCheckoutDiffQuery({
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const comparisonError =
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
const routeEnabled = Boolean(
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey,
@@ -107,13 +97,46 @@ export function useCheckoutDiffQuery({
}),
});
const boundaryError =
requiresUpdate && isConnected
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
: comparisonError;
return deriveCheckoutDiffResult(
boundaryError
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
requiresUpdate && isConnected
? {
requestId: "unsupported",
cwd,
files: [],
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
}
: (query.data ?? null),
);
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
payloadError: CheckoutDiffQueryPayload["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(
payload: CheckoutDiffQueryPayload | null,
): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}
+1 -10
View File
@@ -9,8 +9,6 @@ import {
useReviewAttachmentSnapshot,
useReviewFeedback,
} from "@/review";
import { useSessionStore } from "@/stores/session-store";
import { diffEntryHeaders } from "./diff-snapshot";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
@@ -46,11 +44,7 @@ export function useWorkingDiff({
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const modernDiff = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
);
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
const baseRef = gitStatus?.baseRef ?? undefined;
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
@@ -65,7 +59,6 @@ export function useWorkingDiff({
const {
files,
entries,
payloadError: diffPayloadError,
diffTooLarge,
isLoading: isDiffLoading,
@@ -78,7 +71,6 @@ export function useWorkingDiff({
enabled: enabled && isGit,
queryScope,
});
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
@@ -120,7 +112,6 @@ export function useWorkingDiff({
selectBase,
selectAll,
files,
treeFiles,
diffPayloadError,
diffTooLarge,
isDiffLoading,
+1
View File
@@ -1236,6 +1236,7 @@ export const ar: TranslationResources = {
hosts: "المضيفون",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",
moreActions: "المزيد من الإجراءات",
},
footer: {
usage: "الاستخدام",
+1
View File
@@ -1244,6 +1244,7 @@ export const en = {
hosts: "Hosts",
settings: "Settings",
closeSidebar: "Close sidebar",
moreActions: "More actions",
},
footer: {
usage: "Usage",
+1
View File
@@ -1272,6 +1272,7 @@ export const es: TranslationResources = {
hosts: "Hosts",
settings: "Ajustes",
closeSidebar: "Cerrar barra lateral",
moreActions: "Más acciones",
},
footer: {
usage: "Uso",
+1
View File
@@ -1266,6 +1266,7 @@ export const fr: TranslationResources = {
hosts: "Hôtes",
settings: "Paramètres",
closeSidebar: "Fermer la barre latérale",
moreActions: "Plus d’actions",
},
footer: {
usage: "Utilisation",
+1
View File
@@ -1250,6 +1250,7 @@ export const ja: TranslationResources = {
hosts: "ホスト",
settings: "設定",
closeSidebar: "サイドバーを閉じる",
moreActions: "その他のアクション",
},
footer: {
usage: "使用状況",
+1
View File
@@ -1243,6 +1243,7 @@ export const ko: TranslationResources = {
hosts: "호스트",
settings: "설정",
closeSidebar: "사이드바 닫기",
moreActions: "추가 작업",
},
footer: {
usage: "사용량",
+1
View File
@@ -1262,6 +1262,7 @@ export const ptBR: TranslationResources = {
hosts: "Hosts",
settings: "Configurações",
closeSidebar: "Fechar barra lateral",
moreActions: "Mais ações",
},
footer: {
usage: "Uso",
+1
View File
@@ -1254,6 +1254,7 @@ export const ru: TranslationResources = {
hosts: "Хосты",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",
moreActions: "Дополнительные действия",
},
footer: {
usage: "Использование",
+1
View File
@@ -1228,6 +1228,7 @@ export const zhCN: TranslationResources = {
hosts: "Hosts",
settings: "设置",
closeSidebar: "关闭侧边栏",
moreActions: "更多操作",
},
footer: {
usage: "使用情况",
+14 -7
View File
@@ -33,9 +33,10 @@ import {
} from "./model";
import type { UsageReportEntry, UsageView } from "./types";
// The daemon caches each report for five minutes, so re-reading it is cheap. Only
// an explicit refresh passes `forceRefresh` and reaches the source's API.
// The daemon caches each report for five minutes, so re-reading it is cheap.
// An explicit refresh passes `forceRefresh` to bypass that cache.
const REPORTS_STALE_TIME_MS = 60_000;
const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000;
/** Every report list of a host: its own and each agent's. */
function hostUsageQueryKey(serverId: string) {
@@ -164,11 +165,16 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const NO_REPORTS: UsageReportEntry[] = [];
/**
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
interface UsageHostReportsInput {
serverId: string | null;
shouldPoll?: boolean;
}
/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */
export function useUsageHostReports({
serverId,
shouldPoll = false,
}: UsageHostReportsInput): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
@@ -177,6 +183,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[]
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
+8 -1
View File
@@ -16,6 +16,8 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
@@ -36,9 +38,14 @@ import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const active = useRetainedPanelActive();
const isAppVisible = useAppVisible();
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports(inSidebar ? serverId : null);
const reports = useUsageHostReports({
serverId: inSidebar ? serverId : null,
shouldPoll: active && inSidebar && isAppVisible,
});
const { preferences } = useUsageDisplay(reports);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
+1 -2
View File
@@ -45,8 +45,7 @@
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
"test": "vitest run",
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.2",
@@ -1,38 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { decodeDiffFrame } from "@getpaseo/protocol/binary-frames/index";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
import { DiffStream } from "../src/diff/stream.js";
const artifacts = resolve(import.meta.dirname, "../../../.artifacts/diff-subscription");
const results = [];
for (const count of [1, 30]) {
const corpus = JSON.parse(await readFile(resolve(artifacts, `corpus-${count}.json`), "utf8")) as {
frames: string[];
legacy: string;
};
const frames = corpus.frames.map((value) => Buffer.from(value, "base64"));
const binary: number[] = [];
const json: number[] = [];
for (let iteration = 0; iteration < 15; iteration++) {
let files = 0;
const stream = new DiffStream((state) => {
if (state.phase === "ready") files = state.files.length;
});
let start = performance.now();
for (const frame of frames) stream.receive(decodeDiffFrame(frame));
binary.push(performance.now() - start);
start = performance.now();
validateWSOutboundMessage(JSON.parse(corpus.legacy));
json.push(performance.now() - start);
if (!files) throw new Error("No materialized files");
}
const median = (values: number[]) =>
values.slice(3).sort((a, b) => a - b)[Math.floor((values.length - 3) / 2)];
const result = { count, materializationMs: median(binary), jsonParseValidationMs: median(json) };
results.push(result);
console.log(JSON.stringify(result));
}
await writeFile(resolve(artifacts, "client-metrics.json"), JSON.stringify(results, null, 2));
if (results.some((result) => result.materializationMs > result.jsonParseValidationMs))
process.exitCode = 1;
+2 -61
View File
@@ -1,9 +1,3 @@
import {
DiffStreamRouter,
DiffSubscriptionError,
type DiffCompare,
type DiffState,
} from "./diff/index.js";
import {
ChunkedTransport,
ChunkedTransportProtocolError,
@@ -1417,20 +1411,6 @@ export class DaemonClient {
private connectionFailure: Error | undefined;
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
private connectionState: ConnectionState = { status: "idle" };
private readonly diffStreams = new DiffStreamRouter({
modern: (compare) =>
this.observe(
"diff.subscribe.response",
{ type: "diff.subscribe.request", ...compare },
{
validateSnapshot: (payload) => {
if (payload.error) throw new DiffSubscriptionError(payload.error);
},
},
),
legacy: (cwd, compare) => this.observeCheckoutDiff(cwd, compare),
failed: (error) => this.logger.error({ err: error }, "Diff observer failed"),
});
private readonly terminalStreams = new TerminalStreamRouter();
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
@@ -1780,7 +1760,6 @@ export class DaemonClient {
this.disposeTransport(1000, "Client closed");
this.providerSnapshotUpdates.clear();
this.clearWaiters(new Error("Daemon client closed"));
await this.diffStreams.close();
await this.owned.close();
this.creations.close();
this.rejectPendingSendQueue(new Error("Daemon client closed"));
@@ -2684,12 +2663,7 @@ export class DaemonClient {
private observe<T extends CorrelatedResponseType>(
responseType: T,
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
options?: {
requestId?: string;
timeout?: number;
signal?: AbortSignal;
validateSnapshot?: (payload: CorrelatedResponsePayload<T>) => void;
},
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<T>> {
let resetSource = false;
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
@@ -2715,16 +2689,12 @@ export class DaemonClient {
requestType: message.type,
error: payload.error,
});
options?.validateSnapshot?.(payload);
accept(payload);
return payload;
},
});
} catch (error) {
resetSource =
!(error instanceof DaemonRpcError) &&
!(error instanceof DiffSubscriptionError) &&
this.isConnected;
resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
throw error;
}
},
@@ -4690,26 +4660,6 @@ export class DaemonClient {
});
}
observeDiff(compare: DiffCompare): OwnedSubscription<DiffState> {
return this.diffStreams.observe(compare);
}
async readDiff(compare: DiffCompare): Promise<DiffState> {
const observation = this.observeDiff(compare);
try {
return await new Promise<DiffState>((resolve) => {
observation.subscribe({
snapshot: (state) => {
if (state.phase !== "loading") resolve(state);
},
update: () => {},
});
});
} finally {
await observation.release();
}
}
observeCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
@@ -6986,7 +6936,6 @@ export class DaemonClient {
this.chunkedTransport?.close();
this.chunkedTransport = null;
this.owned.disconnected();
this.diffStreams.disconnected();
this.providerSnapshotUpdates.clear();
this.stopLivenessHeartbeat();
this.cleanupTransport();
@@ -7177,10 +7126,6 @@ export class DaemonClient {
for (const handler of this.hostFrameListeners) handler(hostFrame);
return true;
}
if (rawBytes[0] >= 0x40 && rawBytes[0] <= 0x4f) {
this.diffStreams.receive(rawBytes);
return true;
}
if (isPluginFrameOpcode(rawBytes[0])) {
this.handlePluginFrame(rawBytes);
return true;
@@ -7381,7 +7326,6 @@ export class DaemonClient {
}
this.owned.disconnected();
this.diffStreams.disconnected();
this.providerSnapshotUpdates.clear();
// Clear all pending waiters and queued sends since the connection was lost
@@ -7541,7 +7485,6 @@ export class DaemonClient {
this.reconnectAttempt = 0;
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
this.startLivenessHeartbeat();
this.diffStreams.connected(serverInfo.features?.diffSubscription === true);
this.owned.restore(serverInfo, {
...DEFAULT_CLIENT_CAPABILITIES,
...this.config.capabilities,
@@ -7818,5 +7761,3 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
cwd: merged.cwd,
};
}
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
-210
View File
@@ -1,210 +0,0 @@
import { expect, test } from "vitest";
import { DaemonClient, type DaemonTransport } from "../daemon-client.js";
import {
encodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { DiffState } from "./index.js";
function socket() {
let message: (data: unknown, binary?: boolean) => void = () => {};
let open: () => void = () => {};
let close: (event?: unknown) => void = () => {};
const sent: Record<string, unknown>[] = [];
const transport: DaemonTransport = {
send: (data) => {
if (typeof data === "string") {
const value = JSON.parse(data);
if (value.type === "session") {
sent.push(value.message);
if (value.message.type === "subscription.release.request")
queueMicrotask(() =>
receive({
type: "subscription.release.response",
payload: {
requestId: value.message.requestId,
subscriptionId: value.message.subscriptionId,
released: true,
},
}),
);
}
}
},
close: () => {},
onMessage: (handler) => {
message = handler;
return () => {};
},
onOpen: (handler) => {
open = handler;
return () => {};
},
onClose: (handler) => {
close = handler;
return () => {};
},
onError: () => () => {},
};
const receive = (value: unknown) =>
message(JSON.stringify({ type: "session", message: value }), false);
return {
transport,
sent,
receive,
raw: (bytes: Uint8Array) => message(bytes, true),
frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true),
disconnect: () => close({ code: 1001 }),
open: (modern = true) => {
open();
receive({
type: "status",
payload: {
status: "server_info",
serverId: "srv_test",
hostname: null,
version: null,
features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true },
},
});
},
};
}
const compare = {
cwd: "/repo",
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
} as const;
const begin: DiffFrame = {
opcode: DiffOpcode.Begin,
slot: 3,
revision: 1,
baseRevision: 0,
baseSha: "a",
headSha: "b",
mergeBaseSha: "",
entries: [],
};
const end: DiffFrame = {
opcode: DiffOpcode.End,
slot: 3,
revision: 1,
diffTooLarge: false,
omitted: [],
error: null,
};
async function setup(modern = true) {
const wire = socket();
const client = new DaemonClient({
url: "ws://test",
clientId: "diff-test",
transportFactory: () => wire.transport,
reconnect: { enabled: false },
});
const connecting = client.connect();
wire.open(modern);
await connecting;
return { wire, client };
}
function acknowledge(wire: ReturnType<typeof socket>, ordinal = 1) {
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
wire.receive({
type: "diff.subscribe.response",
payload: {
requestId: request?.requestId,
subscriptionId: `diff-${ordinal}`,
slot: 3,
error: null,
},
});
}
test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => {
const { wire, client } = await setup();
try {
const states: DiffState[] = [];
const observation = client.observeDiff(compare);
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
acknowledge(wire);
wire.frame(begin);
wire.frame(end);
expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]);
wire.disconnect();
const connected = client.connect();
wire.open();
await connected;
acknowledge(wire, 2);
wire.frame(begin);
wire.frame(end);
expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]);
await observation.release();
} finally {
await client.close();
}
});
test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => {
const { wire, client } = await setup();
try {
const observation = client.observeDiff(compare);
const states: DiffState[] = [];
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
acknowledge(wire);
const malformed = { ...begin, slot: 3 };
const bytes = encodeDiffFrame(malformed);
bytes[2] = 255;
wire.raw(bytes);
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } });
const next = client.readDiff(compare);
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
wire.receive({
type: "diff.subscribe.response",
payload: {
requestId: request?.requestId,
subscriptionId: "",
slot: 0,
error: { code: "INVALID_COMPARE", message: "invalid" },
},
});
expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } });
expect(client.isConnected).toBe(true);
await observation.release();
} finally {
await client.close();
}
});
test.each(["uncommitted", "base", "all"] as const)(
"old hosts serve the %s comparison through the tagged compatibility route",
async (mode) => {
const { wire, client } = await setup(false);
try {
const comparison =
mode === "uncommitted"
? compare
: {
...compare,
base: { kind: "rev", rev: "refs/tags/v1" } as const,
head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head,
mergeBase: true,
};
const result = client.readDiff(comparison);
const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request");
expect(request?.compare).toMatchObject({ mode });
wire.receive({
type: "subscribe_checkout_diff_response",
payload: {
requestId: request?.requestId,
subscriptionId: "legacy",
cwd: "/repo",
files: [],
error: null,
},
});
wire.receive({
type: "checkout_diff_update",
payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null },
});
expect(await result).toMatchObject({ phase: "ready", files: [] });
} finally {
await client.close();
}
},
);
-246
View File
@@ -1,246 +0,0 @@
import {
decodeDiffFrame,
type DiffEntry as WireEntry,
type DiffBegin,
} from "@getpaseo/protocol/binary-frames/index";
import type {
DiffSubscribeRequest,
SubscribeCheckoutDiffRequest,
DiffError,
DiffSubscribeResponse,
SubscribeCheckoutDiffResponse,
ParsedDiffFile,
} from "@getpaseo/protocol/messages";
import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js";
import { DiffStream } from "./stream.js";
export type { DiffEndpoint } from "@getpaseo/protocol/messages";
export type DiffCompare = Omit<DiffSubscribeRequest, "type" | "requestId">;
export type DiffEntry = Omit<WireEntry, "body">;
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
export type DiffState =
| { phase: "loading"; revision: number; entries: DiffEntry[] }
| {
phase: "ready";
revision: number;
resolved: ResolvedShas;
diffTooLarge: boolean;
files: ParsedDiffFile[];
}
| { phase: "error"; revision: number; error: DiffError };
type LegacyCompare = SubscribeCheckoutDiffRequest["compare"];
interface Port {
modern(compare: DiffCompare): OwnedSubscription<DiffSubscribeResponse["payload"]>;
legacy(
cwd: string,
compare: LegacyCompare,
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
failed(error: unknown): void;
}
export class DiffSubscriptionError extends Error {
constructor(readonly detail: DiffError) {
super(detail.message);
}
}
interface Registration {
start(modern: boolean): void;
disconnected(): void;
release(): Promise<void>;
}
/** Capability selection, slot ownership and reconnect belong to this domain. */
export class DiffStreamRouter {
private registrations = new Set<Registration>();
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
private mode: boolean | null = null;
constructor(private port: Port) {}
connected(modern: boolean): void {
this.mode = modern;
for (const registration of this.registrations) registration.start(modern);
}
disconnected(): void {
this.mode = null;
this.slots.clear();
for (const registration of this.registrations) registration.disconnected();
}
async close(): Promise<void> {
await Promise.all([...this.registrations].map((registration) => registration.release()));
}
receive(bytes: Uint8Array): void {
const owner = this.slots.get(bytes[1]);
owner?.receive(bytes);
}
private releaseBackend(backend: OwnedSubscription<unknown> | null): void {
void backend?.release().catch(this.port.failed);
}
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
let backend: OwnedSubscription<unknown> | null = null;
let state: (DiffState & { subscriptionId: string }) | null = null;
let slot: number | null = null;
let released = false;
let generation = 0;
let stream: DiffStream;
const observers = new Set<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
const ready = new Promise<DiffState & { subscriptionId: string }>((accept) => {
resolve = accept;
});
const publish = (value: DiffState) => {
if (released) return;
state = { ...value, subscriptionId: backend?.subscriptionId ?? "" };
resolve(state);
for (const observer of observers) {
try {
observer.snapshot(state);
} catch (error) {
this.port.failed(error);
}
}
};
const detach = () => {
if (slot !== null) this.slots.delete(slot);
slot = null;
};
const fail = (error: unknown) => {
detach();
publish({
phase: "error",
revision: 0,
error:
error instanceof DiffSubscriptionError
? error.detail
: { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) },
});
this.releaseBackend(backend);
};
const registration: Registration = {
start: (modern) => {
if (released || backend) return;
const current = ++generation;
const failed = (error: unknown) => {
if (current === generation) fail(error);
};
stream = new DiffStream(publish);
if (modern) {
const observation = this.port.modern(compare);
backend = observation;
observation.subscribe({
snapshot: (payload) => {
if (current !== generation) return;
detach();
if (payload.error) {
fail(new DiffSubscriptionError(payload.error));
return;
}
slot = payload.slot;
const owner = {
receive: (bytes: Uint8Array) => {
try {
stream.receive(decodeDiffFrame(bytes));
} catch (error) {
fail(error);
}
},
};
this.slots.set(slot, owner);
},
update: () => {},
error: failed,
});
void observation.ready.catch(failed);
} else {
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
const legacy = legacyCompare(compare);
if (!legacy) {
fail(
new DiffSubscriptionError({
code: "INVALID_COMPARE",
message: "Update the host to use this comparison",
}),
);
return;
}
const observation = this.port.legacy(compare.cwd, legacy);
backend = observation;
const apply = (
payload: Pick<
SubscribeCheckoutDiffResponse["payload"],
"files" | "error" | "diffTooLarge"
>,
) => {
if (current !== generation) return;
if (payload.error)
publish({
phase: "error",
revision: 0,
error: {
code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN",
message: payload.error.message,
},
});
else
publish({
phase: "ready",
revision: (state?.revision ?? 0) + 1,
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
diffTooLarge: payload.diffTooLarge === true,
files: payload.files,
});
};
observation.subscribe({
snapshot: apply,
update: (message) => {
if (message.type === "checkout_diff_update") apply(message.payload);
},
error: failed,
});
void observation.ready.catch(failed);
}
},
disconnected: () => {
generation++;
detach();
state = null;
const old = backend;
backend = null;
void old?.release().catch(this.port.failed);
},
release: async () => {
if (released) return;
released = true;
generation++;
detach();
this.registrations.delete(registration);
await backend?.release();
observers.clear();
},
};
this.registrations.add(registration);
if (this.mode !== null) registration.start(this.mode);
return {
get subscriptionId() {
return backend?.subscriptionId ?? null;
},
ready,
subscribe: (observer) => {
observers.add(observer);
if (state) observer.snapshot(state);
return () => observers.delete(observer);
},
release: () => registration.release(),
};
}
}
function legacyCompare(compare: DiffCompare): LegacyCompare | null {
if (compare.highlight === false) return null;
const { base, head } = compare;
if (base.kind !== "rev") return null;
if (base.rev === "HEAD" && head.kind === "worktree")
return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace };
if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD")
return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
if (compare.mergeBase && head.kind === "worktree")
return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
return null;
}
-86
View File
@@ -1,86 +0,0 @@
import { expect, test } from "vitest";
import { DiffStream } from "./stream.js";
import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index";
import type { DiffState } from "./index.js";
const entry = {
path: "a.ts",
oldPath: null,
change: "modified" as const,
content: "text" as const,
additions: 1,
deletions: 0,
body: true,
};
const begin = (revision: number, baseRevision = 0): DiffBegin => ({
opcode: DiffOpcode.Begin,
slot: 0,
revision,
baseRevision,
baseSha: "a",
headSha: "b",
mergeBaseSha: "",
entries: [entry],
});
const end = (revision: number) =>
({
opcode: DiffOpcode.End,
slot: 0,
revision,
diffTooLarge: false,
omitted: [],
error: null,
}) as const;
test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => {
const states: DiffState[] = [];
const stream = new DiffStream((state) => states.push(state));
stream.receive(begin(1));
expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] });
stream.receive({
opcode: DiffOpcode.Bodies,
slot: 0,
revision: 1,
files: [{ path: "a.ts", hunks: [] }],
});
expect(states).toHaveLength(1);
stream.receive(end(1));
const ready = states[1];
if (ready.phase !== "ready") throw new Error("Expected ready");
stream.receive(begin(2, 1));
stream.receive({
opcode: DiffOpcode.Bodies,
slot: 0,
revision: 2,
files: [{ path: "a.ts", hunks: [] }],
});
expect(states).toHaveLength(2);
stream.receive(end(2));
const refreshed = states[2];
if (refreshed.phase !== "ready") throw new Error("Expected ready");
expect(refreshed.files[0]).toBe(ready.files[0]);
});
test("superseded and stale frames cannot leave the client loading; End is authoritative", () => {
const states: DiffState[] = [];
const stream = new DiffStream((s) => states.push(s));
stream.receive(begin(1));
stream.receive(begin(2));
stream.receive(end(1));
expect(() => stream.receive(end(2))).toThrow(/Missing/);
stream.receive(begin(3));
stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } });
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } });
stream.receive({ ...begin(4), entries: [] });
stream.receive(end(4));
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
});
test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => {
const states: DiffState[] = [];
const stream = new DiffStream((s) => states.push(s));
stream.receive(begin(1));
stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true });
stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] });
stream.receive(end(2));
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] });
stream.receive({ ...begin(3, 2), entries: [] });
stream.receive(end(3));
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
});
-94
View File
@@ -1,94 +0,0 @@
import {
DiffOpcode,
materializeDiffFile,
type DiffBegin,
type DiffBody,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import type { DiffState } from "./index.js";
/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */
export class DiffStream {
private pending: { begin: DiffBegin; bodies: Map<string, DiffBody> } | null = null;
private committed: Extract<DiffState, { phase: "ready" }> | null = null;
constructor(private publish: (state: DiffState) => void) {}
receive(frame: DiffFrame): void {
if (frame.opcode === DiffOpcode.Begin) {
if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision)
throw new Error("Unknown diff base revision");
if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length)
throw new Error("Duplicate diff entry");
this.pending = { begin: frame, bodies: new Map() };
if (!this.committed)
this.publish({
phase: "loading",
revision: frame.revision,
entries: frame.entries.map(({ body: _body, ...entry }) => entry),
});
return;
}
const pending = this.pending;
if (!pending || frame.revision !== pending.begin.revision) return;
if (frame.opcode === DiffOpcode.Bodies) {
for (const body of frame.files) {
if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body");
const entry = pending.begin.entries.find((candidate) => candidate.path === body.path);
if (!entry?.body) throw new Error("Unexpected diff body");
pending.bodies.set(body.path, body);
}
return;
}
this.pending = null;
if (frame.error) {
this.committed = null;
this.publish({ phase: "error", revision: frame.revision, error: frame.error });
return;
}
const previous = new Map(this.committed?.files.map((file) => [file.path, file]));
const omitted = new Set(frame.omitted);
for (const path of omitted)
if (!pending.begin.entries.some((entry) => entry.path === path))
throw new Error("Unknown omitted diff path");
const files = pending.begin.entries.map((entry) => {
const old = previous.get(entry.path);
const body = pending.bodies.get(entry.path);
if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body)
throw new Error(`Missing diff body: ${entry.path}`);
if (
entry.content === "text" &&
!entry.body &&
!omitted.has(entry.path) &&
(!old || pending.begin.baseRevision === 0)
)
throw new Error(`Missing inherited diff: ${entry.path}`);
let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path));
if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old)
file = { ...file, hunks: old.hunks, status: old.status };
return old && sameFile(old, file) ? old : file;
});
const { revision, baseSha, headSha, mergeBaseSha } = pending.begin;
this.committed = {
phase: "ready",
revision,
resolved: { baseSha, headSha, mergeBaseSha },
files,
diffTooLarge: frame.diffTooLarge,
};
this.publish(this.committed);
}
}
function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean {
if (
a.path !== b.path ||
a.oldPath !== b.oldPath ||
a.isNew !== b.isNew ||
a.isDeleted !== b.isDeleted ||
a.additions !== b.additions ||
a.deletions !== b.deletions ||
a.status !== b.status
)
return false;
if (a.hunks === b.hunks) return true;
return JSON.stringify(a.hunks) === JSON.stringify(b.hunks);
}
-2
View File
@@ -1212,5 +1212,3 @@ function createGeneratedClientId(): string {
: Math.random().toString(36).slice(2);
return `paseo-sdk-${randomId}`;
}
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
@@ -1,4 +1,3 @@
import { decodeDiffFrame, DiffOpcode, type DiffFrame } from "./diff.js";
import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js";
import {
decodeFileTransferFrame,
@@ -13,20 +12,12 @@ import {
} from "./terminal.js";
export type BinaryFrame =
| { kind: "diff"; frame: DiffFrame }
| { kind: "host_route"; frame: HostRouteFrame }
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame }
| { kind: "plugin"; frame: PluginFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
if (Object.values(DiffOpcode).some((opcode) => opcode === bytes[0])) {
return { kind: "diff", frame: decodeDiffFrame(bytes) };
}
return decodeExistingBinaryFrame(bytes);
}
function decodeExistingBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
case HOST_ROUTE_OPCODE: {
const frame = decodeHostRouteFrame(bytes);
@@ -1,270 +0,0 @@
import { expect, test } from "vitest";
import { decodeDiffFrame, encodeDiffFrame } from "./diff.js";
import { decodeBinaryFrame } from "./demux.js";
import { decodePluginFrame } from "./plugin.js";
test("routes diff revisions independently of plugin frames", () => {
const frame = {
opcode: 0x40 as const,
slot: 0,
revision: 1,
baseRevision: 0,
baseSha: "",
headSha: "",
mergeBaseSha: "",
entries: [],
};
const bytes = encodeDiffFrame(frame);
expect([...bytes]).toEqual([64, 0, 1, 1, 0, 0, 0, 0, 0]);
expect(decodeDiffFrame(bytes)).toEqual(frame);
expect(decodeBinaryFrame(bytes)).toEqual({ kind: "diff", frame });
expect(decodePluginFrame(bytes)).toBeNull();
});
test("roundtrips Unicode runs and materializes the existing file shape", async () => {
const { materializeDiffFile } = await import("./diff.js");
const frame = {
opcode: 0x41 as const,
slot: 255,
revision: 128,
files: [
{
path: "é.ts",
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{
type: "add" as const,
content: "😀é",
runs: [
{ style: "string", length: 2 },
{ style: null, length: 2 },
],
},
],
},
],
},
],
};
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
const file = materializeDiffFile(
{
path: "é.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 1,
deletions: 1,
body: true,
},
frame.files[0],
false,
);
expect(file.hunks[0].lines[0].tokens).toEqual([
{ text: "😀", style: "string" },
{ text: "é", style: null },
]);
});
test("preserves binary additions, modifications, deletions and mode-only text", async () => {
const { materializeDiffFile } = await import("./diff.js");
for (const change of ["added", "modified", "deleted"] as const) {
const entry = {
path: "image.png",
oldPath: null,
change,
content: "binary" as const,
additions: 0,
deletions: 0,
body: false,
};
const begin = {
opcode: 0x40 as const,
slot: 1,
revision: 1,
baseRevision: 0,
baseSha: "",
headSha: "",
mergeBaseSha: "",
entries: [entry],
};
expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin);
expect(materializeDiffFile(entry, null, false)).toEqual({
path: "image.png",
isNew: change === "added",
isDeleted: change === "deleted",
additions: 0,
deletions: 0,
status: "binary",
hunks: [],
});
}
expect(
materializeDiffFile(
{
path: "script",
oldPath: null,
change: "modified",
content: "text",
additions: 0,
deletions: 0,
body: true,
},
{ path: "script", hunks: [] },
false,
),
).toEqual({
path: "script",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
status: "ok",
hunks: [],
});
});
test("rejects every truncated prefix, unknown version, trailing data, and invalid coverage", () => {
const end = {
opcode: 0x42 as const,
slot: 1,
revision: 9007199254740991,
diffTooLarge: true,
omitted: ["a.ts"],
error: { code: "REF_NOT_FOUND" as const, message: "missing" },
};
const bytes = encodeDiffFrame(end);
expect(decodeDiffFrame(bytes)).toEqual(end);
for (let length = 0; length < bytes.length; length++)
expect(() => decodeDiffFrame(bytes.slice(0, length))).toThrow();
const version = bytes.slice();
version[2] = 2;
expect(() => decodeDiffFrame(version)).toThrow("version");
expect(() => decodeDiffFrame(Uint8Array.from([...bytes, 0]))).toThrow("Trailing");
expect(() =>
encodeDiffFrame({
opcode: 0x41,
slot: 1,
revision: 1,
files: [
{
path: "x",
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "😀", runs: [{ style: null, length: 1 }] }],
},
],
},
],
}),
).toThrow("cover");
});
test("uses fixed UTF-8 byte vectors while runs count UTF-16 units", () => {
const bytes = Uint8Array.from([
65, 0, 1, 1, 1, 1, 120, 1, 0, 0, 1, 1, 1, 129, 7, 240, 159, 152, 128, 101, 204, 129, 1, 3, 4,
]);
const frame = {
opcode: 0x41 as const,
slot: 0,
revision: 1,
files: [
{
path: "x",
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [
{ type: "add" as const, content: "😀é", runs: [{ style: "string", length: 4 }] },
],
},
],
},
],
};
expect(encodeDiffFrame(frame)).toEqual(bytes);
expect(decodeDiffFrame(bytes)).toEqual(frame);
const unknownStyle = bytes.slice();
unknownStyle[23] = 255;
expect(decodeDiffFrame(unknownStyle)).toEqual({
...frame,
files: [
{
...frame.files[0],
hunks: [
{
...frame.files[0].hunks[0],
lines: [{ type: "add", content: "😀é", runs: [{ style: null, length: 4 }] }],
},
],
},
],
});
});
test("roundtrips generated entry lists across varint boundaries", () => {
for (const revision of [0, 1, 127, 128, 16383, 16384, 4294967296, Number.MAX_SAFE_INTEGER]) {
const entries = Array.from({ length: 130 }, (_, index) => ({
path: `src/${index}-😀.ts`,
oldPath: `old/${index}.ts`,
change: "renamed" as const,
content: "text" as const,
additions: index * 128,
deletions: index,
body: true,
}));
const begin = {
opcode: 0x40 as const,
slot: 255,
revision,
baseRevision: 0,
baseSha: "base",
headSha: "head",
mergeBaseSha: "merge",
entries,
};
expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin);
}
});
test.each([true, false])(
"preserves BOM-prefixed paths and source lines with runs=%s",
(highlight) => {
const frame = {
opcode: 0x41 as const,
slot: 0,
revision: 1,
files: [
{
path: "\uFEFFbom.ts",
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: (["add", "remove", "context"] as const).map((type) => ({
type,
content: "\uFEFFvalue",
runs: highlight ? [{ style: null, length: 6 }] : undefined,
})),
},
],
},
],
};
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
},
);
-360
View File
@@ -1,360 +0,0 @@
import type { ParsedDiffFile } from "../messages.js";
// Opcodes 0x40–0x4f are reserved for diff revisions. Plugins own 0x20–0x2f.
export const DiffOpcode = { Begin: 0x40, Bodies: 0x41, End: 0x42 } as const;
export const DIFF_HIGHLIGHT_STYLES = [
null,
"keyword",
"comment",
"string",
"number",
"literal",
"function",
"definition",
"class",
"type",
"tag",
"attribute",
"property",
"variable",
"operator",
"punctuation",
"regexp",
"escape",
"meta",
"heading",
"link",
] as const;
const CHANGES = ["added", "modified", "deleted", "renamed"] as const;
const CONTENTS = ["text", "binary"] as const;
const KINDS = ["context", "add", "remove", "header"] as const;
const ERRORS = [
null,
"NOT_GIT_REPO",
"REF_NOT_FOUND",
"INVALID_COMPARE",
"TOO_MANY_SUBSCRIPTIONS",
"UNKNOWN",
] as const;
export interface DiffEntry {
path: string;
oldPath: string | null;
change: (typeof CHANGES)[number];
content: (typeof CONTENTS)[number];
additions: number;
deletions: number;
body: boolean;
}
export interface DiffRun {
style: string | null;
length: number;
}
export interface DiffStreamLine {
type: (typeof KINDS)[number];
content: string;
runs?: DiffRun[];
}
export interface DiffStreamHunk {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: DiffStreamLine[];
}
export interface DiffBody {
path: string;
hunks: DiffStreamHunk[];
}
export interface DiffBegin {
opcode: typeof DiffOpcode.Begin;
slot: number;
revision: number;
baseRevision: number;
baseSha: string;
headSha: string;
mergeBaseSha: string;
entries: DiffEntry[];
}
export interface DiffBodies {
opcode: typeof DiffOpcode.Bodies;
slot: number;
revision: number;
files: DiffBody[];
}
export interface DiffEnd {
opcode: typeof DiffOpcode.End;
slot: number;
revision: number;
diffTooLarge: boolean;
omitted: string[];
error: { code: NonNullable<(typeof ERRORS)[number]>; message: string } | null;
}
export type DiffFrame = DiffBegin | DiffBodies | DiffEnd;
export class InvalidDiffFrameError extends Error {}
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
class Writer {
private bytes: number[] = [];
byte(value: number): void {
if (!Number.isInteger(value) || value < 0 || value > 255)
throw new InvalidDiffFrameError("Invalid byte");
this.bytes.push(value);
}
uint(value: number): void {
if (!Number.isSafeInteger(value) || value < 0)
throw new InvalidDiffFrameError("Invalid unsigned integer");
do {
const next = value % 128;
value = Math.floor(value / 128);
this.byte(next + (value ? 128 : 0));
} while (value);
}
str(value: string): void {
const bytes = textEncoder.encode(value);
this.uint(bytes.length);
for (const byte of bytes) this.byte(byte);
}
finish(): Uint8Array {
return Uint8Array.from(this.bytes);
}
}
class Reader {
private offset = 0;
constructor(private bytes: Uint8Array) {}
byte(): number {
if (this.offset >= this.bytes.length) throw new InvalidDiffFrameError("Truncated diff frame");
return this.bytes[this.offset++];
}
uint(): number {
let value = 0;
let factor = 1;
for (let index = 0; index < 8; index++) {
const byte = this.byte();
value += (byte & 127) * factor;
if (!Number.isSafeInteger(value)) throw new InvalidDiffFrameError("Integer overflow");
if (byte < 128) return value;
factor *= 128;
}
throw new InvalidDiffFrameError("Invalid varint");
}
count(): number {
const value = this.uint();
if (value > this.bytes.length - this.offset)
throw new InvalidDiffFrameError("Count exceeds frame");
return value;
}
str(): string {
const size = this.count();
const start = this.offset;
this.offset += size;
try {
return textDecoder.decode(this.bytes.subarray(start, this.offset));
} catch {
throw new InvalidDiffFrameError("Invalid UTF-8");
}
}
done(): void {
if (this.offset !== this.bytes.length) throw new InvalidDiffFrameError("Trailing bytes");
}
}
function enumAt<T>(values: readonly T[], index: number): T {
if (index >= values.length) throw new InvalidDiffFrameError("Unknown enum value");
return values[index];
}
function validateRuns(line: DiffStreamLine): void {
if (!line.runs) return;
let length = 0;
for (const run of line.runs) {
if (!Number.isSafeInteger(run.length) || run.length <= 0)
throw new InvalidDiffFrameError("Invalid run length");
length += run.length;
}
if (length !== line.content.length) throw new InvalidDiffFrameError("Runs do not cover line");
}
function writeLine(writer: Writer, line: DiffStreamLine): void {
validateRuns(line);
writer.byte(KINDS.indexOf(line.type) | (line.runs ? 128 : 0));
writer.str(line.content);
writer.uint(line.runs ? line.runs.length : 0);
for (const run of line.runs ?? []) {
const style = DIFF_HIGHLIGHT_STYLES.findIndex((value) => value === run.style);
writer.byte(Math.max(0, style));
writer.uint(run.length);
}
}
function readLine(reader: Reader): DiffStreamLine {
const flags = reader.byte();
const type = enumAt(KINDS, flags & 127);
const content = reader.str();
const count = reader.count();
const runs: DiffRun[] = [];
for (let index = 0; index < count; index++) {
const styleIndex = reader.byte();
const style = DIFF_HIGHLIGHT_STYLES[styleIndex] ?? null;
runs.push({ style, length: reader.uint() });
}
if (!(flags & 128)) {
if (count) throw new InvalidDiffFrameError("Runs without flag");
return { type, content };
}
const line = { type, content, runs };
validateRuns(line);
return line;
}
export function encodeDiffFrame(frame: DiffFrame): Uint8Array {
const writer = new Writer();
writer.byte(frame.opcode);
writer.byte(frame.slot);
writer.byte(1);
writer.uint(frame.revision);
if (frame.opcode === DiffOpcode.Begin) {
writer.uint(frame.baseRevision);
writer.str(frame.baseSha);
writer.str(frame.headSha);
writer.str(frame.mergeBaseSha);
writer.uint(frame.entries.length);
for (const entry of frame.entries) {
if (entry.content === "binary" && entry.body)
throw new InvalidDiffFrameError("Binary entry has a body");
writer.str(entry.path);
writer.str(entry.oldPath ?? "");
writer.byte(CHANGES.indexOf(entry.change));
writer.byte(CONTENTS.indexOf(entry.content));
writer.uint(entry.additions);
writer.uint(entry.deletions);
writer.byte(Number(entry.body));
}
} else if (frame.opcode === DiffOpcode.Bodies) {
writer.uint(frame.files.length);
for (const file of frame.files) {
writer.str(file.path);
writer.uint(file.hunks.length);
for (const hunk of file.hunks) {
writer.uint(hunk.oldStart);
writer.uint(hunk.oldCount);
writer.uint(hunk.newStart);
writer.uint(hunk.newCount);
writer.uint(hunk.lines.length);
for (const line of hunk.lines) writeLine(writer, line);
}
}
} else {
writer.byte(Number(frame.diffTooLarge));
writer.uint(frame.omitted.length);
for (const path of frame.omitted) writer.str(path);
writer.byte(frame.error ? ERRORS.indexOf(frame.error.code) : 0);
writer.str(frame.error ? frame.error.message : "");
}
return writer.finish();
}
export function decodeDiffFrame(bytes: Uint8Array): DiffFrame {
const reader = new Reader(bytes);
const opcode = reader.byte();
const slot = reader.byte();
if (reader.byte() !== 1) throw new InvalidDiffFrameError("Unknown diff version");
const revision = reader.uint();
let frame: DiffFrame;
if (opcode === DiffOpcode.Begin) {
const baseRevision = reader.uint();
const baseSha = reader.str();
const headSha = reader.str();
const mergeBaseSha = reader.str();
const count = reader.count();
const entries: DiffEntry[] = [];
for (let index = 0; index < count; index++) {
const path = reader.str();
const oldPath = reader.str() || null;
const change = enumAt(CHANGES, reader.byte());
const content = enumAt(CONTENTS, reader.byte());
const additions = reader.uint();
const deletions = reader.uint();
const bodyByte = reader.byte();
if (bodyByte > 1 || (content === "binary" && bodyByte))
throw new InvalidDiffFrameError("Invalid entry body");
entries.push({ path, oldPath, change, content, additions, deletions, body: bodyByte === 1 });
}
frame = { opcode, slot, revision, baseRevision, baseSha, headSha, mergeBaseSha, entries };
} else if (opcode === DiffOpcode.Bodies) {
const count = reader.count();
const files: DiffBody[] = [];
for (let index = 0; index < count; index++) {
const path = reader.str();
const hunkCount = reader.count();
const hunks: DiffStreamHunk[] = [];
for (let h = 0; h < hunkCount; h++) {
const oldStart = reader.uint();
const oldCount = reader.uint();
const newStart = reader.uint();
const newCount = reader.uint();
const lineCount = reader.count();
const lines: DiffStreamLine[] = [];
for (let l = 0; l < lineCount; l++) lines.push(readLine(reader));
hunks.push({ oldStart, oldCount, newStart, newCount, lines });
}
files.push({ path, hunks });
}
frame = { opcode, slot, revision, files };
} else if (opcode === DiffOpcode.End) {
const flags = reader.byte();
if (flags > 1) throw new InvalidDiffFrameError("Unknown flags");
const count = reader.count();
const omitted: string[] = [];
for (let index = 0; index < count; index++) omitted.push(reader.str());
const code = enumAt(ERRORS, reader.byte());
const message = reader.str();
frame = {
opcode,
slot,
revision,
diffTooLarge: flags === 1,
omitted,
error: code ? { code, message } : null,
};
} else {
throw new InvalidDiffFrameError("Unknown diff opcode");
}
reader.done();
return frame;
}
export function runsToTokens(
line: DiffStreamLine,
): NonNullable<ParsedDiffFile["hunks"][number]["lines"][number]["tokens"]> {
let offset = 0;
return (line.runs ?? []).map((run) => {
const text = line.content.slice(offset, offset + run.length);
offset += run.length;
return { text, style: run.style };
});
}
export function materializeDiffFile(
entry: DiffEntry,
body: DiffBody | null,
omitted: boolean,
): ParsedDiffFile {
let status: "ok" | "binary" | "too_large" = "ok";
if (entry.content === "binary") status = "binary";
else if (omitted) status = "too_large";
const hunks = body
? body.hunks.map((hunk) => ({
...hunk,
lines: hunk.lines.map((line) => {
if (line.runs)
return { type: line.type, content: line.content, tokens: runsToTokens(line) };
return { type: line.type, content: line.content };
}),
}))
: [];
const file: ParsedDiffFile = {
path: entry.path,
isNew: entry.change === "added",
isDeleted: entry.change === "deleted",
additions: entry.additions,
deletions: entry.deletions,
hunks,
status,
};
if (entry.oldPath !== null) file.oldPath = entry.oldPath;
return file;
}
@@ -3,4 +3,3 @@ export * from "./file-transfer.js";
export * from "./terminal.js";
export * from "./host-route.js";
export * from "./plugin.js";
export * from "./diff.js";
@@ -1,24 +0,0 @@
import { expect, test } from "vitest";
import { DiffSubscribeRequestSchema, DiffSubscribeResponseSchema } from "./messages.js";
test("accepts explicit endpoints without changing legacy schemas", () => {
const request = {
type: "diff.subscribe.request",
requestId: "r",
cwd: "/repo",
base: { kind: "rev", rev: "v1" },
head: { kind: "worktree" },
mergeBase: true,
highlight: false,
};
expect(DiffSubscribeRequestSchema.parse(request)).toEqual(request);
expect(
DiffSubscribeRequestSchema.safeParse({ ...request, base: { kind: "unknown" } }).success,
).toBe(false);
expect(
DiffSubscribeResponseSchema.safeParse({
type: "diff.subscribe.response",
payload: { requestId: "r", subscriptionId: "s", slot: 256, error: null },
}).success,
).toBe(false);
});
-50
View File
@@ -2288,51 +2288,6 @@ const CheckoutDiffCompareSchema = z.object({
ignoreWhitespace: z.boolean().optional(),
});
export const DiffEndpointSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("rev"), rev: z.string() }),
z.object({ kind: z.literal("index") }),
z.object({ kind: z.literal("worktree") }),
z.object({ kind: z.literal("empty") }),
]);
export const DiffErrorSchema = z.object({
code: z.enum([
"NOT_GIT_REPO",
"REF_NOT_FOUND",
"INVALID_COMPARE",
"TOO_MANY_SUBSCRIPTIONS",
"UNKNOWN",
]),
message: z.string(),
});
export const DiffSubscribeRequestSchema = z.object({
type: z.literal("diff.subscribe.request"),
requestId: z.string(),
cwd: z.string(),
base: DiffEndpointSchema,
head: DiffEndpointSchema,
mergeBase: z.boolean().optional(),
ignoreWhitespace: z.boolean().optional(),
highlight: z.boolean().optional(),
});
// Unsubscribe is fire-and-forget; release has no correlated response.
export const DiffUnsubscribeRequestSchema = z.object({
type: z.literal("diff.unsubscribe.request"),
subscriptionId: z.string(),
});
export const DiffSubscribeResponseSchema = z.object({
type: z.literal("diff.subscribe.response"),
payload: z.object({
requestId: z.string(),
subscriptionId: z.string(),
slot: z.number().int().min(0).max(255),
error: DiffErrorSchema.nullable(),
}),
});
export type DiffEndpoint = z.infer<typeof DiffEndpointSchema>;
export type DiffError = z.infer<typeof DiffErrorSchema>;
export type DiffSubscribeRequest = z.infer<typeof DiffSubscribeRequestSchema>;
export type DiffSubscribeResponse = z.infer<typeof DiffSubscribeResponseSchema>;
export const CheckoutStatusRequestSchema = z.object({
type: z.literal("checkout_status_request"),
cwd: z.string(),
@@ -3473,8 +3428,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AgentDetachRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
DiffSubscribeRequestSchema,
DiffUnsubscribeRequestSchema,
CheckoutStatusRequestSchema,
CheckoutDiffGetRequestSchema,
SubscribeCheckoutDiffRequestSchema,
@@ -3934,7 +3887,6 @@ export const ServerInfoStatusPayloadSchema = z
selectiveAgentTimeline: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
diffSubscription: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(),
@@ -5476,7 +5428,6 @@ const CheckoutStatusCommonSchema = z.object({
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
baseRefExact: z.string().nullable().optional(),
comparisonBaseRef: z.string().nullable().optional(),
// The full ref currentBranch tracks, as git resolves `<branch>@{upstream}`:
// "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a
// "refs/heads/..." ref for a branch tracking a local branch. Null when there is no
@@ -7284,7 +7235,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentDeletedMessageSchema,
AgentArchivedMessageSchema,
CloseItemsResponseSchema,
DiffSubscribeResponseSchema,
CheckoutStatusResponseSchema,
CheckoutStatusUpdateSchema,
CheckoutDiffGetResponseSchema,
+1 -2
View File
@@ -102,8 +102,7 @@
"test:e2e:real": "npm run test:integration:real",
"test:e2e:local": "npm run test:integration:local",
"test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1",
"test:e2e:ui": "vitest --ui e2e.test.ts",
"measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts"
"test:e2e:ui": "vitest --ui e2e.test.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
@@ -1,111 +0,0 @@
import { performance } from "node:perf_hooks";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
encodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { DiffSubscriptions } from "../src/server/diff/index.js";
import { createFileObserver } from "../src/server/file-observer/index.js";
import { runGitCommand } from "../src/utils/run-git-command.js";
import { parseAndHighlightDiff } from "../src/server/utils/diff-highlighter.js";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
const cwd = resolve(import.meta.dirname, "../../..");
const artifacts = resolve(cwd, ".artifacts/diff-subscription");
await mkdir(artifacts, { recursive: true });
const pinnedHead = "3ed57e9dd";
const observer = createFileObserver();
const engine = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [resolve(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
try {
for (const count of [1, 30]) {
const [base, head] = await Promise.all([
runGitCommand(["rev-parse", `${pinnedHead}~${count}`], { cwd }),
runGitCommand(["rev-parse", pinnedHead], { cwd }),
]);
const started = performance.now();
const frames: DiffFrame[] = [];
const encoded: Uint8Array[] = [];
const subscription = await engine.subscribe(
{
cwd,
base: { kind: "rev", rev: base.stdout.trim() },
head: { kind: "rev", rev: head.stdout.trim() },
},
async (frame) => {
frames.push(frame);
encoded.push(encodeDiffFrame(frame));
},
);
await subscription.start();
const coldMs = performance.now() - started;
await subscription.release();
const raw = await runGitCommand(["diff", base.stdout.trim(), head.stdout.trim()], {
cwd,
maxOutputBytes: 50 * 1024 * 1024,
});
const contentAt = async (sha: string, path: string) => {
const result = await runGitCommand(["show", `${sha}:${path}`], {
cwd,
acceptExitCodes: [0, 128],
});
return result.exitCode === 0 ? result.stdout : null;
};
const legacyFiles = await parseAndHighlightDiff(raw.stdout, cwd, {
getOldFileContent: (file) => contentAt(base.stdout.trim(), file.path),
getNewFileContent: (file) => contentAt(head.stdout.trim(), file.path),
});
const legacy = JSON.stringify({
type: "session",
message: {
type: "checkout_diff_update",
payload: { cwd, subscriptionId: "benchmark", files: legacyFiles, error: null },
},
});
const parseStart = performance.now();
const validation = validateWSOutboundMessage(JSON.parse(legacy));
if (!validation.success) throw validation.error;
const jsonParseValidationMs = performance.now() - parseStart;
const begin = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
const end = frames.findLast((frame) => frame.opcode === DiffOpcode.End);
if (!begin || begin.opcode !== DiffOpcode.Begin || !end || end.opcode !== DiffOpcode.End)
throw new Error("Revision was not delimited");
if (end.error) throw new Error(end.error.message);
const metrics = {
pinnedHead: head.stdout.trim(),
count,
files: begin.entries.length,
rawPatchBytes: Buffer.byteLength(raw.stdout),
encodedBytes: encoded.reduce((sum, bytes) => sum + bytes.length, 0),
legacyJsonBytes: Buffer.byteLength(legacy),
coldMs,
jsonParseValidationMs,
omitted: end.omitted,
};
console.log(JSON.stringify(metrics));
await writeFile(
resolve(artifacts, `corpus-${count}.json`),
JSON.stringify({
metrics,
legacy,
frames: encoded.map((bytes) => Buffer.from(bytes).toString("base64")),
}),
);
}
} finally {
await engine.close();
await observer.close();
}
@@ -194,8 +194,6 @@ const INBOUND_PERMISSION = {
stash_pop_request: "workspace.write",
stash_save_request: "workspace.write",
"checkout.diff.get.request": "workspace.read",
"diff.subscribe.request": "workspace.read",
"diff.unsubscribe.request": "workspace.read",
subscribe_checkout_diff_request: "workspace.read",
subscribe_terminal_request: "workspace.read",
subscribe_terminals_request: "workspace.read",
@@ -439,7 +437,6 @@ const OUTBOUND_PERMISSION = {
stash_save_response: "workspace.read",
status: "daemon.read",
"checkout.diff.get.response": "workspace.read",
"diff.subscribe.response": "workspace.read",
subscribe_checkout_diff_response: "workspace.read",
subscribe_terminal_response: "workspace.read",
"terminal.rename.response": "workspace.write",
@@ -1,253 +1,488 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import pino from "pino";
import { expect, test } from "vitest";
import { CheckoutDiffManager, type CheckoutDiffSnapshotPayload } from "./checkout-diff-manager.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { createFileObserver } from "./file-observer/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
async function withCheckout(
run: (input: {
cwd: string;
manager: CheckoutDiffManager;
git(...args: string[]): string;
}) => Promise<void>,
): Promise<void> {
const cwd = await mkdtemp(join(tmpdir(), "legacy-diff-adapter-"));
const observer = createFileObserver();
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const manager = new CheckoutDiffManager({
logger: pino({ level: "silent" }),
paseoHome: cwd,
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
workspaceGitService: {
async requestWorkingTreeWatch(root, onChange) {
const subscription = await observer.subscribe(root, () => onChange(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await subscription.unsubscribe();
},
};
},
const { toCheckoutErrorMock } = vi.hoisted(() => ({
toCheckoutErrorMock: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
vi.mock("./checkout-git-utils.js", () => ({
toCheckoutError: toCheckoutErrorMock,
}));
import type pino from "pino";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createWorkspaceSnapshot(
overrides?: Partial<WorkspaceGitRuntimeSnapshot["git"]>,
): WorkspaceGitRuntimeSnapshot {
return {
cwd: "/tmp/repo",
git: {
isGit: true,
repoRoot: "/tmp/repo",
mainRepoRoot: null,
currentBranch: "feature",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 1, behind: 0 },
aheadOfOrigin: 1,
behindOfOrigin: 0,
hasRemote: true,
diffStat: { additions: 1, deletions: 0 },
...overrides,
},
});
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await run({ cwd, manager, git });
} finally {
await manager.dispose();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
forge: {
featuresEnabled: false,
authState: "no_remote",
pullRequest: null,
error: null,
},
};
}
test("legacy callers share one live comparison, receive edits, and release it", async () => {
await withCheckout(async ({ cwd, manager }) => {
await mkdir(join(cwd, "sub"));
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
const first: CheckoutDiffSnapshotPayload[] = [];
const second: CheckoutDiffSnapshotPayload[] = [];
const [a, b] = await Promise.all([
manager.subscribe({ cwd: join(cwd, "sub"), compare: { mode: "uncommitted" } }, (snapshot) =>
first.push(snapshot),
),
manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) =>
second.push(snapshot),
),
]);
expect(a.initial.cwd).toBe(join(cwd, "sub"));
expect(a.initial.files).toEqual(b.initial.files);
expect(a.initial.files.map((file) => file.path)).toEqual(["x.ts"]);
expect(manager.getMetrics()).toMatchObject({
checkoutDiffTargetCount: 1,
checkoutDiffSubscriptionCount: 2,
});
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
await expect.poll(() => first.length, { timeout: 10000 }).toBeGreaterThan(0);
expect(first.at(-1)?.files).toEqual(second.at(-1)?.files);
await a.unsubscribe();
await b.unsubscribe();
expect(manager.getMetrics().checkoutDiffTargetCount).toBe(0);
});
}, 30_000);
function createPendingManager() {
const watches: Array<{
cwd: string;
onChange: () => void;
unsubscribeCalls: number;
resolve(): void;
}> = [];
const workspaceGitService = {
getCheckoutDiff: async () => ({ diff: "", structured: [] }),
getSnapshot: async () => createWorkspaceSnapshot(),
test("Committed ignores working-tree edits while All and Uncommitted include them", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("checkout", "-b", "feature");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
const committed = await manager.read({ cwd, compare: { mode: "base", baseRef: "main" } });
const all = await manager.read({ cwd, compare: { mode: "all", baseRef: "main" } });
const uncommitted = await manager.read({ cwd, compare: { mode: "uncommitted" } });
expect(committed.files[0].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "add", content: "const value = 1;" }),
);
expect(all.files[0].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "add", content: "const value = 2;" }),
);
expect(uncommitted.files[0].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "remove", content: "const value = 1;" }),
);
});
}, 30_000);
test("cancelling a legacy subscriber during a shared revision does not revive it", async () => {
await withCheckout(async ({ cwd, manager }) => {
await writeFile(join(cwd, "x.ts"), "export const value = 1;\n");
const abort = new AbortController();
let cancelled = false;
const modern = await manager.streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
if (frame.opcode !== 0x40 || cancelled) return;
await waitForSubscriberCount(manager, 2);
cancelled = true;
abort.abort();
},
);
const legacy = manager.subscribe(
{ cwd, compare: { mode: "uncommitted" }, signal: abort.signal },
() => {
throw new Error("Cancelled subscriber received an update");
},
);
try {
await modern.start();
const result = await legacy;
expect(result.initial).toMatchObject({
files: [],
error: { message: "Diff subscription cancelled" },
});
await result.unsubscribe();
} finally {
await modern.release();
}
});
}, 30_000);
async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise<void> {
await expect
.poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 })
.toBe(count);
}
test("moving a tag refreshes binary file metadata even though binary files have no bodies", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("tag", "v1");
await writeFile(join(cwd, "image.bin"), Buffer.from([0, 1]));
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "binary");
await writeFile(join(cwd, "image.bin"), Buffer.from([0, 2]));
const updates: CheckoutDiffSnapshotPayload[] = [];
const subscription = await manager.subscribe(
{ cwd, compare: { mode: "all", baseRef: "v1" } },
(snapshot) => updates.push(snapshot),
);
expect(subscription.initial.files[0]).toMatchObject({
path: "image.bin",
isNew: true,
status: "binary",
});
git("update-ref", "refs/tags/v1", "HEAD");
await expect.poll(() => updates.at(-1)?.files[0]?.isNew, { timeout: 10000 }).toBe(false);
await subscription.unsubscribe();
});
}, 30000);
test("legacy diffs retain successful bodies when an oversized sibling is removed", async () => {
await withCheckout(async ({ cwd, manager }) => {
await writeFile(join(cwd, "a.ts"), "const value = 1;\n");
await writeFile(join(cwd, "b.txt"), "x".repeat(1024 * 1024 + 1));
const updates: CheckoutDiffSnapshotPayload[] = [];
const subscription = await manager.subscribe(
{ cwd, compare: { mode: "uncommitted" } },
(snapshot) => updates.push(snapshot),
);
expect(subscription.initial.files).toMatchObject([
{ path: "a.ts", status: "ok" },
{ path: "b.txt", status: "too_large" },
]);
await rm(join(cwd, "b.txt"));
await manager.scheduleRefreshForCwd(cwd);
await expect.poll(() => updates.at(-1)?.error).toBeNull();
expect(updates.at(-1)?.files[0]).toMatchObject({
path: "a.ts",
hunks: [
{
lines: expect.arrayContaining([expect.objectContaining({ content: "const value = 1;" })]),
registerWorkspace: () => ({ unsubscribe: () => {} }),
requestWorkingTreeWatch: (cwd: string, onChange: () => void) => {
const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>();
const watch = {
cwd,
onChange,
unsubscribeCalls: 0,
resolve: () => {
pending.resolve({
repoRoot: "/tmp/repo",
unsubscribe: () => {
watch.unsubscribeCalls += 1;
},
});
},
],
};
watches.push(watch);
return pending.promise;
},
};
const logger = { child: () => logger, warn: () => {} };
const manager = new CheckoutDiffManager({
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
logger: logger as unknown as pino.Logger,
paseoHome: "/tmp/paseo-test",
workspaceGitService,
});
return { manager, watches };
}
describe("CheckoutDiffManager", () => {
beforeEach(() => {
vi.useFakeTimers();
toCheckoutErrorMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
function createManager(options?: {
repoRoot?: string | null;
getCheckoutDiffImplementation?: ReturnType<typeof vi.fn>;
}) {
const unsubscribe = vi.fn();
const workspaceUnsubscribe = vi.fn();
let onChange: (() => void) | null = null;
let onWorkspaceSnapshot: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => {
onChange = listener;
return {
repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot,
unsubscribe,
};
});
const workspaceGitService = {
subscribe: vi.fn(),
registerWorkspace: vi.fn(
(_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => {
onWorkspaceSnapshot = listener;
return { unsubscribe: workspaceUnsubscribe };
},
),
getSnapshot: vi.fn(async () => createWorkspaceSnapshot()),
getCheckoutDiff:
options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })),
refresh: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
dispose: vi.fn(),
};
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
logger: logger as unknown as pino.Logger,
paseoHome: "/tmp/paseo-test",
workspaceGitService: workspaceGitService as unknown as WorkspaceGitService,
});
return {
manager,
workspaceGitService,
mockRequestWorkingTreeWatch,
unsubscribe,
getOnChange: () => onChange,
getOnWorkspaceSnapshot: () => onWorkspaceSnapshot,
workspaceUnsubscribe,
};
}
test("subscribe requests a working tree watch with the correct cwd", async () => {
const { manager, mockRequestWorkingTreeWatch } = createManager();
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
"/tmp/repo/packages/server",
expect.any(Function),
);
});
test("unsubscribe calls the working tree watch unsubscribe", async () => {
const { manager, unsubscribe } = createManager();
const subscription = await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
subscription.unsubscribe();
});
});
test("legacy subscriptions reselect the preferred base when a remote ref appears", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("checkout", "-b", "feature");
await writeFile(join(cwd, "b.ts"), "export const b = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
const updates: CheckoutDiffSnapshotPayload[] = [];
const params = { cwd, compare: { mode: "base" as const, baseRef: "main" } };
const subscription = await manager.subscribe(params, (snapshot) => updates.push(snapshot));
try {
expect(subscription.initial.files.map((file) => file.path)).toEqual(["b.ts"]);
git("update-ref", "refs/remotes/origin/main", "HEAD");
await manager.scheduleRefreshForCwd(cwd);
await expect.poll(() => updates.at(-1)?.files.length, { timeout: 5000 }).toBe(0);
expect(updates.at(-1)).toEqual(await manager.read(params));
} finally {
await subscription.unsubscribe();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
test("cancels a subscription while its working tree watch is still opening", async () => {
const { manager, watches } = createPendingManager();
const abort = new AbortController();
const pendingSubscription = manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
signal: abort.signal,
},
() => {},
);
abort.abort();
watches[0].resolve();
await pendingSubscription;
expect(watches[0].unsubscribeCalls).toBe(1);
expect(manager.getMetrics()).toEqual({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
});
});
test("shares one opening target between concurrent subscriptions", async () => {
const { manager, watches } = createPendingManager();
const firstSubscription = manager.subscribe(
{ cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } },
() => {},
);
const secondSubscription = manager.subscribe(
{ cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } },
() => {},
);
expect(watches).toHaveLength(1);
watches[0].resolve();
const [first, second] = await Promise.all([firstSubscription, secondSubscription]);
expect(manager.getMetrics().checkoutDiffSubscriptionCount).toBe(2);
first.unsubscribe();
expect(watches[0].unsubscribeCalls).toBe(0);
second.unsubscribe();
expect(watches[0].unsubscribeCalls).toBe(1);
});
test("diffCwd uses repoRoot from the working tree watch result", async () => {
const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" });
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
"/tmp/repo",
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
undefined,
);
});
test("diff refresh is triggered when the working tree watch callback fires", async () => {
const getCheckoutDiff = vi
.fn()
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
});
const { manager, getOnChange } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
const listener = vi.fn();
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
listener,
);
const onChange = getOnChange();
expect(onChange).toBeTypeOf("function");
onChange?.();
await vi.advanceTimersByTimeAsync(150);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
cwd: "/tmp/repo/packages/server",
files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
error: null,
});
});
test("watch-triggered refresh forces a cache bypass on getCheckoutDiff", async () => {
const getCheckoutDiff = vi
.fn()
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
});
const { manager, getOnChange } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
vi.fn(),
);
expect(getCheckoutDiff).toHaveBeenNthCalledWith(
1,
"/tmp/repo",
expect.objectContaining({ mode: "uncommitted" }),
undefined,
);
const onChange = getOnChange();
onChange?.();
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
const watchFiredCall = getCheckoutDiff.mock.calls[1];
expect(watchFiredCall[2]).toEqual({
force: true,
reason: expect.stringContaining("working-tree"),
});
});
test("an edit during an in-flight diff refresh produces one final follow-up", async () => {
const inFlightDiff = createDeferred<{
diff: string;
structured: Array<{
path: string;
additions: number;
deletions: number;
status: "modified";
}>;
}>();
const getCheckoutDiff = vi
.fn()
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "tracked.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockImplementationOnce(() => inFlightDiff.promise)
.mockResolvedValue({
diff: "",
structured: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }],
});
const { manager, getOnChange } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
const listener = vi.fn();
await manager.subscribe({ cwd: "/tmp/repo", compare: { mode: "uncommitted" } }, listener);
getOnChange()?.();
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
for (let event = 0; event < 100; event += 1) {
getOnChange()?.();
}
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
inFlightDiff.resolve({
diff: "",
structured: [{ path: "tracked.ts", additions: 2, deletions: 1, status: "modified" }],
});
await vi.waitFor(() => {
expect(getCheckoutDiff).toHaveBeenCalledTimes(3);
expect(listener).toHaveBeenLastCalledWith({
cwd: "/tmp/repo",
files: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }],
error: null,
});
});
});
test("base diff subscriptions ignore ordinary working tree edits", async () => {
const getCheckoutDiff = vi.fn(async () => ({
diff: "",
structured: [{ path: "committed.ts", additions: 1, deletions: 0, status: "modified" }],
}));
const { manager, getOnChange, mockRequestWorkingTreeWatch } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
await manager.subscribe(
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
getOnChange()?.();
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
expect(mockRequestWorkingTreeWatch).not.toHaveBeenCalled();
});
test("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => {
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
const { manager, getOnWorkspaceSnapshot } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
await manager.subscribe(
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
getOnWorkspaceSnapshot()?.(
createWorkspaceSnapshot({
isDirty: true,
diffStat: { additions: 5, deletions: 2 },
}),
);
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
});
test("base diff subscriptions refresh for structural workspace changes", async () => {
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
const { manager, getOnWorkspaceSnapshot, workspaceGitService } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
await manager.subscribe(
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledTimes(1);
getOnWorkspaceSnapshot()?.(
createWorkspaceSnapshot({
currentBranch: "feature-2",
isDirty: true,
diffStat: { additions: 5, deletions: 2 },
}),
);
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
expect(getCheckoutDiff.mock.calls[1]?.[2]).toBeUndefined();
getOnWorkspaceSnapshot()?.(createWorkspaceSnapshot({ aheadBehind: { ahead: 2, behind: 0 } }));
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(3);
});
test("falls back to cwd when the working tree watch returns no repo root", async () => {
const { manager, workspaceGitService } = createManager({ repoRoot: null });
await manager.subscribe(
{
cwd: "/tmp/plain",
compare: { mode: "uncommitted" },
},
() => {},
);
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
"/tmp/plain",
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
undefined,
);
});
});
test("cold many-file reads batch Git patches while matching the legacy reader", async () => {
const { getCheckoutDiff } = await import("../utils/checkout-git.js");
const { startGitCommandMetrics, stopGitCommandMetrics } =
await import("../utils/run-git-command.js");
await withCheckout(async ({ cwd, manager, git }) => {
const count = 1000;
for (let index = 0; index < count; index++)
await writeFile(join(cwd, `file-${index}.ts`), "export const value = 0;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "many files");
for (let index = 0; index < count; index++)
await writeFile(join(cwd, `file-${index}.ts`), "export const value = 1;\n");
const legacyStart = performance.now();
const legacy = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
const legacyMs = performance.now() - legacyStart;
startGitCommandMetrics();
const streamedStart = performance.now();
const streamed = await manager.read({ cwd, compare: { mode: "uncommitted" } });
const streamedMs = performance.now() - streamedStart;
const metrics = stopGitCommandMetrics();
const patches = metrics.commands.filter((command) => command.args.includes("--patch"));
const artifactDirectory = join(import.meta.dirname, "../../../../.artifacts/diff-subscription");
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
join(artifactDirectory, "cold-many-file-metrics.json"),
JSON.stringify({ count, legacyMs, streamedMs, patchCommands: patches.length }, null, 2),
);
expect(streamed.error).toBeNull();
expect(streamed.files).toHaveLength(count);
expect(legacy.structured).toHaveLength(count);
expect(streamed.files.map((file) => [file.path, file.hunks])).toEqual(
legacy.structured?.map((file) => [file.path, file.hunks]),
);
expect(patches.length).toBeLessThanOrEqual(Math.ceil(count / 64));
});
}, 60000);
@@ -1,201 +1,400 @@
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { DiffSubscriptions, type CommitFileRead } from "./diff/index.js";
import {
DiffOpcode,
materializeDiffFile,
type DiffFrame,
type DiffBegin,
type DiffBody,
} from "@getpaseo/protocol/binary-frames/index";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import {
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES,
resolveCheckoutDiffBase,
} from "../utils/checkout-git.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { expandTilde } from "../utils/path.js";
import { toCheckoutError } from "./checkout-git-utils.js";
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
type CheckoutDiffWorkspace = Pick<
WorkspaceGitService,
"getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch"
>;
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
export type CheckoutDiffSnapshotPayload = Omit<
Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>["payload"],
"subscriptionId"
>;
export interface CheckoutDiffMetrics {
checkoutDiffTargetCount: number;
checkoutDiffSubscriptionCount: number;
checkoutDiffWatcherCount: number;
checkoutDiffFallbackRefreshTargetCount: number;
}
interface CheckoutDiffWatchTarget {
key: string;
cwd: string;
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
workingTreeWatchUnsubscribe: (() => void) | null;
workspaceGitUnsubscribe: (() => void) | null;
latestWorkspaceStructureFingerprint: string | null;
latestWorkspaceWorktreeFingerprint: string | null;
latestWorkspaceForgeFingerprint: string | null;
debounceTimer: NodeJS.Timeout | null;
pendingDebounceForce: boolean;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
refreshQueuedForce: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestFingerprint: string | null;
openPromise: Promise<void> | null;
}
export interface CheckoutDiffSubscriptionRequest {
cwd: string;
compare: CheckoutDiffCompareInput;
signal?: AbortSignal;
}
export interface CheckoutDiffSubscription {
initial: CheckoutDiffSnapshotPayload;
unsubscribe(): void;
unsubscribe: () => void;
}
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
// Old clients receive the same JSON snapshot while the shared engine owns reads and observation.
export class CheckoutDiffManager {
readonly streams: DiffSubscriptions;
private readonly paseoHome: string;
private readonly workspaceGitService: CheckoutDiffWorkspace;
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
constructor(options: {
logger: pino.Logger;
paseoHome: string;
workspaceGitService: Pick<WorkspaceGitService, "requestWorkingTreeWatch">;
workspaceGitService: CheckoutDiffWorkspace;
workspaceRuntimeStore: WorkspaceRuntimeStore;
}) {
this.streams = new DiffSubscriptions(options.workspaceGitService);
this.paseoHome = options.paseoHome;
}
readCommitFile(input: CommitFileRead) {
return this.streams.readCommitFile(input);
this.workspaceGitService = options.workspaceGitService;
this.workspaceRuntimeStore = options.workspaceRuntimeStore;
}
async read(
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
): Promise<CheckoutDiffSnapshotPayload> {
const subscription = await this.subscribe(params, () => {});
await subscription.unsubscribe();
return subscription.initial;
return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare));
}
async subscribe(
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): Promise<CheckoutDiffSubscription> {
const cwd = expandTilde(params.cwd);
let resolveInitial!: (snapshot: CheckoutDiffSnapshotPayload) => void;
const initial = new Promise<CheckoutDiffSnapshotPayload>((resolve) => {
resolveInitial = resolve;
});
let delivered = false;
let pending: DiffBegin | null = null;
let files = new Map<string, ParsedDiffFile>();
let bodies = new Map<string, DiffBody>();
const receive = async (frame: DiffFrame) => {
if (params.signal?.aborted) return;
if (frame.opcode === DiffOpcode.Begin) {
pending = frame;
bodies = new Map();
const cwd = params.cwd;
const compare = this.normalizeCompare(params.compare);
const target = this.ensureTarget(cwd, compare);
target.listeners.add(listener);
target.openPromise ??= this.openTarget(target);
let isSubscribed = true;
const unsubscribe = () => {
if (!isSubscribed) {
return;
}
if (!pending || frame.revision !== pending.revision) return;
if (frame.opcode === DiffOpcode.Bodies) {
for (const body of frame.files) bodies.set(body.path, body);
return;
}
let payload: CheckoutDiffSnapshotPayload;
if (frame.error) {
const message = frame.error.message;
payload = {
cwd,
files: [],
error: toCheckoutError(new Error(message)),
diffTooLarge: frame.diffTooLarge,
};
files.clear();
} else {
const next = new Map<string, ParsedDiffFile>();
const omitted = new Set(frame.omitted);
for (const entry of pending.entries) {
const previous = pending.baseRevision ? files.get(entry.path) : undefined;
const body = bodies.get(entry.path) ?? null;
const file = materializeDiffFile(entry, body, omitted.has(entry.path));
if (!entry.body && previous && entry.content === "text" && !omitted.has(entry.path))
file.hunks = previous.hunks;
next.set(entry.path, file);
}
files = next;
// Stream omissions remain file placeholders; the JSON budget rejects a legacy snapshot.
payload = legacySnapshot(cwd, [...next.values()]);
}
pending = null;
if (delivered) listener(payload);
else {
delivered = true;
resolveInitial(payload);
}
isSubscribed = false;
params.signal?.removeEventListener("abort", unsubscribe);
this.removeListener(target, listener);
};
params.signal?.addEventListener("abort", unsubscribe, { once: true });
if (params.signal?.aborted) {
unsubscribe();
}
try {
const baseRef = await resolveCheckoutDiffBase(cwd, params.compare, {
paseoHome: this.paseoHome,
});
if (!baseRef) return { initial: { cwd, files: [], error: null }, unsubscribe() {} };
const subscription = await this.streams.subscribe(
{
cwd,
base: { kind: "rev", rev: baseRef },
checkoutBase:
params.compare.mode === "uncommitted"
? undefined
: { compare: params.compare, paseoHome: this.paseoHome },
head:
params.compare.mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
mergeBase: params.compare.mode !== "uncommitted",
ignoreWhitespace: params.compare.ignoreWhitespace,
},
receive,
);
const unsubscribe = async () => {
if (!delivered) {
delivered = true;
resolveInitial({
cwd,
files: [],
error: toCheckoutError(new Error("Diff subscription cancelled")),
});
}
params.signal?.removeEventListener("abort", unsubscribe);
await subscription.release();
};
params.signal?.addEventListener("abort", unsubscribe, { once: true });
if (params.signal?.aborted) {
await subscription.release();
throw new Error("Diff subscription cancelled");
}
await subscription.start();
return { initial: await initial, unsubscribe };
await target.openPromise;
const initial =
target.latestPayload ??
(await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
diffCwd: target.diffCwd,
}));
target.latestPayload = initial;
target.latestFingerprint = JSON.stringify(initial);
return { initial, unsubscribe };
} catch (error) {
return { initial: { cwd, files: [], error: toCheckoutError(error) }, unsubscribe() {} };
unsubscribe();
throw error;
}
}
scheduleRefreshForCwd(cwd: string): Promise<void> {
return this.streams.invalidateForCwd(expandTilde(cwd));
scheduleRefreshForCwd(cwd: string): void {
const resolvedCwd = expandTilde(cwd);
for (const target of this.targets.values()) {
if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) {
continue;
}
this.scheduleTargetRefresh(target);
}
}
getMetrics(): CheckoutDiffMetrics {
const metrics = this.streams.getMetrics();
let checkoutDiffSubscriptionCount = 0;
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
}
return {
checkoutDiffTargetCount: metrics.targets,
checkoutDiffSubscriptionCount: metrics.subscriptions,
checkoutDiffWatcherCount: metrics.metadataObservers,
checkoutDiffTargetCount: this.targets.size,
checkoutDiffSubscriptionCount,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
};
}
async dispose(): Promise<void> {
await this.streams.close();
}
}
function legacySnapshot(cwd: string, files: ParsedDiffFile[]): CheckoutDiffSnapshotPayload {
let bytes = 2;
for (let index = 0; index < files.length; index++) {
bytes += Buffer.byteLength(JSON.stringify(files[index])) + Number(index > 0);
if (bytes > CHECKOUT_DIFF_MAX_STRUCTURED_BYTES) {
return legacyTooLarge(cwd);
dispose(): void {
for (const target of this.targets.values()) {
this.closeTarget(target);
}
this.targets.clear();
}
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: compare.mode, ignoreWhitespace };
}
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
return JSON.stringify([
cwd,
compare.mode,
compare.mode !== "uncommitted" ? (compare.baseRef ?? "") : "",
compare.ignoreWhitespace === true,
]);
}
private closeTarget(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
target.workingTreeWatchUnsubscribe?.();
target.workingTreeWatchUnsubscribe = null;
target.workspaceGitUnsubscribe?.();
target.workspaceGitUnsubscribe = null;
target.listeners.clear();
}
private rememberWorkspaceSnapshot(
target: CheckoutDiffWatchTarget,
snapshot: WorkspaceGitRuntimeSnapshot,
): void {
const structureFingerprint = JSON.stringify({
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
baseRef: snapshot.git.baseRef,
baseRefExact: snapshot.git.baseRefExact,
aheadBehind: snapshot.git.aheadBehind,
aheadOfOrigin: snapshot.git.aheadOfOrigin,
behindOfOrigin: snapshot.git.behindOfOrigin,
hasRemote: snapshot.git.hasRemote,
});
const worktreeFingerprint = JSON.stringify({
isDirty: snapshot.git.isDirty,
diffStat: snapshot.git.diffStat,
});
const forgeFingerprint = JSON.stringify(snapshot.forge);
const previousStructureFingerprint = target.latestWorkspaceStructureFingerprint;
const previousWorktreeFingerprint = target.latestWorkspaceWorktreeFingerprint;
const previousForgeFingerprint = target.latestWorkspaceForgeFingerprint;
target.latestWorkspaceStructureFingerprint = structureFingerprint;
target.latestWorkspaceWorktreeFingerprint = worktreeFingerprint;
target.latestWorkspaceForgeFingerprint = forgeFingerprint;
if (previousStructureFingerprint === null) {
return;
}
const structureChanged = structureFingerprint !== previousStructureFingerprint;
const worktreeChanged = worktreeFingerprint !== previousWorktreeFingerprint;
const forgeChanged = forgeFingerprint !== previousForgeFingerprint;
if (structureChanged || (!worktreeChanged && !forgeChanged)) {
this.scheduleTargetRefresh(target, false);
}
}
return { cwd, files, error: null };
}
function legacyTooLarge(cwd: string): CheckoutDiffSnapshotPayload {
return {
cwd,
files: [],
diffTooLarge: true,
error: toCheckoutError(new Error("Diff too large to display")),
};
private removeListener(
target: CheckoutDiffWatchTarget,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): void {
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeTarget(target);
if (this.targets.get(target.key) === target) {
this.targets.delete(target.key);
}
}
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget, force = true): void {
target.pendingDebounceForce ||= force;
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
}
target.debounceTimer = setTimeout(() => {
target.debounceTimer = null;
const pendingForce = target.pendingDebounceForce;
target.pendingDebounceForce = false;
void this.refreshTarget(target, pendingForce);
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
}
private async computeCheckoutDiffSnapshot(
cwd: string,
compare: CheckoutDiffCompareInput,
options?: { diffCwd?: string; force?: boolean; reason?: string },
): Promise<CheckoutDiffSnapshotPayload> {
const diffCwd = options?.diffCwd ?? cwd;
try {
const diffResult = await this.workspaceGitService.getCheckoutDiff(
diffCwd,
{
mode: compare.mode,
baseRef: compare.baseRef,
ignoreWhitespace: compare.ignoreWhitespace,
includeStructured: true,
},
options?.force
? { force: true, reason: options.reason ?? "checkout-diff-refresh" }
: undefined,
);
if (diffResult.diffTooLarge) {
return {
cwd,
files: [],
diffTooLarge: true,
error: toCheckoutError(new Error("Diff too large to display")),
};
}
const files = [...(diffResult.structured ?? [])];
files.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
return {
cwd,
files,
error: null,
};
} catch (error) {
return {
cwd,
files: [],
error: toCheckoutError(error),
};
}
}
private async refreshTarget(target: CheckoutDiffWatchTarget, force: boolean): Promise<void> {
if (target.refreshPromise) {
target.refreshQueued = true;
target.refreshQueuedForce ||= force;
return;
}
target.refreshPromise = (async () => {
let currentForce = force;
do {
target.refreshQueued = false;
target.refreshQueuedForce = false;
const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
diffCwd: target.diffCwd,
force: currentForce,
...(currentForce ? { reason: "working-tree-watch" } : {}),
});
target.latestPayload = snapshot;
const fingerprint = JSON.stringify(snapshot);
if (fingerprint !== target.latestFingerprint) {
target.latestFingerprint = fingerprint;
for (const listener of target.listeners) {
listener(snapshot);
}
}
currentForce = target.refreshQueuedForce;
} while (target.refreshQueued);
})();
try {
await target.refreshPromise;
} finally {
target.refreshPromise = null;
}
}
private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget {
const targetKey = this.buildTargetKey(cwd, compare);
const existing = this.targets.get(targetKey);
if (existing) {
return existing;
}
const target: CheckoutDiffWatchTarget = {
key: targetKey,
cwd,
diffCwd: cwd,
compare,
listeners: new Set(),
workingTreeWatchUnsubscribe: null,
workspaceGitUnsubscribe: null,
latestWorkspaceStructureFingerprint: null,
latestWorkspaceWorktreeFingerprint: null,
latestWorkspaceForgeFingerprint: null,
debounceTimer: null,
pendingDebounceForce: false,
refreshPromise: null,
refreshQueued: false,
refreshQueuedForce: false,
latestPayload: null,
latestFingerprint: null,
openPromise: null,
};
this.targets.set(targetKey, target);
return target;
}
private async openTarget(target: CheckoutDiffWatchTarget): Promise<void> {
if (target.compare.mode !== "uncommitted") {
const snapshot =
this.workspaceRuntimeStore.get(target.cwd) ??
(await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false }));
target.diffCwd = snapshot.git.repoRoot ?? target.cwd;
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
return;
}
this.rememberWorkspaceSnapshot(target, snapshot);
const workspaceSubscription = this.workspaceGitService.registerWorkspace(
{ cwd: target.cwd },
(nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot),
);
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
workspaceSubscription.unsubscribe();
return;
}
target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe;
if (target.compare.mode === "base") return;
}
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
target.cwd,
() => this.scheduleTargetRefresh(target),
);
target.diffCwd = repoRoot ?? target.cwd;
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
unsubscribe();
return;
}
target.workingTreeWatchUnsubscribe = unsubscribe;
}
}
@@ -62,7 +62,6 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef,
baseRefExact: snapshot.git.baseRefExact,
comparisonBaseRef: snapshot.git.comparisonBaseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
upstreamRef: snapshot.git.upstreamRef ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
@@ -84,7 +83,6 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef ?? null,
baseRefExact: snapshot.git.baseRefExact,
comparisonBaseRef: snapshot.git.comparisonBaseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
upstreamRef: snapshot.git.upstreamRef ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
@@ -293,263 +293,3 @@ test("large source diffs stay renderable for reads, subscriptions, and commit fi
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("streams whole-file batches and answers a same-socket ping before the revision ends", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "diff-stream-wire-"));
let probe: Awaited<ReturnType<typeof openDiffWireProbe>> | null = null;
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "README"), "base\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
for (let file = 0; file < 252; file++) {
const content =
Array.from(
{ length: 80 },
(_, line) =>
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
).join("\n") + "\n";
writeFileSync(join(cwd, `${file}.ts`), content);
}
expect(ctx.client.getLastServerInfoMessage()).toMatchObject({
features: { diffSubscription: true },
});
const legacy = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
const legacyJsonBytes = Buffer.byteLength(JSON.stringify(legacy.files));
expect(legacyJsonBytes).toBeGreaterThanOrEqual(10 * 1024 * 1024);
probe = await openDiffWireProbe(ctx.daemon.port, cwd);
const { encodedBytes, bodyFrames, pongDuringRevision, entryCount } = await probe.done;
expect(entryCount).toBe(252);
expect(bodyFrames).toBeGreaterThan(1);
expect(pongDuringRevision).toBe(true);
const artifactDirectory = join(
import.meta.dirname,
"../../../../../.artifacts/diff-subscription",
);
mkdirSync(artifactDirectory, { recursive: true });
writeFileSync(
join(artifactDirectory, "wire-metrics.json"),
JSON.stringify({ legacyJsonBytes, encodedBytes, bodyFrames, pongDuringRevision }, null, 2),
);
} finally {
probe?.socket.terminate();
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 60_000);
interface DiffWireResult {
encodedBytes: number;
bodyFrames: number;
pongDuringRevision: boolean;
entryCount: number;
}
async function openDiffWireProbe(port: number, cwd: string) {
const { WebSocket } = await import("ws");
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
const activeSocket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
await new Promise<void>((resolve, reject) => {
activeSocket.once("open", resolve);
activeSocket.once("error", reject);
});
let encodedBytes = 0;
let endReceived = false;
let pingSent = false;
let pongDuringRevision = false;
let bodyFrames = 0;
let entryCount = 0;
const done = new Promise<DiffWireResult>((resolve, reject) => {
activeSocket.on("message", (data, binary) => {
try {
if (binary) {
if (!Buffer.isBuffer(data)) throw new Error("Expected buffer");
encodedBytes += data.byteLength;
const frame = decodeDiffFrame(new Uint8Array(data));
if (frame.opcode === DiffOpcode.Begin) entryCount = frame.entries.length;
if (frame.opcode === DiffOpcode.Bodies) {
bodyFrames++;
if (!pingSent) {
pingSent = true;
activeSocket.send(
JSON.stringify({
type: "session",
message: { type: "ping", requestId: "mid-revision", clientSentAt: 1 },
}),
);
}
}
if (frame.opcode === DiffOpcode.End) {
expect(frame.error).toBeNull();
endReceived = true;
resolve({ encodedBytes, bodyFrames, pongDuringRevision, entryCount });
}
} else {
const envelope = JSON.parse(data.toString());
if (
envelope.message?.type === "pong" &&
envelope.message.payload.requestId === "mid-revision"
)
pongDuringRevision = !endReceived;
if (envelope.message?.payload?.status === "server_info")
activeSocket.send(
JSON.stringify({
type: "session",
message: {
type: "diff.subscribe.request",
requestId: "stream-diff",
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
},
}),
);
if (envelope.message?.type === "diff.subscribe.response")
expect(envelope.message.payload.error).toBeNull();
}
} catch (error) {
reject(error);
}
});
activeSocket.once("error", reject);
});
activeSocket.send(
JSON.stringify({
type: "hello",
clientId: "diff-wire-test",
clientType: "browser",
protocolVersion: 1,
capabilities: { owned_subscriptions: true },
}),
);
return { socket: activeSocket, done };
}
test("SDK diff journey publishes a tag tree, commits a one-file refresh and shares two sockets", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "sdk-stream-diff-"));
const { DaemonClient } = await import("../test-utils/daemon-client.js");
const { startGitCommandMetrics, stopGitCommandMetrics } =
await import("../../utils/run-git-command.js");
const second = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "a.ts"), "export const a = 1;\n");
writeFileSync(join(cwd, "b.ts"), "export const b = 1;\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
git(cwd, "tag", "v1");
writeFileSync(join(cwd, "a.ts"), "export const a = 2;\n");
writeFileSync(join(cwd, "b.ts"), "export const b = 2;\n");
const compare = {
cwd,
base: { kind: "rev", rev: "refs/tags/v1" },
head: { kind: "worktree" },
mergeBase: true,
} as const;
const states: import("@getpaseo/client").DiffState[] = [];
const observation = ctx.client.observeDiff(compare);
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
expect(states[0]).toMatchObject({
phase: "loading",
entries: [{ path: "a.ts" }, { path: "b.ts" }],
});
const initial = states.at(-1);
if (initial?.phase !== "ready") throw new Error("Expected ready");
const initialStateCount = states.length;
await second.connect();
startGitCommandMetrics();
const duplicateStates: import("@getpaseo/client").DiffState[] = [];
const duplicate = second.observeDiff(compare);
duplicate.subscribe({ snapshot: (state) => duplicateStates.push(state), update: () => {} });
await expect.poll(() => duplicateStates.at(-1)?.phase).toBe("ready");
const metrics = stopGitCommandMetrics();
expect(duplicateStates.at(-1)).toMatchObject({
phase: "ready",
files: [{ path: "a.ts" }, { path: "b.ts" }],
});
expect(metrics.commands.filter((command) => command.args.includes("--raw"))).toHaveLength(0);
startGitCommandMetrics();
writeFileSync(join(cwd, "a.ts"), "export const a = 3;\n");
await expect
.poll(() => states.at(-1)?.revision, { timeout: 15000 })
.toBeGreaterThan(initial.revision);
await expect.poll(() => duplicateStates.at(-1)?.revision).toBeGreaterThan(initial.revision);
const refreshMetrics = stopGitCommandMetrics();
expect(
refreshMetrics.commands.filter((command) => command.args.includes("--raw")),
).toHaveLength(1);
const refreshed = states.at(-1);
if (refreshed?.phase !== "ready") throw new Error("Expected ready");
expect(refreshed.files[0]).not.toBe(initial.files[0]);
expect(refreshed.files[1]).toBe(initial.files[1]);
expect(states.slice(initialStateCount).map((state) => state.phase)).not.toContain("loading");
expect(states.map((state) => state.phase)).not.toContain("error");
await duplicate.release();
await observation.release();
} finally {
await second.close();
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 45000);
test("commit-file RPC keeps the existing read ceiling for patches larger than one MiB", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "commit-file-budget-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "large.txt"), "base\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
writeFileSync(join(cwd, "large.txt"), ("x".repeat(100) + "\n").repeat(11000));
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "large change");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const { getCommitFileDiff } = await import("../../utils/checkout-git.js");
const expected = await getCommitFileDiff({ cwd, sha, path: "large.txt" });
const actual = await ctx.client.getCommitFileDiff(cwd, sha, "large.txt");
expect(expected?.hunks.length).toBeGreaterThan(0);
expect(actual.file?.hunks).toEqual(expected?.hunks);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test("commit-file RPC selects renamed content when the source path becomes a directory", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "commit-rename-directory-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "a.txt"), "original line\n".repeat(16));
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
git(cwd, "mv", "a.txt", "z.txt");
writeFileSync(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n");
mkdirSync(join(cwd, "a.txt"));
writeFileSync(join(cwd, "a.txt", "child.txt"), "replacement line\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const { getCommitFileDiff } = await import("../../utils/checkout-git.js");
const expected = await getCommitFileDiff({ cwd, sha, path: "z.txt" });
const actual = await ctx.client.getCommitFileDiff(cwd, sha, "z.txt");
expect(expected?.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content))).toContain(
"renamed line",
);
expect(actual.file?.hunks).toEqual(expected?.hunks);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
@@ -142,8 +142,7 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy
commitFile(cwd, "large-b.js", "const value = 0;\n");
// Short lines stay eligible for highlighting; a giant single line is skipped
// before token expansion and cannot exercise the structured-payload limit.
// Stay below the full-source highlighting budget as well.
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(3_500);
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000);
writeFileSync(path.join(cwd, "large-a.js"), denseExpression);
writeFileSync(path.join(cwd, "large-b.js"), denseExpression);
@@ -690,7 +690,6 @@ describe("commit file diffs", () => {
expect(result).toEqual({
file: {
path: filePath,
status: "ok",
isNew: false,
isDeleted: false,
additions: 1,
@@ -1,30 +0,0 @@
import { encodeDiffFrame, DiffOpcode, type DiffBody } from "@getpaseo/protocol/binary-frames/index";
export async function* packBodies(
files: AsyncIterable<DiffBody> | Iterable<DiffBody>,
revision: number,
): AsyncGenerator<DiffBody[]> {
let batch: DiffBody[] = [];
let size = 0;
for await (const file of files) {
const bytes = encodeDiffFrame({
opcode: DiffOpcode.Bodies,
slot: 0,
revision,
files: [file],
}).length;
if (batch.length && size + bytes > 256 * 1024) {
yield batch;
batch = [];
size = 0;
}
batch.push(file);
size += bytes;
if (size >= 256 * 1024) {
yield batch;
batch = [];
size = 0;
}
}
if (batch.length) yield batch;
}
@@ -1,76 +0,0 @@
import { classifyGitMetadataPath } from "../git-metadata-event-rules.js";
import { resolve } from "node:path";
import { runGitCommand } from "../../utils/run-git-command.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { createFileObserver, type FileObserverSubscription } from "../file-observer/index.js";
export interface DiffDependencies {
cwd: string;
refs: string[];
index: boolean;
worktree: boolean;
}
// Own metadata observations here; comparison managers declare inputs rather than watching paths.
export class DiffDependencyObserver {
private observer = createFileObserver();
constructor(private workspaceGit: Pick<WorkspaceGitService, "requestWorkingTreeWatch">) {}
async declareDependencies(
input: DiffDependencies,
changed: () => void,
): Promise<() => Promise<void>> {
if (input.refs.length === 0 && !input.index && !input.worktree) return async () => {};
let polling: ReturnType<typeof setInterval> | null = null;
const subscriptions: FileObserverSubscription[] = [];
let workingTree: Awaited<ReturnType<WorkspaceGitService["requestWorkingTreeWatch"]>> | null =
null;
try {
const paths = await runGitCommand(["rev-parse", "--absolute-git-dir", "--git-common-dir"], {
cwd: input.cwd,
});
const roots = new Set(
paths.stdout
.split(/\r?\n/)
.filter((path) => path.length > 0)
.map((path) => resolve(input.cwd, path)),
);
for (const root of roots) {
const subscription = await this.observer.subscribe(
root,
(error, events) => {
if (error) {
changed();
polling ??= setInterval(changed, 5000);
return;
}
const affectsInput = events.some((event) => {
const path = event.path.slice(root.length + 1).replaceAll("\\", "/");
if (path === "index") return input.index;
return classifyGitMetadataPath("common", path).kind !== "ignore";
});
if (affectsInput) changed();
},
{ ignore: [resolve(root, "objects"), resolve(root, "logs"), resolve(root, "hooks")] },
);
subscriptions.push(subscription);
}
if (input.worktree)
workingTree = await this.workspaceGit.requestWorkingTreeWatch(input.cwd, changed);
return async () => {
if (polling) clearInterval(polling);
await workingTree?.unsubscribe();
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
};
} catch (error) {
if (polling) clearInterval(polling);
await workingTree?.unsubscribe();
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
throw error;
}
}
getObservationCount(): number {
return this.observer.getDiagnostics().activeObservationCount;
}
async close(): Promise<void> {
await this.observer.close();
}
}
-667
View File
@@ -1,667 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm, readFile, lstat } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { listComparison, type Comparison } from "./git.js";
test("working tree comparisons preserve staged deletions whose file remains on disk", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-comparison-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
git("rm", "--cached", "x.ts");
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
expect(comparison.files.map((file) => file.entry)).toEqual([
{
path: "x.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 1,
deletions: 1,
body: true,
},
]);
const body = await readBody(comparison);
expect(body.body.hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "const value = 1;" },
{ type: "add", content: "const value = 2;" },
]);
expect(comparison.metrics.highlightCalls).toBe(0);
await comparison.close();
expect(git("diff", "--cached", "--name-status")).toBe("D\tx.ts");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("supports empty trees, unborn HEAD, staged and unstaged comparisons", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-endpoints-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
const unborn = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
expect(unborn.files.map((file) => file.entry.path)).toEqual(["x.ts"]);
await unborn.close();
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const empty = await listComparison({
cwd,
base: { kind: "empty" },
head: { kind: "rev", rev: "HEAD" },
});
expect(empty.files[0].entry.change).toBe("added");
await empty.close();
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
git("add", ".");
const indexPath = join(cwd, ".git", "index");
const originalIndex = await readFile(indexPath);
const originalMtime = (await lstat(indexPath, { bigint: true })).mtimeNs;
const staged = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "index" },
highlight: false,
});
expect(await readFile(indexPath)).toEqual(originalIndex);
expect((await lstat(indexPath, { bigint: true })).mtimeNs).toBe(originalMtime);
const stagedSha = staged.headSha;
expect(staged.files[0].entry).toMatchObject({ additions: 1, deletions: 1 });
await staged.close();
await writeFile(join(cwd, "x.ts"), "const value = 3;\n");
const unstaged = await listComparison({
cwd,
base: { kind: "index" },
head: { kind: "worktree" },
highlight: false,
});
expect(unstaged.baseSha).toBe(stagedSha);
const record = await readBody(unstaged);
expect(record.body.hunks[0].lines).toContainEqual({
type: "remove",
content: "const value = 2;",
});
await unstaged.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("lists oversized untracked files without treating their truncated contents as additions", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-large-file-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "huge.ts"), Buffer.alloc(2 * 1024 * 1024, 120));
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
expect(comparison.files[0].entry).toMatchObject({
path: "huge.ts",
additions: 0,
deletions: 0,
content: "text",
});
expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" });
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("preserves highlighting context outside the emitted hunks", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-highlight-context-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
const lines = [
"/*",
...Array.from({ length: 30 }, (_, index) => `comment ${index}`),
"*/",
"export const value = 1;",
];
await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
lines[15] = "new comment";
await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
const record = await readBody(comparison);
expect(record.body.hunks[0].lines.find((line) => line.type === "add")).toMatchObject({
content: "new comment",
runs: [{ style: "comment", length: 11 }],
});
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("reads patches independently of repository color and prefix presentation settings", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-presentation-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "x.ts"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
git("config", "color.ui", "always");
git("config", "diff.noprefix", "true");
await writeFile(join(cwd, "x.ts"), "after\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
const record = await readBody(comparison);
expect(record.body.hunks).toHaveLength(1);
expect(record.body.hunks[0].lines).toContainEqual({ type: "add", content: "after" });
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("merge-base index comparisons resolve against HEAD while reporting both endpoint SHAs", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-index-merge-base-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const common = git("rev-parse", "HEAD");
git("checkout", "-b", "side");
await writeFile(join(cwd, "side.txt"), "side\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "side");
const side = git("rev-parse", "HEAD");
git("checkout", "main");
await writeFile(join(cwd, "main.txt"), "main\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "main");
await writeFile(join(cwd, "staged.txt"), "staged\n");
git("add", "staged.txt");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "side" },
head: { kind: "index" },
mergeBase: true,
});
expect(comparison.baseSha).toBe(side);
expect(comparison.mergeBaseSha).toBe(common);
expect(comparison.files.map((file) => file.entry.path)).toEqual(["main.txt", "staged.txt"]);
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
async function readBody(comparison: Comparison) {
const record = await comparison.read(comparison.files[0]);
if (record.kind !== "ready") throw new Error("Expected a readable file body");
return record;
}
test.each(["worktree", "index", "rev"] as const)(
"%s whitespace filtering omits spacing-only edits and retains meaningful edits",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-whitespace-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "a.ts"), "const x = 1;\n");
await writeFile(join(cwd, "b.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD").trim();
await writeFile(join(cwd, "a.ts"), "const x = 1;\n");
await writeFile(join(cwd, "b.txt"), "after\n");
if (headKind !== "worktree") git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
ignoreWhitespace: true,
highlight: false,
});
expect(comparison.files.map((file) => file.entry.path)).toEqual(["b.txt"]);
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"comparison roots preserve trailing folder spaces",
async () => {
const root = await mkdtemp(join(tmpdir(), "diff-space-"));
const cwd = join(root, "repo ");
const { mkdir } = await import("node:fs/promises");
await mkdir(cwd);
try {
execFileSync("git", ["init", "-b", "main"], { cwd });
const { worktreeRoot } = await import("./git.js");
expect(await worktreeRoot(cwd)).toBe(cwd);
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
test.each(["worktree", "index", "rev"] as const)(
"%s rename reads select the destination when the old path becomes a directory",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-rename-directory-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "original line\n".repeat(16));
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
git("mv", "a.txt", "z.txt");
await writeFile(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n");
await mkdir(join(cwd, "a.txt"));
await writeFile(join(cwd, "a.txt", "child.txt"), "replacement line\n");
git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "rename");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
highlight: false,
});
try {
const file = comparison.files.find((candidate) => candidate.entry.path === "z.txt");
if (!file) throw new Error("Missing rename");
expect(file.entry.oldPath).toBe("a.txt");
const record = await comparison.read(file);
if (record.kind !== "ready") throw new Error("Expected readable rename");
const lines = record.body.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content));
expect(lines).toContain("renamed line");
expect(lines).not.toContain("replacement line");
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"a dangling untracked symlink exposes its target text alongside tracked changes",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-dangling-link-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { symlink } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "after\n");
await symlink("not-created-yet", join(cwd, "link"));
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt", "link"]);
const record = await comparison.read(comparison.files[1]);
if (record.kind !== "ready") throw new Error("Expected link body");
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "not-created-yet",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test("untracked nested repositories do not hide tracked edits", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-nested-repository-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "after\n");
await mkdir(join(cwd, "nested"));
git("-C", "nested", "init");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt"]);
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test.each(["worktree", "index", "rev"] as const)(
"%s preserves hunks for paths containing a patch prefix delimiter",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-path-delimiter-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await mkdir(join(cwd, "dir b"));
await writeFile(join(cwd, "dir b", "file.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "dir b", "file.txt"), "after\n");
if (headKind !== "worktree") git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
highlight: false,
});
try {
expect(comparison.files[0].entry.path).toBe("dir b/file.txt");
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test("highlighted BOM source content survives the production frame codec", async () => {
const { encodeDiffFrame, decodeDiffFrame } =
await import("@getpaseo/protocol/binary-frames/index");
const cwd = await mkdtemp(join(tmpdir(), "diff-bom-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
await writeFile(join(cwd, "bom.ts"), "\uFEFFexport const value = 1;\n");
const comparison = await listComparison({
cwd,
base: { kind: "empty" },
head: { kind: "worktree" },
});
try {
const record = await readBody(comparison);
const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] };
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
expect(
record.body.hunks
.flatMap((hunk) => hunk.lines)
.some((line) => line.content.startsWith("\uFEFF")),
).toBe(true);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test.skipIf(process.platform === "win32")(
"symlink replacement retains both patch sections",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-type-change-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { symlink, unlink } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await symlink("old-target", join(cwd, "file.txt"));
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await unlink(join(cwd, "file.txt"));
await writeFile(join(cwd, "file.txt"), "replacement contents\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files[0].entry).toMatchObject({ additions: 1, deletions: 1 });
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
{ type: "remove", content: "old-target" },
{ type: "add", content: "replacement contents" },
]),
);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"highlighted symlink replacements use stored link text instead of target contents",
async () => {
const { encodeDiffFrame, decodeDiffFrame } =
await import("@getpaseo/protocol/binary-frames/index");
const cwd = await mkdtemp(join(tmpdir(), "diff-highlighted-link-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { symlink, unlink } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "file.ts"), "export const old = 1;\n");
await writeFile(join(cwd, "target.ts"), "export const entirelyDifferentLength = 2;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await unlink(join(cwd, "file.ts"));
await symlink("target.ts", join(cwd, "file.ts"));
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
try {
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual(
expect.objectContaining({ type: "add", content: "target.ts" }),
);
const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] };
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test("files removed between listing and highlighting retain their deletion patch", async () => {
const { encodeDiffFrame, decodeDiffFrame } =
await import("@getpaseo/protocol/binary-frames/index");
const cwd = await mkdtemp(join(tmpdir(), "diff-source-disappeared-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.ts"), "export const old = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.ts"), "export const old = 2;\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
try {
expect(comparison.files[0].entry.change).toBe("modified");
await rm(join(cwd, "a.ts"));
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual(
expect.objectContaining({ type: "remove", content: "export const old = 1;" }),
);
const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] };
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("a batched oversized tracked patch does not hide its small sibling", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-batch-budget-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
await writeFile(join(cwd, "b.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "oversized\n".repeat(1024 * 1024));
await writeFile(join(cwd, "b.txt"), "after\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" });
const sibling = await comparison.read(comparison.files[1]);
if (sibling.kind !== "ready") throw new Error("Expected readable sibling");
expect(sibling.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("replacing a submodule with a regular file preserves its replacement body", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-replacement-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await mkdir(join(cwd, "sub"));
git("-C", "sub", "init", "-b", "main");
git("-C", "sub", "config", "user.name", "Test");
git("-C", "sub", "config", "user.email", "test@example.com");
await writeFile(join(cwd, "sub", "a.txt"), "base\n");
git("-C", "sub", "add", ".");
git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "base");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "record submodule");
await rm(join(cwd, "sub"), { recursive: true, force: true });
await writeFile(join(cwd, "sub"), "replacement contents\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
try {
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual(
expect.objectContaining({ type: "add", content: "replacement contents" }),
);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
-417
View File
@@ -1,417 +0,0 @@
import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js";
import { readGitInputIdentities } from "./inputs.js";
import { Patches } from "./patches.js";
import { HighlightSources } from "./sources.js";
import {
prepareAllChangesIndex,
PER_FILE_DIFF_MAX_BYTES,
GIT_PATCH_FORMAT_ARGS,
inspectUntrackedFile,
snapshotComparisonIndex,
resolveCheckoutDiffBase,
type CheckoutDiffCompare,
} from "../../utils/checkout-git.js";
import { lstat, readFile, readlink } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { resolve } from "node:path";
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
import { parseDiff, highlightDiffWithFileContent } from "../utils/diff-highlighter.js";
export type Compare = Omit<DiffSubscribeRequest, "type" | "requestId"> & {
checkoutBase?: { compare: CheckoutDiffCompare; paseoHome: string };
};
export interface ListedFile {
entry: DiffEntry;
identity: string;
source: { kind: "tracked" } | { kind: "untracked"; size: number };
}
interface ResolvedComparison {
baseSha: string;
headSha: string;
mergeBaseSha: string;
comparisonBaseSha: string;
}
export type DiffFileRead = { kind: "ready"; body: DiffBody; bytes: number } | { kind: "too_large" };
export interface Comparison extends Pick<
ResolvedComparison,
"baseSha" | "headSha" | "mergeBaseSha"
> {
files: ListedFile[];
metrics: { highlightCalls: number };
close(): Promise<void>;
selectBodies(paths: ReadonlySet<string>): void;
read(
file: ListedFile,
budget?: { remainingBytes?: number; maxPatchBytes?: number },
): Promise<DiffFileRead>;
}
export class DiffReadError extends Error {
constructor(
readonly code: "INVALID_COMPARE" | "NOT_GIT_REPO" | "REF_NOT_FOUND",
message: string,
) {
super(message);
}
}
const EMPTY = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
export function validateCompare(compare: Compare): void {
if (compare.head.kind === "empty")
throw new DiffReadError("INVALID_COMPARE", "The head cannot be empty");
if (compare.base.kind === "worktree")
throw new DiffReadError("INVALID_COMPARE", "The working tree cannot be the base");
if (compare.base.kind === "index" && compare.head.kind !== "worktree")
throw new DiffReadError("INVALID_COMPARE", "An index base requires a working tree head");
if (compare.mergeBase && compare.base.kind !== "rev")
throw new DiffReadError("INVALID_COMPARE", "Merge base requires a revision base");
}
export async function worktreeRoot(cwd: string): Promise<string> {
const result = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
acceptExitCodes: [0, 128],
});
if (result.exitCode !== 0) throw new DiffReadError("NOT_GIT_REPO", "Not a git repository");
const root = parseGitRevParsePath(result.stdout);
if (root === null) throw new DiffReadError("NOT_GIT_REPO", "Git returned no working tree root");
return resolve(root);
}
async function resolveEndpoint(
cwd: string,
endpoint: DiffEndpoint,
indexFile: string | null,
): Promise<string> {
if (endpoint.kind === "empty") return EMPTY;
if (endpoint.kind === "index") {
if (indexFile === null) throw new Error("Index comparisons require an index snapshot");
const result = await runGitCommand(["write-tree"], {
cwd,
envOverlay: { GIT_INDEX_FILE: indexFile },
});
return result.stdout.trim();
}
const ref = endpoint.kind === "rev" ? endpoint.rev : "HEAD";
const result = await runGitCommand(
["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
{
cwd,
acceptExitCodes: [0, 128],
},
);
if (result.exitCode === 0) return result.stdout.trim();
if (ref === "HEAD") {
const unborn = await runGitCommand(["rev-parse", "--verify", "HEAD"], {
cwd,
acceptExitCodes: [0, 128],
});
if (unborn.exitCode !== 0) return EMPTY;
}
throw new DiffReadError("REF_NOT_FOUND", `Revision not found: ${ref}`);
}
function parseStats(
text: string,
): Map<string, { additions: number; deletions: number; binary: boolean }> {
const fields = text.split("\0");
const stats = new Map<string, { additions: number; deletions: number; binary: boolean }>();
for (let index = 0; index < fields.length - 1; index++) {
const line = fields[index];
const tab = line.indexOf("\t");
const second = line.indexOf("\t", tab + 1);
const add = line.slice(0, tab);
const del = line.slice(tab + 1, second);
let path = line.slice(second + 1);
if (!path) {
index++;
path = fields[++index];
}
stats.set(path, {
additions: Number(add) || 0,
deletions: Number(del) || 0,
binary: add === "-",
});
}
return stats;
}
export async function listComparison(compare: Compare): Promise<Comparison> {
if (compare.checkoutBase) {
const selected = await resolveCheckoutDiffBase(compare.cwd, compare.checkoutBase.compare, {
paseoHome: compare.checkoutBase.paseoHome,
});
if (!selected) throw new DiffReadError("REF_NOT_FOUND", "Comparison base is unavailable");
compare = { ...compare, base: { kind: "rev", rev: selected } };
}
validateCompare(compare);
const cwd = compare.cwd;
let privateIndex: Awaited<ReturnType<typeof prepareAllChangesIndex>> = null;
try {
if (compare.base.kind === "index" || compare.head.kind === "index")
privateIndex = await snapshotComparisonIndex(cwd);
const indexFile = privateIndex ? privateIndex.indexFile : null;
const baseSha = await resolveEndpoint(cwd, compare.base, indexFile);
const headSha = await resolveEndpoint(cwd, compare.head, indexFile);
let mergeBaseSha = "";
let comparisonBaseSha = baseSha;
if (compare.mergeBase) {
const headCommit =
compare.head.kind === "index"
? await resolveEndpoint(cwd, { kind: "rev", rev: "HEAD" }, indexFile)
: headSha;
if (headCommit === EMPTY) comparisonBaseSha = EMPTY;
else if (baseSha !== EMPTY) {
const result = await runGitCommand(["merge-base", baseSha, headCommit], {
cwd,
acceptExitCodes: [0, 1],
});
mergeBaseSha = result.stdout.trim();
if (mergeBaseSha) comparisonBaseSha = mergeBaseSha;
}
}
if (compare.head.kind === "worktree" && compare.base.kind !== "index")
privateIndex = await prepareAllChangesIndex(cwd, comparisonBaseSha);
return await readComparisonList(
compare,
{ baseSha, headSha, mergeBaseSha, comparisonBaseSha },
privateIndex,
);
} catch (error) {
await privateIndex?.cleanup();
throw error;
}
}
async function readComparisonList(
compare: Compare,
resolved: ResolvedComparison,
privateIndex: Awaited<ReturnType<typeof prepareAllChangesIndex>>,
): Promise<Comparison> {
const { baseSha, headSha, mergeBaseSha, comparisonBaseSha } = resolved;
const cwd = compare.cwd;
const args = diffArguments(compare, comparisonBaseSha, headSha);
const options: GitCommandOptions = {
cwd,
envOverlay: {
GIT_OPTIONAL_LOCKS: "0",
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
},
};
const { files, worktreeFiles } = await readTrackedFiles(compare, args, options);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
const gitInputs = await readGitInputIdentities(files, worktreeFiles, options);
for (const file of files) file.identity += `:${gitInputs.get(file.entry.path) ?? ""}`;
const metrics = { highlightCalls: 0 };
const patches = new Patches(args, options, files);
const sources = new HighlightSources({
cwd,
baseSha: comparisonBaseSha,
headSha: compare.head.kind === "worktree" ? null : headSha,
files,
});
return {
baseSha,
headSha,
mergeBaseSha,
files,
metrics,
selectBodies: (paths) => patches.select(paths),
close: async () => {
await privateIndex?.cleanup();
},
async read(file, budget = {}) {
const maxPatchBytes = budget.maxPatchBytes ?? PER_FILE_DIFF_MAX_BYTES;
const remainingBytes = budget.remainingBytes ?? maxPatchBytes;
if (file.source.kind === "untracked" && file.source.size > maxPatchBytes) {
return { kind: "too_large" };
}
const result =
file.source.kind === "untracked"
? await runGitCommand(
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
{
...options,
acceptExitCodes: [0, 1],
maxOutputBytes: maxPatchBytes + 1,
},
)
: await patches.read(file, maxPatchBytes);
const bytes = Buffer.byteLength(result.stdout);
if (result.truncated || bytes > remainingBytes || bytes > maxPatchBytes)
return { kind: "too_large" };
// File-type changes have a deletion and addition section for the same path.
const sections = parseDiff(result.stdout).filter(
(candidate) => candidate.path === file.entry.path,
);
if (!sections.length)
return { kind: "ready", body: { path: file.entry.path, hunks: [] }, bytes };
const parsed = {
path: file.entry.path,
isNew: file.entry.change === "added",
isDeleted: file.entry.change === "deleted",
additions: file.entry.additions,
deletions: file.entry.deletions,
hunks: sections.flatMap((section) => section.hunks),
};
let highlighted = parsed;
if (compare.highlight !== false) {
metrics.highlightCalls++;
const context = await sources.read(file);
highlighted = await highlightDiffWithFileContent(parsed, cwd, context);
}
const hunks = highlighted.hunks.map((hunk) => ({
oldStart: hunk.oldStart,
oldCount: hunk.oldCount,
newStart: hunk.newStart,
newCount: hunk.newCount,
lines: hunk.lines.map((line) => {
if (!line.tokens) return { type: line.type, content: line.content };
return {
type: line.type,
content: line.content,
runs: line.tokens
.filter((token) => token.text.length > 0)
.map((token) => ({ style: token.style, length: token.text.length })),
};
}),
}));
return { kind: "ready", body: { path: file.entry.path, hunks }, bytes };
},
};
}
async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string> }> {
const [raw, stat] = await Promise.all([
runGitCommand([...args, "--raw", "--no-abbrev", "-z"], options),
runGitCommand([...args, "--numstat", "-z"], options),
]);
if (raw.truncated || stat.truncated)
throw new Error("Diff entry list exceeded the git output budget");
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeFiles = new Set<string>();
for (let index = 0; index < fields.length - 1; index++) {
const header = fields[index].split(" ");
const code = header[4]?.[0];
let path = fields[++index];
let oldPath: string | null = null;
if (code === "R" || code === "C") {
oldPath = path;
path = fields[++index];
}
const counts = stats.get(path);
// With -w, a raw metadata entry need not have a numstat record.
if (!counts && compare.ignoreWhitespace) continue;
if (!counts) throw new Error(`Missing diff statistics for ${path}`);
let change: DiffEntry["change"] = "modified";
if (code === "A") change = "added";
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
if (compare.head.kind === "worktree" && code !== "D") {
const worktree = await readWorktreeIdentity(path, header, options);
identity += `:${worktree.identity}`;
if (worktree.regular) worktreeFiles.add(path);
}
files.push({
entry: {
path,
oldPath,
change,
content: counts.binary ? "binary" : "text",
additions: counts.additions,
deletions: counts.deletions,
body: !counts.binary,
},
identity,
source: { kind: "tracked" },
});
}
return { files, worktreeFiles };
}
async function readWorktreeIdentity(
path: string,
header: string[],
options: GitCommandOptions,
): Promise<{ identity: string; regular: boolean }> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = fileStatIdentity(value);
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
identity += `:${submodule.stdout}`;
}
return { identity, regular: value.isFile() };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
const revisions: string[] = [];
if (compare.base.kind !== "index") revisions.push(baseSha);
if (compare.head.kind === "rev") revisions.push(headSha);
if (compare.head.kind === "index") revisions.unshift("--cached");
const args = ["diff", ...GIT_PATCH_FORMAT_ARGS, "--no-textconv", "-M", ...revisions];
if (compare.ignoreWhitespace) args.push("-w");
return args;
}
async function readUntrackedText(
path: string,
symlink: boolean,
omitted: boolean,
): Promise<Buffer> {
if (symlink) return Buffer.from(await readlink(path));
if (omitted) return Buffer.alloc(0);
return readFile(path);
}
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
const cwd = options.cwd;
const files: ListedFile[] = [];
const untracked = await runGitCommand(
["ls-files", "--others", "--exclude-standard", "-z"],
options,
);
for (const path of untracked.stdout.split("\0").filter(Boolean)) {
const value = await lstat(resolve(cwd, path), { bigint: true });
if (!value.isFile() && !value.isSymbolicLink()) continue;
const symlink = value.isSymbolicLink();
let binary = false;
let omitted = false;
if (!symlink) {
const inspected = await inspectUntrackedFile(cwd, path);
binary = inspected.stat?.isBinary === true;
omitted = inspected.truncated;
}
const content = await readUntrackedText(resolve(cwd, path), symlink, binary || omitted);
const additions =
binary || content.length === 0
? 0
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
files.push({
entry: {
path,
oldPath: null,
change: "added",
content: binary ? "binary" : "text",
additions,
deletions: 0,
body: !binary,
},
identity: fileStatIdentity(value),
source: { kind: "untracked", size: Number(value.size) },
});
}
return files;
}
function fileStatIdentity(value: BigIntStats): string {
// Timestamp-preserving copies still change file status or replace the inode.
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
}
-453
View File
@@ -1,453 +0,0 @@
import { TOTAL_DIFF_MAX_BYTES } from "../../utils/checkout-git.js";
import { DEFAULT_MAX_OUTPUT_BYTES, runGitCommand } from "../../utils/run-git-command.js";
import { expandTilde, createRealpathAwarePathMatcher } from "../../utils/path.js";
import { packBodies } from "./batches.js";
import { setImmediate } from "node:timers/promises";
import {
DiffOpcode,
materializeDiffFile,
type DiffFrame,
type DiffEntry,
type DiffBody,
} from "@getpaseo/protocol/binary-frames/index";
import type {
DiffError,
DiffEndpoint,
ParsedDiffFile,
CheckoutCommitFileDiffRequest,
} from "@getpaseo/protocol/messages";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import {
listComparison,
validateCompare,
worktreeRoot,
DiffReadError,
type Compare,
type Comparison,
} from "./git.js";
import { DiffDependencyObserver } from "./dependencies.js";
interface RecordCache {
identity: string;
body: DiffBody;
bytes: number;
}
interface Listener {
emit(frame: DiffFrame): Promise<void>;
active: boolean;
delivery: Promise<void>;
}
interface Target {
key: string;
compare: Compare;
listeners: Set<Listener>;
revision: number;
committed: number;
cache: Map<string, RecordCache>;
entries: DiffEntry[];
resolved: Pick<Comparison, "baseSha" | "headSha" | "mergeBaseSha">;
error: DiffError | null;
omitted: string[];
refresh: Promise<void> | null;
dirty: boolean;
release: (() => Promise<void>) | null;
opening: Promise<void> | null;
closed: boolean;
timer: ReturnType<typeof setTimeout> | null;
}
export interface CommitFileRead extends Pick<
CheckoutCommitFileDiffRequest,
"cwd" | "sha" | "path"
> {
signal?: AbortSignal;
}
export interface DiffSubscription {
start(): Promise<void>;
release(): Promise<void>;
}
export class DiffSubscriptions {
private targets = new Map<string, Target>();
private dependencies: DiffDependencyObserver;
private computations = 0;
private highlightCalls = 0;
constructor(workspaceGit: Pick<WorkspaceGitService, "requestWorkingTreeWatch">) {
this.dependencies = new DiffDependencyObserver(workspaceGit);
}
getMetrics() {
return {
targets: this.targets.size,
subscriptions: [...this.targets.values()].reduce(
(sum, target) => sum + target.listeners.size,
0,
),
computations: this.computations,
highlightCalls: this.highlightCalls,
metadataObservers: this.dependencies.getObservationCount(),
};
}
async readCommitFile(input: CommitFileRead): Promise<ParsedDiffFile | null> {
input.signal?.throwIfAborted();
const cwd = await worktreeRoot(expandTilde(input.cwd));
const result = await runGitCommand(
["rev-list", "--parents", "--max-count=1", "--end-of-options", input.sha],
{ cwd },
);
const [head, parent] = result.stdout.trim().split(" ");
if (!head) throw new DiffReadError("REF_NOT_FOUND", `Revision not found: ${input.sha}`);
const base: DiffEndpoint = parent ? { kind: "rev", rev: parent } : { kind: "empty" };
const comparison = await listComparison({ cwd, base, head: { kind: "rev", rev: head } });
try {
input.signal?.throwIfAborted();
const file = comparison.files.find((candidate) => candidate.entry.path === input.path);
if (!file || file.entry.content === "binary") return null;
comparison.selectBodies(new Set([file.entry.path]));
const record = await comparison.read(file, { maxPatchBytes: DEFAULT_MAX_OUTPUT_BYTES });
input.signal?.throwIfAborted();
return materializeDiffFile(
file.entry,
record.kind === "ready" ? record.body : null,
record.kind === "too_large",
);
} finally {
await comparison.close();
}
}
async subscribe(compare: Compare, emit: Listener["emit"]): Promise<DiffSubscription> {
validateCompare(compare);
compare = {
...compare,
cwd: await worktreeRoot(expandTilde(compare.cwd)),
mergeBase: compare.mergeBase === true,
ignoreWhitespace: compare.ignoreWhitespace === true,
highlight: compare.highlight !== false,
};
const key = JSON.stringify([
compare.cwd,
compare.checkoutBase
? ["checkout", JSON.stringify(compare.checkoutBase)]
: endpointKey(compare.base),
endpointKey(compare.head),
compare.mergeBase,
compare.ignoreWhitespace,
compare.highlight,
]);
let target = this.targets.get(key);
if (!target) {
target = {
key,
compare,
listeners: new Set(),
revision: 0,
committed: 0,
cache: new Map(),
entries: [],
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
error: null,
omitted: [],
refresh: null,
dirty: false,
release: null,
opening: null,
closed: false,
timer: null,
};
this.targets.set(key, target);
const captured = target;
try {
target.opening = this.dependencies
.declareDependencies(
{
cwd: compare.cwd,
refs: [compare.base, compare.head].flatMap((endpoint) =>
endpoint.kind === "rev" && !/^[a-f0-9]{40,64}$/.test(endpoint.rev)
? [endpoint.rev]
: [],
),
index: compare.base.kind === "index" || compare.head.kind !== "rev",
worktree: compare.head.kind === "worktree",
},
() => this.invalidate(captured),
)
.then((release) => {
captured.release = release;
return undefined;
});
} catch (error) {
this.targets.delete(key);
throw error;
}
}
try {
await target.opening;
} catch (error) {
target.closed = true;
this.targets.delete(key);
throw error;
}
if (target.closed) throw new Error("Diff subscription cancelled");
const current = target;
const listener: Listener = { emit, active: false, delivery: Promise.resolve() };
current.listeners.add(listener);
let released = false;
return {
start: async () => {
while (current.refresh) await current.refresh;
if (current.closed || released) return;
listener.active = true;
if (!current.committed) await this.refresh(current);
else {
const replay = this.replay(current, listener);
listener.delivery = replay.catch(() => {});
await replay;
}
},
release: async () => {
if (released) return;
released = true;
listener.active = false;
current.listeners.delete(listener);
if (current.listeners.size) return;
current.closed = true;
if (current.timer) clearTimeout(current.timer);
this.targets.delete(key);
await current.release?.();
await current.refresh;
},
};
}
async invalidateForCwd(cwd: string): Promise<void> {
let root: string;
try {
root = await worktreeRoot(expandTilde(cwd));
} catch (error) {
if (error instanceof DiffReadError && error.code === "NOT_GIT_REPO") return;
throw error;
}
const matches = createRealpathAwarePathMatcher(root);
for (const target of this.targets.values())
if (matches(target.compare.cwd)) this.invalidate(target);
}
private invalidate(target: Target): void {
if (target.closed) return;
target.dirty = true;
if (target.listeners.size === 0) return;
if (target.timer) clearTimeout(target.timer);
target.timer = setTimeout(() => {
target.timer = null;
void this.refresh(target);
}, 150);
}
private async emit(target: Target, frame: DiffFrame): Promise<void> {
await Promise.all(
[...target.listeners]
.filter((listener) => listener.active)
.map((listener) => {
const sent = listener.delivery.then(() => listener.emit(frame));
listener.delivery = sent.catch(() => {});
return sent;
}),
);
await setImmediate();
}
private async replay(target: Target, listener: Listener): Promise<void> {
const entries = target.entries.map((entry) => ({ ...entry, body: entry.content !== "binary" }));
await listener.emit({
opcode: DiffOpcode.Begin,
slot: 0,
revision: target.committed,
baseRevision: 0,
...target.resolved,
entries,
});
const bodies: DiffBody[] = [];
for (const entry of entries) {
const cached = target.cache.get(entry.path);
if (entry.body && cached) bodies.push(cached.body);
}
for await (const files of packBodies(bodies, target.committed)) {
await listener.emit({
opcode: DiffOpcode.Bodies,
slot: 0,
revision: target.committed,
files,
});
await setImmediate();
}
await listener.emit({
opcode: DiffOpcode.End,
slot: 0,
revision: target.committed,
diffTooLarge: target.omitted.length > 0,
omitted: target.omitted,
error: target.error,
});
}
private async refresh(target: Target): Promise<void> {
if (target.refresh || target.closed) return target.refresh ?? undefined;
target.refresh = this.run(target).finally(() => {
target.refresh = null;
});
return target.refresh;
}
private async run(target: Target): Promise<void> {
do {
target.dirty = false;
const revision = ++target.revision;
let comparison: Comparison | null = null;
let began = false;
try {
this.computations++;
comparison = await listComparison(target.compare);
const resolved = {
baseSha: comparison.baseSha,
headSha: comparison.headSha,
mergeBaseSha: comparison.mergeBaseSha,
};
const tooManyFiles = comparison.files.length > 10000;
const entries = comparison.files.map((file) => ({
...file.entry,
body: tooManyFiles
? file.entry.content === "text"
: needsBody(target, file.entry, file.identity),
}));
await this.emit(target, {
opcode: DiffOpcode.Begin,
slot: 0,
revision,
baseRevision: target.error ? 0 : target.committed,
...resolved,
entries,
});
began = true;
const cache = new Map<string, RecordCache>();
const omitted: string[] = [];
const state = { cache, omitted, total: 0 };
const records = this.readBodies(target, comparison, entries, state);
for await (const files of packBodies(records, revision)) {
if (isInterrupted(target)) break;
await this.emit(target, { opcode: DiffOpcode.Bodies, slot: 0, revision, files });
}
if (isInterrupted(target)) continue;
await this.emit(target, {
opcode: DiffOpcode.End,
slot: 0,
revision,
omitted,
diffTooLarge: omitted.length > 0,
error: null,
});
target.committed = revision;
target.resolved = resolved;
target.entries = entries;
target.cache = cache;
target.omitted = omitted;
target.error = null;
} catch (error) {
const failure = diffError(error);
if (!began)
await this.emit(target, {
opcode: DiffOpcode.Begin,
slot: 0,
revision,
baseRevision: 0,
baseSha: "",
headSha: "",
mergeBaseSha: "",
entries: [],
});
await this.emit(target, {
opcode: DiffOpcode.End,
slot: 0,
revision,
diffTooLarge: false,
omitted: [],
error: failure,
});
target.error = failure;
target.committed = revision;
target.entries = [];
target.cache.clear();
target.omitted = [];
} finally {
if (comparison) this.highlightCalls += comparison.metrics.highlightCalls;
await comparison?.close();
}
} while (target.dirty && !target.closed);
}
private async *readBodies(
target: Target,
comparison: Comparison,
entries: DiffEntry[],
state: { cache: Map<string, RecordCache>; omitted: string[]; total: number },
): AsyncGenerator<DiffBody> {
if (entries.length > 10000) {
for (const entry of entries) if (entry.content === "text") state.omitted.push(entry.path);
return;
}
comparison.selectBodies(
new Set(entries.filter((entry) => entry.body).map((entry) => entry.path)),
);
for (let index = 0; index < comparison.files.length; index++) {
if (isInterrupted(target)) return;
const file = comparison.files[index];
const entry = entries[index];
if (entry.content === "binary") continue;
let record = target.cache.get(entry.path);
if (entry.body || !record) {
const read = await comparison.read(file, {
remainingBytes: TOTAL_DIFF_MAX_BYTES - state.total,
});
if (read.kind === "too_large") {
state.omitted.push(entry.path);
continue;
}
record = { identity: file.identity, body: read.body, bytes: read.bytes };
}
if (exceedsBudget(state.total, record.bytes)) {
state.omitted.push(entry.path);
continue;
}
state.total += record.bytes;
state.cache.set(entry.path, record);
if (entry.body) yield record.body;
}
}
async close(): Promise<void> {
for (const target of this.targets.values()) {
target.closed = true;
if (target.timer) clearTimeout(target.timer);
await target.opening;
await target.release?.();
await target.refresh;
}
this.targets.clear();
await this.dependencies.close();
}
}
function isInterrupted(target: Target): boolean {
return target.closed || target.dirty;
}
function exceedsBudget(total: number, bytes: number): boolean {
return total + bytes > TOTAL_DIFF_MAX_BYTES;
}
function needsBody(target: Target, entry: DiffEntry, identity: string): boolean {
return (
entry.content === "text" &&
(target.error !== null || target.cache.get(entry.path)?.identity !== identity)
);
}
function diffError(error: unknown): DiffError {
return {
code: error instanceof DiffReadError ? error.code : "UNKNOWN",
message: error instanceof Error ? error.message : String(error),
};
}
export { DiffSession } from "./session.js";
function endpointKey(endpoint: DiffEndpoint): string[] {
if (endpoint.kind === "rev") return [endpoint.kind, endpoint.rev];
return [endpoint.kind];
}
-70
View File
@@ -1,70 +0,0 @@
import { createHash } from "node:crypto";
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
import type { ListedFile } from "./git.js";
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
// for effective attributes/config and filtered blob identities without writing objects.
export async function readGitInputIdentities(
files: ListedFile[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
): Promise<Map<string, string>> {
const paths = files
.filter((file) => file.entry.content === "text")
.map((file) => file.entry.path);
if (!paths.length) return new Map();
const regular = paths.filter((path) => worktreeFiles.has(path));
const [config, attributes, blobs] = await Promise.all([
runGitCommand(["config", "--null", "--list"], options),
runGitCommand(["check-attr", "--all", "-z", "--stdin"], {
...options,
input: paths.join("\0") + "\0",
}),
regular.length
? runGitCommand(["hash-object", "--stdin-paths"], {
...options,
input: regular.map(quotePath).join("\n") + "\n",
})
: null,
]);
if (config.truncated || attributes.truncated || blobs?.truncated)
throw new Error("Git input identity exceeded the output budget");
const byPath = new Map<string, string[]>();
const fields = attributes.stdout.split("\0");
if (fields.at(-1) === "") fields.pop();
if (fields.length % 3) throw new Error("Git returned invalid attributes");
for (let index = 0; index < fields.length; index += 3) {
const values = byPath.get(fields[index]) ?? [];
values.push(JSON.stringify([fields[index + 1], fields[index + 2]]));
byPath.set(fields[index], values);
}
const hashes = blobs?.stdout.trim().split(/\r?\n/) ?? [];
if (hashes.length !== regular.length) throw new Error("Git returned incomplete blob identities");
const blobByPath = new Map(regular.map((path, index) => [path, hashes[index]]));
const configIdentity = createHash("sha256").update(config.stdout).digest("hex");
return new Map(
paths.map((path) => [
path,
createHash("sha256")
.update(
JSON.stringify([
configIdentity,
byPath.get(path)?.sort() ?? [],
blobByPath.get(path) ?? null,
]),
)
.digest("hex"),
]),
);
}
function quotePath(path: string): string {
let quoted = '"';
for (const byte of Buffer.from(path)) {
quoted +=
byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92
? String.fromCharCode(byte)
: `\\${byte.toString(8).padStart(3, "0")}`;
}
return quoted + '"';
}
@@ -1,80 +0,0 @@
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
import { parseDiff } from "../utils/diff-highlighter.js";
import type { ListedFile } from "./git.js";
interface Patch {
stdout: string;
truncated: boolean;
}
// Acquire one bounded page lazily. Highlighting and delivery remain per file.
export class Patches {
private files: ListedFile[];
private pending = new Map<string, Promise<Map<string, Patch>>>();
constructor(
private args: string[],
private options: GitCommandOptions,
files: ListedFile[],
) {
this.files = files.filter((file) => file.source.kind === "tracked" && file.entry.body);
}
select(paths: ReadonlySet<string>): void {
this.files = this.files.filter((file) => paths.has(file.entry.path));
}
async read(file: ListedFile, maxBytes: number): Promise<Patch> {
const existing = this.pending.get(file.entry.path);
if (existing) return (await existing).get(file.entry.path) ?? { stdout: "", truncated: false };
const renamed = file.entry.oldPath !== null;
const candidates = this.files.filter(
(candidate) => (candidate.entry.oldPath !== null) === renamed,
);
const index = candidates.indexOf(file);
const page = renamed || index < 0 ? [file] : candidates.slice(index, index + 64);
this.pending.clear();
const reading = this.load(page, maxBytes);
for (const candidate of page) this.pending.set(candidate.entry.path, reading);
return (await reading).get(file.entry.path) ?? { stdout: "", truncated: false };
}
private async load(files: ListedFile[], maxBytes: number): Promise<Map<string, Patch>> {
const paths = [
...new Set(
files.flatMap((file) =>
file.entry.oldPath === null ? [file.entry.path] : [file.entry.oldPath, file.entry.path],
),
),
];
const result = await runGitCommand(
[
...this.args,
...(files[0].entry.oldPath !== null ? ["--diff-filter=RC"] : []),
"--patch",
"--",
...paths.map((path) => `:(literal)${path}`),
],
{
...this.options,
acceptExitCodes: [0, 1],
maxOutputBytes: (files.length === 1 ? maxBytes : 8 * maxBytes) + 1,
},
);
if (files.length === 1) return new Map([[files[0].entry.path, result]]);
if (result.truncated) {
const middle = Math.ceil(files.length / 2);
return new Map([
...(await this.load(files.slice(0, middle), maxBytes)),
...(await this.load(files.slice(middle), maxBytes)),
]);
}
const patches = new Map<string, Patch>();
for (const section of result.stdout.split(/(?=^diff --git )/m).filter(Boolean)) {
const hunkStart = section.indexOf("\n@@ ");
const parsed = parseDiff(hunkStart < 0 ? section : section.slice(0, hunkStart))[0];
if (!parsed) throw new Error("Git returned an invalid patch header");
const previous = patches.get(parsed.path)?.stdout ?? "";
const stdout = previous + section;
patches.set(parsed.path, { stdout, truncated: Buffer.byteLength(stdout) > maxBytes });
}
return patches;
}
}
@@ -1,96 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import pino from "pino";
import { expect, test } from "vitest";
import {
decodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { DiffSession, DiffSubscriptions } from "./index.js";
import { createFileObserver } from "../file-observer/index.js";
import { SessionDelivery } from "../session/owned-subscriptions/index.js";
test("a failed physical send releases that subscriber while its sibling completes the revision", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-send-failure-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
const first = {};
const second = {};
let failFirst = false;
const firstFrames: DiffFrame[] = [];
const secondFrames: DiffFrame[] = [];
const delivery = new SessionDelivery(
() => {},
async (source, bytes) => {
if (source === first && failFirst) throw new Error("Physical send failed");
const frame = decodeDiffFrame(bytes);
const received = source === first ? firstFrames : secondFrames;
received.push(frame);
},
);
delivery.attach(first, true);
delivery.attach(second, true);
const session = new DiffSession(subscriptions, pino({ level: "silent" }));
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "x.ts"), "export const value = 1;\n");
for (const [source, requestId] of [
[first, "first"],
[second, "second"],
] as const) {
const message = {
type: "diff.subscribe.request" as const,
requestId,
cwd,
base: { kind: "rev" as const, rev: "HEAD" },
head: { kind: "worktree" as const },
};
await delivery.request(source, message, () => session.subscribe(message, delivery, () => {}));
const received = source === first ? firstFrames : secondFrames;
await waitForEnd(received);
}
secondFrames.length = 0;
failFirst = true;
await writeFile(join(cwd, "x.ts"), "export const value = 2;\n");
await waitForEnd(secondFrames);
const begins = secondFrames
.filter((frame) => frame.opcode === DiffOpcode.Begin)
.map((frame) => frame.revision);
expect(new Set(begins).size).toBe(begins.length);
expect(secondFrames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
expect(delivery.subscriptionIds(first, "diffs")).toEqual([]);
expect(delivery.subscriptionIds(second, "diffs")).toHaveLength(1);
} finally {
await delivery.close();
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
async function waitForEnd(frames: DiffFrame[]): Promise<void> {
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
}
@@ -1,86 +0,0 @@
import type { Logger } from "pino";
import { encodeDiffFrame } from "@getpaseo/protocol/binary-frames/index";
import type {
DiffSubscribeRequest,
SessionOutboundMessage,
DiffError,
} from "@getpaseo/protocol/messages";
import type { SessionDelivery, OwnedSubscription } from "../session/owned-subscriptions/index.js";
import { DiffReadError } from "./git.js";
import type { DiffSubscriptions, DiffSubscription } from "./index.js";
export class DiffSession {
private slots = new Set<number>();
constructor(
private subscriptions: DiffSubscriptions,
private logger: Pick<Logger, "debug" | "error">,
) {}
async subscribe(
msg: DiffSubscribeRequest,
ownership: SessionDelivery,
emit: (message: SessionOutboundMessage) => void,
): Promise<void> {
const slot = Array.from({ length: 256 }, (_, value) => value).find(
(value) => !this.slots.has(value),
);
if (slot === undefined) {
this.reject(msg, emit, {
code: "TOO_MANY_SUBSCRIPTIONS",
message: "Too many diff subscriptions",
});
return;
}
this.slots.add(slot);
let subscription: DiffSubscription | null = null;
const owner = ownership.begin("diffs", undefined, async () => {
this.slots.delete(slot);
await subscription?.release();
});
try {
subscription = await this.subscriptions.subscribe(msg, async (frame) => {
if (owner.signal.aborted) return;
const bytes = encodeDiffFrame({ ...frame, slot });
try {
await owner.emitBinaryAndWait(bytes);
} catch (error) {
void this.stopFailedSource(owner, error);
}
});
if (owner.signal.aborted) {
await subscription.release();
return;
}
emit({
type: "diff.subscribe.response",
payload: { requestId: msg.requestId, subscriptionId: owner.responseId, slot, error: null },
});
// Start outside the request context: the owned subscription carries delivery authority.
void subscription.start().catch((error) => this.stopFailedSource(owner, error));
} catch (error) {
await owner.release();
const code = error instanceof DiffReadError ? error.code : "UNKNOWN";
this.reject(msg, emit, {
code,
message: error instanceof Error ? error.message : String(error),
});
}
}
private async stopFailedSource(owner: OwnedSubscription, error: unknown): Promise<void> {
if (!owner.signal.aborted) this.logger.debug({ err: error }, "Diff frame delivery failed");
try {
await owner.release();
} catch (failure) {
this.logger.error({ err: failure }, "Failed to release diff subscription");
}
}
private reject(
msg: DiffSubscribeRequest,
emit: (message: SessionOutboundMessage) => void,
error: DiffError,
): void {
emit({
type: "diff.subscribe.response",
payload: { requestId: msg.requestId, subscriptionId: "", slot: 0, error },
});
}
}
@@ -1,76 +0,0 @@
import { lstat, readlink } from "node:fs/promises";
import { resolve } from "node:path";
import { readGitFileContents, readGitFileContentAtRef } from "../../utils/git-file-contents.js";
import type { ListedFile } from "./git.js";
interface SourceOptions {
cwd: string;
baseSha: string;
headSha: string | null;
files: ListedFile[];
}
interface HighlightContext {
oldFileContent: string | null;
newFileContent: string | null | undefined;
}
// Keep one bounded page of source text; bodies are still highlighted only when requested.
export class HighlightSources {
private indices: Map<string, number>;
private page = -1;
private pending: Promise<Map<string, string | null> | null> | null = null;
constructor(private options: SourceOptions) {
this.indices = new Map(options.files.map((file, index) => [file.entry.path, index]));
}
async read(file: ListedFile): Promise<HighlightContext> {
const index = this.indices.get(file.entry.path);
if (index === undefined) throw new Error("Unknown comparison file");
const page = Math.floor(index / 64) * 64;
if (this.pending === null || this.page !== page) {
this.page = page;
this.pending = this.load(page);
}
const contents = await this.pending;
const oldFileContent =
file.entry.change === "added"
? null
: await this.content(contents, this.options.baseSha, file.entry.oldPath ?? file.entry.path);
let newFileContent: string | null | undefined;
if (this.options.headSha !== null) {
newFileContent = await this.content(contents, this.options.headSha, file.entry.path);
} else if (file.entry.change !== "deleted") {
const path = resolve(this.options.cwd, file.entry.path);
newFileContent = await readWorktreeLink(path);
}
return { oldFileContent, newFileContent };
}
private async load(start: number): Promise<Map<string, string | null> | null> {
const specs: string[] = [];
for (const file of this.options.files.slice(start, start + 64)) {
if (file.entry.content === "binary") continue;
if (file.entry.change !== "added")
specs.push(`${this.options.baseSha}:${file.entry.oldPath ?? file.entry.path}`);
if (this.options.headSha !== null) specs.push(`${this.options.headSha}:${file.entry.path}`);
}
return readGitFileContents(this.options.cwd, specs);
}
private async content(
contents: Map<string, string | null> | null,
ref: string,
path: string,
): Promise<string | null> {
if (contents === null) return readGitFileContentAtRef(this.options.cwd, ref, path);
const content = contents.get(`${ref}:${path}`);
if (content === undefined) throw new Error("Missing comparison source");
return content;
}
}
async function readWorktreeLink(path: string): Promise<string | undefined> {
try {
if ((await lstat(path)).isSymbolicLink()) return await readlink(path);
} catch {
// Source context is optional; files can disappear after Git reads the patch.
}
return undefined;
}
@@ -1,710 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { DiffOpcode, type DiffFrame } from "@getpaseo/protocol/binary-frames/index";
import { DiffSubscriptions } from "./index.js";
import { createFileObserver } from "../file-observer/index.js";
test("shares computation, highlights only the changed file, recovers refs and releases observations", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-subscriptions-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, onChange) {
const subscription = await observer.subscribe(root, () => onChange(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: () => {
void subscription.unsubscribe();
},
};
},
});
const first: DiffFrame[] = [];
const second: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
for (let index = 0; index < 252; index++)
await writeFile(join(cwd, `${index}.ts`), `const value = ${index};\n`);
const compare = {
cwd,
base: { kind: "rev" as const, rev: "HEAD" },
head: { kind: "worktree" as const },
highlight: true,
};
const coldStarted = performance.now();
let lastEndAt = coldStarted;
const a = await subscriptions.subscribe(compare, async (frame) => {
first.push(frame);
if (frame.opcode === DiffOpcode.End) lastEndAt = performance.now();
});
await a.start();
const coldMs = lastEndAt - coldStarted;
const before = subscriptions.getMetrics().computations;
const previousHighlightCalls = subscriptions.getMetrics().highlightCalls;
const b = await subscriptions.subscribe(compare, async (frame) => {
second.push(frame);
});
await b.start();
expect(subscriptions.getMetrics()).toEqual({
targets: 1,
subscriptions: 2,
computations: before,
highlightCalls: previousHighlightCalls,
metadataObservers: 1,
});
first.length = 0;
second.length = 0;
const refreshStarted = performance.now();
await writeFile(join(cwd, "9.ts"), "const value = 999;\n");
await expect
.poll(() => first.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 10000,
})
.toBeGreaterThan(0);
const begin = first.find((frame) => frame.opcode === DiffOpcode.Begin);
if (!begin || begin.opcode !== DiffOpcode.Begin) throw new Error("Missing begin");
expect(begin.entries.filter((entry) => entry.body).map((entry) => entry.path)).toEqual([
"9.ts",
]);
const bodies = first.filter((frame) => frame.opcode === DiffOpcode.Bodies);
expect(bodies).toHaveLength(1);
expect(second).toEqual(first);
await expect
.poll(() => subscriptions.getMetrics().highlightCalls, { timeout: 10000 })
.toBe(previousHighlightCalls + 1);
const artifactDirectory = join(
import.meta.dirname,
"../../../../../.artifacts/diff-subscription",
);
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
join(artifactDirectory, "refresh-metrics.json"),
JSON.stringify(
{
files: 252,
coldMs,
oneFileRefreshMs: lastEndAt - refreshStarted,
changedBodies: bodies.length,
highlightCalls: subscriptions.getMetrics().highlightCalls - previousHighlightCalls,
},
null,
2,
),
);
await a.release();
await b.release();
expect(subscriptions.getMetrics().targets).toBe(0);
expect(subscriptions.getMetrics().metadataObservers).toBe(0);
const missing: DiffFrame[] = [];
const recovery = await subscriptions.subscribe(
{
...compare,
base: { kind: "rev", rev: "refs/tags/later" },
head: { kind: "rev", rev: "HEAD" },
},
async (frame) => {
missing.push(frame);
},
);
await recovery.start();
expect(missing.at(-1)).toMatchObject({
opcode: DiffOpcode.End,
error: { code: "REF_NOT_FOUND" },
});
missing.length = 0;
git("update-ref", "refs/tags/later", "HEAD");
await expect
.poll(
() => missing.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null),
{ timeout: 10_000 },
)
.toBe(true);
await recovery.release();
} finally {
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 60_000);
test("supersedes an in-flight revision at a batch boundary and ends the replacement", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-supersede-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, onChange) {
const watch = await observer.subscribe(root, () => onChange(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
const frames: DiffFrame[] = [];
let interrupted = false;
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const content =
Array.from(
{ length: 100 },
(_, index) => `export const fn${index} = (value: string) => ({ value, count: ${index} });`,
).join("\n") + "\n";
for (let index = 0; index < 80; index++) await writeFile(join(cwd, `${index}.ts`), content);
const subscription = await subscriptions.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
if (frame.opcode === DiffOpcode.Bodies && !interrupted) {
interrupted = true;
await writeFile(join(cwd, "0.ts"), content + "export const added = true;\n");
await subscriptions.invalidateForCwd(cwd);
}
},
);
await subscription.start();
const begins = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
expect(interrupted).toBe(true);
expect(begins.length).toBeGreaterThan(1);
for (let index = 0; index < begins.length - 1; index++) {
const revision = begins[index].revision;
const ended = frames.some(
(frame) => frame.opcode === DiffOpcode.End && frame.revision === revision,
);
const superseded = begins[index + 1].revision > revision;
expect(ended || superseded).toBe(true);
}
expect(frames.at(-1)).toMatchObject({
opcode: DiffOpcode.End,
revision: begins.at(-1)?.revision,
error: null,
});
await subscription.release();
} finally {
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30_000);
test("moving, deleting and recreating a branch recovers, and staging changes the resolved index SHA", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-ref-index-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
git("branch", "compare");
await writeFile(join(cwd, "x.ts"), "export const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
const head = git("rev-parse", "HEAD");
const branch = await subscriptions.subscribe(
{ cwd, base: { kind: "rev", rev: "refs/heads/compare" }, head: { kind: "rev", rev: "HEAD" } },
async (frame) => {
frames.push(frame);
},
);
await branch.start();
frames.length = 0;
git("update-ref", "refs/heads/compare", head);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
baseSha: head,
entries: [],
});
frames.length = 0;
git("update-ref", "-d", "refs/heads/compare");
await expect
.poll(
() =>
frames.some(
(frame) => frame.opcode === DiffOpcode.End && frame.error?.code === "REF_NOT_FOUND",
),
{ timeout: 10000 },
)
.toBe(true);
frames.length = 0;
git("update-ref", "refs/heads/compare", head);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null), {
timeout: 10000,
})
.toBe(true);
await branch.release();
frames.length = 0;
const staged = await subscriptions.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "index" } },
async (frame) => {
frames.push(frame);
},
);
await staged.start();
const old = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
if (!old || old.opcode !== DiffOpcode.Begin) throw new Error("Missing initial index revision");
frames.length = 0;
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
git("add", "staged.ts");
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
const changed = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
if (!changed || changed.opcode !== DiffOpcode.Begin) throw new Error("Missing staged revision");
expect(changed.headSha).not.toBe(old.headSha);
expect(changed.entries.map((entry) => entry.path)).toEqual(["staged.ts"]);
await staged.release();
} finally {
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
test("a cached replay completes before a live refresh can depend on it", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-replay-order-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch() {
return { repoRoot: cwd, unsubscribe: async () => {} };
},
});
let resume!: () => void;
let began!: () => void;
const paused = new Promise<void>((resolve) => {
resume = resolve;
});
const replayBegan = new Promise<void>((resolve) => {
began = resolve;
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "a.txt"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "first\n");
const compare = {
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
} as const;
const first = await subscriptions.subscribe(compare, async () => {});
await first.start();
const second = await subscriptions.subscribe(compare, async (frame) => {
frames.push(frame);
if (frames.length === 1) {
began();
await paused;
}
});
const replay = second.start();
await replayBegan;
await writeFile(join(cwd, "a.txt"), "second\n");
await subscriptions.invalidateForCwd(cwd);
await new Promise((resolve) => setTimeout(resolve, 250));
resume();
await replay;
await expect
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 10000,
})
.toBe(2);
let committed = 0;
for (const frame of frames) {
if (frame.opcode === DiffOpcode.Begin) expect([0, committed]).toContain(frame.baseRevision);
if (frame.opcode === DiffOpcode.End) committed = frame.revision;
}
await second.release();
await first.release();
} finally {
resume();
await subscriptions.close();
await rm(cwd, { recursive: true, force: true });
}
});
test("submodule commits invalidate cached worktree gitlink bodies", async () => {
const { lstat } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-cache-"));
const sub = join(cwd, "sub");
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await mkdir(sub);
git("-C", "sub", "init", "-b", "main");
git("-C", "sub", "config", "user.name", "Test");
git("-C", "sub", "config", "user.email", "test@example.com");
await writeFile(join(sub, "a.txt"), "A\n");
git("-C", "sub", "add", ".");
git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "A");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "record A");
await writeFile(join(sub, "a.txt"), "B\n");
git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "B");
const before = await lstat(sub, { bigint: true });
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
frames.length = 0;
await writeFile(join(sub, "a.txt"), "C\n");
git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "C");
const sha = git("-C", "sub", "rev-parse", "HEAD");
expect((await lstat(sub, { bigint: true })).mtimeNs).toBe(before.mtimeNs);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const bodies = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(bodies.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual(
expect.objectContaining({ type: "add", content: `Subproject commit ${sha}` }),
);
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test.each([true, false])(
"tracked=%s timestamp-preserving replacements refresh cached bodies",
async (tracked) => {
const { lstat, utimes } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-preserved-time-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "file.txt");
const timestamp = new Date("2000-01-01T00:00:00Z");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
if (tracked) await writeFile(path, "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(path, "first\n");
await utimes(path, timestamp, timestamp);
const compare = {
cwd,
base: { kind: "rev" as const, rev: "HEAD" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
const before = await lstat(path, { bigint: true });
frames.length = 0;
await writeFile(path, "later\n");
await utimes(path, timestamp, timestamp);
const after = await lstat(path, { bigint: true });
expect(after.mtimeNs).toBe(before.mtimeNs);
expect(after.size).toBe(before.size);
expect(after.ctimeNs).not.toBe(before.ctimeNs);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const files = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual(
expect.objectContaining({ type: "add", content: "later" }),
);
const { listComparison } = await import("./git.js");
const fresh = await listComparison(compare);
try {
const record = await fresh.read(fresh.files[0]);
if (record.kind !== "ready") throw new Error("Expected readable replacement");
expect(files).toEqual([record.body]);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.each([true, false])(
"autocrlf=%s highlighted CRLF worktrees keep the revision readable",
async (autocrlf) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-crlf-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.autocrlf", String(autocrlf));
await writeFile(join(cwd, "a.ts"), "export const value = 1;\r\n");
await writeFile(join(cwd, "b.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.ts"), "export const value = 2;\r\n");
await writeFile(join(cwd, "b.txt"), "after\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
const files = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(files.map((file) => file.path)).toEqual(["a.ts", "b.txt"]);
const line = files[0].hunks
.flatMap((hunk) => hunk.lines)
.find((candidate) => candidate.type === "add");
expect(line?.content).toBe(`export const value = 2;${autocrlf ? "" : "\r"}`);
expect(line?.runs?.reduce((length, run) => length + run.length, 0)).toBe(
line?.content.length,
);
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test("explicit refresh accepts an equivalent worktree path alias", async () => {
const { symlink } = await import("node:fs/promises");
const root = await mkdtemp(join(tmpdir(), "diff-refresh-alias-"));
const cwd = join(root, "repo");
const alias = join(root, "alias");
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(directory) {
return { repoRoot: directory, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
await mkdir(cwd);
await symlink(cwd, alias, "junction");
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "a.txt"), "first\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = git("rev-parse", "HEAD");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
frames.length = 0;
await streams.invalidateForCwd(alias);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, revision: 2, error: null });
await subscription.release();
} finally {
await streams.close();
await rm(root, { recursive: true, force: true });
}
});
test("attribute changes refresh interpreted content without rewriting the tracked file", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-attribute-input-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.autocrlf", "false");
await writeFile(join(cwd, "file.txt"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "file.txt"), "first\r\n");
const compare = {
cwd,
base: { kind: "rev" as const, rev: "HEAD" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
frames.length = 0;
await writeFile(join(cwd, ".git", "info", "attributes"), "file.txt text eol=lf\n");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
const files = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual(
expect.objectContaining({ type: "add", content: "first" }),
);
const { listComparison } = await import("./git.js");
const fresh = await listComparison(compare);
try {
const record = await fresh.read(fresh.files[0]);
if (record.kind !== "ready") throw new Error("Expected interpreted body");
expect(files).toEqual([record.body]);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test("explicit refresh resolves subdirectories without refreshing a nested repository's parent", async () => {
const root = await mkdtemp(join(tmpdir(), "diff-refresh-directory-"));
const cwd = join(root, "repo");
const directory = join(cwd, "workspace");
const nested = join(directory, "nested");
const git = (at: string, ...args: string[]) =>
execFileSync("git", args, { cwd: at, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(at) {
return { repoRoot: at, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
await mkdir(nested, { recursive: true });
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
await writeFile(join(cwd, "file.txt"), "before\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git(cwd, "rev-parse", "HEAD");
await writeFile(join(cwd, "file.txt"), "after\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = git(cwd, "rev-parse", "HEAD");
git(nested, "init", "-b", "main");
git(nested, "config", "user.name", "Test");
git(nested, "config", "user.email", "test@example.com");
await writeFile(join(nested, "nested.txt"), "nested content\n");
git(nested, "add", ".");
git(nested, "-c", "commit.gpgsign=false", "commit", "-m", "nested base");
const nestedHead = git(nested, "rev-parse", "HEAD");
const subscription = await streams.subscribe(
{ cwd: directory, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
frames.length = 0;
await streams.invalidateForCwd(directory);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null, revision: 2 });
const nestedFrames: DiffFrame[] = [];
const sibling = await streams.subscribe(
{ cwd: nested, base: { kind: "empty" }, head: { kind: "rev", rev: nestedHead } },
async (frame) => {
nestedFrames.push(frame);
},
);
await sibling.start();
nestedFrames.length = 0;
const before = streams.getMetrics().computations;
await streams.invalidateForCwd(nested);
await expect
.poll(() => nestedFrames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(streams.getMetrics().computations).toBe(before + 1);
await sibling.release();
await subscription.release();
} finally {
await streams.close();
await rm(root, { recursive: true, force: true });
}
});
@@ -29,8 +29,6 @@ test("a rename-only commit returns zero added or deleted lines through the clien
expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({
file: {
path: "new.txt",
oldPath: "old.txt",
status: "ok",
isNew: false,
isDeleted: false,
additions: 0,
@@ -100,138 +98,3 @@ test.skipIf(process.platform === "win32")(
}
},
);
test("streamed Changes preserves BOM-prefixed paths and highlighted content", async () => {
const cwd = mkdtempSync(join(tmpdir(), "stream-bom-diff-"));
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
execFileSync("git", ["init", "-b", "main"], { cwd });
writeFileSync(join(cwd, "\uFEFFbom.ts"), "\uFEFFexport const value = 1;\n");
writeFileSync(join(cwd, "other.txt"), "other edit\n");
await client.connect();
const states: import("@getpaseo/client").DiffState[] = [];
const observation = client.observeDiff({
cwd,
base: { kind: "empty" },
head: { kind: "worktree" },
});
const unsubscribe = observation.subscribe({
snapshot: (state) => states.push(state),
update: () => {},
});
try {
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
const readyState = states.at(-1);
if (readyState?.phase !== "ready") throw new Error("Expected ready diff");
expect(readyState.files.map((file) => file.path)).toContain("other.txt");
const bomFile = readyState.files.find((file) => file.path === "\uFEFFbom.ts");
const addedLine = bomFile?.hunks
.flatMap((hunk) => hunk.lines)
.find((line) => line.type === "add");
expect(addedLine?.content).toBe("\uFEFFexport const value = 1;");
expect(addedLine?.tokens?.map((token) => token.text).join("")).toBe(addedLine?.content);
expect(states.some((state) => state.phase === "error")).toBe(false);
} finally {
unsubscribe();
await observation.release();
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
test("checkout refresh from a subdirectory updates a pinned streamed comparison", async () => {
const cwd = mkdtempSync(join(tmpdir(), "stream-subdirectory-refresh-"));
const { mkdirSync } = await import("node:fs");
const workspace = join(cwd, "workspace");
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
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");
mkdirSync(workspace);
writeFileSync(join(cwd, "file.txt"), "committed content\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const head = git("rev-parse", "HEAD");
await client.connect();
const states: import("@getpaseo/client").DiffState[] = [];
const observation = client.observeDiff({
cwd: workspace,
base: { kind: "empty" },
head: { kind: "rev", rev: head },
});
const unsubscribe = observation.subscribe({
snapshot: (state) => states.push(state),
update: () => {},
});
try {
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
const initial = states.at(-1);
if (initial?.phase !== "ready") throw new Error("Expected initial diff");
expect(initial.files[0]).toMatchObject({ path: "file.txt", status: "ok" });
writeFileSync(join(cwd, ".git", "info", "attributes"), "file.txt -diff\n");
expect(await client.checkoutRefresh(workspace)).toMatchObject({ success: true, error: null });
await expect
.poll(() => states.at(-1)?.revision, { timeout: 15000 })
.toBeGreaterThan(initial.revision);
expect(states.at(-1)).toMatchObject({
phase: "ready",
files: [{ path: "file.txt", status: "binary", hunks: [] }],
});
} finally {
unsubscribe();
await observation.release();
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
test("refreshing a non-Git workspace succeeds while a Git comparison is open", async () => {
const cwd = mkdtempSync(join(tmpdir(), "stream-refresh-git-"));
const plain = mkdtempSync(join(tmpdir(), "stream-refresh-plain-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
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");
writeFileSync(join(cwd, "file.txt"), "committed content\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await client.connect();
const states: import("@getpaseo/client").DiffState[] = [];
const observation = client.observeDiff({
cwd,
base: { kind: "empty" },
head: { kind: "rev", rev: git("rev-parse", "HEAD") },
});
const unsubscribe = observation.subscribe({
snapshot: (state) => states.push(state),
update: () => {},
});
try {
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
const revision = states.at(-1)?.revision;
expect(await client.checkoutRefresh(plain)).toMatchObject({ success: true, error: null });
expect(states.at(-1)).toMatchObject({ phase: "ready", revision });
} finally {
unsubscribe();
await observation.release();
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
rmSync(plain, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -39,7 +39,7 @@ describe("Git metadata event rules", () => {
["common", "hooks/pre-commit", { kind: "ignore" }],
["common", "logs/refs/heads/main", { kind: "ignore" }],
["common", "objects/ab/cdef", { kind: "ignore" }],
["common", "refs/tags/v1", { kind: "all", refreshBase: true }],
["common", "refs/tags/v1", { kind: "ignore" }],
["common", "FETCH_HEAD", { kind: "ignore" }],
["common", "packed-refs.lock", { kind: "ignore" }],
] as const)("classifies %s metadata path %s", (scope, path, expected) => {
@@ -73,14 +73,6 @@ export const GIT_METADATA_EVENT_RULES = [
path: "refs/heads/",
route: "local-ref",
},
{
id: "tag",
scope: "common",
match: "prefix",
path: "refs/tags/",
route: "all",
refreshBase: true,
},
{
id: "remote-branch",
scope: "common",
+1 -10
View File
@@ -1,4 +1,3 @@
import { DiffSession } from "./diff/index.js";
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
@@ -842,7 +841,6 @@ export class Session {
private readonly workspaceDirectory: WorkspaceDirectory;
private readonly voiceSessions: VoiceSessions;
private readonly checkoutSession: CheckoutSession;
private readonly diffSession: DiffSession;
private readonly scheduleSession: ScheduleSession;
private readonly providerCatalogSession: ProviderCatalogSession;
private readonly usageSession: UsageSession;
@@ -1006,7 +1004,6 @@ export class Session {
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
},
});
this.diffSession = new DiffSession(checkoutDiffManager.streams, this.sessionLogger);
this.checkoutSession = new CheckoutSession({
host: {
emit: (msg) => this.emit(msg),
@@ -3131,10 +3128,6 @@ export class Session {
// eslint-disable-next-line complexity
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "diff.subscribe.request":
return this.diffSession.subscribe(msg, this.delivery, (reply) => this.emit(reply));
case "diff.unsubscribe.request":
return this.delivery.release(msg.subscriptionId);
case "checkout_status_request":
return this.checkoutSession.handleStatusRequest(msg);
case "checkout.commits.list.request":
@@ -3444,9 +3437,7 @@ export class Session {
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source);
return;
}
if (binaryFrame.kind === "terminal") {
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
}
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
}
private async handleRestartServerRequest(
@@ -50,7 +50,6 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
const subscriptions: FakeDiffSubscription[] = [];
const refreshedCwds: string[] = [];
const subscriber: CheckoutDiffSubscriber = {
readCommitFile: async () => null,
read: async (params) => ({ ...initial, cwd: params.cwd }),
subscribe: async (params, listener) => {
let isSubscribed = true;
@@ -78,7 +77,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
unsubscribe,
};
},
scheduleRefreshForCwd: async (cwd) => {
scheduleRefreshForCwd: (cwd) => {
refreshedCwds.push(cwd);
},
};
@@ -51,6 +51,7 @@ import {
pullCurrentBranch,
pushCurrentBranch,
listCheckoutCommits,
getCommitFileDiff,
} from "../../../utils/checkout-git.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import { expandTilde } from "../../../utils/path.js";
@@ -108,7 +109,6 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub
* real CheckoutDiffManager satisfies this structurally; tests supply a fake.
*/
export interface CheckoutDiffSubscriber {
readCommitFile: import("../../diff/index.js").DiffSubscriptions["readCommitFile"];
read(
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
): Promise<CheckoutDiffSnapshotPayload>;
@@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber {
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): Promise<CheckoutDiffSubscription>;
scheduleRefreshForCwd(cwd: string): Promise<void>;
scheduleRefreshForCwd(cwd: string): void;
}
export interface CheckoutSessionOptions {
@@ -301,12 +301,7 @@ export class CheckoutSession {
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
throw new Error(`Invalid path: ${path}`);
}
const file = await this.checkoutDiffManager.readCommitFile({
cwd: expandTilde(cwd),
sha,
path,
signal,
});
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal });
this.host.emit({
type: "checkout.commits.file_diff.response",
payload: { cwd, sha, path, file, error: null, requestId },
@@ -499,7 +494,7 @@ export class CheckoutSession {
includeForge: true,
reason: "manual-refresh",
});
await this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
this.host.emit({
type: "checkout.refresh.response",
payload: {
@@ -553,8 +548,8 @@ export class CheckoutSession {
* Notify the live diff subscriptions that the working tree at `cwd` changed.
* Called by the command handlers below after they mutate the repository.
*/
private scheduleDiffRefresh(cwd: string): Promise<void> {
return this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
private scheduleDiffRefresh(cwd: string): void {
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
}
// ---------------------------------------------------------------------------
@@ -568,7 +563,7 @@ export class CheckoutSession {
try {
const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch);
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
@@ -620,7 +615,7 @@ export class CheckoutSession {
try {
const result = await this.host.renameCurrentBranch(cwd, branch);
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true });
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
// Branch is a git fact derived per-descriptor from each workspace's own
@@ -662,7 +657,7 @@ export class CheckoutSession {
try {
await discardChanges(cwd, paths);
await this.gitMutation.notifyGitMutation(cwd, "discard-changes");
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout.discard_changes.response",
payload: { cwd, success: true, error: null, requestId },
@@ -689,7 +684,7 @@ export class CheckoutSession {
timeout: 120_000,
});
await this.gitMutation.notifyGitMutation(cwd, "stash-push");
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
@@ -712,7 +707,7 @@ export class CheckoutSession {
timeout: 120_000,
});
await this.gitMutation.notifyGitMutation(cwd, "stash-pop");
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
@@ -764,7 +759,7 @@ export class CheckoutSession {
addAll: msg.addAll ?? true,
});
await this.gitMutation.notifyGitMutation(cwd, "commit-changes");
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_commit_response",
@@ -825,7 +820,7 @@ export class CheckoutSession {
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }),
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
]);
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_merge_response",
@@ -871,7 +866,7 @@ export class CheckoutSession {
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true });
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_merge_from_base_response",
@@ -903,7 +898,7 @@ export class CheckoutSession {
try {
await pullCurrentBranch(cwd);
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true });
await this.scheduleDiffRefresh(cwd);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_pull_response",
@@ -1,4 +1,3 @@
import { DiffSubscriptions } from "./diff/index.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
import {
@@ -106,13 +105,6 @@ describe("snapshot mutation ownership boundary", () => {
const session = asInternals<SessionInternals>(
new Session({
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
streams: new DiffSubscriptions({
async requestWorkingTreeWatch() {
throw new Error("Unexpected diff observation");
},
}),
}),
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
messageReceipts: createMessageReceiptsStub(),
agentMessages: createAgentMessageDeliveryStub(),
@@ -70,6 +70,11 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
function extractPathFromDiffHeader(lines: string[]): string {
const firstLine = lines[0] ?? "";
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
@@ -81,21 +86,10 @@ function extractPathFromDiffHeader(lines: string[]): string {
return newPath;
}
// The ---/+++ records are unambiguous even when a path contains " b/".
const oldMetadata = extractPathFromMetadata(lines, "--- ");
const newMetadata = extractPathFromMetadata(lines, "+++ ");
const metadata = newMetadata ?? oldMetadata;
if (metadata) {
const prefixed =
(oldMetadata !== null &&
newMetadata !== null &&
usesDiffPathPrefixes(oldMetadata, newMetadata)) ||
firstLine === `a/${metadata.slice(2)} b/${metadata.slice(2)}`;
return prefixed ? metadata.slice(2) : metadata;
}
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
return metadataPath;
}
const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/);
@@ -468,8 +462,7 @@ function applyTokensToHunks(
newLineNum++;
}
const matchingTokens = tokensForPatchLine(tokens, line.content);
return matchingTokens ? { ...line, tokens: matchingTokens } : line;
return tokens ? { ...line, tokens } : line;
});
return { ...hunk, lines: highlightedLines };
@@ -478,25 +471,6 @@ function applyTokensToHunks(
return { ...file, hunks: highlightedHunks };
}
function tokensForPatchLine(
tokens: HighlightToken[] | undefined,
content: string,
): HighlightToken[] | undefined {
if (!tokens) return undefined;
const source = tokens.map((token) => token.text).join("");
if (source === content) return tokens;
// Git can normalize a CRLF worktree while source highlighting retains its CR.
if (source !== `${content}\r`) return undefined;
const aligned: HighlightToken[] = [];
let remaining = content.length;
for (const token of tokens) {
const text = token.text.slice(0, remaining);
if (text) aligned.push({ text, style: token.style });
remaining -= text.length;
}
return aligned;
}
/**
* Parse and highlight a complete diff, using actual file content when available.
*/
@@ -1223,7 +1223,7 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
await this.checkoutDiffManager.dispose();
this.checkoutDiffManager.dispose();
await this.workspaceGitService.dispose();
this.pendingConnections.clear();
this.sessions.clear();
@@ -2020,7 +2020,6 @@ export class VoiceAssistantWebSocketServer {
usageSetting: true,
usageSources: true,
ownedSubscriptions: true,
diffSubscription: true,
binaryChunkingV1: true,
agentRequestReceipts: true,
workspaceRequestReceipts: true,
@@ -254,7 +254,7 @@ function createSessionForWireCompatTest(options?: {
directorySync: options?.directorySync,
scheduleService: {} as SessionOptions["scheduleService"],
checkoutDiffManager: {
async scheduleRefreshForCwd() {},
scheduleRefreshForCwd() {},
onWorkspaceStateMayHaveChanged() {},
} as unknown as SessionOptions["checkoutDiffManager"],
github: {
@@ -1,8 +1,10 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { execFile, execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, onTestFinished, test } from "vitest";
import { DaemonClient } from "./test-utils/index.js";
@@ -13,7 +15,7 @@ import {
DaemonClient as SocketDaemonClient,
type WebSocketLike,
} from "@getpaseo/client/internal/daemon-client";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
// The reshaped workspace.create.request forwards its worktree `source`
// (action/refName/branchName/githubPrNumber/worktreeSlug) into createWorktreeCore.
@@ -929,3 +931,107 @@ test("agent creation rejects legacy worktree intent without provisioning or exec
await rm(tempRoot, { recursive: true, force: true });
}
});
test("archiving a local import preserves the containing worktree owned by another daemon", async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "local-import-archive-"));
const daemons: TestPaseoDaemon[] = [];
const clients: DaemonClient[] = [];
try {
const repo = path.join(root, "repo");
const git = (...args: string[]) => execFileSync("git", args, { cwd: repo, stdio: "pipe" });
mkdirSync(repo);
git("init", "-b", "main");
git("config", "user.name", "Paseo Test");
git("config", "user.email", "test@getpaseo.local");
mkdirSync(path.join(repo, "fixtures"));
writeFileSync(path.join(repo, "fixtures", "inside.txt"), "inside\n");
writeFileSync(path.join(repo, "outside.txt"), "outside\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "fixture");
for (const name of ["owner", "importer"]) {
const daemon = await createTestPaseoDaemon({
paseoHomeRoot: path.join(root, name),
staticDir: path.join(root, name, "static"),
agentClients: {},
});
daemons.push(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
clients.push(client);
await client.connect();
}
const importer = daemons[1]!;
const created = await clients[0]!.createWorkspace({
source: {
kind: "worktree",
cwd: repo,
branchName: "feature/local-import",
baseBranch: "main",
},
});
expect(created.error).toBeNull();
if (!created.workspace) throw new Error("Missing owner workspace");
const worktree = created.workspace.workspaceDirectory;
const nested = path.join(worktree, "fixtures");
writeFileSync(path.join(worktree, "untracked.txt"), "keep me\n");
const cli = async (...args: string[]) => {
const result = await promisify(execFile)(
process.execPath,
[
"--conditions=source",
"--import",
import.meta.resolve("tsx"),
fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${importer.port}`,
...args,
"--json",
],
{
cwd: repo,
env: {
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
),
PASEO_HOME: importer.paseoHome,
},
timeout: 30000,
},
);
return JSON.parse(result.stdout);
};
const imported = await cli("workspace", "create", "--isolation", "local", "--path", nested);
console.log("local import", {
isolation: imported.isolation,
nestedCwd: imported.cwd === nested,
});
const workspaceId = imported.workspaceId;
expect(typeof workspaceId).toBe("string");
const archived = await cli("workspace", "archive", workspaceId);
console.log("archive", {
status: archived.status,
parentSurvives: existsSync(worktree),
trackedFileSurvives: existsSync(path.join(worktree, "outside.txt")),
untrackedFileSurvives: existsSync(path.join(worktree, "untracked.txt")),
gitMetadataSurvives: existsSync(path.join(worktree, ".git")),
});
expect((await clients[0]!.fetchWorkspaces()).entries.map((entry) => entry.id)).toContain(
created.workspace.id,
);
expect(archived.status).toBe("archived");
expect(existsSync(worktree), "local import archive must preserve its parent worktree").toBe(
true,
);
expect(readFileSync(path.join(worktree, "outside.txt"), "utf8")).toBe("outside\n");
expect(readFileSync(path.join(worktree, "untracked.txt"), "utf8")).toBe("keep me\n");
expect(readFileSync(path.join(nested, "inside.txt"), "utf8")).toBe("inside\n");
expect((await clients[1]!.fetchWorkspaces()).entries.map((entry) => entry.id)).not.toContain(
workspaceId,
);
} finally {
for (const client of clients) await client.close();
for (const daemon of daemons.toReversed()) await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 180000);
@@ -11,6 +11,7 @@ import {
type CheckoutSnapshotFacts,
type CheckoutStatusGit,
} from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { createFileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js";
@@ -249,15 +250,24 @@ test("recursive observation updates tracked state and prunes ignored storms", as
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: path.join(tempDir, "paseo-home"),
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener);
const diffSubscription = await service.requestWorkingTreeWatch(repoDir, diffListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: repoDir, compare: { mode: "uncommitted" } },
diffListener,
);
cleanup.push(async () => {
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
await service.dispose();
expect(activeWatcherCount).toBe(0);
rmSync(tempDir, { recursive: true, force: true });
@@ -302,7 +312,6 @@ test("recursive observation updates tracked state and prunes ignored storms", as
getCheckoutShortstat.mockClear();
getCheckoutWorktreeState.mockClear();
getCheckoutDiff.mockClear();
diffListener.mockClear();
runGitCommand.mockClear();
deliveredEvents.length = 0;
@@ -335,7 +344,18 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenCalled();
expect(diffListener).toHaveBeenLastCalledWith({
cwd: repoDir,
files: [
{
path: "src/tracked.txt",
additions: 2,
deletions: 0,
status: "modified",
},
],
error: null,
});
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
},
{ timeout: 5_000 },
@@ -351,7 +371,11 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenCalled();
expect(diffListener).toHaveBeenLastCalledWith(
expect.objectContaining({
files: [expect.objectContaining({ additions: 3 })],
}),
);
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
@@ -385,7 +409,18 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenCalled();
expect(diffListener).toHaveBeenLastCalledWith({
cwd: repoDir,
files: [
{
path: "build/tracked.txt",
additions: 2,
deletions: 0,
status: "modified",
},
],
error: null,
});
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
@@ -399,7 +434,6 @@ test("recursive observation updates tracked state and prunes ignored storms", as
getCheckoutShortstat.mockClear();
getCheckoutWorktreeState.mockClear();
getCheckoutDiff.mockClear();
diffListener.mockClear();
runGitCommand.mockClear();
summaryListener.mockClear();
diffListener.mockClear();
@@ -415,7 +449,18 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenCalled();
expect(diffListener).toHaveBeenLastCalledWith({
cwd: repoDir,
files: [
{
path: "build/tracked.txt",
additions: 4,
deletions: 0,
status: "modified",
},
],
error: null,
});
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
@@ -429,7 +474,6 @@ test("recursive observation updates tracked state and prunes ignored storms", as
getCheckoutShortstat.mockClear();
getCheckoutWorktreeState.mockClear();
getCheckoutDiff.mockClear();
diffListener.mockClear();
runGitCommand.mockClear();
for (let index = 0; index < 100; index += 1) {
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import type pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { FileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -427,30 +428,41 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("a tracked edit refreshes summary and notifies worktree consumers without structural or forge work", async () => {
test("a tracked edit refreshes summary and active uncommitted diff without structural or forge work", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
let diffStat = { additions: 1, deletions: 0 };
let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" };
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { isDirty: true }),
);
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getPullRequestStatus = vi.fn();
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] }));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
await service.getSnapshot(REPO_CWD);
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledWith(
REPO_CWD,
@@ -463,6 +475,7 @@ describe("WorkspaceGitService checkout observation", () => {
});
diffStat = { additions: 4, deletions: 2 };
diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" };
watcher.records[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
]);
@@ -476,7 +489,11 @@ describe("WorkspaceGitService checkout observation", () => {
}),
}),
);
expect(diffListener).toHaveBeenCalledTimes(1);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }],
error: null,
});
});
await flushPromises();
@@ -485,6 +502,7 @@ describe("WorkspaceGitService checkout observation", () => {
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
@@ -1213,7 +1231,7 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("metadata-only changes refresh worktree summary and invalidate the uncommitted projection", async () => {
test("metadata-only changes refresh worktree summary and active uncommitted diff", async () => {
const watcher = createWatcherHarness();
let isDirty = true;
let diffStat = { additions: 3, deletions: 1 };
@@ -1228,10 +1246,19 @@ describe("WorkspaceGitService checkout observation", () => {
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
@@ -1254,13 +1281,16 @@ describe("WorkspaceGitService checkout observation", () => {
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [],
error: null,
});
});
expect(await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).toMatchObject({
structured: [],
});
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
@@ -1274,14 +1304,20 @@ describe("WorkspaceGitService checkout observation", () => {
const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState });
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {});
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
watcher.records
@@ -1289,7 +1325,6 @@ describe("WorkspaceGitService checkout observation", () => {
?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await flushPromises();
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
watcher.records
@@ -1297,10 +1332,12 @@ describe("WorkspaceGitService checkout observation", () => {
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(150);
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
await vi.waitFor(() => {
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
});
subscription.unsubscribe();
diffSubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
@@ -2722,23 +2759,39 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("a working-tree watcher event refreshes summary and notifies worktree consumers", async () => {
test("a working-tree watcher event refreshes summary and diff subscribers", async () => {
const watcher = createWatcherHarness();
let isDirty = false;
let diffStat = { additions: 0, deletions: 0 };
let diffFiles: Array<{
path: string;
additions: number;
deletions: number;
status: string;
}> = [];
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles }));
const service = createService(watcher, {
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const listener = vi.fn();
const diffListener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
@@ -2746,6 +2799,7 @@ describe("WorkspaceGitService checkout observation", () => {
});
isDirty = true;
diffStat = { additions: 7, deletions: 3 };
diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }];
getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
@@ -2760,12 +2814,17 @@ describe("WorkspaceGitService checkout observation", () => {
}),
}),
);
expect(diffListener).toHaveBeenCalledTimes(1);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }],
error: null,
});
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
diffSubscription.unsubscribe();
subscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
});
@@ -161,7 +161,6 @@ export interface WorkspaceGitRuntimeSnapshot {
isDirty: boolean | null;
baseRef: string | null;
baseRefExact?: string | null;
comparisonBaseRef?: string | null;
aheadBehind: { ahead: number; behind: number } | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
@@ -3627,7 +3626,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
baseRef: checkoutStatus.baseRef,
baseRefExact: checkoutStatus.baseRefExact,
comparisonBaseRef: checkoutStatus.comparisonBaseRef,
aheadBehind: checkoutStatus.aheadBehind,
upstreamRef: checkoutStatus.upstreamRef,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
@@ -3,12 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it } from "vitest";
import {
getCommitFileDiff,
listCheckoutCommits,
getCheckoutStatus,
getCheckoutDiff,
} from "./checkout-git.js";
import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js";
import { writePaseoWorktreeMetadata } from "./worktree-metadata.js";
const tempDirs: string[] = [];
@@ -398,45 +393,3 @@ describe("listCheckoutCommits", () => {
]);
});
});
it("compares tag-based worktrees in status, history and legacy diff modes", async () => {
const { repoDir: mainRepoDir, tempDir } = initRepoOnMain();
const worktreesRoot = join(tempDir, "worktrees");
const repoDir = join(worktreesRoot, "repo-hash", "tag-feature");
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
const context = { worktreesRoot };
git(["tag", "v1"], mainRepoDir);
git(["worktree", "add", "-b", "tag-feature", repoDir, "v1"], mainRepoDir);
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "refs/tags/v1" });
commitFile(repoDir, "tag.txt", "committed\n", "tag feature");
writeFileSync(join(repoDir, "tag.txt"), "edited\n");
const status = await getCheckoutStatus(repoDir, context);
expect(status).toMatchObject({
isGit: true,
comparisonBaseRef: "refs/tags/v1",
aheadBehind: { ahead: 1, behind: 0 },
});
const history = await listCheckoutCommits({ cwd: repoDir, context });
expect(history.baseRef).toBe("refs/tags/v1");
expect(history.commits[0].subject).toBe("tag feature");
const committed = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/tags/v1" });
expect(committed.diff).toContain("+committed");
const all = await getCheckoutDiff(repoDir, { mode: "all", baseRef: "refs/tags/v1" });
expect(all.diff).toContain("+edited");
});
it("announces a usable comparison base while checked out on the base branch", async () => {
const cwd = makeTempDir();
git(["init", "-b", "main"], cwd);
git(["config", "user.name", "Test"], cwd);
git(["config", "user.email", "test@example.com"], cwd);
writeFileSync(join(cwd, "a.txt"), "before\n");
git(["add", "."], cwd);
commit(cwd, "base");
writeFileSync(join(cwd, "a.txt"), "after\n");
const status = await getCheckoutStatus(cwd);
expect(status).toMatchObject({ isGit: true, comparisonBaseRef: "refs/heads/main" });
if (!status.isGit || !status.comparisonBaseRef) throw new Error("Missing comparison base");
const all = await getCheckoutDiff(cwd, { mode: "all", baseRef: status.comparisonBaseRef });
expect(all.diff).toContain("+after");
});
+43 -95
View File
@@ -40,7 +40,7 @@ import {
} from "../services/forge-cli-command.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
import { readGitFileContents, readGitFileContentAtRef } from "./git-file-contents.js";
import { readGitFileContents } from "./git-file-contents.js";
import { unquoteGitPath } from "./git-quoted-path.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
@@ -613,6 +613,22 @@ async function listCheckoutFileChanges(
return Array.from(byPath.values());
}
async function readGitFileContentAtRef(
cwd: string,
ref: string,
path: string,
): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["show", `${ref}:${path}`], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
return stdout;
} catch {
return null;
}
}
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
@@ -653,12 +669,7 @@ function normalizeNumstatPath(pathField: string): string {
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
export const GIT_PATCH_FORMAT_ARGS = [
"--no-ext-diff",
"--no-color",
"--src-prefix=a/",
"--dst-prefix=b/",
] as const;
const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return [
@@ -895,7 +906,6 @@ export interface CheckoutStatusGitNonPaseo {
isDirty: boolean;
baseRef: string | null;
baseRefExact?: string | null;
comparisonBaseRef?: string | null;
aheadBehind: AheadBehind | null;
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
@@ -916,7 +926,6 @@ export interface CheckoutStatusGitPaseo {
isDirty: boolean;
baseRef: string;
baseRefExact?: string | null;
comparisonBaseRef?: string | null;
aheadBehind: AheadBehind | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
@@ -1739,24 +1748,6 @@ function isQualifiedBranchRef(ref: string): boolean {
return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/");
}
async function resolveCommitComparisonRef(
cwd: string,
name: string,
context?: CheckoutContext,
): Promise<string> {
await getRunGitCommand(context)(["rev-parse", "--verify", `${name}^{commit}`], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const result = await getRunGitCommand(context)(["rev-parse", "--symbolic-full-name", name], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
return result.stdout.trim() || name;
}
async function resolveBestComparisonBaseRef(
cwd: string,
baseRef: string,
@@ -1783,17 +1774,11 @@ async function resolveBestComparisonBaseRef(
return normalized.localName;
}
return resolveCommitComparisonRef(cwd, baseRef, context);
}
async function resolveQualifiedComparisonBaseRef(
cwd: string,
baseRef: string,
context: CheckoutContext,
): Promise<string> {
const comparison = context.facts?.isGit ? context.facts.comparisonBaseRef : null;
const ref = comparison ?? (await resolveBestComparisonBaseRef(cwd, baseRef, context));
return resolveCommitComparisonRef(cwd, ref, context);
const refName =
baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/")
? normalized.originRef
: normalized.localName;
throw new Error(`Base branch not found locally or on origin: ${refName}`);
}
async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<string> {
@@ -1816,7 +1801,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<st
return `origin/${normalizedBaseRef}`;
}
if (!hasLocal && !hasOrigin) {
return resolveCommitComparisonRef(cwd, baseRef);
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
}
const { stdout } = await runGitCommand(
@@ -2254,8 +2239,8 @@ export async function getCheckoutSnapshotFacts(
};
}
export const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
export const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
@@ -2316,7 +2301,7 @@ async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
}
}
export async function inspectUntrackedFile(
async function inspectUntrackedFile(
cwd: string,
relativePath: string,
): Promise<{ stat: FileStat; truncated: boolean }> {
@@ -2414,10 +2399,6 @@ export async function getCheckoutStatus(
const baseRef = facts.resolvedBaseRef;
const mainRepoRoot = facts.mainRepoRoot;
const factsContext = { ...context, facts };
// Ref-derived counts skip the current base branch; the diff consumer still needs its ref.
const comparisonBaseRef = baseRef
? await resolveQualifiedComparisonBaseRef(cwd, baseRef, factsContext).catch(() => null)
: null;
const aheadBehind =
baseRef && currentBranch
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
@@ -2440,7 +2421,6 @@ export async function getCheckoutStatus(
isDirty,
baseRef: displayBaseRef ?? baseRef,
baseRefExact: baseRef,
comparisonBaseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
@@ -2460,7 +2440,6 @@ export async function getCheckoutStatus(
isDirty,
baseRef: displayBaseRef,
baseRefExact: baseRef,
comparisonBaseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
@@ -2825,7 +2804,6 @@ async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<
* binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha),
* which the caller maps to a typed checkout error.
*/
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
export async function getCommitFileDiff({
cwd,
sha,
@@ -3592,21 +3570,6 @@ async function resolveCheckoutDiffRefs(
if (compare.mode === "uncommitted") {
return { baseRef: "HEAD", includeUntracked: true };
}
const bestBaseRef = await resolveCheckoutDiffBase(cwd, compare, context);
if (!bestBaseRef) return null;
return {
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
targetRef: compare.mode === "base" ? "HEAD" : undefined,
includeUntracked: compare.mode === "all",
};
}
export async function resolveCheckoutDiffBase(
cwd: string,
compare: CheckoutDiffCompare,
context?: CheckoutContext,
): Promise<string | null> {
if (compare.mode === "uncommitted") return "HEAD";
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const baseRef = resolveOperationBaseRef({
storedBaseRef,
@@ -3616,12 +3579,17 @@ export async function resolveCheckoutDiffBase(
if (!baseRef) {
return null;
}
return resolveBestComparisonBaseRef(cwd, baseRef);
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
return {
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
targetRef: compare.mode === "base" ? "HEAD" : undefined,
includeUntracked: compare.mode === "all",
};
}
// Git's working-tree diff ignores files absent from the index, even when they
// exist in the comparison tree. Restore those entries only in a private index.
export async function prepareAllChangesIndex(cwd: string, baseRef: string) {
async function prepareAllChangesIndex(cwd: string, baseRef: string) {
const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
@@ -3644,41 +3612,21 @@ export async function prepareAllChangesIndex(cwd: string, baseRef: string) {
entries += result.stdout;
}
if (!entries) return null;
const snapshot = await snapshotComparisonIndex(cwd);
try {
await runGitCommand(["update-index", "-z", "--index-info"], {
cwd,
input: entries,
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: snapshot.indexFile },
});
return snapshot;
} catch (error) {
await snapshot.cleanup();
throw error;
}
}
// write-tree rewrites its index. A private snapshot prevents an index read from
// invalidating the observation that requested it.
export async function snapshotComparisonIndex(cwd: string) {
const directory = await mkdtemp(resolve(tmpdir(), "paseo-comparison-index-"));
const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-"));
const indexFile = resolve(directory, "index");
try {
const index = await runGitCommand(["rev-parse", "--git-path", "index"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const source = resolveGitRevParsePath(cwd, index.stdout);
if (!source) throw new Error("Git did not return an index path");
try {
await copyFile(source, indexFile);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
await runGitCommand(["read-tree", "--empty"], {
cwd,
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
});
}
const indexPath = resolveGitRevParsePath(cwd, index.stdout);
if (!indexPath) throw new Error("Git did not return an index path");
await copyFile(indexPath, indexFile);
await runGitCommand(["update-index", "-z", "--index-info"], {
cwd,
input: entries,
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
});
return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) };
} catch (error) {
await rm(directory, { recursive: true, force: true });
+1 -15
View File
@@ -1,4 +1,4 @@
import { runGitCommandBytes, runGitCommand } from "./run-git-command.js";
import { runGitCommandBytes } from "./run-git-command.js";
// Match the existing git-show ceiling. Oversized batches use bounded per-file reads.
const MAX_CONTENT_BYTES = 20 * 1024 * 1024;
@@ -46,17 +46,3 @@ export async function readGitFileContents(
if (offset !== result.stdout.length) throw new Error("Unexpected git object output");
return contents;
}
export async function readGitFileContentAtRef(
cwd: string,
ref: string,
path: string,
): Promise<string | null> {
const result = await runGitCommand(["show", `${ref}:${path}`], {
cwd,
envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
acceptExitCodes: [0, 128],
});
if (result.exitCode !== 0 || result.truncated) return null;
return result.stdout;
}
+1 -1
View File
@@ -20,7 +20,7 @@ import {
} from "./git-process-scheduler.js";
const DEFAULT_TIMEOUT_MS = 30_000;
export const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
const DEFAULT_STDERR_LIMIT = 2048;
let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));