mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4847149428 | ||
|
|
27872a8e86 | ||
|
|
1b0da5fce5 | ||
|
|
02ad59908f | ||
|
|
b509d499f7 | ||
|
|
f539ccf1c0 | ||
|
|
ac08077f7a | ||
|
|
79717e2bd3 | ||
|
|
22f50d4dfa | ||
|
|
2d7a115614 | ||
|
|
680e6077a8 | ||
|
|
835e339516 | ||
|
|
1927d859bc | ||
|
|
fcde2072ab | ||
|
|
2071bfda7f | ||
|
|
ed4657bdf8 | ||
|
|
71439e51a5 | ||
|
|
d92495487f |
@@ -1,8 +1,8 @@
|
||||
# Agent stream disclosures
|
||||
|
||||
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
|
||||
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
|
||||
first painted frame. No animation.
|
||||
a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the
|
||||
tapped trigger at the same screen y in the first painted frame. No animation.
|
||||
|
||||
## The contract
|
||||
|
||||
@@ -14,12 +14,20 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||
```
|
||||
|
||||
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
|
||||
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a
|
||||
timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
A toggle that calls `setState` directly skips the hold: on Android the expanded content paints
|
||||
over the next row for a frame before the list learns the new size.
|
||||
|
||||
`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in
|
||||
place. Header triggers sit on the top edge, the default. A long user message's show more sits under
|
||||
the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks
|
||||
above the trigger, and the rows below stay put.
|
||||
|
||||
Following output:
|
||||
|
||||
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||
- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on
|
||||
native while following: there the end pin already keeps the row's bottom in place.
|
||||
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
|
||||
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
|
||||
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
|
||||
@@ -33,13 +41,16 @@ ends at its end and the trigger moves by the difference.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
|
||||
the row's top after layout, before paint (`strategy-web.tsx`).
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the
|
||||
scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the
|
||||
content past the offset, before the reconcile reads it. Restoring from the clamped offset would
|
||||
apply the shrink twice.
|
||||
|
||||
## Native
|
||||
|
||||
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||
not hold. Summary lines and todo cards expand inline.
|
||||
not hold. Summary lines, todo cards, plan cards and show more expand inline.
|
||||
|
||||
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
|
||||
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
|
||||
@@ -51,14 +62,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
||||
|
||||
One owner keeps the scroll offset at any moment:
|
||||
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`). |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
|
||||
The end pin is a zero-height view at the bottom of the content and the content's first native
|
||||
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
|
||||
@@ -78,15 +89,38 @@ jumps by the keyboard height after the keyboard settles.
|
||||
|
||||
### The hold
|
||||
|
||||
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
|
||||
|
||||
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
|
||||
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
|
||||
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
|
||||
one down, and a size change with no data change keeps the first row in view whatever
|
||||
1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row
|
||||
tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row
|
||||
below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away
|
||||
and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row
|
||||
(`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin
|
||||
keeps the content end, and the row's bottom with it, in the frame the new height mounts.
|
||||
|
||||
A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no
|
||||
row below it: its bottom is the content end (the running indicator is the list footer, not a
|
||||
row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin
|
||||
anchors only once it is mounted, and a pin that mounts in the same commit as the new height
|
||||
anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s
|
||||
commit mounts with the pin already in place. The pin keeps the distance from the end, and each
|
||||
content size change clamps it to the scroll room left, so the hold re-sticks if the list was
|
||||
at its end when tapped or a collapse used up the room. The release tracks that distance
|
||||
instead of reading the offset: the new content size reaches JS before the pin's scroll does.
|
||||
A reply landing below the row ends the pin in the commit that mounts the reply, so the reply
|
||||
grows below the row and the trigger stays where it was. A reply that lands before `apply` runs
|
||||
moves the hold to the reply's row.
|
||||
|
||||
Upstream LegendList anchors only rows from the first fully visible one down to the last fully
|
||||
visible one, and a size change with no data change keeps the first row in view whatever
|
||||
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
|
||||
at the top as in view and applies `shouldRestorePosition` to size changes too.
|
||||
instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList
|
||||
patch counts the rows cut off at the top, and the row just past the bottom, as in view and
|
||||
applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp
|
||||
prediction: the scroll view clamps after the adjustment
|
||||
([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near
|
||||
the end to the end.
|
||||
|
||||
2. Run `apply`.
|
||||
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
|
||||
LegendList's container does not re-render and would learn the new height from `onLayout` a task
|
||||
@@ -130,7 +164,7 @@ With every turn collapsed, flinging from a cold open into history did not blank
|
||||
|
||||
### LegendList patch
|
||||
|
||||
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
|
||||
`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists
|
||||
them by purpose, and
|
||||
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
|
||||
Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
@@ -276,7 +276,7 @@ its teardown; reconnect creates new server IDs for surviving client handles. See
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting; a window with no validated inbound frame is a miss, and two misses in a row tear the socket down. When the app returns to the foreground the client runs a fresh cycle of two 5-second windows back to back, then reconnects without backoff, because a socket that died while the OS had JS suspended stays `OPEN` and accepts sends: only inbound frames prove it. Misses and probes from before the suspension do not count toward that cycle, and a window whose timer fires long after its deadline spanned another suspension, so it is discarded and the cycle starts over. Any complete frame inside a window keeps the connection, so a slow multi-frame transfer survives; a single frame that takes longer than the two windows to arrive after foreground does not, and the fix for that is smaller daemon-to-client frames, not a longer window. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. Only one card per overlay layer is open: pointing at another trigger replaces the open card at once, so a sweep down a list never shows two cards or leaves one behind. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
|
||||
@@ -276,3 +276,52 @@ test("footer timing fits the chat column and swapping labels keeps controls in p
|
||||
await expectFooterWithinWidth(page, 300);
|
||||
await expectFooterWithinWidth(page, 880);
|
||||
});
|
||||
|
||||
async function visibleFooterLeftEdges(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => candidate.checkVisibility());
|
||||
return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.filter((footer) => footer.checkVisibility())
|
||||
.map((footer) => Math.round(footer.getBoundingClientRect().left));
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFootersInOneColumn(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const edges = await visibleFooterLeftEdges(page);
|
||||
return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges;
|
||||
})
|
||||
.toBe("one column");
|
||||
}
|
||||
|
||||
test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
// Wider than the chat's max width, so the column is centered with space on both sides.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates");
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expectFootersInOneColumn(page);
|
||||
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expectFootersInOneColumn(page);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
closeActiveAgentWithShortcut,
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
@@ -20,8 +21,12 @@ import {
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
unarchiveAgentFromConversation,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer";
|
||||
import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
@@ -51,6 +56,34 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("restoring a closed agent retains its unsent composer draft after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "archived-composer-",
|
||||
title: "Restore unsent draft",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessage(page, "A completed conversation before closing");
|
||||
await awaitAssistantMessage(page);
|
||||
await expectAgentIdle(page);
|
||||
await fillComposerDraft(page, "Keep this unsent message after closing");
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
await closeActiveAgentWithShortcut(page, agent.agentId);
|
||||
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
|
||||
await page.reload();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, "Restore unsent draft");
|
||||
await clickSessionRow(page, "Restore unsent draft");
|
||||
await expectArchivedAgentFocused(page, agent.agentId);
|
||||
await unarchiveAgentFromConversation(page);
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
|
||||
@@ -27,15 +27,22 @@ async function holdBranchSuggestions(page: Page) {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage(async (message) => {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
|
||||
await gate;
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "branch_suggestions_response"
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
}
|
||||
browser.send(message);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ 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, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -262,6 +266,126 @@ 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();
|
||||
|
||||
@@ -499,6 +623,35 @@ 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,
|
||||
}) => {
|
||||
@@ -1311,11 +1464,13 @@ 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(180_000);
|
||||
test.setTimeout(300_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);
|
||||
@@ -1894,12 +2049,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): 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`),
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
async function prepareNarrowSplit(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
@@ -74,33 +74,38 @@ async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
async function expectPanesKeepTabRails(page: Page, panes: Locator[]) {
|
||||
for (const pane of panes) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function launchTerminalFromPaneRail(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
async function maximizeAndRestorePane(pane: Locator) {
|
||||
await pane.getByTestId("workspace-maximize-pane").click();
|
||||
await pane.getByTestId("workspace-restore-pane").click();
|
||||
}
|
||||
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await launchTerminalFromPaneRail(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await maximizeAndRestorePane(originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -118,17 +123,15 @@ async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
|
||||
@@ -79,6 +79,12 @@ 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");
|
||||
@@ -100,6 +106,7 @@ 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(() =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
@@ -69,6 +70,8 @@ test.describe("New workspace live pin", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
// The composer appears before its provider catalog has hydrated.
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
|
||||
@@ -95,66 +95,63 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
test("keeps sidebar and detail scrolling independent through section changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
await test.step("scrolls the sidebar and detail pane independently", async () => {
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
await test.step("keeps the sidebar position after rapid consecutive selections", async () => {
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
await test.step("keeps the sidebar position when switching between app and host sections", async () => {
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectOnlyWorkspaceHoverCardFor,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -80,6 +82,22 @@ async function openWorkspaceHoverCard(page: import("@playwright/test").Page, wor
|
||||
return hoverCard;
|
||||
}
|
||||
|
||||
async function sortWorkspacesBySidebarOrder<T extends { workspaceId: string }>(
|
||||
page: Page,
|
||||
workspaces: T[],
|
||||
): Promise<T[]> {
|
||||
const positioned = await Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const bounds = await page
|
||||
.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))
|
||||
.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspace.workspaceId}`);
|
||||
return { workspace, y: bounds.y };
|
||||
}),
|
||||
);
|
||||
return positioned.sort((left, right) => left.y - right.y).map(({ workspace }) => workspace);
|
||||
}
|
||||
|
||||
interface PaseoOwnedWorktree {
|
||||
projectName: string;
|
||||
workspaceId: string;
|
||||
@@ -430,6 +448,38 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("scanning up the workspace rows leaves only the resting row's hover card open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspaces = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `sidebar-hover-scan-${index}-`,
|
||||
title: `Hover scan ${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
for (const workspace of workspaces) {
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
}
|
||||
const rowsTopToBottom = await sortWorkspacesBySidebarOrder(page, workspaces);
|
||||
const top = rowsTopToBottom[0]!;
|
||||
|
||||
for (let scan = 0; scan < 3; scan += 1) {
|
||||
await sweepPointerAcrossWorkspaceRows(
|
||||
page,
|
||||
rowsTopToBottom.map((workspace) => workspace.workspaceId).toReversed(),
|
||||
);
|
||||
await expectOnlyWorkspaceHoverCardFor(page, top.workspaceName);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function panes(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return visible(page, "workspace-split-resize-handle");
|
||||
}
|
||||
|
||||
async function openSplitWorkspace(
|
||||
page: Page,
|
||||
repoPrefix: string,
|
||||
split: "Split pane right" | "Split pane down",
|
||||
): Promise<SeededWorkspace> {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await runWorkspaceActionFromCommandCenter(page, split);
|
||||
await expect(splitDividers(page)).toHaveCount(1);
|
||||
await expect(panes(page)).toHaveCount(2);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function expectOneSharedWorkspaceHeader(page: Page): Promise<void> {
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1);
|
||||
await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The tab counter belongs to the single-pane layout: split panes always show their rails. */
|
||||
async function expectPanesKeepTabRails(page: Page): Promise<void> {
|
||||
for (const pane of await panes(page).all()) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Workspace header with split panes", () => {
|
||||
test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stacked panes share one header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right");
|
||||
try {
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right");
|
||||
try {
|
||||
await dragDivider(splitDividers(page), -300);
|
||||
const [left, right] = await panes(page).all();
|
||||
const leftBox = await left.boundingBox();
|
||||
const rightBox = await right.boundingBox();
|
||||
expect(leftBox!.width).toBeLessThan(500);
|
||||
expect(rightBox!.width).toBeGreaterThan(500);
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing back to one pane returns the merged compact header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right");
|
||||
try {
|
||||
const [, emptyPane] = await panes(page).all();
|
||||
const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row");
|
||||
await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover();
|
||||
await emptyPaneRail.locator('[data-testid*="-close-"]').first().click();
|
||||
await expect(splitDividers(page)).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
|
||||
await page.keyboard.press("Alt+Shift+w");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||
visible: true,
|
||||
@@ -237,6 +242,10 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export async function unarchiveAgentFromConversation(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
|
||||
@@ -96,6 +96,35 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<voi
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
/** Scans the rows in the given order at about a user's pace, then rests on the last one. */
|
||||
export async function sweepPointerAcrossWorkspaceRows(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
): Promise<void> {
|
||||
const centers: Array<{ x: number; y: number }> = [];
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const bounds = await row.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspaceId}`);
|
||||
centers.push({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 });
|
||||
}
|
||||
const [first, ...rest] = centers;
|
||||
await page.mouse.move(first!.x, first!.y);
|
||||
for (const center of rest) {
|
||||
await page.mouse.move(center.x, center.y, { steps: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceHoverCardFor(
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards.getByTestId("hover-card-workspace-name")).toHaveText(workspaceName);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { act, createRef, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { MessagePreview } from "@/components/user-message";
|
||||
import { DisclosureRow } from "./disclosure";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
// Load translations so the toggles expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
// The plan body renders through the app's Markdown renderer, which reads through React Query.
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
interface Hold {
|
||||
rowId: string;
|
||||
triggerEdge: DisclosureTriggerEdge;
|
||||
/** Whether the expanded content was already on screen when the strategy received the hold. */
|
||||
appliedBefore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so
|
||||
* a toggle that skips the hold still changes state but leaves no record.
|
||||
*/
|
||||
function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) {
|
||||
const holds: Hold[] = [];
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdRow: (heldRowId, apply, triggerEdge) => {
|
||||
holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() });
|
||||
apply();
|
||||
},
|
||||
};
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
viewportRef.current = viewport;
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DisclosureRow rowId={rowId} viewportRef={viewportRef}>
|
||||
{children}
|
||||
</DisclosureRow>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
return holds;
|
||||
}
|
||||
|
||||
const view = () => within(container);
|
||||
|
||||
const preLine = { whiteSpace: "pre-line" } as const;
|
||||
|
||||
async function waitForLayout() {
|
||||
await act(async () => {
|
||||
for (let frame = 0; frame < 2; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("timeline disclosures hold their row", () => {
|
||||
it("routes user message show more and show less through the row's hold", async () => {
|
||||
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
|
||||
const holds = mountRow(
|
||||
"message-1",
|
||||
() => view().queryByRole("button", { name: "Show less" }) !== null,
|
||||
<MessagePreview>
|
||||
<span style={preLine}>{longText}</span>
|
||||
</MessagePreview>,
|
||||
);
|
||||
// The preview learns it overflows from its first layout.
|
||||
await waitForLayout();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show more" }).click());
|
||||
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show less" }).click());
|
||||
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
|
||||
|
||||
expect(holds).toEqual([
|
||||
// Show more sits under the text it opens.
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: false },
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes plan card collapse and expand through the row's hold", () => {
|
||||
const header = () => view().getByRole("button", { name: "Approved plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
const holds = mountRow(
|
||||
"plan-1",
|
||||
isExpanded,
|
||||
<PlanCard title="Plan" text="Step one" outcome="approved" />,
|
||||
);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
|
||||
expect(holds).toEqual([
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: true },
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggles a plan card outside a timeline row, as the pending permission card renders", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PlanCard title="Plan" text="Step one" outcome="pending" />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
const header = () => view().getByRole("button", { name: "Plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
|
||||
import type { StreamViewportHandle } from "./strategy";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
/**
|
||||
* Runs a disclosure's state change. Inside a timeline row, the row that owns the
|
||||
* trigger keeps its screen position through the change; elsewhere `apply` runs as is.
|
||||
* Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen
|
||||
* position through the change; elsewhere `apply` runs as is. A trigger below the content it
|
||||
* opens passes `triggerEdge: "bottom"`.
|
||||
*/
|
||||
export type DisclosureHold = (apply: () => void) => void;
|
||||
export type DisclosureHold = (
|
||||
apply: () => void,
|
||||
options?: { triggerEdge?: DisclosureTriggerEdge },
|
||||
) => void;
|
||||
|
||||
const applyInPlace: DisclosureHold = (apply) => apply();
|
||||
|
||||
@@ -21,10 +25,10 @@ export function DisclosureRow({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hold = useMemo<DisclosureHold>(
|
||||
() => (apply) => {
|
||||
() => (apply, options) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.holdRow(rowId, apply);
|
||||
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { act, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { View } from "react-native";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { disclosureAnchor } from "./native-disclosure-hold";
|
||||
|
||||
// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch).
|
||||
// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row.
|
||||
@@ -130,7 +131,16 @@ function mountList(heldRowId: string) {
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
const rowBottom = (rowId: string) => {
|
||||
const state = listRef.current?.getState();
|
||||
const position = state?.positionByKey(rowId);
|
||||
const size = state?.sizes.get(rowId);
|
||||
if (state === undefined || position === undefined || size === undefined) {
|
||||
throw new Error(`Row ${rowId} is not laid out`);
|
||||
}
|
||||
return position + size - state.scroll;
|
||||
};
|
||||
return { render, scrollTo, heldRowTop, rowSize, rowBottom };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -171,6 +181,72 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row. The
|
||||
// strategy anchors the row below it, whose top is that bottom edge.
|
||||
const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `row-${index}`,
|
||||
height: index === 3 ? 60 : 40,
|
||||
}));
|
||||
|
||||
function mountBottomTriggerHold(rowId: string) {
|
||||
const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom");
|
||||
if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`);
|
||||
return mountList(anchor.rowId);
|
||||
}
|
||||
|
||||
it("keeps the bottom of a row whose trigger sits under its growing content", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(100);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(80);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(140);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(40);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => {
|
||||
// In the app the row ends with the message's timestamp under the trigger, so a trigger near the
|
||||
// bottom of the viewport has its row's bottom, and the row below, out of view.
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(40);
|
||||
const rowBottom = list.rowBottom("row-3");
|
||||
expect(rowBottom).toBe(140);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type DisclosureAnchor,
|
||||
} from "./native-disclosure-hold";
|
||||
|
||||
function createFrames() {
|
||||
let nextId = 1;
|
||||
@@ -39,22 +44,22 @@ describe("native disclosure hold", () => {
|
||||
it("holds the row until two frames pass without an item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBe("turn-1");
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
|
||||
it("restarts the quiet frames on every item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
@@ -79,11 +84,11 @@ describe("native disclosure hold", () => {
|
||||
it("moves the hold to the latest toggled row and releases once", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
hold.hold("turn-2");
|
||||
hold.hold({ kind: "row", rowId: "turn-2" });
|
||||
|
||||
expect(hold.heldRowId()).toBe("turn-2");
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
@@ -94,12 +99,137 @@ describe("native disclosure hold", () => {
|
||||
it("drops the hold without releasing when cancelled", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds the content end until two quiet frames pass, with no row to anchor on", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "end" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-frame disclosure hold", () => {
|
||||
it("anchors the row a reply landed as while the toggle waited for its frame", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
let anchor: DisclosureAnchor = { kind: "end" };
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => anchor,
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
expect(applied).toEqual([]);
|
||||
|
||||
anchor = { kind: "row", rowId: "turn-2" };
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]);
|
||||
});
|
||||
|
||||
it("still lands the toggle when the hold is cancelled before its frame", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("lands a waiting toggle before the next hold starts", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
frames.runFrame();
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disclosure anchor", () => {
|
||||
const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }];
|
||||
|
||||
it("keeps the top of a row whose trigger sits on its top edge", () => {
|
||||
expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" });
|
||||
});
|
||||
|
||||
it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => {
|
||||
expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-1",
|
||||
});
|
||||
});
|
||||
|
||||
// A prompt the agent has not answered yet is the last row; the running indicator is the footer.
|
||||
it("keeps the content end for a bottom-edge trigger on the last row", () => {
|
||||
expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" });
|
||||
});
|
||||
|
||||
// The strategy re-derives this each render, so a reply lets go of the end in the commit it lands.
|
||||
it("stops keeping the content end once a reply lands below the row", () => {
|
||||
expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("end hold distance", () => {
|
||||
const viewportHeight = 600;
|
||||
|
||||
it("keeps the distance from the end the toggle started at", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 2000, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(20);
|
||||
});
|
||||
|
||||
it("shrinks to the scroll room a collapse leaves", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 610, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(10);
|
||||
});
|
||||
|
||||
it("stays at the end when output grows the content after a collapse reached the end", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 600, viewportHeight });
|
||||
distance.noteContentSize({ contentHeight: 700, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */
|
||||
export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" };
|
||||
|
||||
/**
|
||||
* The native viewport's disclosure hold. While a row is held, it is the only row the list may
|
||||
* anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list
|
||||
* measures the toggled row and any rows it inserted over one or more frames, and end following
|
||||
* must not resume between the change and the list's last layout correction.
|
||||
* The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the
|
||||
* list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for
|
||||
* `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more
|
||||
* frames, and end following must not resume between the change and the list's last layout
|
||||
* correction.
|
||||
*/
|
||||
export interface NativeDisclosureHold {
|
||||
heldRowId(): string | null;
|
||||
hold(rowId: string): void;
|
||||
/** The row the list may anchor on; null outside a hold and while the hold keeps the end. */
|
||||
anchorRowId(): string | null;
|
||||
hold(anchor: DisclosureAnchor): void;
|
||||
/**
|
||||
* Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit
|
||||
* already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits.
|
||||
* A later hold or a cancel lands a waiting toggle right away.
|
||||
*/
|
||||
holdNextFrame(
|
||||
resolveAnchor: () => DisclosureAnchor,
|
||||
apply: (anchor: DisclosureAnchor) => void,
|
||||
): void;
|
||||
noteItemSizeChanged(): void;
|
||||
/** Drops the hold without releasing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
@@ -17,9 +34,10 @@ export function createNativeDisclosureHold(input: {
|
||||
cancelFrame: (id: number) => void;
|
||||
onRelease: () => void;
|
||||
}): NativeDisclosureHold {
|
||||
let heldRowId: string | null = null;
|
||||
let anchor: DisclosureAnchor | null = null;
|
||||
let frameId: number | null = null;
|
||||
let remainingFrames = 0;
|
||||
let waitingToggle: { frameId: number; land: () => void } | null = null;
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (frameId !== null) {
|
||||
@@ -35,7 +53,7 @@ export function createNativeDisclosureHold(input: {
|
||||
return;
|
||||
}
|
||||
frameId = null;
|
||||
heldRowId = null;
|
||||
anchor = null;
|
||||
input.onRelease();
|
||||
};
|
||||
|
||||
@@ -45,20 +63,80 @@ export function createNativeDisclosureHold(input: {
|
||||
frameId = input.requestFrame(tick);
|
||||
};
|
||||
|
||||
const landWaitingToggle = () => {
|
||||
if (!waitingToggle) return;
|
||||
const { frameId: waitingFrameId, land } = waitingToggle;
|
||||
waitingToggle = null;
|
||||
input.cancelFrame(waitingFrameId);
|
||||
land();
|
||||
};
|
||||
|
||||
const startHold = (nextAnchor: DisclosureAnchor) => {
|
||||
anchor = nextAnchor;
|
||||
restartCountdown();
|
||||
};
|
||||
|
||||
return {
|
||||
heldRowId: () => heldRowId,
|
||||
hold(rowId) {
|
||||
heldRowId = rowId;
|
||||
restartCountdown();
|
||||
anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null),
|
||||
hold(nextAnchor) {
|
||||
landWaitingToggle();
|
||||
startHold(nextAnchor);
|
||||
},
|
||||
holdNextFrame(resolveAnchor, apply) {
|
||||
landWaitingToggle();
|
||||
const waitingFrameId = input.requestFrame(() => {
|
||||
waitingToggle = null;
|
||||
const nextAnchor = resolveAnchor();
|
||||
startHold(nextAnchor);
|
||||
apply(nextAnchor);
|
||||
});
|
||||
waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) };
|
||||
},
|
||||
noteItemSizeChanged() {
|
||||
if (heldRowId !== null) {
|
||||
if (anchor !== null) {
|
||||
restartCountdown();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
landWaitingToggle();
|
||||
stopCountdown();
|
||||
heldRowId = null;
|
||||
anchor = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so
|
||||
* a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled
|
||||
* row's bottom. The last row's bottom is the end of the content.
|
||||
*/
|
||||
export function disclosureAnchor(
|
||||
rows: readonly { id: string }[],
|
||||
rowId: string,
|
||||
triggerEdge: DisclosureTriggerEdge,
|
||||
): DisclosureAnchor {
|
||||
if (triggerEdge === "top") return { kind: "row", rowId };
|
||||
const index = rows.findIndex((row) => row.id === rowId);
|
||||
const below = rows[index + 1];
|
||||
return below ? { kind: "row", rowId: below.id } : { kind: "end" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's distance from the end during an end hold. The end pin keeps the distance through every
|
||||
* content size change, and the scroll view clamps it to the scroll room the content has at that
|
||||
* moment. A clamp is permanent: content that grows afterwards does not bring the distance back.
|
||||
*/
|
||||
export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance {
|
||||
let distance = distanceAtToggle;
|
||||
return {
|
||||
noteContentSize({ contentHeight, viewportHeight }) {
|
||||
distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight));
|
||||
},
|
||||
current: () => distance,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndHoldDistance {
|
||||
noteContentSize(input: { contentHeight: number; viewportHeight: number }): void;
|
||||
current(): number;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("reading anchor", () => {
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
@@ -59,13 +59,32 @@ describe("reading anchor", () => {
|
||||
it("keeps a pinned row in place instead of the row under the reading line", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!);
|
||||
anchor.pin(rows[2]!, "top", 600);
|
||||
// The row being read grows; only the pinned row below it holds its position.
|
||||
expect(
|
||||
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]),
|
||||
).toBe(900);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a row pinned by a trigger below its content", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 600);
|
||||
// The pinned row grows downward; its bottom, where the trigger sits, holds its position.
|
||||
const grown = { ...rows[2]!, height: rows[2]!.height + 300 };
|
||||
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900);
|
||||
});
|
||||
|
||||
it("measures a pinned row's correction from the offset at the toggle", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(900, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 900);
|
||||
// The row shrinks and the browser clamps the offset to the shorter content before the
|
||||
// reconcile reads it.
|
||||
const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 };
|
||||
expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600);
|
||||
});
|
||||
|
||||
it("leaves expansion of the row being read in place", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
@@ -109,8 +128,8 @@ describe("reading anchor", () => {
|
||||
it("projects a prepend range without consuming the correction before layout commits", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
interface RowGeometry {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) =>
|
||||
edge === "top" ? row.top : row.top + row.height;
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
@@ -14,7 +19,16 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
export function createReadingAnchor() {
|
||||
let anchor: { id: string; top: number } | null = null;
|
||||
// The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on,
|
||||
// with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the
|
||||
// offset before the next reconcile reads it.
|
||||
let anchor: {
|
||||
id: string;
|
||||
edge: DisclosureTriggerEdge;
|
||||
position: number;
|
||||
pinnedScrollTop?: number;
|
||||
} | null = null;
|
||||
const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top });
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
@@ -22,9 +36,12 @@ export function createReadingAnchor() {
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
row: RowGeometry | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
) =>
|
||||
anchor && row && row.id === getRowId(replacements)
|
||||
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
|
||||
: scrollTop;
|
||||
return {
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
@@ -33,14 +50,14 @@ export function createReadingAnchor() {
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
// A disclosure names the row that owns its trigger. That row keeps its top
|
||||
// through the toggle, even when another row sits under the reading line.
|
||||
pin(row: Pick<RowGeometry, "id" | "top">) {
|
||||
anchor = { id: row.id, top: row.top };
|
||||
// A disclosure names the row that owns its trigger and the edge the trigger sits on. That
|
||||
// edge stays in place through the toggle, even when another row sits under the reading line.
|
||||
pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) {
|
||||
anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop };
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
},
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
@@ -57,7 +74,7 @@ export function createReadingAnchor() {
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,10 +35,20 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type EndHoldDistance,
|
||||
} from "./native-disclosure-hold";
|
||||
import { EndPinnedScrollView } from "./native-end-pin";
|
||||
import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import type {
|
||||
DisclosureTriggerEdge,
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
StreamViewportHandle,
|
||||
} from "./strategy";
|
||||
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
|
||||
import {
|
||||
isHistoryStartLoadingOperation,
|
||||
@@ -306,8 +316,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isAtEnd = useStableEvent(
|
||||
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
|
||||
);
|
||||
// A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the
|
||||
// tracked distance instead of the offset: the content size reaches JS before the pin's scroll does.
|
||||
const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null);
|
||||
const releaseDisclosure = useStableEvent(() => {
|
||||
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
|
||||
setEndHold(null);
|
||||
bottomAnchorController.releaseDisclosure({
|
||||
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
|
||||
});
|
||||
});
|
||||
const [disclosureHold] = useState(() =>
|
||||
createNativeDisclosureHold({
|
||||
@@ -317,16 +333,54 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onRelease: releaseDisclosure,
|
||||
}),
|
||||
);
|
||||
useEffect(() => () => disclosureHold.cancel(), [disclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row. The row's measurement trigger
|
||||
// makes that same commit carry the row's new height.
|
||||
const holdRow = useStableEvent((rowId: string, apply: () => void) => {
|
||||
disclosureHold.hold(rowId);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
const cancelDisclosureHold = useStableEvent(() => {
|
||||
disclosureHold.cancel();
|
||||
setEndHold(null);
|
||||
});
|
||||
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row, or on the row below it for a
|
||||
// trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry
|
||||
// the row's new height. While following, a bottom trigger keeps following: the end pin holds the
|
||||
// content end, and with it the bottom of the row, in the frame the new height mounts.
|
||||
// The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from
|
||||
// any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing.
|
||||
// So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers.
|
||||
const latestDisclosureAnchor = useStableEvent(
|
||||
(rowId: string, triggerEdge: DisclosureTriggerEdge) =>
|
||||
disclosureAnchor(rows, rowId, triggerEdge),
|
||||
);
|
||||
const holdRow = useStableEvent(
|
||||
(rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => {
|
||||
const applyAndMeasure = () => {
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
};
|
||||
if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") {
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
const anchor = disclosureAnchor(rows, rowId, triggerEdge);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
if (anchor.kind === "row" || !isListPlacedRef.current) {
|
||||
setEndHold(null);
|
||||
disclosureHold.hold(anchor);
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
setEndHold({
|
||||
rowId,
|
||||
distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)),
|
||||
});
|
||||
disclosureHold.holdNextFrame(
|
||||
() => latestDisclosureAnchor(rowId, triggerEdge),
|
||||
(latestAnchor) => {
|
||||
if (latestAnchor.kind === "row") setEndHold(null);
|
||||
applyAndMeasure();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const handleItemSizeChanged = useStableEvent(
|
||||
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
|
||||
newRowEstimates.noteMeasured(info.itemData, info.size);
|
||||
@@ -334,8 +388,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
);
|
||||
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
|
||||
const heldRowId = disclosureHold.heldRowId();
|
||||
return heldRowId === null || item.id === heldRowId;
|
||||
const anchorRowId = disclosureHold.anchorRowId();
|
||||
return anchorRowId === null || item.id === anchorRowId;
|
||||
});
|
||||
// One owner keeps the scroll offset at any moment:
|
||||
// - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It
|
||||
@@ -349,16 +403,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands
|
||||
// short of the real end and the controller reads that as the user scrolling away.
|
||||
// - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and
|
||||
// LegendList keeps only the held row in place.
|
||||
// LegendList keeps only the held row in place, or the row below it when the trigger sits on the
|
||||
// held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and
|
||||
// one on the last row mounts the pin for the hold: the row's bottom is the content end.
|
||||
// - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below
|
||||
// the rows the user is dragging, and resumes following if the gesture ends at the end.
|
||||
// - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading
|
||||
// above or rows growing below leave what the user is reading where it is.
|
||||
const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom";
|
||||
const pinsEnd = isFollowingEnd && isListPlaced;
|
||||
// An end hold pins the end only while its row's bottom is the end. A reply landing below the row
|
||||
// drops the pin in the commit that mounts the reply, so the reply grows below the held row.
|
||||
const isEndHeld =
|
||||
endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end";
|
||||
const isEndPinned = isFollowingEnd || isEndHeld;
|
||||
const pinsEnd = isEndPinned && isListPlaced;
|
||||
const maintainVisibleContentPosition = useMemo(
|
||||
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isFollowingEnd, shouldRestorePosition],
|
||||
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isEndPinned, shouldRestorePosition],
|
||||
);
|
||||
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
|
||||
const renderScrollComponent = useCallback(
|
||||
@@ -505,7 +566,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can
|
||||
// keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave
|
||||
// no row LegendList may anchor a history page on. The gesture's end decides following.
|
||||
disclosureHold.cancel();
|
||||
cancelDisclosureHold();
|
||||
bottomAnchorController.beginUserScroll();
|
||||
historyStartPagination.rearm();
|
||||
});
|
||||
@@ -581,6 +642,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentHeight: nextContentHeight,
|
||||
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
|
||||
};
|
||||
endHold?.distance.noteContentSize(streamViewportMetricsRef.current);
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
// Content growing below a detached viewport moves the end away without a scroll event.
|
||||
if (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -91,10 +91,15 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
|
||||
return {
|
||||
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
|
||||
hold: (rowId: string, apply: () => void) =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply)),
|
||||
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
|
||||
rowTop: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
rowBottom: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top,
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
@@ -139,6 +144,53 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.rowTop("message-5")).toBe(triggerTop);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row.
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
const triggerBottom = timeline.rowBottom("message-5");
|
||||
|
||||
timeline.hold(
|
||||
"message-5",
|
||||
() => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
|
||||
timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
timeline.scrollTo(60);
|
||||
await timeline.settle();
|
||||
// message-2 is cut off at the top; message-3 is fully in view.
|
||||
for (const index of [2, 3]) {
|
||||
const triggerBottom = timeline.rowBottom(`message-${index}`);
|
||||
const grown = history.map((item, position) =>
|
||||
position === index - 1 ? row(index, 240) : item,
|
||||
);
|
||||
|
||||
timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
|
||||
timeline.hold(
|
||||
`message-${index}`,
|
||||
() => timeline.render({ historyMounted: history }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes following when collapsing an anchored row lands back at the end", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
|
||||
@@ -82,6 +82,8 @@ const initialFillStyle: CSSProperties = {
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
// Rows and the live auxiliary (turn footer, pending permissions) center their content with
|
||||
// `alignSelf`, which only applies inside a flex container.
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -416,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
height: measurement.size,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
@@ -1170,11 +1173,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdRow: (rowId, apply) => {
|
||||
holdRow: (rowId, apply, triggerEdge) => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
const row = measureRows()?.find((candidate) => candidate.id === rowId);
|
||||
if (row) readingAnchor.pin(row);
|
||||
const scrollTop = scrollContainerRef.current?.scrollTop;
|
||||
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
|
||||
disclosurePendingRef.current = true;
|
||||
apply();
|
||||
},
|
||||
@@ -1362,7 +1366,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
|
||||
<div ref={auxiliaryRef} style={streamRowStyle}>
|
||||
{liveAuxiliary}
|
||||
</div>
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,16 +46,23 @@ export interface ScrollToMessageOccurrence {
|
||||
targetTop(): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge of its row a disclosure's trigger sits on. A header trigger sits on the top: the content
|
||||
* it opens grows below it. A trigger under the content it opens, such as a long message's show
|
||||
* more, sits on the bottom.
|
||||
*/
|
||||
export type DisclosureTriggerEdge = "top" | "bottom";
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/**
|
||||
* Run a disclosure's state change so the row that owns its trigger keeps its visual top.
|
||||
* Following output stops; it resumes if the change leaves the list at its end.
|
||||
* Run a disclosure's state change so the row that owns its trigger keeps the edge the trigger
|
||||
* sits on in place. Following output stops; it resumes if the change leaves the list at its end.
|
||||
*/
|
||||
holdRow: (rowId: string, apply: () => void) => void;
|
||||
holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { createNativeStreamStrategy } from "./strategy-native";
|
||||
import { CompletedTurnFooterRow } from "./turn-footer";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// A Sunday, so a turn that ended on Friday shows a weekday timestamp.
|
||||
const NOW = new Date(2026, 10, 8, 5, 0);
|
||||
const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11);
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
host = document.createElement("div");
|
||||
host.style.width = "880px";
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
await i18n.changeLanguage("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const reply: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "reply",
|
||||
text: "Done.",
|
||||
timestamp: FRIDAY_NIGHT,
|
||||
};
|
||||
|
||||
function renderCompletedFooter(timing: TurnTiming): void {
|
||||
act(() =>
|
||||
root.render(
|
||||
<CompletedTurnFooterRow
|
||||
strategy={createNativeStreamStrategy()}
|
||||
items={[reply]}
|
||||
timing={timing}
|
||||
startIndex={0}
|
||||
supportsTimelineCursor={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** How many painted lines each footer label takes. */
|
||||
function paintedLinesByLabel(element: HTMLElement): Record<string, number> {
|
||||
const lines: Record<string, number> = {};
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent ?? "";
|
||||
if (!text.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label
|
||||
// may be sized by the other: a timestamp with more characters can still paint narrower.
|
||||
it.each([
|
||||
{
|
||||
language: "en",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 },
|
||||
duration: "Worked for 26s",
|
||||
},
|
||||
// CJK glyphs make the shorter string paint wider in every font.
|
||||
{
|
||||
language: "ja",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 },
|
||||
duration: "作業時間 59m 58s",
|
||||
},
|
||||
])("keeps both timing labels on one line when the chat has room ($language)", async (input) => {
|
||||
await i18n.changeLanguage(input.language);
|
||||
|
||||
renderCompletedFooter(input.timing);
|
||||
|
||||
expect(paintedLinesByLabel(host)).toEqual({
|
||||
[input.duration]: 1,
|
||||
"Friday 11:11 PM": 1,
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -50,7 +51,8 @@ function PlanCardContent({
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
|
||||
const hold = useDisclosureHold();
|
||||
const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, disableOuterSpacing && styles.containerCompact],
|
||||
[disableOuterSpacing],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
type WorkspaceCompactHeaderTabs,
|
||||
} from "@/screens/workspace/adaptive-workspace-chrome";
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
@@ -66,12 +66,13 @@ import { isNative } from "@/constants/platform";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
/** The full workspace header: title, project row and workspace actions. */
|
||||
renderMainHeader?: () => ReactNode;
|
||||
renderCompactHeader: (
|
||||
tabs: WorkspaceTabDescriptor[],
|
||||
activeTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => ReactNode;
|
||||
/**
|
||||
* The compact workspace header. With one presented pane it carries that pane's tab switcher in
|
||||
* place of its tab rail; above a split it carries workspace controls only.
|
||||
*/
|
||||
renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -128,6 +129,11 @@ interface SplitNodeViewProps extends Omit<
|
||||
windowChromeCorners: WindowChromeCorners;
|
||||
maximizedPaneId: string | null;
|
||||
workspaceHasMultiplePanes: boolean;
|
||||
/**
|
||||
* One presented pane draws the workspace header itself and may merge its tabs into the compact
|
||||
* header. Split panes share one header above them and always show their tab rails.
|
||||
*/
|
||||
ownsWorkspaceHeader: boolean;
|
||||
onTogglePaneMaximized: (paneId: string) => void;
|
||||
}
|
||||
|
||||
@@ -244,11 +250,27 @@ export function SplitContainer({
|
||||
: inheritedWindowChromeCorners;
|
||||
const mainColumnStyle = styles.mainColumn;
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root);
|
||||
const sharedCompactHeader = useMemo(
|
||||
() =>
|
||||
panesShareWorkspaceHeader ? (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(null)}
|
||||
</WindowChromeSafeArea>
|
||||
) : null,
|
||||
[panesShareWorkspaceHeader, renderCompactHeader],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id="SplitContainer">
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{panesShareWorkspaceHeader ? (
|
||||
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
</AdaptiveWorkspaceChrome>
|
||||
) : null}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
@@ -284,7 +306,12 @@ export function SplitContainer({
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
windowChromeCorners={
|
||||
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
|
||||
? "none"
|
||||
: windowChromeCorners
|
||||
}
|
||||
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
@@ -423,6 +450,7 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
ownsWorkspaceHeader,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitNodeViewProps) {
|
||||
@@ -511,6 +539,7 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
ownsWorkspaceHeader={ownsWorkspaceHeader}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
@@ -578,6 +607,7 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
ownsWorkspaceHeader={ownsWorkspaceHeader}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
@@ -638,6 +668,7 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
ownsWorkspaceHeader,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitPaneViewProps) {
|
||||
@@ -770,13 +801,24 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
createNewTab,
|
||||
});
|
||||
const compactHeader = useMemo(
|
||||
() => (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
|
||||
</WindowChromeSafeArea>
|
||||
),
|
||||
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
|
||||
() =>
|
||||
ownsWorkspaceHeader ? (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader({
|
||||
tabs: paneTabs,
|
||||
activeTabKey: activeTabDescriptor?.key ?? "",
|
||||
actions: compactHeaderActions,
|
||||
})}
|
||||
</WindowChromeSafeArea>
|
||||
) : null,
|
||||
[
|
||||
ownsWorkspaceHeader,
|
||||
renderCompactHeader,
|
||||
paneTabs,
|
||||
activeTabDescriptor?.key,
|
||||
compactHeaderActions,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
@@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
testID={`workspace-pane-${pane.id}`}
|
||||
>
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
|
||||
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -17,23 +18,17 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import {
|
||||
computePosition,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
type Placement,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "./anchor";
|
||||
import { computePosition, type Alignment, type Placement, type Rect } from "./anchor";
|
||||
|
||||
const CLOSE_GRACE_MS = 100;
|
||||
|
||||
// One card per layer: moving onto a sibling trigger replaces the open card instead of stacking
|
||||
// a second one beside it for the grace period. Nested cards open on a higher layer.
|
||||
const openCardCloseByLayer = new Map<number, () => void>();
|
||||
|
||||
interface HoverCardContextValue {
|
||||
open: boolean;
|
||||
layer: number;
|
||||
@@ -116,12 +111,26 @@ function WebHoverCard({
|
||||
}, CLOSE_GRACE_MS);
|
||||
}, [keyboardFocusInside]);
|
||||
|
||||
const closeNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const openNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, disabled]);
|
||||
if (disabled) return;
|
||||
const openSibling = openCardCloseByLayer.get(layer);
|
||||
if (openSibling && openSibling !== closeNow) openSibling();
|
||||
openCardCloseByLayer.set(layer, closeNow);
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
if (openCardCloseByLayer.get(layer) === closeNow) openCardCloseByLayer.delete(layer);
|
||||
};
|
||||
}, [closeNow, layer, open]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardInputEvent): boolean => {
|
||||
@@ -299,17 +308,29 @@ interface HoverCardContentProps {
|
||||
|
||||
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
|
||||
const ctx = useContext(HoverCardContext);
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
if (!ctx?.open) return null;
|
||||
return (
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
<Portal hostName={portalHostName}>
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function readDomRect(view: View | null): Rect | null {
|
||||
const element = view as unknown as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
// Rendered inside the portal so the card is placed in the commit that mounts it: the first
|
||||
// painted frame already sits beside the trigger. Web-only, like the rest of the open card.
|
||||
function HoverCardSurface({
|
||||
layer,
|
||||
triggerRef,
|
||||
@@ -327,48 +348,29 @@ function HoverCardSurface({
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
const overlayRef = useRef<View>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
measureElement(triggerRef.current),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([triggerRect, hostRect]) => {
|
||||
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [portalHostName, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor || !contentSize) return;
|
||||
const place = useCallback(() => {
|
||||
const triggerRect = readDomRect(triggerRef.current);
|
||||
const overlayRect = readDomRect(overlayRef.current);
|
||||
const content = contentRef.current as unknown as HTMLElement | null;
|
||||
if (!triggerRect || !overlayRect || !content) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
triggerRect: anchor.triggerRect,
|
||||
contentSize,
|
||||
triggerRect,
|
||||
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
placement,
|
||||
flipHorizontal: true,
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
|
||||
}, [alignment, anchor, contentSize, offset, placement]);
|
||||
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
|
||||
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
|
||||
}, [alignment, contentRef, offset, placement, triggerRef]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
useLayoutEffect(place, [place]);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
@@ -381,24 +383,26 @@ function HoverCardSurface({
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
return (
|
||||
<Portal hostName={portalHostName}>
|
||||
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
<View
|
||||
ref={overlayRef}
|
||||
pointerEvents="box-none"
|
||||
style={[styles.portalOverlay, { zIndex: layer }]}
|
||||
>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={place}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
|
||||
const PREVIEW_HEIGHT = 240;
|
||||
// Show more and show less sit under the text they open and close.
|
||||
const TRIGGER_BELOW_CONTENT = { triggerEdge: "bottom" } as const;
|
||||
// Native View still clips with "scroll", but Yoga measures its children without
|
||||
// the height cap. "hidden" caps the measured body too, hiding the expand control.
|
||||
const previewStyle: ViewStyle = {
|
||||
@@ -26,7 +29,11 @@ export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
|
||||
[],
|
||||
);
|
||||
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
|
||||
[hold],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import type { DiffState, 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,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
["status", "providers_snapshot_update"],
|
||||
() => {},
|
||||
),
|
||||
observeCheckoutDiff(cwd, compare) {
|
||||
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,
|
||||
};
|
||||
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
|
||||
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
||||
const handle = observe(
|
||||
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
||||
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
||||
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
||||
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
"subscriptionId" in message.payload &&
|
||||
message.payload.subscriptionId === subscriptionId,
|
||||
);
|
||||
if (!config.rejectCheckoutDiffSubscribe) return handle;
|
||||
return {
|
||||
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;
|
||||
return {
|
||||
...modern,
|
||||
subscribe: (observer) => {
|
||||
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
||||
return () => {};
|
||||
@@ -328,7 +373,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("reopen");
|
||||
expect(same.requestId).toBe("diff:1:ready");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
@@ -430,7 +475,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff-1",
|
||||
requestId: "diff:1:ready",
|
||||
});
|
||||
|
||||
fake.emit({
|
||||
@@ -449,7 +494,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: `subscription:${serverSubscriptionId}`,
|
||||
requestId: "diff:2:ready",
|
||||
});
|
||||
|
||||
unsubscribeObserver();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -23,14 +25,9 @@ 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 CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
||||
subscription: OwnedSubscription<DiffState>;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalsRoute {
|
||||
@@ -75,7 +72,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"];
|
||||
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
|
||||
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
|
||||
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
|
||||
}
|
||||
|
||||
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
}
|
||||
for (const [key, desired] of input.desired) {
|
||||
if (input.active.has(key)) continue;
|
||||
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
|
||||
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 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: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
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) },
|
||||
});
|
||||
},
|
||||
update: () => {},
|
||||
error: (error) => {
|
||||
if (input.active.get(key) === registration) input.active.delete(key);
|
||||
console.error("[server-data] observeCheckoutDiff failed", {
|
||||
console.error("[server-data] observeDiff failed", {
|
||||
serverId: input.serverId,
|
||||
cwd: desired.cwd,
|
||||
error,
|
||||
|
||||
@@ -46,6 +46,31 @@ function makeHost(): HostProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(host: HostProfile): HostRuntimeSnapshot {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
|
||||
const report = formatServerInfoSection({
|
||||
@@ -61,31 +86,11 @@ describe("app diagnostics report", () => {
|
||||
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
const snapshot = makeSnapshot(host);
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z");
|
||||
expect(report).toContain("direct TCP");
|
||||
expect(report).toContain("relay");
|
||||
expect(report).toContain("local socket");
|
||||
@@ -97,6 +102,58 @@ describe("app diagnostics report", () => {
|
||||
expect(report).not.toContain("tcp-password");
|
||||
});
|
||||
|
||||
test.each(["connecting", "offline", "error"] as const)(
|
||||
"does not claim a previously connected host was never online while %s",
|
||||
(connectionStatus) => {
|
||||
const host = makeHost();
|
||||
const snapshot = makeSnapshot(host);
|
||||
const report = formatHostRuntimeSection({
|
||||
host,
|
||||
snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null },
|
||||
});
|
||||
|
||||
expect(report).toContain("Last online: unknown");
|
||||
expect(report).not.toContain("Last online: never");
|
||||
},
|
||||
);
|
||||
|
||||
test("does not infer connection history when the runtime has not started", () => {
|
||||
const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null });
|
||||
|
||||
expect(report).toContain("Last online: unknown");
|
||||
});
|
||||
|
||||
test.each(["online", "error"] as const)(
|
||||
"identifies a routed connection while %s",
|
||||
(connectionStatus) => {
|
||||
const host = makeHost();
|
||||
host.connections.push({ id: "routed-origin", type: "routed", originServerId: "srv-origin" });
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "routed-origin",
|
||||
activeConnection: { type: "routed", endpoint: "srv-origin", display: "via srv-origin" },
|
||||
connectionStatus,
|
||||
client: null,
|
||||
lastError: connectionStatus === "error" ? "Host disconnected" : null,
|
||||
lastOnlineAt: null,
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map(),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("Active connection: routed");
|
||||
expect(report).toContain("Connection 5: routed, active, probe=unknown");
|
||||
expect(report).toContain(`Status: ${connectionStatus}`);
|
||||
expect(report).toContain(`Last error: ${snapshot.lastError ?? "none"}`);
|
||||
expect(report).not.toContain("srv-origin");
|
||||
},
|
||||
);
|
||||
|
||||
test("redacts saved connection secrets from collected daemon and desktop text", () => {
|
||||
const host = makeHost();
|
||||
const redacted = redactAppDiagnosticReport(
|
||||
|
||||
@@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: {
|
||||
? describeConnectionKind(snapshot.activeConnection.type)
|
||||
: "none",
|
||||
},
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" },
|
||||
{ label: "Last error", value: snapshot?.lastError ?? "none" },
|
||||
{
|
||||
label: "Agent directory",
|
||||
@@ -100,6 +100,8 @@ export function describeConnectionKind(type: HostConnection["type"] | string): s
|
||||
return "local pipe";
|
||||
case "relay":
|
||||
return "relay";
|
||||
case "routed":
|
||||
return "routed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
type DiffTreeRow,
|
||||
} from "@/git/diff-tree";
|
||||
import { FileHeader } from "@/git/file-header";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
export interface ChangedFilesTreeProps {
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
collapsedFolderPaths: string[];
|
||||
|
||||
@@ -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" ? [row.file] : [],
|
||||
row.kind === "file" ? [files[row.fileIndex]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -1094,6 +1095,7 @@ interface DiffBodyContentProps {
|
||||
emptyMessage: string;
|
||||
emptyAction: ChangesEmptyAction | null;
|
||||
children: ReactElement;
|
||||
loadingLabel: string;
|
||||
checkingRepositoryLabel: string;
|
||||
notRepositoryLabel: string;
|
||||
}
|
||||
@@ -1110,11 +1112,16 @@ function DiffBodyContent({
|
||||
emptyAction,
|
||||
children,
|
||||
checkingRepositoryLabel,
|
||||
loadingLabel,
|
||||
notRepositoryLabel,
|
||||
}: DiffBodyContentProps) {
|
||||
if (isStatusLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||
</View>
|
||||
@@ -1136,12 +1143,16 @@ function DiffBodyContent({
|
||||
}
|
||||
if (isDiffLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (diffTooLarge) {
|
||||
if (diffTooLarge && !hasChanges) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (diffErrorMessage) {
|
||||
@@ -1320,7 +1331,7 @@ function ChangesTreeRail({
|
||||
}: {
|
||||
shown: boolean;
|
||||
children: ReactElement;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1360,7 +1371,7 @@ function ChangesBody({
|
||||
children: ReactElement;
|
||||
desktopTreeVisible: boolean;
|
||||
isMobile: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1585,6 +1596,7 @@ export function ChangesSurface({
|
||||
selectBase: handleSelectBase,
|
||||
selectAll: handleSelectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
@@ -1834,6 +1846,7 @@ 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")}
|
||||
>
|
||||
@@ -1864,7 +1877,7 @@ export function ChangesSurface({
|
||||
presentation={presentation}
|
||||
desktopTreeVisible={desktopTreeVisible}
|
||||
isMobile={isMobile}
|
||||
files={files}
|
||||
files={treeFiles}
|
||||
mode={workingMode}
|
||||
onSelectFile={handleSelectTreeFile}
|
||||
treeWidth={instanceState.treeWidth}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type DiffPayload = CheckoutDiffSnapshot;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
@@ -30,6 +31,7 @@ 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 };
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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.",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
|
||||
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
|
||||
// renders. The tree renders on every form factor, consistent with the Files
|
||||
// explorer.
|
||||
//
|
||||
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
export interface DiffTreeFileNode {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
/** basename, e.g. "diff-pane.tsx" */
|
||||
name: string;
|
||||
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
|
||||
|
||||
export interface DiffTreeFileRow {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
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: ParsedDiffFile[]): DiffTreeDirNode {
|
||||
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
|
||||
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
|
||||
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
fileNameForPath,
|
||||
formatDiffCount,
|
||||
} from "@/git/file-header-presentation";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FileHeaderProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
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: ParsedDiffFile) {
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
|
||||
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: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
|
||||
return diffFileChangeKind(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { deriveCheckoutDiffResult } from "./use-diff-query";
|
||||
import { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
|
||||
function createParsedDiffFile(path: string): ParsedDiffFile {
|
||||
return {
|
||||
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
files: [],
|
||||
entries: null,
|
||||
payloadError: error,
|
||||
diffTooLarge: true,
|
||||
isLoading: false,
|
||||
@@ -68,3 +69,27 @@ 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();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
checkoutDiffComparisonError,
|
||||
deriveCheckoutDiffResult,
|
||||
type CheckoutDiffSnapshot,
|
||||
} from "./diff-snapshot";
|
||||
export { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
|
||||
|
||||
// Re-export the canonical protocol type so all consumers share one definition.
|
||||
export type { ParsedDiffFile };
|
||||
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
|
||||
const comparisonError =
|
||||
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
|
||||
const routeEnabled = Boolean(
|
||||
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
|
||||
);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
return deriveCheckoutDiffResult(
|
||||
const boundaryError =
|
||||
requiresUpdate && isConnected
|
||||
? {
|
||||
requestId: "unsupported",
|
||||
cwd,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
||||
}
|
||||
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
|
||||
: comparisonError;
|
||||
return deriveCheckoutDiffResult(
|
||||
boundaryError
|
||||
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
|
||||
: (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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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";
|
||||
@@ -44,7 +46,11 @@ export function useWorkingDiff({
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
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 currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function useWorkingDiff({
|
||||
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
payloadError: diffPayloadError,
|
||||
diffTooLarge,
|
||||
isLoading: isDiffLoading,
|
||||
@@ -71,6 +78,7 @@ export function useWorkingDiff({
|
||||
enabled: enabled && isGit,
|
||||
queryScope,
|
||||
});
|
||||
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
|
||||
const reviewDraftKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftKey({
|
||||
@@ -112,6 +120,7 @@ export function useWorkingDiff({
|
||||
selectBase,
|
||||
selectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { AgentDirectoryReplica } from "./agent-replica";
|
||||
|
||||
function payload(title: string): AgentSnapshotPayload {
|
||||
@@ -58,7 +60,7 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
|
||||
|
||||
describe("AgentDirectoryReplica", () => {
|
||||
it.each(["delta", "snapshot"] as const)(
|
||||
"does not erase freshly fetched history when the active directory removes it by %s",
|
||||
"preserves history and unsent drafts when the active directory removes an agent by %s",
|
||||
(removal) => {
|
||||
const serverId = `agent-replica-history-${removal}`;
|
||||
const store = useSessionStore.getState();
|
||||
@@ -68,6 +70,18 @@ describe("AgentDirectoryReplica", () => {
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const draftKey = buildDraftStoreKey({ serverId, agentId: "agent" });
|
||||
const draft = {
|
||||
text: "Keep this unsent draft",
|
||||
attachments: [
|
||||
{
|
||||
kind: "workspace_file" as const,
|
||||
path: "README.md",
|
||||
selection: { kind: "whole_file" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
useDraftStore.getState().saveDraftInput({ draftKey, draft });
|
||||
const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" };
|
||||
replica.submitTimelineAgent(replica.captureTimeline("agent"), archived);
|
||||
store.applyAgentTimelineResponseState(serverId, "agent", {
|
||||
@@ -93,7 +107,9 @@ describe("AgentDirectoryReplica", () => {
|
||||
status: "synced",
|
||||
items: [{ text: "server history" }],
|
||||
});
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)).toEqual(draft);
|
||||
replica.remove("agent");
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined();
|
||||
expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({
|
||||
status: "cold",
|
||||
});
|
||||
|
||||
@@ -190,9 +190,6 @@ export class AgentStoreProjection {
|
||||
agentLastActivity.delete(agentId);
|
||||
return { ...state, agentLastActivity };
|
||||
});
|
||||
useDraftStore.getState().clearDraftInput({
|
||||
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
||||
});
|
||||
const initKey = getInitKey(this.serverId, agentId);
|
||||
if (getInitDeferred(initKey)) {
|
||||
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
|
||||
@@ -201,6 +198,9 @@ export class AgentStoreProjection {
|
||||
|
||||
remove(agentId: string): void {
|
||||
this.removeFromDirectory(agentId);
|
||||
useDraftStore.getState().clearDraftInput({
|
||||
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
||||
});
|
||||
// Only entity deletion destroys the transcript. A filtered active-list
|
||||
// response can arrive after a fresh history response for an archived agent.
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
@@ -430,6 +430,20 @@ function agentPermission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
|
||||
const relayPrimary: HostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test",
|
||||
};
|
||||
|
||||
const relayAlternate: HostConnection = {
|
||||
id: "relay:relay-next.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay-next.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test",
|
||||
};
|
||||
|
||||
function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
@@ -1566,6 +1580,233 @@ describe("HostRuntimeController", () => {
|
||||
expect(switched).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the live connection while one alternate sample is re-read at active ticks", async () => {
|
||||
// Replays the recorded Android sequence: both relays probed at startup, the alternate
|
||||
// measured once, then only the active connection is re-read every ten seconds.
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
connections: [relayPrimary, relayAlternate],
|
||||
preferredConnectionId: relayPrimary.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
[relayPrimary.id]: 1053,
|
||||
[relayAlternate.id]: 251,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
const clientsAfterStart = clients.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_500);
|
||||
activeClient.heartbeatReportsRtt(376);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(clients.length).toBe(clientsAfterStart);
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
expect(controller.getSnapshot().client).toBe(activeClient);
|
||||
expect(activeClient.isDisposed()).toBe(false);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("does not hold the previous socket's RTT against a reconnected active connection", async () => {
|
||||
// Replays the recorded foreground recovery: the active client reconnects on its own while an
|
||||
// alternate probe is in flight. The recovered socket has no heartbeat RTT yet.
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
connections: [relayPrimary, relayAlternate],
|
||||
preferredConnectionId: relayPrimary.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
[relayPrimary.id]: 300,
|
||||
[relayAlternate.id]: 392,
|
||||
};
|
||||
let connectGate: Promise<void> | null = null;
|
||||
const baseDeps = makeDeps(latencies, clients);
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
...baseDeps,
|
||||
connectToDaemon: async (input) => {
|
||||
if (connectGate) await connectGate;
|
||||
return baseDeps.connectToDaemon(input);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.heartbeatReportsRtt(403);
|
||||
await vi.advanceTimersByTimeAsync(150_500);
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
|
||||
activeClient.setConnectionState({ status: "disconnected", reason: "liveness_timeout" });
|
||||
activeClient.setConnectionState({ status: "connecting", attempt: 2 });
|
||||
activeClient.heartbeatReportsRtt(null);
|
||||
latencies[relayAlternate.id] = 280;
|
||||
const alternateConnect = createDeferred<void>();
|
||||
connectGate = alternateConnect.promise;
|
||||
await vi.advanceTimersByTimeAsync(2_800);
|
||||
activeClient.setConnectionState({ status: "connected" });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
alternateConnect.resolve();
|
||||
connectGate = null;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(controller.getSnapshot().probeByConnectionId.get(relayAlternate.id)).toEqual({
|
||||
status: "available",
|
||||
latencyMs: 280,
|
||||
});
|
||||
const clientsAfterProbe = clients.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(clients.length).toBe(clientsAfterProbe);
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
expect(controller.getSnapshot().client).toBe(activeClient);
|
||||
expect(activeClient.isDisposed()).toBe(false);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("starts the switch streak over when the active socket reconnects", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 60,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
}
|
||||
|
||||
activeClient.setConnectionState({ status: "disconnected", reason: "network changed" });
|
||||
activeClient.setConnectionState({ status: "connected" });
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
}
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("ends the switch streak when a fresh probe of the alternate fails", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 60,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
}
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = new Error("relay unreachable");
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().probeByConnectionId.get("relay:relay.paseo.sh:443")).toEqual({
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
});
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
}
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("restarts the streak when a different candidate becomes the fastest fresh sample", async () => {
|
||||
useHostRuntimeClock();
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
};
|
||||
const host = makeHost({
|
||||
connections: [direct, relayPrimary, relayAlternate],
|
||||
preferredConnectionId: direct.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
[direct.id]: 15,
|
||||
[relayPrimary.id]: 60,
|
||||
[relayAlternate.id]: 70,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(direct.id);
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
|
||||
latencies[relayPrimary.id] = 30;
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(direct.id);
|
||||
}
|
||||
|
||||
latencies[relayAlternate.id] = 20;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(direct.id);
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
}
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayAlternate.id);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("exposes one snapshot with active connection and status from same source", async () => {
|
||||
const host = makeHost();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
|
||||
@@ -1063,37 +1063,46 @@ export class HostRuntimeController {
|
||||
expectedProbeVersion: requestVersion,
|
||||
});
|
||||
}
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
this.resetSwitchCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeProbe || activeProbe.status !== "available") {
|
||||
// Adaptive switching counts fresh comparisons only. The probe map keeps the last known
|
||||
// sample per connection for display and failover; a streak hit needs a candidate measured
|
||||
// in this cycle and a heartbeat RTT from the socket that is active right now. Re-reading
|
||||
// the cached map at every ten-second active tick turned one idle-probe sample into three
|
||||
// "consecutive" wins, and a reconnected socket inherited its predecessor's RTT, so a
|
||||
// working relay connection was replaced within seconds of coming online.
|
||||
const sampledCandidates = connectionsToProbe.filter(
|
||||
(connection) => connection.id !== currentActiveConnectionId,
|
||||
);
|
||||
if (sampledCandidates.length === 0) {
|
||||
// An active-only tick carries no evidence about any candidate.
|
||||
return;
|
||||
}
|
||||
const fastest =
|
||||
sampledCandidates
|
||||
.flatMap((connection) => {
|
||||
const probe = probeByConnectionId.get(connection.id);
|
||||
return probe?.status === "available"
|
||||
? [{ connectionId: connection.id, latencyMs: probe.latencyMs }]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => left.latencyMs - right.latencyMs)[0] ?? null;
|
||||
if (!fastest) {
|
||||
// Every candidate sampled this cycle failed: that breaks the streak.
|
||||
this.resetSwitchCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
const available = Array.from(probeByConnectionId.entries())
|
||||
.filter(
|
||||
(entry): entry is [string, Extract<ConnectionProbeState, { status: "available" }>] =>
|
||||
entry[1].status === "available",
|
||||
)
|
||||
.map(([connectionId, probe]) => ({
|
||||
connectionId,
|
||||
latencyMs: probe.latencyMs,
|
||||
}))
|
||||
.sort((left, right) => left.latencyMs - right.latencyMs);
|
||||
|
||||
const fastest = available[0] ?? null;
|
||||
if (!fastest || fastest.connectionId === currentActiveConnectionId) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
const activeRttMs = this.currentActiveSocketRttMs();
|
||||
if (activeRttMs === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const improvement = activeProbe.latencyMs - fastest.latencyMs;
|
||||
const improvement = activeRttMs - fastest.latencyMs;
|
||||
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
this.resetSwitchCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1105,8 +1114,7 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
if (this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
this.resetSwitchCandidate();
|
||||
await this.switchToConnection({
|
||||
connectionId: fastest.connectionId,
|
||||
expectedProbeVersion: requestVersion,
|
||||
@@ -1217,6 +1225,19 @@ export class HostRuntimeController {
|
||||
});
|
||||
}
|
||||
|
||||
// The client clears its heartbeat RTT when a socket connects, so a connected client's RTT
|
||||
// describes the socket in use right now; a reconnecting client still carries the old one.
|
||||
private currentActiveSocketRttMs(): number | null {
|
||||
const client = this.snapshot.client;
|
||||
if (!client || client.getConnectionState().status !== "connected") return null;
|
||||
return client.getLastLivenessRttMs();
|
||||
}
|
||||
|
||||
private resetSwitchCandidate(): void {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
}
|
||||
|
||||
// The active connection is probed through its own client, which may still be
|
||||
// reconnecting when the probe starts. A verdict taken then is stale if the client
|
||||
// has since connected, so the decision uses the client's current state.
|
||||
@@ -1506,6 +1527,8 @@ export class HostRuntimeController {
|
||||
return;
|
||||
}
|
||||
if (state.status === "connected") this.learnRelaySecurity(client, connection.id);
|
||||
// Switch evidence belongs to the socket it was measured against.
|
||||
if (state.status !== "connected") this.resetSwitchCandidate();
|
||||
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
|
||||
@@ -2,12 +2,17 @@ import { useCallback, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { selectWorkspaceChrome } from "./workspace-tab-layout";
|
||||
import { WorkspaceTabMeasurementScope } from "./workspace-desktop-tabs-row";
|
||||
import type { WorkspaceTabDescriptor } from "./workspace-tabs-types";
|
||||
|
||||
/**
|
||||
* Shows `children` (full header and tab rail) or `compactHeader` below the compact width.
|
||||
* Without a compact header it always shows `children`, keeping the same tree so they never remount.
|
||||
*/
|
||||
export function AdaptiveWorkspaceChrome({
|
||||
compactHeader,
|
||||
children,
|
||||
}: {
|
||||
compactHeader: ReactNode;
|
||||
compactHeader: ReactNode | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
@@ -18,12 +23,21 @@ export function AdaptiveWorkspaceChrome({
|
||||
return (
|
||||
<WorkspaceTabMeasurementScope>
|
||||
<View onLayout={measure}>
|
||||
{selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
|
||||
{compactHeader === null
|
||||
? children
|
||||
: selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
|
||||
</View>
|
||||
</WorkspaceTabMeasurementScope>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pane's tabs, as its compact tab switcher and pane menu need them. */
|
||||
export interface WorkspaceCompactHeaderTabs {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
actions: WorkspaceCompactHeaderActions;
|
||||
}
|
||||
|
||||
export interface WorkspaceCompactHeaderActions {
|
||||
pane?: {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
type WorkspaceCompactHeaderTabs,
|
||||
} from "./adaptive-workspace-chrome";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
@@ -3389,11 +3389,11 @@ function WorkspaceScreenContent({
|
||||
],
|
||||
);
|
||||
const renderWorkspaceTabSwitcher = useCallback(
|
||||
(
|
||||
headerTabs: WorkspaceTabDescriptor[],
|
||||
headerActiveTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => {
|
||||
({
|
||||
tabs: headerTabs,
|
||||
activeTabKey: headerActiveTabKey,
|
||||
actions,
|
||||
}: WorkspaceCompactHeaderTabs) => {
|
||||
return (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
paneId={actions.pane?.id}
|
||||
@@ -3434,14 +3434,11 @@ function WorkspaceScreenContent({
|
||||
handleCloseTabById,
|
||||
],
|
||||
);
|
||||
// Without a pane's tabs (above a split) the header carries workspace controls only: split panes
|
||||
// keep their tab rails.
|
||||
const renderCompactWorkspaceHeader = useCallback(
|
||||
(
|
||||
headerTabs: WorkspaceTabDescriptor[],
|
||||
headerActiveTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
actionsOnly = false,
|
||||
) => {
|
||||
const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions);
|
||||
(paneTabs: WorkspaceCompactHeaderTabs | null, actionsOnly = false) => {
|
||||
const tabSwitcher = paneTabs ? renderWorkspaceTabSwitcher(paneTabs) : null;
|
||||
return (
|
||||
<CompactWorkspaceHeader
|
||||
headerVariant={actionsOnly ? "full" : "compact"}
|
||||
@@ -3457,7 +3454,7 @@ function WorkspaceScreenContent({
|
||||
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
|
||||
>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
pane={actions.pane}
|
||||
pane={paneTabs?.actions.pane}
|
||||
onExitFocusMode={isFocusModeEnabled ? toggleFocusMode : undefined}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -3509,21 +3506,17 @@ function WorkspaceScreenContent({
|
||||
workspacePanelActive,
|
||||
],
|
||||
);
|
||||
const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, {
|
||||
closeAbove: handleCloseTabsToLeft,
|
||||
closeBelow: handleCloseTabsToRight,
|
||||
closeOthers: handleCloseOtherTabs,
|
||||
});
|
||||
const fullWorkspaceActions = renderCompactWorkspaceHeader(
|
||||
const workspaceCompactHeaderTabs = {
|
||||
tabs,
|
||||
activeTabKey,
|
||||
{
|
||||
actions: {
|
||||
closeAbove: handleCloseTabsToLeft,
|
||||
closeBelow: handleCloseTabsToRight,
|
||||
closeOthers: handleCloseOtherTabs,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs);
|
||||
const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true);
|
||||
const desktopSplitContent = useMemo(() => {
|
||||
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
|
||||
return null;
|
||||
|
||||
@@ -117,3 +117,11 @@ export const Maximize2 = StubIcon;
|
||||
export const Workflow = StubIcon;
|
||||
export const ZoomIn = StubIcon;
|
||||
export const ZoomOut = StubIcon;
|
||||
export const CheckSquare = StubIcon;
|
||||
export const Circle = StubIcon;
|
||||
export const FileSymlink = StubIcon;
|
||||
export const MessageSquareCode = StubIcon;
|
||||
export const MicVocal = StubIcon;
|
||||
export const Scissors = StubIcon;
|
||||
export const Sparkles = StubIcon;
|
||||
export const TriangleAlertIcon = StubIcon;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// react-native-web has no ToastAndroid. The toast host imports it by name for its Android
|
||||
// branch; Metro and Node tolerate the missing binding, but browser tests load native ESM,
|
||||
// which rejects the import and fails every module that reaches the toast context.
|
||||
export * from "react-native-web/dist/index.js";
|
||||
|
||||
export const ToastAndroid = {
|
||||
SHORT: 0,
|
||||
LONG: 1,
|
||||
TOP: 0,
|
||||
BOTTOM: 1,
|
||||
CENTER: 2,
|
||||
show() {},
|
||||
showWithGravity() {},
|
||||
};
|
||||
@@ -253,12 +253,12 @@ export default defineConfig({
|
||||
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
|
||||
},
|
||||
// Point to the ESM build so Vite can transform its imports and apply the
|
||||
// Point to react-native-web's ESM build so Vite can transform its imports and apply the
|
||||
// react alias below (the CJS build uses require('react') which bypasses
|
||||
// Vite alias resolution).
|
||||
// Vite alias resolution). The stub re-exports it and adds what web lacks.
|
||||
{
|
||||
find: "react-native",
|
||||
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native.mjs"),
|
||||
},
|
||||
{ find: "react", replacement: resolvePackageEntry("react") },
|
||||
{
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"prepack": "npm run build:clean",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocketServer, type WebSocket as ServerSocket } from "ws";
|
||||
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay/e2ee";
|
||||
import { DaemonClient } from "./daemon-client.js";
|
||||
import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js";
|
||||
import {
|
||||
createEncryptedTransport,
|
||||
createWebSocketTransportFactory,
|
||||
@@ -190,3 +194,103 @@ test("native drain capability survives WebSocket and encryption wrappers", async
|
||||
});
|
||||
expect(encrypted.supportsDrain).toBe(false);
|
||||
});
|
||||
|
||||
interface ObservedSocket {
|
||||
closeFrame: { code: number; reason: string } | null;
|
||||
}
|
||||
|
||||
/** A daemon endpoint that accepts sockets and never answers, so every attempt times out. */
|
||||
async function silentDaemon() {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await new Promise<void>((resolve) => server.once("listening", () => resolve()));
|
||||
const sockets: ObservedSocket[] = [];
|
||||
server.on("connection", (socket: ServerSocket) => {
|
||||
const observed: ObservedSocket = { closeFrame: null };
|
||||
sockets.push(observed);
|
||||
socket.on("close", (code, reason) => {
|
||||
observed.closeFrame = { code, reason: reason.toString() };
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing port");
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}/ws`,
|
||||
sockets,
|
||||
openSockets: () => sockets.filter((entry) => entry.closeFrame === null),
|
||||
stop: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
for (const client of server.clients) client.terminate();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Node's global WebSocket applies the WHATWG close rules Chromium and Electron apply:
|
||||
// close() accepts only 1000 or 3000-4999 and a reason of at most 123 UTF-8 bytes.
|
||||
describe("abandoned connections close their WebSocket", () => {
|
||||
test.each([
|
||||
{ name: "direct", e2ee: false },
|
||||
{ name: "relay E2EE", e2ee: true },
|
||||
])(
|
||||
"$name: a timed-out attempt sends a close frame before the client reconnects",
|
||||
async ({ e2ee }) => {
|
||||
const daemon = await silentDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: daemon.url,
|
||||
clientId: "cid_abandoned_close_test",
|
||||
webSocketFactory: defaultWebSocketFactory,
|
||||
connectTimeoutMs: 1_500,
|
||||
reconnect: { enabled: true, baseDelayMs: 50, maxDelayMs: 50 },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
...(e2ee
|
||||
? {
|
||||
e2ee: {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
try {
|
||||
void client.connect().catch(() => {});
|
||||
await vi.waitFor(() => expect(daemon.sockets.length).toBeGreaterThanOrEqual(2), {
|
||||
timeout: 4_000,
|
||||
});
|
||||
const [abandoned] = daemon.sockets;
|
||||
await vi.waitFor(() => expect(abandoned?.closeFrame).not.toBeNull(), { timeout: 500 });
|
||||
expect(abandoned?.closeFrame?.reason).toBe("Connection timed out");
|
||||
|
||||
// The newest attempt may still be connecting; aborting it closes without a frame.
|
||||
await client.close();
|
||||
await vi.waitFor(() => expect(daemon.openSockets()).toEqual([]), { timeout: 500 });
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
}
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
test("a code or reason the WebSocket API rejects still closes the socket", async () => {
|
||||
const daemon = await silentDaemon();
|
||||
try {
|
||||
const transport = createWebSocketTransportFactory(defaultWebSocketFactory)({
|
||||
url: daemon.url,
|
||||
});
|
||||
await new Promise<void>((resolve) => transport.onOpen(() => resolve()));
|
||||
const reason = `Decryption failed: ${"é".repeat(100)}`;
|
||||
|
||||
expect(() => transport.close(1011, reason)).not.toThrow();
|
||||
|
||||
await vi.waitFor(() => expect(daemon.sockets[0]?.closeFrame).not.toBeNull(), {
|
||||
timeout: 500,
|
||||
});
|
||||
const frame = daemon.sockets[0]?.closeFrame;
|
||||
expect(frame?.code).toBe(1000);
|
||||
expect(reason.startsWith(frame?.reason ?? "")).toBe(true);
|
||||
expect(Buffer.byteLength(frame?.reason ?? "")).toBe(123);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
// connect timeouts does not crash the CLI with an unhandled error event.
|
||||
const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws);
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
ws.close(toSendableCloseCode(code), toSendableCloseReason(reason));
|
||||
} finally {
|
||||
if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") {
|
||||
suppressEarlyCloseError();
|
||||
@@ -97,6 +97,32 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
};
|
||||
}
|
||||
|
||||
// WHATWG WebSocket.close throws for a code other than 1000 or 3000-4999 and for a
|
||||
// reason over 123 UTF-8 bytes, and the throw leaves the socket open: the peer keeps
|
||||
// streaming into a connection nobody reads. Browsers, Electron and Node's global
|
||||
// WebSocket enforce this; Node's `ws` accepts more. Translate once here so callers
|
||||
// keep their reasons and every runtime sends the same close frame.
|
||||
const MAX_CLOSE_REASON_BYTES = 123;
|
||||
|
||||
function toSendableCloseCode(code: number | undefined): number {
|
||||
if (code === 1000 || (code !== undefined && code >= 3000 && code <= 4999)) return code;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
function toSendableCloseReason(reason: string | undefined): string | undefined {
|
||||
if (reason === undefined) return undefined;
|
||||
const encoder = new TextEncoder();
|
||||
if (encoder.encode(reason).byteLength <= MAX_CLOSE_REASON_BYTES) return reason;
|
||||
let truncated = "";
|
||||
let bytes = 0;
|
||||
for (const character of reason) {
|
||||
bytes += encoder.encode(character).byteLength;
|
||||
if (bytes > MAX_CLOSE_REASON_BYTES) break;
|
||||
truncated += character;
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
function bindWsMessageHandler(
|
||||
ws: WebSocketLike,
|
||||
handler: (data: unknown, isBinary: boolean) => void,
|
||||
|
||||
@@ -728,6 +728,10 @@ class FakeDaemon {
|
||||
this.onClose({ reason });
|
||||
}
|
||||
|
||||
daemonSendsPong(): void {
|
||||
this.onMessage(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
|
||||
triggerError(event?: unknown): void {
|
||||
this.onError(event);
|
||||
}
|
||||
@@ -2605,7 +2609,7 @@ test("keeps default connect timeout shorter than session RPC waiters", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("foreground checks a silently broken socket through two normal heartbeat misses", async () => {
|
||||
test("foreground tears down a silently dead socket after two fresh five-second windows and reconnects at once", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
@@ -2623,14 +2627,23 @@ test("foreground checks a silently broken socket through two normal heartbeat mi
|
||||
await connection;
|
||||
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// The first silent window is followed by a second check at once, not ten seconds later.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
client.ensureConnected();
|
||||
// The reconnect is immediate: no 1.5s backoff after a foreground liveness teardown.
|
||||
expect(attempts).toBe(2);
|
||||
second.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
// The replacement runs the normal heartbeat cadence.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(second.pingTimestamps()).toEqual(["20s"]);
|
||||
expect(second.closesFromClient()).toEqual([]);
|
||||
});
|
||||
|
||||
test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => {
|
||||
@@ -2675,22 +2688,26 @@ test("chunks prevent heartbeat misses until silence causes two misses", async ()
|
||||
void client.measureLatency({ timeoutMs: 5_000 }).catch((error: unknown) => {
|
||||
measurementError = error;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// Chunks complete every four seconds for the first 36 seconds; no pong ever arrives.
|
||||
for (let index = 0; index < 9; index++) {
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
expect(measurementError).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Latency measurement timed out (5000ms)",
|
||||
}),
|
||||
);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(index === 0 ? 5_000 : 10_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
// No complete message or pong has arrived, but progress prevents misses.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
// Progress inside the foreground window returned the heartbeat to its normal cadence.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s"]);
|
||||
await vi.advanceTimersByTimeAsync(19_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s"]);
|
||||
await vi.advanceTimersByTimeAsync(24_999);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s", "65s"]);
|
||||
expect(daemon.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
});
|
||||
|
||||
@@ -2712,13 +2729,14 @@ test("foreground preserves a multi-MB transfer with its pong queued behind chunk
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(3_001);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
// No complete message or pong has arrived, but progress prevents misses.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]);
|
||||
// The first chunk landed seven seconds after foreground: the first window was silent, the
|
||||
// second saw progress, so the cycle ended without a teardown and the cadence is normal again.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "20s"]);
|
||||
daemon.finishTransfer();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
@@ -2742,13 +2760,14 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
for (let index = 0; index < 12; index++) {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
// Each completed window starts the next ping ten seconds later,
|
||||
// even though the response and all queued pongs are still in flight.
|
||||
const now = (index + 1) * 4;
|
||||
const expected = ["0s"];
|
||||
if (index >= 4) expected.push("25s");
|
||||
if (index >= 9) expected.push("50s");
|
||||
if (now >= 15) expected.push("15s");
|
||||
if (now >= 40) expected.push("40s");
|
||||
expect(daemon.pingTimestamps()).toEqual(expected);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
@@ -2758,6 +2777,393 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
});
|
||||
|
||||
function connectThroughReplacements(
|
||||
clientId: string,
|
||||
daemons: FakeDaemon[],
|
||||
options?: { reconnect?: { enabled: boolean } },
|
||||
): { client: DaemonClient; attempts: () => number } {
|
||||
let attempts = 0;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId,
|
||||
logger: noopLogger,
|
||||
...(options?.reconnect ? { reconnect: options.reconnect } : {}),
|
||||
transportFactory: () => {
|
||||
const daemon = daemons[Math.min(attempts, daemons.length - 1)]!;
|
||||
attempts += 1;
|
||||
return daemon.transport;
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
return { client, attempts: () => attempts };
|
||||
}
|
||||
|
||||
test("foreground supersedes a heartbeat that straddled the suspension without counting a miss", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-before-expiry", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
first.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(10_000); // heartbeat ping outstanding when the OS suspends JS
|
||||
await vi.advanceTimersByTimeAsync(14_990); // resume: AppState lands before the stale timer
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "24.99s"]);
|
||||
await vi.advanceTimersByTimeAsync(10); // the stale deadline passes without a miss
|
||||
await vi.advanceTimersByTimeAsync(4_990);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "24.99s", "29.99s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("foreground does not inherit a miss recorded before the app was suspended", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-after-expiry", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
first.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(25_000); // ping at 10s missed at 25s, before or during suspension
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "25s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// Two fresh windows: the old miss would have torn down here.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "25s", "30s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a slow but healthy socket survives foreground after a miss before the suspension", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-slow-pong", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
daemon.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(25_000); // one miss
|
||||
daemon.daemonAnswersPingsAfter("5.5s");
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a pong that arrives after the first foreground window keeps the connection and restores the cadence", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("5.5s");
|
||||
const { client, attempts } = connectThroughReplacements("slow-pong", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(55_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
// After the late pong the heartbeat runs ten seconds after each settled probe again.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "15.5s", "31s", "46.5s"]);
|
||||
});
|
||||
|
||||
test("a latency measurement outstanding at foreground keeps its own deadline, then the cycle runs", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("latency-then-foreground", [
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
const measurement = client.measureLatency({ timeoutMs: 1_000 });
|
||||
const measurementError = measurement.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurementError).resolves.toEqual(
|
||||
expect.objectContaining({ message: "Latency measurement timed out (1000ms)" }),
|
||||
);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "1s", "6s"]);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a shared latency probe's activity from before foreground does not satisfy the fresh check", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("stale-activity", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
void client.measureLatency({ timeoutMs: 5_000 }).catch(() => {});
|
||||
first.receiveChunk(true); // a frame arrived while the measurement was outstanding, then the socket died
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// The measurement's own deadline passed; the foreground cycle starts its first fresh window now.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a latency measurement that joined a heartbeat probe survives the foreground check", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("2s");
|
||||
const { client, attempts } = connectThroughReplacements("latency-joins-heartbeat", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
|
||||
const measurement = client.measureLatency({ timeoutMs: 5_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s"]);
|
||||
// The heartbeat's pong lands at 12s and answers the measurement that joined it.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurement).resolves.toBe(2000);
|
||||
await vi.advanceTimersByTimeAsync(19_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "22s"]);
|
||||
});
|
||||
|
||||
test("a joined latency measurement outlives a silent foreground window until its own deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("7s");
|
||||
const { client, attempts } = connectThroughReplacements("latency-outlives-window", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
|
||||
const measurement = client.measureLatency({ timeoutMs: 10_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // the first window closes silent at 16s
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s"]);
|
||||
// The heartbeat's pong lands at 17s, inside the second window and before the 20s deadline.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurement).resolves.toBe(7000);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s", "27s"]);
|
||||
});
|
||||
|
||||
// Fake timers cannot suspend: a suspension is modelled as the wall clock jumping forward while
|
||||
// the pending window timer stays queued, then firing late on resume.
|
||||
async function suspendFor(ms: number): Promise<void> {
|
||||
vi.setSystemTime(Date.now() + ms);
|
||||
}
|
||||
|
||||
test("a foreground window that spanned a second suspension is discarded when the timer fires first", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-timer", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 1 silent, window 2 outstanding
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await suspendFor(60_000);
|
||||
daemon.daemonAnswersPingsAfter("fast"); // the socket is healthy on resume
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
|
||||
client.ensureConnected({ verify: true }); // then AppState lands
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a foreground window that spanned a second suspension is discarded when AppState fires first", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-appstate", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await suspendFor(60_000);
|
||||
daemon.daemonAnswersPingsAfter("fast");
|
||||
client.ensureConnected({ verify: true }); // AppState lands before the queued timer
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a dead socket after a second suspension mid-cycle still gets two fresh windows", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-dead", [
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
first.receiveChunk(true); // a frame before the second suspension is not evidence after it
|
||||
await suspendFor(60_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // the late timer discards window 2 and starts over
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s", "15s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("repeated foreground events share one cycle and do not restart its windows", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("repeated-foreground", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
for (let elapsedSeconds = 1; elapsedSeconds <= 9; elapsedSeconds++) {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
}
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
second.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
});
|
||||
|
||||
test("a late pong from the replaced socket does not count for the replacement", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
second.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("late-pong", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(2);
|
||||
second.openConnection();
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(second.pingTimestamps()).toEqual(["10s"]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
first.daemonSendsPong();
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
expect(second.pingTimestamps()).toEqual(["10s", "15s"]);
|
||||
expect(second.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
});
|
||||
|
||||
test("closing the client in the middle of a foreground cycle stops it", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("close-mid-cycle", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await client.close();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1000, reason: "Client closed" }]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(client.getConnectionState()).toEqual({ status: "disposed" });
|
||||
});
|
||||
|
||||
test("a foreground teardown with reconnect disabled reports the timeout and stays down", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("no-reconnect", [daemon], {
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(client.getConnectionState()).toEqual({
|
||||
status: "disconnected",
|
||||
reason: "Liveness check timed out (5000ms)",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("the immediate reconnect after a foreground teardown backs off normally if it fails", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const third = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("no-storm", [first, second, third]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(2);
|
||||
second.daemonClosesWith("connection refused");
|
||||
await vi.advanceTimersByTimeAsync(1_499);
|
||||
expect(attempts()).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts()).toBe(3);
|
||||
third.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
});
|
||||
|
||||
test("an obsolete heartbeat cannot close a replacement connection", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
@@ -2887,6 +3293,18 @@ test("a heartbeat sharing a latency measurement still saves the RTT", async () =
|
||||
expect(session.lastLivenessRttMs()).toBe(2000);
|
||||
});
|
||||
|
||||
test("a measurement that joins an in-flight ping reports that ping's round trip", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
session.daemonAnswersPingsAfter("2s");
|
||||
await session.connect();
|
||||
await session.advance(11_000); // the heartbeat ping sent at 10s is still outstanding
|
||||
const measurement = session.measureLatency({ timeoutMs: 5_000 });
|
||||
await session.advance(1_000);
|
||||
await expect(measurement).resolves.toBe(2000);
|
||||
expect(session.lastLivenessRttMs()).toBe(2000);
|
||||
});
|
||||
|
||||
test("reports the round-trip time of the last successful heartbeat", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
DiffStreamRouter,
|
||||
DiffSubscriptionError,
|
||||
type DiffCompare,
|
||||
type DiffState,
|
||||
} from "./diff/index.js";
|
||||
import {
|
||||
ChunkedTransport,
|
||||
ChunkedTransportProtocolError,
|
||||
@@ -1137,6 +1143,12 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
|
||||
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
||||
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
|
||||
// Window of each check after the app returns to the foreground. A socket that silently died
|
||||
// while the app was suspended stays OPEN and accepts sends, so only inbound frames prove it.
|
||||
const FOREGROUND_LIVENESS_TIMEOUT_MS = 5_000;
|
||||
// A foreground window whose timer fires this much later than scheduled (wall clock) spanned
|
||||
// another suspension or a blocked JS thread, so its silence is no evidence either way.
|
||||
const SUSPENDED_WINDOW_SLACK_MS = 2_000;
|
||||
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
|
||||
|
||||
/** Default timeout for waiting for connection before sending queued messages */
|
||||
@@ -1299,13 +1311,27 @@ interface PingProbe {
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
startedAt: number;
|
||||
// Wall clock, which keeps running while the OS has JS suspended; perfNow may not.
|
||||
startedAtWallMs: number;
|
||||
receivedActivity: boolean;
|
||||
// Whether a timeout on this ping should be recorded as a liveness failure. Only the
|
||||
// heartbeat sets this; a latency measurement never drives teardown, even when a
|
||||
// heartbeat tick shares (dedupes onto) an in-flight measurement ping.
|
||||
drivesLivenessFailure: boolean;
|
||||
role: PingProbeRole;
|
||||
}
|
||||
|
||||
// A latency measurement waits for the next pong, not for a particular probe: the liveness
|
||||
// cycle may replace the probe the measurement joined, and a pong that answers an expired window
|
||||
// still proves the socket. It settles on a pong, a connection failure, or its own deadline.
|
||||
interface PongWaiter {
|
||||
startedAt: number;
|
||||
resolve: (rttMs: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
// Who started the probe. A "latency" probe never drives teardown, even when a heartbeat
|
||||
// tick shares (dedupes onto) it. A "heartbeat" probe runs the regular cadence. A
|
||||
// "foreground" probe belongs to the verification cycle that ensureConnected({ verify })
|
||||
// runs after the app resumes: short window, immediate second check, reconnect at once.
|
||||
type PingProbeRole = "latency" | "heartbeat" | "foreground";
|
||||
|
||||
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26.
|
||||
export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean {
|
||||
return features?.usageSources === true || features?.providerUsageList === true;
|
||||
@@ -1414,6 +1440,20 @@ 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>();
|
||||
@@ -1443,6 +1483,7 @@ export class DaemonClient {
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
|
||||
private pingProbe: PingProbe | null = null;
|
||||
private readonly pongWaiters = new Set<PongWaiter>();
|
||||
private livenessHeartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastLivenessRttMs: number | null = null;
|
||||
private consecutiveLivenessFailures = 0;
|
||||
@@ -1763,10 +1804,12 @@ 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"));
|
||||
this.rejectPingProbe(new Error("Daemon client closed"));
|
||||
this.rejectPongWaiters(new Error("Daemon client closed"));
|
||||
this.terminalStreams.clearSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
if (this.runtimeMetricsInterval) {
|
||||
@@ -1789,7 +1832,7 @@ export class DaemonClient {
|
||||
this.shouldReconnect = true;
|
||||
}
|
||||
if (this.connectionState.status === "connected") {
|
||||
if (options?.verify) this.tickLivenessHeartbeat();
|
||||
if (options?.verify) this.verifyConnectionOnForeground();
|
||||
return;
|
||||
}
|
||||
if (this.connectionState.status === "connecting") return;
|
||||
@@ -2540,43 +2583,41 @@ export class DaemonClient {
|
||||
|
||||
measureLatency(params?: { timeoutMs?: number }): Promise<number> {
|
||||
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
let deadline: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs);
|
||||
});
|
||||
return Promise.race([
|
||||
this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }),
|
||||
timeout,
|
||||
])
|
||||
.catch((error) => {
|
||||
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
||||
})
|
||||
.finally(() => clearTimeout(deadline));
|
||||
}
|
||||
|
||||
private async livenessPing(params?: { timeoutMs?: number }): Promise<number> {
|
||||
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
try {
|
||||
const rttMs = await this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: true });
|
||||
this.lastLivenessRttMs = rttMs;
|
||||
return rttMs;
|
||||
} catch (error) {
|
||||
throw toTimeoutError(error, "Liveness check", timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
private sendPingAwaitRtt(params: {
|
||||
timeoutMs: number;
|
||||
drivesLivenessFailure: boolean;
|
||||
}): Promise<number> {
|
||||
if (this.connectionState.status !== "connected" || !this.transport) {
|
||||
return Promise.reject(
|
||||
new Error(`Transport not connected (status: ${this.connectionState.status})`),
|
||||
);
|
||||
}
|
||||
const waiter: PongWaiter = { startedAt: perfNow(), resolve: () => {}, reject: () => {} };
|
||||
const pong = new Promise<number>((resolve, reject) => {
|
||||
waiter.resolve = resolve;
|
||||
waiter.reject = reject;
|
||||
});
|
||||
let deadline: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs);
|
||||
});
|
||||
this.pongWaiters.add(waiter);
|
||||
// The probe only puts a ping on the wire; its own outcome is the heartbeat's concern. A
|
||||
// measurement that joins an in-flight ping reports that ping's round trip, so the clock
|
||||
// starts at the send, not at the join.
|
||||
const probe = this.ensurePingProbe({ timeoutMs, role: "latency" });
|
||||
void probe.promise.catch(() => {});
|
||||
waiter.startedAt = probe.startedAt;
|
||||
return Promise.race([pong, timeout])
|
||||
.catch((error) => {
|
||||
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(deadline);
|
||||
this.pongWaiters.delete(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
// One ping is outstanding at a time; a caller that finds one shares it.
|
||||
private ensurePingProbe(params: { timeoutMs: number; role: PingProbeRole }): PingProbe {
|
||||
if (this.pingProbe) {
|
||||
return this.pingProbe.promise;
|
||||
return this.pingProbe;
|
||||
}
|
||||
|
||||
const startedAt = perfNow();
|
||||
@@ -2592,10 +2633,20 @@ export class DaemonClient {
|
||||
this.pingProbe = null;
|
||||
const error = new PingTimeoutError(timeoutMs);
|
||||
probe.reject(error);
|
||||
if (
|
||||
probe.role === "foreground" &&
|
||||
Date.now() - probe.startedAtWallMs > timeoutMs + SUSPENDED_WINDOW_SLACK_MS
|
||||
) {
|
||||
// The app was suspended again inside this window. Discard it, any frame it saw and
|
||||
// the misses before it; the heartbeat tick that owns this probe starts the cycle over.
|
||||
probe.receivedActivity = false;
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
return;
|
||||
}
|
||||
// Keep the deadline fixed so subsequent pings renew the daemon socket lease
|
||||
// during a long download. Progress proves life even when its pong is queued.
|
||||
if (probe.drivesLivenessFailure && !probe.receivedActivity) {
|
||||
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs));
|
||||
if (probe.role !== "latency" && !probe.receivedActivity) {
|
||||
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs), probe.role);
|
||||
}
|
||||
};
|
||||
const probe: PingProbe = {
|
||||
@@ -2605,7 +2656,8 @@ export class DaemonClient {
|
||||
timeoutHandle: setTimeout(expire, timeoutMs),
|
||||
receivedActivity: false,
|
||||
startedAt,
|
||||
drivesLivenessFailure: params.drivesLivenessFailure,
|
||||
startedAtWallMs: Date.now(),
|
||||
role: params.role,
|
||||
};
|
||||
this.pingProbe = probe;
|
||||
|
||||
@@ -2613,7 +2665,8 @@ export class DaemonClient {
|
||||
if (this.pingProbe !== probe) return;
|
||||
const sendError = error instanceof Error ? error : new Error(String(error));
|
||||
this.rejectPingProbe(sendError);
|
||||
if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError);
|
||||
this.rejectPongWaiters(sendError);
|
||||
if (probe.role !== "latency") this.recordLivenessFailure(sendError, probe.role);
|
||||
};
|
||||
try {
|
||||
void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend);
|
||||
@@ -2621,7 +2674,7 @@ export class DaemonClient {
|
||||
rejectSend(error);
|
||||
}
|
||||
|
||||
return promise;
|
||||
return probe;
|
||||
}
|
||||
|
||||
private startLivenessHeartbeat(): void {
|
||||
@@ -2648,14 +2701,55 @@ export class DaemonClient {
|
||||
}, LIVENESS_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private tickLivenessHeartbeat(): void {
|
||||
/**
|
||||
* The app came back to the foreground. Start a fresh verification cycle: two
|
||||
* FOREGROUND_LIVENESS_TIMEOUT_MS windows back to back, then teardown and an immediate
|
||||
* reconnect. Misses recorded before the app was suspended do not count, and a heartbeat
|
||||
* probe that straddled the suspension measured the suspension, not the socket, so it is
|
||||
* dropped without a miss. Latency measurements wait for the next pong, so replacing the
|
||||
* probe they joined does not fail them.
|
||||
*/
|
||||
private verifyConnectionOnForeground(): void {
|
||||
// A cycle already running keeps its deadline and its window count.
|
||||
if (this.pingProbe?.role === "foreground") return;
|
||||
if (this.pingProbe?.role === "heartbeat") {
|
||||
this.rejectPingProbe(new Error("Heartbeat superseded by foreground check"));
|
||||
}
|
||||
// A shared latency probe keeps its deadline, but only frames that arrive from now on count
|
||||
// for this check; a latency probe never reads this flag for itself.
|
||||
if (this.pingProbe) this.pingProbe.receivedActivity = false;
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
this.tickLivenessHeartbeat("foreground");
|
||||
}
|
||||
|
||||
private tickLivenessHeartbeat(check: "heartbeat" | "foreground" = "heartbeat"): void {
|
||||
this.stopLivenessHeartbeat();
|
||||
const transport = this.transport;
|
||||
const tick = this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS });
|
||||
void tick
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.transport === transport) this.scheduleNextLivenessHeartbeat();
|
||||
if (this.connectionState.status !== "connected" || !transport) return;
|
||||
const probe = this.ensurePingProbe({
|
||||
timeoutMs:
|
||||
check === "foreground" ? FOREGROUND_LIVENESS_TIMEOUT_MS : LIVENESS_HEARTBEAT_TIMEOUT_MS,
|
||||
role: check,
|
||||
});
|
||||
void probe.promise
|
||||
.then(
|
||||
(rttMs) => {
|
||||
this.lastLivenessRttMs = rttMs;
|
||||
return true;
|
||||
},
|
||||
() => probe.receivedActivity,
|
||||
)
|
||||
.then((alive) => {
|
||||
// A replaced transport, or a probe that superseded this one, owns the next step.
|
||||
if (this.transport !== transport || this.pingProbe) return;
|
||||
if (!alive && check === "foreground") {
|
||||
// Zero inbound frames in a foreground window: check again now. The second silent
|
||||
// window reaches the threshold inside recordLivenessFailure.
|
||||
this.tickLivenessHeartbeat("foreground");
|
||||
return;
|
||||
}
|
||||
this.scheduleNextLivenessHeartbeat();
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2666,7 +2760,12 @@ export class DaemonClient {
|
||||
private observe<T extends CorrelatedResponseType>(
|
||||
responseType: T,
|
||||
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
|
||||
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
|
||||
options?: {
|
||||
requestId?: string;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
validateSnapshot?: (payload: CorrelatedResponsePayload<T>) => void;
|
||||
},
|
||||
): OwnedSubscription<CorrelatedResponsePayload<T>> {
|
||||
let resetSource = false;
|
||||
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
|
||||
@@ -2692,12 +2791,16 @@ export class DaemonClient {
|
||||
requestType: message.type,
|
||||
error: payload.error,
|
||||
});
|
||||
options?.validateSnapshot?.(payload);
|
||||
accept(payload);
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
|
||||
resetSource =
|
||||
!(error instanceof DaemonRpcError) &&
|
||||
!(error instanceof DiffSubscriptionError) &&
|
||||
this.isConnected;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -4732,6 +4835,26 @@ 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 },
|
||||
@@ -7014,6 +7137,7 @@ export class DaemonClient {
|
||||
this.chunkedTransport?.close();
|
||||
this.chunkedTransport = null;
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.stopLivenessHeartbeat();
|
||||
this.cleanupTransport();
|
||||
@@ -7146,6 +7270,7 @@ export class DaemonClient {
|
||||
messageType: "pong",
|
||||
});
|
||||
this.resolvePingProbe();
|
||||
this.resolvePongWaiters();
|
||||
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
|
||||
return;
|
||||
}
|
||||
@@ -7204,6 +7329,10 @@ 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;
|
||||
@@ -7391,6 +7520,8 @@ export class DaemonClient {
|
||||
reason?: string;
|
||||
event?: string;
|
||||
reasonCode?: string;
|
||||
// Attempt now instead of after the backoff delay. Failures of that attempt back off as usual.
|
||||
immediate?: boolean;
|
||||
}): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
@@ -7404,6 +7535,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
|
||||
// Clear all pending waiters and queued sends since the connection was lost
|
||||
@@ -7420,6 +7552,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(connectionError, true);
|
||||
this.rejectPendingSendQueue(connectionError);
|
||||
this.rejectPingProbe(connectionError);
|
||||
this.rejectPongWaiters(connectionError);
|
||||
this.terminalStreams.clearSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
|
||||
@@ -7439,6 +7572,10 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input?.immediate) {
|
||||
void this.attemptConnect();
|
||||
return;
|
||||
}
|
||||
this.armReconnectTimer();
|
||||
}
|
||||
|
||||
@@ -7513,7 +7650,18 @@ export class DaemonClient {
|
||||
probe.reject(error);
|
||||
}
|
||||
|
||||
private recordLivenessFailure(error: Error): void {
|
||||
private resolvePongWaiters(): void {
|
||||
const now = perfNow();
|
||||
for (const waiter of this.pongWaiters) waiter.resolve(now - waiter.startedAt);
|
||||
this.pongWaiters.clear();
|
||||
}
|
||||
|
||||
private rejectPongWaiters(error: Error): void {
|
||||
for (const waiter of this.pongWaiters) waiter.reject(error);
|
||||
this.pongWaiters.clear();
|
||||
}
|
||||
|
||||
private recordLivenessFailure(error: Error, role: PingProbeRole): void {
|
||||
this.consecutiveLivenessFailures += 1;
|
||||
if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) {
|
||||
return;
|
||||
@@ -7525,6 +7673,8 @@ export class DaemonClient {
|
||||
reason: error.message,
|
||||
event: "LIVENESS_TIMEOUT",
|
||||
reasonCode: "liveness_timeout",
|
||||
// The foreground cycle already waited two windows; the user is looking at the app.
|
||||
immediate: role === "foreground",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7563,6 +7713,7 @@ 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,
|
||||
@@ -7839,3 +7990,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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: [] });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
@@ -1300,6 +1300,8 @@ function createGeneratedClientId(): string {
|
||||
return `paseo-sdk-${randomId}`;
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
interface WorkspaceLabelAssignment {
|
||||
client: DaemonClient;
|
||||
workspaceId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { decodeDiffFrame, DiffOpcode, type DiffFrame } from "./diff.js";
|
||||
import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js";
|
||||
import {
|
||||
decodeFileTransferFrame,
|
||||
@@ -12,12 +13,20 @@ 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);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,360 @@
|
||||
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,3 +3,4 @@ export * from "./file-transfer.js";
|
||||
export * from "./terminal.js";
|
||||
export * from "./host-route.js";
|
||||
export * from "./plugin.js";
|
||||
export * from "./diff.js";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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);
|
||||
});
|
||||
@@ -2322,6 +2322,51 @@ 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(),
|
||||
@@ -3466,6 +3511,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentDetachRequestMessageSchema,
|
||||
AgentRewindRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
DiffSubscribeRequestSchema,
|
||||
DiffUnsubscribeRequestSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
CheckoutDiffGetRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
@@ -3932,6 +3979,7 @@ 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(),
|
||||
@@ -5522,6 +5570,7 @@ 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
|
||||
@@ -7335,6 +7384,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentDeletedMessageSchema,
|
||||
AgentArchivedMessageSchema,
|
||||
CloseItemsResponseSchema,
|
||||
DiffSubscribeResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
CheckoutStatusUpdateSchema,
|
||||
CheckoutDiffGetResponseSchema,
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
"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"
|
||||
"test:e2e:ui": "vitest --ui e2e.test.ts",
|
||||
"measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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();
|
||||
}
|
||||
@@ -469,7 +469,7 @@ class TestAgentSession implements AgentSession {
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
this.interrupted = false;
|
||||
const turnId = `turn-${++this.turnIdCounter}`;
|
||||
const turnId = `turn-${this.id}-${++this.turnIdCounter}`;
|
||||
// Use setTimeout so events arrive after the caller sets up the foreground waiter
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
@@ -13487,3 +13487,35 @@ test("stops only a currently owned public background item with its current token
|
||||
expect(stopped).toEqual(["one"]);
|
||||
await manager.closeAgent(agent.id);
|
||||
});
|
||||
|
||||
test("turns keep distinct identities across an idle runtime collection and resume", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-identity-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const collect = runtimeCleanup(manager);
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const consume = async (prompt: string, clientMessageId: string) => {
|
||||
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
|
||||
}
|
||||
};
|
||||
await consume("first", "first");
|
||||
const later = new Date(Date.now() + 61 * 60_000);
|
||||
expect((await collect({ now: later })).collected).toHaveLength(1);
|
||||
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
|
||||
await consume("second", "second");
|
||||
const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows;
|
||||
const userTurnIds = rows
|
||||
.filter((row) => row.item.type === "user_message")
|
||||
.map((row) => row.turnId);
|
||||
expect(userTurnIds).toHaveLength(2);
|
||||
expect(new Set(userTurnIds).size).toBe(2);
|
||||
} finally {
|
||||
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2517,12 +2517,30 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async clearAgentAttention(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.attention.requiresAttention) {
|
||||
agent.attention = { requiresAttention: false };
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
const agent = this.agents.get(agentId);
|
||||
if (agent) {
|
||||
if (agent.attention.requiresAttention) {
|
||||
agent.attention = { requiresAttention: false };
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = this.requireRegistry();
|
||||
const record = await registry.get(agentId);
|
||||
if (!record || record.internal || record.archivedAt || record.requiresAttention !== true) {
|
||||
return;
|
||||
}
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
updatedAt: this.nextStoredUpdatedAt(record),
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
this.dispatchStoredAgentState(nextRecord);
|
||||
}
|
||||
|
||||
async markAgentUnread(agentId: string): Promise<void> {
|
||||
|
||||
@@ -690,6 +690,7 @@ export interface AgentSession {
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
/** Returns an ID unique across the agent lifetime, including resumed sessions and daemon restarts. */
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
|
||||
@@ -1206,7 +1206,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
);
|
||||
|
||||
const firstRunId = internal.autonomousTurn?.id ?? null;
|
||||
expect(firstRunId).toBe("autonomous-turn-1");
|
||||
expect(firstRunId).toEqual(expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/));
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
@@ -1360,7 +1360,9 @@ test("a main-session frame opens no turn until Claude starts one", async () => {
|
||||
// session start a turn of its own, with no prompt from the user.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
@@ -1410,7 +1412,9 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
expect(internal.turnState).toBe("autonomous");
|
||||
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
|
||||
@@ -1446,7 +1450,9 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after
|
||||
|
||||
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-2$/),
|
||||
);
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
@@ -1483,7 +1489,9 @@ test("an idle session state closes an autonomous turn that no result closed", as
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
|
||||
// Safety net: Claude says nothing at all is running any more.
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
@@ -1685,7 +1693,9 @@ test("a new turn clears an interrupt flag no result will consume", async () => {
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
|
||||
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
|
||||
await internal.routeSdkMessageFromPump(
|
||||
|
||||
@@ -1345,6 +1345,30 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps turn identities distinct across resumed session objects", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle = {
|
||||
provider: "claude" as const,
|
||||
sessionId: "saved-session",
|
||||
metadata: { cwd: process.cwd() },
|
||||
};
|
||||
const first = await client.resumeSession(handle);
|
||||
const firstTurn = await first.startTurn("first");
|
||||
await first.close();
|
||||
const resumed = await client.resumeSession(handle);
|
||||
try {
|
||||
const nextTurn = await resumed.startTurn("second");
|
||||
expect(nextTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("disables Claude hooks for internal agents only", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -2378,6 +2378,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private historySessionId: string | null = null;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private readonly turnNamespace = randomUUID();
|
||||
private nextTurnOrdinal = 1;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
@@ -3983,7 +3984,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(owner: "foreground" | "autonomous"): string {
|
||||
return `${owner}-turn-${this.nextTurnOrdinal++}`;
|
||||
return `${owner}-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
|
||||
|
||||
@@ -1359,7 +1359,7 @@ describe("Codex app-server provider", () => {
|
||||
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
|
||||
createConfig({ thinkingOptionId: undefined }),
|
||||
);
|
||||
await firstSession.startTurn("first turn");
|
||||
const firstTurn = await firstSession.startTurn("first turn");
|
||||
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
const saved = waitForNextEvent(firstSession, "thread_started");
|
||||
beforeRestart.startsTurn({ threadId: "thread-1" });
|
||||
@@ -1378,7 +1378,8 @@ describe("Codex app-server provider", () => {
|
||||
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
|
||||
thinkingOptionId: "medium",
|
||||
});
|
||||
await resumed.startTurn("turn after restart");
|
||||
const resumedTurn = await resumed.startTurn("turn after restart");
|
||||
expect(resumedTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
beforeRestart.assertNoErrors();
|
||||
afterRestart.assertNoErrors();
|
||||
@@ -4853,6 +4854,7 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const started = waitForNextEvent(session, "turn_started");
|
||||
const resultPromise = session.run("Delegate the investigation.");
|
||||
await appServer.waitForTurnStart();
|
||||
const child = waitForProviderSubagent(session, "legacy-only-child-thread");
|
||||
@@ -4870,7 +4872,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(child).resolves.toMatchObject({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
turnId: "codex-turn-0",
|
||||
turnId: (await started).turnId,
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "legacy-only-child-thread",
|
||||
@@ -4880,7 +4882,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(spawn).resolves.toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "codex-turn-0",
|
||||
turnId: (await started).turnId,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "spawn-legacy-only-child",
|
||||
|
||||
@@ -3529,6 +3529,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private startupEvents: AgentStreamEvent[] | null = null;
|
||||
private readonly turnNamespace = randomUUID();
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
@@ -5719,7 +5720,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(): string {
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
return `codex-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -32,6 +32,16 @@ import type {
|
||||
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
|
||||
const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000;
|
||||
|
||||
// Keep event correlation and turn ordering assertions exact while accepting a fresh namespace.
|
||||
function turnIdAt(events: AgentStreamEvent[], ordinal: number): string {
|
||||
const started = events.find((event) => event.type === "turn_started");
|
||||
if (!started || started.type !== "turn_started" || typeof started.turnId !== "string") {
|
||||
throw new Error("Expected a turn_started event with a turn ID");
|
||||
}
|
||||
const namespace = started.turnId.slice(0, started.turnId.lastIndexOf("-"));
|
||||
return `${namespace}-${ordinal}`;
|
||||
}
|
||||
|
||||
function tmpCwd(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
|
||||
try {
|
||||
@@ -757,6 +767,19 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps turn identities distinct for the same persisted session", async () => {
|
||||
const first = await createParentSession("ses_persisted");
|
||||
const firstTurn = await first.parent.startTurn("first");
|
||||
await first.parent.close();
|
||||
const resumed = await createParentSession("ses_persisted");
|
||||
try {
|
||||
const nextTurn = await resumed.parent.startTurn("second");
|
||||
expect(nextTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
} finally {
|
||||
await resumed.parent.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ saved: "default", variant: undefined },
|
||||
{ saved: "high", variant: "high" },
|
||||
@@ -1638,10 +1661,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
item: { type: "user_message", clientMessageId: "client-message-1" },
|
||||
});
|
||||
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1833,7 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "error",
|
||||
message: "Provider retry (attempt 1): model does not exist",
|
||||
},
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
await session.close();
|
||||
@@ -2641,11 +2664,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(
|
||||
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
||||
).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["turn_canceled", "opencode-turn-0"],
|
||||
["turn_started", "opencode-turn-1"],
|
||||
["timeline", "opencode-turn-1"],
|
||||
["turn_completed", "opencode-turn-1"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["turn_canceled", turnIdAt(events, 0)],
|
||||
["turn_started", turnIdAt(events, 1)],
|
||||
["timeline", turnIdAt(events, 1)],
|
||||
["turn_completed", turnIdAt(events, 1)],
|
||||
]);
|
||||
} finally {
|
||||
settleRetry.resolve();
|
||||
@@ -2706,7 +2729,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
item: expect.objectContaining({ text: "Wake output streams live." }),
|
||||
}),
|
||||
),
|
||||
@@ -2726,11 +2749,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
|
||||
expect.objectContaining({ type: "turn_completed", turnId: turnIdAt(events, 1) }),
|
||||
),
|
||||
);
|
||||
await expect(session.startTurn("after the wake")).resolves.toEqual({
|
||||
turnId: "opencode-turn-2",
|
||||
turnId: turnIdAt(events, 2),
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2843,13 +2866,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 7,
|
||||
outputTokens: 3,
|
||||
@@ -2858,7 +2881,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
const autonomousCompletion = events.find(
|
||||
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
|
||||
(event) => event.type === "turn_completed" && event.turnId === turnIdAt(events, 1),
|
||||
);
|
||||
expect(autonomousCompletion?.type).toBe("turn_completed");
|
||||
if (autonomousCompletion?.type === "turn_completed") {
|
||||
@@ -2867,7 +2890,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Autonomous wake completed.",
|
||||
@@ -2875,7 +2898,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
|
||||
turnId: "opencode-turn-2",
|
||||
turnId: turnIdAt(events, 2),
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2954,7 +2977,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
});
|
||||
|
||||
openCode.emitEvent({
|
||||
@@ -2962,7 +2985,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
properties: { sessionID: "ses_retried_stop" },
|
||||
});
|
||||
await expect(session.startTurn("replacement")).resolves.toEqual({
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
});
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
} finally {
|
||||
@@ -3058,7 +3081,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3767,7 +3790,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
];
|
||||
const replacement = session.startTurn("second");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
|
||||
expect(countEvents(events, "turn_canceled")).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -4009,6 +4032,9 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
return { data: true };
|
||||
};
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
await session.startTurn("first");
|
||||
const interrupt = session.interrupt();
|
||||
@@ -4022,7 +4048,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(openCode.calls.sessionStatus).toHaveLength(1);
|
||||
|
||||
releaseIdle.resolve();
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
@@ -4722,9 +4748,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
"turn_completed",
|
||||
]);
|
||||
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
turnIdAt(events, 0),
|
||||
turnIdAt(events, 0),
|
||||
turnIdAt(events, 0),
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4760,7 +4786,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
});
|
||||
});
|
||||
for (const event of assistantTurnEvents({
|
||||
@@ -4774,9 +4800,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4881,9 +4907,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
]);
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
@@ -4927,9 +4953,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
@@ -5003,7 +5029,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5118,12 +5144,12 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await streamDrained.promise;
|
||||
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
@@ -5174,7 +5200,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect.objectContaining({
|
||||
type: "permission_requested",
|
||||
request: expect.objectContaining({ id: "perm_child_external" }),
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3409,6 +3409,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private partTypes = new Map<string, string>();
|
||||
private availableModesCache: AgentMode[] | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly turnNamespace = randomUUID();
|
||||
private nextTurnOrdinal = 0;
|
||||
private turnState: OpenCodeTurnState = { status: "idle" };
|
||||
/**
|
||||
@@ -4886,7 +4887,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
private createTurnId(): string {
|
||||
this.gapRepairRevision += 1;
|
||||
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
||||
return `opencode-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
|
||||
|
||||
@@ -196,6 +196,8 @@ 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",
|
||||
@@ -443,6 +445,7 @@ 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,488 +1,253 @@
|
||||
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
|
||||
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,
|
||||
},
|
||||
forge: {
|
||||
featuresEnabled: false,
|
||||
authState: "no_remote",
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingManager() {
|
||||
const watches: Array<{
|
||||
async function withCheckout(
|
||||
run: (input: {
|
||||
cwd: string;
|
||||
onChange: () => void;
|
||||
unsubscribeCalls: number;
|
||||
resolve(): void;
|
||||
}> = [];
|
||||
const workspaceGitService = {
|
||||
getCheckoutDiff: async () => ({ diff: "", structured: [] }),
|
||||
getSnapshot: async () => createWorkspaceSnapshot(),
|
||||
|
||||
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: () => {} };
|
||||
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(),
|
||||
logger: logger as unknown as pino.Logger,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
workspaceGitService,
|
||||
workspaceGitService: {
|
||||
async requestWorkingTreeWatch(root, onChange) {
|
||||
const subscription = await observer.subscribe(root, () => onChange(), {
|
||||
ignore: [join(root, ".git")],
|
||||
});
|
||||
return {
|
||||
repoRoot: root,
|
||||
unsubscribe: async () => {
|
||||
await subscription.unsubscribe();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
return { manager, watches };
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
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),
|
||||
),
|
||||
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,
|
||||
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);
|
||||
|
||||
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" },
|
||||
},
|
||||
() => {},
|
||||
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(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
|
||||
"/tmp/repo/packages/server",
|
||||
expect.any(Function),
|
||||
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("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();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("cancels a subscription while its working tree watch is still opening", async () => {
|
||||
const { manager, watches } = createPendingManager();
|
||||
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();
|
||||
|
||||
const pendingSubscription = manager.subscribe(
|
||||
{
|
||||
cwd: "/tmp/repo/packages/server",
|
||||
compare: { mode: "uncommitted" },
|
||||
signal: abort.signal,
|
||||
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();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
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" },
|
||||
const legacy = manager.subscribe(
|
||||
{ cwd, compare: { mode: "uncommitted" }, signal: abort.signal },
|
||||
() => {
|
||||
throw new Error("Cancelled subscriber received an update");
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
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" }],
|
||||
try {
|
||||
await modern.start();
|
||||
const result = await legacy;
|
||||
expect(result.initial).toMatchObject({
|
||||
files: [],
|
||||
error: { message: "Diff subscription cancelled" },
|
||||
});
|
||||
|
||||
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 result.unsubscribe();
|
||||
} finally {
|
||||
await modern.release();
|
||||
}
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
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,
|
||||
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",
|
||||
});
|
||||
|
||||
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();
|
||||
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("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => {
|
||||
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
|
||||
const { manager, getOnWorkspaceSnapshot } = createManager({
|
||||
getCheckoutDiffImplementation: getCheckoutDiff,
|
||||
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;" })]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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,400 +1,201 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type pino from "pino";
|
||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.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 { 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 {
|
||||
private readonly workspaceGitService: CheckoutDiffWorkspace;
|
||||
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
||||
|
||||
readonly streams: DiffSubscriptions;
|
||||
private readonly paseoHome: string;
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
paseoHome: string;
|
||||
workspaceGitService: CheckoutDiffWorkspace;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "requestWorkingTreeWatch">;
|
||||
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||
}) {
|
||||
this.workspaceGitService = options.workspaceGitService;
|
||||
this.workspaceRuntimeStore = options.workspaceRuntimeStore;
|
||||
this.streams = new DiffSubscriptions(options.workspaceGitService);
|
||||
this.paseoHome = options.paseoHome;
|
||||
}
|
||||
readCommitFile(input: CommitFileRead) {
|
||||
return this.streams.readCommitFile(input);
|
||||
}
|
||||
|
||||
async read(
|
||||
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
|
||||
): Promise<CheckoutDiffSnapshotPayload> {
|
||||
return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare));
|
||||
const subscription = await this.subscribe(params, () => {});
|
||||
await subscription.unsubscribe();
|
||||
return subscription.initial;
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
params: CheckoutDiffSubscriptionRequest,
|
||||
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
||||
): Promise<CheckoutDiffSubscription> {
|
||||
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) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
isSubscribed = false;
|
||||
params.signal?.removeEventListener("abort", unsubscribe);
|
||||
this.removeListener(target, listener);
|
||||
};
|
||||
params.signal?.addEventListener("abort", unsubscribe, { once: true });
|
||||
if (params.signal?.aborted) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
unsubscribe();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRefreshForCwd(cwd: string): void {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
for (const target of this.targets.values()) {
|
||||
if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) {
|
||||
continue;
|
||||
if (!pending || frame.revision !== pending.revision) return;
|
||||
if (frame.opcode === DiffOpcode.Bodies) {
|
||||
for (const body of frame.files) bodies.set(body.path, body);
|
||||
return;
|
||||
}
|
||||
this.scheduleTargetRefresh(target);
|
||||
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);
|
||||
}
|
||||
};
|
||||
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 };
|
||||
} catch (error) {
|
||||
return { initial: { cwd, files: [], error: toCheckoutError(error) }, unsubscribe() {} };
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRefreshForCwd(cwd: string): Promise<void> {
|
||||
return this.streams.invalidateForCwd(expandTilde(cwd));
|
||||
}
|
||||
getMetrics(): CheckoutDiffMetrics {
|
||||
let checkoutDiffSubscriptionCount = 0;
|
||||
|
||||
for (const target of this.targets.values()) {
|
||||
checkoutDiffSubscriptionCount += target.listeners.size;
|
||||
}
|
||||
|
||||
const metrics = this.streams.getMetrics();
|
||||
return {
|
||||
checkoutDiffTargetCount: this.targets.size,
|
||||
checkoutDiffSubscriptionCount,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffTargetCount: metrics.targets,
|
||||
checkoutDiffSubscriptionCount: metrics.subscriptions,
|
||||
checkoutDiffWatcherCount: metrics.metadataObservers,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
return { cwd, files, error: null };
|
||||
}
|
||||
|
||||
function legacyTooLarge(cwd: string): CheckoutDiffSnapshotPayload {
|
||||
return {
|
||||
cwd,
|
||||
files: [],
|
||||
diffTooLarge: true,
|
||||
error: toCheckoutError(new Error("Diff too large to display")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ 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,
|
||||
@@ -83,6 +84,7 @@ 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,3 +293,263 @@ 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,7 +142,8 @@ 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.
|
||||
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000);
|
||||
// Stay below the full-source highlighting budget as well.
|
||||
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(3_500);
|
||||
writeFileSync(path.join(cwd, "large-a.js"), denseExpression);
|
||||
writeFileSync(path.join(cwd, "large-b.js"), denseExpression);
|
||||
|
||||
|
||||
@@ -690,6 +690,7 @@ describe("commit file diffs", () => {
|
||||
expect(result).toEqual({
|
||||
file: {
|
||||
path: filePath,
|
||||
status: "ok",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
affectsGitDiffInterpretation,
|
||||
classifyGitMetadataPath,
|
||||
type GitMetadataScope,
|
||||
} 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: (kind: "endpoints" | "interpretation") => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
const hasMutableEndpoints = input.refs.length > 0 || input.index || input.worktree;
|
||||
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 [gitDir, commonDir] = paths.stdout.split(/\r?\n/);
|
||||
if (!gitDir || !commonDir) throw new Error("Git returned incomplete metadata roots");
|
||||
const roots = new Map<string, GitMetadataScope[]>();
|
||||
for (const [directory, scope] of [
|
||||
[gitDir, "worktree"],
|
||||
[commonDir, "common"],
|
||||
] as const) {
|
||||
const root = resolve(input.cwd, directory);
|
||||
const scopes = roots.get(root) ?? [];
|
||||
scopes.push(scope);
|
||||
roots.set(root, scopes);
|
||||
}
|
||||
for (const [root, scopes] of roots) {
|
||||
const subscription = await this.observer.subscribe(
|
||||
root,
|
||||
(error, events) => {
|
||||
if (error) {
|
||||
changed("endpoints");
|
||||
polling ??= setInterval(() => changed("endpoints"), 5000);
|
||||
return;
|
||||
}
|
||||
let interpretation = false;
|
||||
const endpoints = events.some((event) => {
|
||||
const path = event.path.slice(root.length + 1).replaceAll("\\", "/");
|
||||
if (scopes.some((scope) => affectsGitDiffInterpretation(scope, path)))
|
||||
interpretation = true;
|
||||
if (!hasMutableEndpoints) return false;
|
||||
if (path === "index") return input.index;
|
||||
return scopes.some((scope) => classifyGitMetadataPath(scope, path).kind !== "ignore");
|
||||
});
|
||||
if (endpoints) changed("endpoints");
|
||||
else if (interpretation) changed("interpretation");
|
||||
},
|
||||
{ ignore: [resolve(root, "objects"), resolve(root, "logs"), resolve(root, "hooks")] },
|
||||
);
|
||||
subscriptions.push(subscription);
|
||||
}
|
||||
workingTree = await this.workspaceGit.requestWorkingTreeWatch(input.cwd, () =>
|
||||
changed(input.worktree ? "endpoints" : "interpretation"),
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ ignoreWhitespace: false, after: "export const value = 2;\n", files: ["a.ts"] },
|
||||
{ ignoreWhitespace: true, after: "export const value = 1;\n", files: [] },
|
||||
])(
|
||||
"interpretation checks ignore content edits but detect new binary attributes, whitespace=$ignoreWhitespace",
|
||||
async ({ ignoreWhitespace, after, files }) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-interpretation-check-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
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, "a.ts"), "export const value = 1;\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
const base = git("rev-parse", "HEAD");
|
||||
await writeFile(join(cwd, "a.ts"), after);
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||
const compare = {
|
||||
cwd,
|
||||
base: { kind: "rev" as const, rev: base },
|
||||
head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") },
|
||||
ignoreWhitespace,
|
||||
highlight: false,
|
||||
};
|
||||
const comparison = await listComparison(compare);
|
||||
try {
|
||||
expect(comparison.files.map((file) => file.entry.path)).toEqual(files);
|
||||
expect(await comparison.hasInterpretationChanged()).toBe(false);
|
||||
await writeFile(join(cwd, "a.ts"), "uncommitted content\n");
|
||||
expect(await comparison.hasInterpretationChanged()).toBe(false);
|
||||
await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n");
|
||||
expect(await comparison.hasInterpretationChanged()).toBe(true);
|
||||
const fresh = await listComparison(compare);
|
||||
try {
|
||||
expect(fresh.files.map((file) => file.entry)).toEqual([
|
||||
expect.objectContaining({ path: "a.ts", content: "binary" }),
|
||||
]);
|
||||
} finally {
|
||||
await fresh.close();
|
||||
}
|
||||
} finally {
|
||||
await comparison.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,431 @@
|
||||
import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js";
|
||||
import { readGitInputs } 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;
|
||||
hasInterpretationChanged(): Promise<boolean>;
|
||||
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, interpretationPaths } = 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));
|
||||
for (const file of files) interpretationPaths.add(file.entry.path);
|
||||
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
|
||||
for (const file of files)
|
||||
file.identity += JSON.stringify([
|
||||
gitInputs.identities.get(file.entry.path),
|
||||
gitInputs.identities.get(file.entry.oldPath ?? 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),
|
||||
hasInterpretationChanged: gitInputs.hasInterpretationChanged,
|
||||
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>; interpretationPaths: Set<string> }> {
|
||||
const [raw, stat] = await Promise.all([
|
||||
runGitCommand([...args.filter((arg) => arg !== "-w"), "--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>();
|
||||
const interpretationPaths = 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];
|
||||
}
|
||||
interpretationPaths.add(path);
|
||||
if (oldPath !== null) interpretationPaths.add(oldPath);
|
||||
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 && !counts.binary) 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, interpretationPaths };
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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;
|
||||
checkInterpretation: (() => Promise<boolean>) | null;
|
||||
interpretationCheck: Promise<void> | null;
|
||||
interpretationDirty: 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,
|
||||
checkInterpretation: null,
|
||||
interpretationCheck: null,
|
||||
interpretationDirty: 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",
|
||||
},
|
||||
(kind) => {
|
||||
if (kind === "interpretation") this.invalidateInterpretation(captured);
|
||||
else 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;
|
||||
await current.interpretationCheck;
|
||||
},
|
||||
};
|
||||
}
|
||||
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 invalidateInterpretation(target: Target): void {
|
||||
if (target.closed) return;
|
||||
target.interpretationDirty = true;
|
||||
target.interpretationCheck ??= this.checkInterpretation(target).finally(() => {
|
||||
target.interpretationCheck = null;
|
||||
});
|
||||
}
|
||||
private async checkInterpretation(target: Target): Promise<void> {
|
||||
do {
|
||||
target.interpretationDirty = false;
|
||||
try {
|
||||
if (!target.checkInterpretation || (await target.checkInterpretation())) {
|
||||
this.invalidate(target);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// A normal revision owns error delivery and recovery.
|
||||
this.invalidate(target);
|
||||
return;
|
||||
}
|
||||
} while (target.interpretationDirty && !target.closed);
|
||||
}
|
||||
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);
|
||||
target.checkInterpretation = comparison.hasInterpretationChanged;
|
||||
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;
|
||||
await target.interpretationCheck;
|
||||
}
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
|
||||
|
||||
interface Interpretation {
|
||||
config: string;
|
||||
attributes: Map<string, string[]>;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
// 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 readGitInputs(
|
||||
paths: string[],
|
||||
worktreeFiles: Set<string>,
|
||||
options: GitCommandOptions,
|
||||
) {
|
||||
const regular = paths.filter((path) => worktreeFiles.has(path));
|
||||
const [interpretation, blobs] = await Promise.all([
|
||||
readInterpretation(paths, options),
|
||||
regular.length
|
||||
? runGitCommand(["hash-object", "--stdin-paths"], {
|
||||
...options,
|
||||
input: regular.map(quotePath).join("\n") + "\n",
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
if (blobs?.truncated) throw new Error("Git input identity exceeded the output budget");
|
||||
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 identities = new Map(
|
||||
paths.map((path) => [
|
||||
path,
|
||||
fingerprint([
|
||||
interpretation.config,
|
||||
interpretation.attributes.get(path) ?? [],
|
||||
blobByPath.get(path) ?? null,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
identities,
|
||||
hasInterpretationChanged: interpretationCheck(paths, options.cwd, interpretation.fingerprint),
|
||||
};
|
||||
}
|
||||
|
||||
function interpretationCheck(
|
||||
paths: string[],
|
||||
cwd: string,
|
||||
baseline: string,
|
||||
): () => Promise<boolean> {
|
||||
// Retain only the input description, never a comparison's patch/source pages.
|
||||
return async () => {
|
||||
const current = await readInterpretation(paths, {
|
||||
cwd,
|
||||
envOverlay: { GIT_OPTIONAL_LOCKS: "0" },
|
||||
});
|
||||
return current.fingerprint !== baseline;
|
||||
};
|
||||
}
|
||||
|
||||
async function readInterpretation(
|
||||
paths: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<Interpretation> {
|
||||
if (!paths.length) return { config: "", attributes: new Map(), fingerprint: "" };
|
||||
const [config, result] = await Promise.all([
|
||||
runGitCommand(["config", "--null", "--list"], options),
|
||||
runGitCommand(["check-attr", "--all", "-z", "--stdin"], {
|
||||
...options,
|
||||
input: paths.join("\0") + "\0",
|
||||
}),
|
||||
]);
|
||||
if (config.truncated || result.truncated)
|
||||
throw new Error("Git input identity exceeded the output budget");
|
||||
const attributes = new Map<string, string[]>();
|
||||
const fields = result.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 = attributes.get(fields[index]) ?? [];
|
||||
values.push(JSON.stringify([fields[index + 1], fields[index + 2]]));
|
||||
attributes.set(fields[index], values);
|
||||
}
|
||||
for (const values of attributes.values()) values.sort();
|
||||
const configIdentity = createHash("sha256").update(config.stdout).digest("hex");
|
||||
return {
|
||||
config: configIdentity,
|
||||
attributes,
|
||||
fingerprint: fingerprint([
|
||||
configIdentity,
|
||||
paths.map((path) => [path, attributes.get(path) ?? []]),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprint(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).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 + '"';
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user