mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Reduce stalls when opening large diffs (#4574)
* perf(app): reduce avoidable work when opening large diffs Large snapshots were recursively reconstructed for reference stability, while native scroll worklets captured complete file models. Preserve identity at the data producers and prepare text for the visible window without rebuilding unchanged files. * test(app): await commit diff query completion without polling * chore(app): remove profiling artifacts from the source tree
This commit is contained in:
@@ -3,6 +3,7 @@ import { QueryClient, CancelledError } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages";
|
||||
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import { applyProvidersSnapshotUpdate } from "./push-router";
|
||||
import { createProviderSnapshotCache } from "./provider-snapshot-cache";
|
||||
import {
|
||||
resolveProviderIconName,
|
||||
@@ -296,3 +297,41 @@ it("keeps a replacement query when the cancelled native fetch finishes late", as
|
||||
replaceProviderSnapshotIcons(serverId, []);
|
||||
}
|
||||
});
|
||||
|
||||
describe("provider snapshot publication identity", () => {
|
||||
it("keeps the hash cache's canonical objects across a catalog change and later announcements", async () => {
|
||||
const cache = createProviderSnapshotCache(createStorage());
|
||||
const queryClient = new QueryClient();
|
||||
const serverId = "canonical-provider-snapshot";
|
||||
const client = {
|
||||
async getProvidersSnapshot() {
|
||||
throw new Error("Unexpected content fetch");
|
||||
},
|
||||
};
|
||||
const changed = {
|
||||
...snapshot,
|
||||
snapshotHash: "next-hash",
|
||||
compactSnapshot: compactProviderSnapshot([{ ...entries[0]!, label: "Updated provider" }]),
|
||||
};
|
||||
try {
|
||||
for (const body of [snapshot, changed, changed]) {
|
||||
await applyProvidersSnapshotUpdate({
|
||||
serverId,
|
||||
queryClient,
|
||||
cache,
|
||||
client,
|
||||
message: { type: "providers_snapshot_update", payload: body },
|
||||
});
|
||||
const canonical = await cache.materialize(serverId, body);
|
||||
const published = queryClient.getQueryData<GetProvidersSnapshotResponseMessage["payload"]>(
|
||||
providersSnapshotQueryKey(serverId, body.cwd),
|
||||
)!;
|
||||
expect(published.entries).toBe(canonical.entries);
|
||||
expect(published.compactSnapshot).toBe(canonical.compactSnapshot);
|
||||
expect(published.entries[0]!.models).toBe(canonical.entries[0]!.models);
|
||||
}
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function fetchProvidersSnapshot(input: {
|
||||
const body = await queryClient.fetchQuery({
|
||||
queryKey: ["providerSnapshotContent", input.serverId, hash],
|
||||
gcTime: 0,
|
||||
structuralSharing: false,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -134,6 +135,7 @@ export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
queryFn: ({ signal }) => fetchProvidersSnapshot({ ...input, signal }),
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
|
||||
@@ -146,6 +146,77 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||
}
|
||||
|
||||
describe("server data push router", () => {
|
||||
it("retains equal diff files without reconstructing changed file bodies", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "diff-sharing";
|
||||
const cwd = "/repo";
|
||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false);
|
||||
const subscriptionId = "diff-sharing";
|
||||
queryClient.getQueryCache().build(queryClient, {
|
||||
queryKey,
|
||||
queryFn: skipToken,
|
||||
meta: checkoutDiffPushRoute({
|
||||
enabled: false,
|
||||
serverId,
|
||||
cwd,
|
||||
subscriptionId,
|
||||
compare: { mode: "uncommitted" },
|
||||
}),
|
||||
});
|
||||
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
|
||||
type Payload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
const files: Payload["files"] = ["a.ts", "b.ts"].map((path) => ({
|
||||
path,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 2,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{
|
||||
type: "add",
|
||||
content: "const answer = 42",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " answer = 42", style: null },
|
||||
],
|
||||
},
|
||||
{ type: "add", content: "answer", tokens: [{ text: "answer", style: null }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
const publish = (incomingFiles: Payload["files"], requestId: string) => {
|
||||
fake.emit({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: { subscriptionId, cwd, files: incomingFiles, requestId, error: null },
|
||||
});
|
||||
return queryClient.getQueryData<Payload>(queryKey)!;
|
||||
};
|
||||
try {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("reopen");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
expect(next.files[1]).toBe(first.files[1]);
|
||||
expect(next.files[0]).toBe(changed[0]);
|
||||
expect(next.files[0]!.hunks[0]!.lines[0]!.tokens![0]!.style).toBe("variable");
|
||||
expect(publish([], "deleted").files).toEqual([]);
|
||||
} finally {
|
||||
unmount();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes provider snapshot and daemon config payloads until detached", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
@@ -203,6 +204,7 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
retry: false,
|
||||
queryFn: ({ signal }) => {
|
||||
const incoming = announcement;
|
||||
@@ -510,6 +512,7 @@ function setCheckoutDiffPayload(input: {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
query.setOptions({ ...query.options, structuralSharing: shareCheckoutDiff });
|
||||
input.queryClient.setQueryData<CheckoutDiffCachePayload>(query.queryKey, input.payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> =
|
||||
> & {
|
||||
dataShape: "list" | "value";
|
||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||
staleTimeMs: number;
|
||||
};
|
||||
} & (
|
||||
| { staleTimeMs: number; immutableWhen?: never }
|
||||
| { staleTimeMs?: never; immutableWhen: (data: TQueryFnData) => boolean }
|
||||
);
|
||||
|
||||
export function useReplicaQuery<
|
||||
TQueryFnData,
|
||||
@@ -88,7 +90,7 @@ function replicaQueryOptions<
|
||||
};
|
||||
}
|
||||
|
||||
function fetchQueryOptions<
|
||||
export function fetchQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
@@ -96,11 +98,11 @@ function fetchQueryOptions<
|
||||
>(
|
||||
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
||||
if (!Number.isFinite(input.staleTimeMs)) {
|
||||
if (!input.immutableWhen && !Number.isFinite(input.staleTimeMs)) {
|
||||
throw new Error("Fetch queries must declare a finite staleTimeMs.");
|
||||
}
|
||||
|
||||
const { dataShape, meta, staleTimeMs, ...options } = input;
|
||||
const { dataShape, meta, staleTimeMs, immutableWhen, ...options } = input;
|
||||
return {
|
||||
...options,
|
||||
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
|
||||
@@ -111,7 +113,10 @@ function fetchQueryOptions<
|
||||
dataShape,
|
||||
},
|
||||
},
|
||||
refetchOnMount: "always",
|
||||
staleTime: staleTimeMs,
|
||||
refetchOnMount: immutableWhen ? true : "always",
|
||||
staleTime: immutableWhen
|
||||
? (query) =>
|
||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||
: staleTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import { commitFileDiffQueryOptions } from "./commit-file-diff-query";
|
||||
|
||||
const file = { path: "a.ts", isNew: true, isDeleted: false, additions: 0, deletions: 0, hunks: [] };
|
||||
|
||||
describe("commit file diff fetching", () => {
|
||||
it("reuses a cached commit body on remount and fetches on explicit refresh", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
let calls = 0;
|
||||
const options = fetchQueryOptions(
|
||||
commitFileDiffQueryOptions({
|
||||
serverId: "host",
|
||||
cwd: "/repo",
|
||||
sha: "abc",
|
||||
path: "a.ts",
|
||||
enabled: true,
|
||||
client: {
|
||||
async getCommitFileDiff() {
|
||||
calls++;
|
||||
return { file };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
options.queryKey,
|
||||
{ file },
|
||||
{ updatedAt: Date.now() - 24 * 60 * 60 * 1000 },
|
||||
);
|
||||
const observer = new QueryObserver(queryClient, options);
|
||||
const detach = observer.subscribe(() => {});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(calls).toBe(0);
|
||||
expect(observer.getCurrentResult().data?.file).toBe(file);
|
||||
await observer.refetch();
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
detach();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a cached null result when reopened", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const options = fetchQueryOptions(
|
||||
commitFileDiffQueryOptions({
|
||||
serverId: "host",
|
||||
cwd: "/repo",
|
||||
sha: "abc",
|
||||
path: "a.ts",
|
||||
enabled: true,
|
||||
client: {
|
||||
async getCommitFileDiff() {
|
||||
return { file };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.setQueryData(options.queryKey, { file: null });
|
||||
const observer = new QueryObserver(queryClient, options);
|
||||
const settled = Promise.withResolvers<void>();
|
||||
const detach = observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle") settled.resolve();
|
||||
});
|
||||
try {
|
||||
await settled.promise;
|
||||
expect(observer.getCurrentResult().data?.file).toEqual(file);
|
||||
} finally {
|
||||
detach();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "./query-keys";
|
||||
import { shareCommitFileDiff } from "./diff-sharing";
|
||||
|
||||
export function commitFileDiffQueryOptions(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
sha: string;
|
||||
path: string;
|
||||
client: Pick<DaemonClient, "getCommitFileDiff"> | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
return {
|
||||
queryKey: checkoutCommitFileDiffQueryKey(input.serverId, input.cwd, input.sha, input.path),
|
||||
queryFn: () => {
|
||||
if (!input.client) throw new Error("Host disconnected");
|
||||
return input.client.getCommitFileDiff(input.cwd, input.sha, input.path);
|
||||
},
|
||||
enabled: input.enabled,
|
||||
gcTime: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
// Only successful file bodies are immutable. Null/error results can recover.
|
||||
immutableWhen: (data: Awaited<ReturnType<DaemonClient["getCommitFileDiff"]>>) =>
|
||||
data.file !== null,
|
||||
dataShape: "value" as const,
|
||||
refetchOnWindowFocus: false,
|
||||
structuralSharing: shareCommitFileDiff,
|
||||
};
|
||||
}
|
||||
@@ -26,9 +26,10 @@ export function HorizontalScroll({
|
||||
const scrollId = useId();
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const edgeRef = useRef(true);
|
||||
const path = file.path;
|
||||
const handler = useAnimatedScrollHandler({
|
||||
onScroll(event) {
|
||||
offsets.value = { ...offsets.value, [file.path]: event.contentOffset.x };
|
||||
offsets.value = { ...offsets.value, [path]: event.contentOffset.x };
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -71,6 +71,55 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
}
|
||||
|
||||
describe("diff document model", () => {
|
||||
it("materializes only visible rows of a large file while preserving full horizontal extent", () => {
|
||||
const source = file("large.ts");
|
||||
source.hunks[0]!.lines = Array.from({ length: 1000 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: index === 900 ? "a very wide offscreen line ".repeat(20) : `line ${index}`,
|
||||
tokens: [{ text: `line ${index}`, style: null }],
|
||||
}));
|
||||
let advancedCharacters = 0;
|
||||
const measureText: TextMeasurer = {
|
||||
measure: (text) => text.length * 10,
|
||||
measureAdvances(graphemes) {
|
||||
advancedCharacters += graphemes.join("").length;
|
||||
return graphemes.map((_, index) => (index + 1) * 10);
|
||||
},
|
||||
};
|
||||
const base = input({ files: [source], wrapLines: false, viewportWidth: 300, measureText });
|
||||
const eager = buildDiffDocumentModel(base);
|
||||
advancedCharacters = 0;
|
||||
const first = buildDiffDocumentModel({
|
||||
...base,
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
|
||||
});
|
||||
expect(measuredFragments(first, source.path)).toBe(3);
|
||||
expect(advancedCharacters).toBeLessThan(100);
|
||||
expect(first.height).toBe(eager.height);
|
||||
expect(first.files[0]!.contentWidth).toBe(eager.files[0]!.contentWidth);
|
||||
const priorCells = firstLineCells(first, source.path);
|
||||
advancedCharacters = 0;
|
||||
const next = buildDiffDocumentModel({
|
||||
...base,
|
||||
reuseFrom: [first],
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT + 900 * 18, height: 54 },
|
||||
});
|
||||
expect(firstLineCells(next, source.path)).toBe(priorCells);
|
||||
expect(next.rows[900]).toMatchObject({
|
||||
kind: "line",
|
||||
cells: [{ fragments: [{ text: source.hunks[0]!.lines[900]!.content }] }],
|
||||
});
|
||||
expect(advancedCharacters).toBeLessThan(600);
|
||||
expect(next.height).toBe(eager.height);
|
||||
advancedCharacters = 0;
|
||||
buildDiffDocumentModel({
|
||||
...base,
|
||||
reuseFrom: [next],
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
|
||||
});
|
||||
expect(advancedCharacters).toBe(0);
|
||||
});
|
||||
|
||||
it("does not imperatively scroll for a no-op relayout", () => {
|
||||
expect(shouldApplyRelayoutScroll(320, 320)).toBe(false);
|
||||
expect(shouldApplyRelayoutScroll(320, 320.4)).toBe(false);
|
||||
|
||||
@@ -157,21 +157,26 @@ function appendReusableFileRows(candidate: {
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
candidate.file.rowStart === candidate.rows.length;
|
||||
if (retainsRowGeometry) {
|
||||
candidate.rows.push(...reusedRows);
|
||||
return {
|
||||
bottom: candidate.bodyTop + candidate.file.bodyHeight,
|
||||
maximumHorizontalOverflow: Math.max(
|
||||
0,
|
||||
candidate.file.contentWidth - candidate.input.viewportWidth,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let top = candidate.bodyTop;
|
||||
for (const reusedRow of reusedRows) {
|
||||
const nextRow = repositionReusedRow(reusedRow, candidate, top);
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
top += nextRow.height;
|
||||
}
|
||||
@@ -232,71 +237,45 @@ function appendNewFileRows(candidate: {
|
||||
};
|
||||
}
|
||||
|
||||
const deferredLines =
|
||||
candidate.input.materializationWindow && !candidate.input.wrapLines
|
||||
? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input)
|
||||
: null;
|
||||
const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0;
|
||||
const shouldMaterialize =
|
||||
deferredLines === null ||
|
||||
intersectsMaterializationWindow(
|
||||
candidate.input,
|
||||
candidate.fileTop,
|
||||
candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT,
|
||||
);
|
||||
return shouldMaterialize
|
||||
? appendMeasuredFileRows(candidate)
|
||||
: appendGeometryFileRows(candidate, deferredLines);
|
||||
}
|
||||
|
||||
function appendGeometryFileRows(
|
||||
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||
lines: readonly GeometryLine[],
|
||||
): FileRowsResult {
|
||||
let top = candidate.bodyTop;
|
||||
for (const line of lines) {
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
fileIndex: candidate.fileIndex,
|
||||
path: candidate.file.path,
|
||||
top,
|
||||
height: line.height,
|
||||
cells: line.cells,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += line.height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 };
|
||||
}
|
||||
|
||||
function appendMeasuredFileRows(
|
||||
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||
): FileRowsResult {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
lines.reduce((height, line) => height + line.height, 0) +
|
||||
DIFF_BODY_BORDER_HEIGHT;
|
||||
const measureWidth =
|
||||
!candidate.input.wrapLines &&
|
||||
intersectsMaterializationWindow(candidate.input, candidate.fileTop, fileBottom);
|
||||
let top = candidate.bodyTop;
|
||||
let maximumHorizontalOverflow = 0;
|
||||
for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) {
|
||||
const columnCount = sourceCells.length;
|
||||
const columnWidth = candidate.input.viewportWidth / columnCount;
|
||||
const availableWidth = Math.max(
|
||||
candidate.input.measureText.measure("M"),
|
||||
columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
const cells = sourceCells.map((source) => {
|
||||
if (!source) return null;
|
||||
const cell = measureCell({ source, availableWidth, input: candidate.input });
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
||||
);
|
||||
return cell;
|
||||
}) as DiffLineRow["cells"];
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
const columnWidth = candidate.input.viewportWidth / cells.length;
|
||||
if (measureWidth) {
|
||||
for (const cell of cells) {
|
||||
if (!cell) continue;
|
||||
const width =
|
||||
cell.fragments.length > 0
|
||||
? measureCellWidth(cell)
|
||||
: candidate.input.measureText.measure(displayLineText(cell.content));
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + width - columnWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
const textHeight = Math.max(
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||
);
|
||||
const reviewHeight = reviewHeightForCells(cells, candidate.input);
|
||||
const height = textHeight + reviewHeight;
|
||||
const height = textHeight + line.reviewHeight;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -305,13 +284,46 @@ function appendMeasuredFileRows(
|
||||
top,
|
||||
height,
|
||||
cells,
|
||||
reviewHeight,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
|
||||
}
|
||||
|
||||
function materializeCells(
|
||||
cells: DiffLineRow["cells"],
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
gutterWidth: number,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
): DiffLineRow["cells"] {
|
||||
const availableWidth = Math.max(
|
||||
input.measureText.measure("M"),
|
||||
input.viewportWidth / cells.length - gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
return cells.map((cell) => {
|
||||
if (!cell || cell.fragments.length > 0) return cell;
|
||||
const sourceLine =
|
||||
file.hunks[cell.sourceIdentity.hunkIndex]!.lines[cell.sourceIdentity.lineIndex]!;
|
||||
return measureCell({
|
||||
source: {
|
||||
...cell,
|
||||
tokenText: cell.type === "header" ? [] : compactHighlightTokens(sourceLine.tokens ?? []),
|
||||
},
|
||||
availableWidth,
|
||||
input,
|
||||
});
|
||||
}) as DiffLineRow["cells"];
|
||||
}
|
||||
|
||||
function displayLineText(text: string): string {
|
||||
return text.includes("\t")
|
||||
? segmentGraphemes(text)
|
||||
.map((segment) => segment.text)
|
||||
.join("")
|
||||
: text;
|
||||
}
|
||||
|
||||
export function reviewGeometryKey(
|
||||
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
|
||||
): string {
|
||||
@@ -334,7 +346,11 @@ export function retainReusableModels(
|
||||
previous: readonly DiffDocumentModel[] | undefined,
|
||||
next: DiffDocumentModel,
|
||||
): DiffDocumentModel[] {
|
||||
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
||||
const sources = new Set(next.files.map((file) => file.file));
|
||||
const compatible = previous?.filter((model) =>
|
||||
model.files.every((file) => sources.has(file.file)),
|
||||
);
|
||||
const fullest = [...(compatible ?? []), next].reduce((best, candidate) =>
|
||||
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
|
||||
);
|
||||
return fullest === next ? [next] : [fullest, next];
|
||||
@@ -437,16 +453,17 @@ function intersectsMaterializationWindow(
|
||||
|
||||
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
|
||||
if (file.isCollapsed) return false;
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).some((row) => {
|
||||
if (row.kind !== "line") return true;
|
||||
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
|
||||
return row.cells.some((cell) => cell && cell.fragments.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function indexReusableModels(
|
||||
models: readonly DiffDocumentModel[] | undefined,
|
||||
): ReusableModelIndex[] {
|
||||
return (models ?? []).map((model) => {
|
||||
// The latest model includes text prepared by later scroll windows.
|
||||
return (models ?? []).toReversed().map((model) => {
|
||||
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
|
||||
const measuredFiles = new Set<DiffFileSection>();
|
||||
for (const file of model.files) {
|
||||
|
||||
@@ -110,7 +110,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(() => {
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
viewport.width,
|
||||
@@ -172,8 +171,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[family, props.palette, typography.lineHeight, typography.size],
|
||||
);
|
||||
const textLayout = useMemo(
|
||||
() => prepareNativeTextLayout(textLayoutStore, model),
|
||||
[model, textLayoutStore],
|
||||
() =>
|
||||
prepareNativeTextLayout(
|
||||
textLayoutStore,
|
||||
model,
|
||||
diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
),
|
||||
[model, textLayoutStore, fileWindowTop, viewport.height],
|
||||
);
|
||||
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
||||
const headerTextLayout = useMemo(
|
||||
@@ -386,11 +390,12 @@ function NativeCanvasFileHeader({
|
||||
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
|
||||
[file, paints, textLayout, viewportWidth],
|
||||
);
|
||||
const { top, bottom, headerHeight } = file;
|
||||
const stickyStyle = useAnimatedStyle(() => {
|
||||
const pinOffset = Math.max(0, scrollTop.value - file.top);
|
||||
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
|
||||
const pinOffset = Math.max(0, scrollTop.value - top);
|
||||
const maximumPinOffset = Math.max(0, bottom - headerHeight - top);
|
||||
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
|
||||
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
|
||||
}, [bottom, headerHeight, top, scrollTop]);
|
||||
const style = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("cached ASCII text metrics", () => {
|
||||
|
||||
expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]);
|
||||
expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]);
|
||||
expect(metrics.measure("abba")).toBe(40);
|
||||
expect(measured).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface PrimaryTextFace {
|
||||
}
|
||||
|
||||
export interface CachedAsciiTextMetrics {
|
||||
/** Whole-line width after checking glyph coverage and excluding shaping. */
|
||||
measure(text: string): number;
|
||||
hasEveryGlyph(text: string): boolean;
|
||||
measureAdvances(graphemes: readonly string[]): number[];
|
||||
}
|
||||
@@ -97,24 +99,39 @@ export function advancesFor(measurer: TextMeasurer): (graphemes: readonly string
|
||||
export function createAdditiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return additiveAdvances(cachedWidths(measure));
|
||||
}
|
||||
|
||||
function cachedWidths(measure: (text: string) => number): (text: string) => number {
|
||||
const widths = new Map<string, number>();
|
||||
return (text) => {
|
||||
let width = widths.get(text);
|
||||
if (width === undefined) {
|
||||
width = measure(text);
|
||||
widths.set(text, width);
|
||||
}
|
||||
return width;
|
||||
};
|
||||
}
|
||||
|
||||
function additiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return (graphemes) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
width = measure(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
advance += width;
|
||||
return advance;
|
||||
});
|
||||
return graphemes.map((grapheme) => (advance += measure(grapheme)));
|
||||
};
|
||||
}
|
||||
|
||||
export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics {
|
||||
const glyphCoverage = new Map<string, boolean>();
|
||||
const measure = cachedWidths((text) => primary.measure(text));
|
||||
return {
|
||||
measure(text) {
|
||||
let width = 0;
|
||||
for (const character of text) width += measure(character);
|
||||
return width;
|
||||
},
|
||||
hasEveryGlyph(text) {
|
||||
for (const character of text) {
|
||||
let hasGlyph = glyphCoverage.get(character);
|
||||
@@ -126,7 +143,7 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs
|
||||
}
|
||||
return true;
|
||||
},
|
||||
measureAdvances: createAdditiveAdvances((text) => primary.measure(text)),
|
||||
measureAdvances: additiveAdvances(measure),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
type SkFont,
|
||||
type SkParagraph,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import { fragmentTextForRange, visibleRowRange } from "./model";
|
||||
import { codeTextColor } from "./palette";
|
||||
import {
|
||||
createCachedAsciiTextMetrics,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createFallbackAwareTextMeasurer,
|
||||
requiresShaping,
|
||||
type CachedAsciiTextMetrics,
|
||||
} from "./text-measurement";
|
||||
@@ -86,7 +85,8 @@ export function shapeNativeHeaderText(input: {
|
||||
export interface NativeTextLayoutStore {
|
||||
font: SkFont;
|
||||
asciiMetrics: CachedAsciiTextMetrics;
|
||||
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
||||
paragraphsByCell: Map<DiffCell, Array<SkParagraph | null>>;
|
||||
textStyles: Map<string, ReturnType<typeof textStyle>>;
|
||||
ownedParagraphs: Set<SkParagraph>;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
@@ -97,6 +97,8 @@ export interface NativeTextLayoutStore {
|
||||
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
|
||||
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
|
||||
layout.ownedParagraphs.clear();
|
||||
layout.paragraphsByCell.clear();
|
||||
layout.textStyles.clear();
|
||||
layout.font.dispose();
|
||||
}
|
||||
|
||||
@@ -114,7 +116,8 @@ export function createNativeTextLayoutStore(input: {
|
||||
glyphIds: (text) => font.getGlyphIDs(text),
|
||||
measure: (text) => font.getTextWidth(text),
|
||||
}),
|
||||
paragraphsByCell: new WeakMap(),
|
||||
paragraphsByCell: new Map(),
|
||||
textStyles: new Map(),
|
||||
ownedParagraphs: new Set(),
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
@@ -126,30 +129,64 @@ export function createNativeTextLayoutStore(input: {
|
||||
export function prepareNativeTextLayout(
|
||||
store: NativeTextLayoutStore,
|
||||
model: DiffDocumentModel,
|
||||
window?: { top: number; height: number },
|
||||
): NativeTextLayout {
|
||||
const paragraphs = model.rows.map((row) => {
|
||||
if (row.kind !== "line") return [];
|
||||
return row.cells.map((cell) => {
|
||||
const range = window
|
||||
? visibleRowRange(model.rows, window.top, window.height)
|
||||
: { start: 0, end: model.rows.length };
|
||||
const retainedCells = new Set<DiffCell>();
|
||||
const retainedParagraphs = new Set<SkParagraph>();
|
||||
const paragraphs: NativeTextLayout["paragraphs"] = [];
|
||||
paragraphs.length = model.rows.length;
|
||||
for (let index = range.start; index < range.end; index++) {
|
||||
const row = model.rows[index]!;
|
||||
if (row.kind !== "line") continue;
|
||||
paragraphs[index] = row.cells.map((cell) => {
|
||||
if (!cell) return [];
|
||||
retainedCells.add(cell);
|
||||
const cached = store.paragraphsByCell.get(cell);
|
||||
if (cached) return cached;
|
||||
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) return null;
|
||||
const paragraph = createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
});
|
||||
const start = window ? Math.max(0, Math.floor((window.top - row.top) / model.lineHeight)) : 0;
|
||||
const end = window
|
||||
? Math.min(
|
||||
cell.fragments.length,
|
||||
Math.ceil((window.top + window.height - row.top) / model.lineHeight),
|
||||
)
|
||||
: cell.fragments.length;
|
||||
const cellParagraphs: Array<SkParagraph | null> = [];
|
||||
cellParagraphs.length = cell.fragments.length;
|
||||
for (let fragmentIndex = start; fragmentIndex < end; fragmentIndex++) {
|
||||
const fragment = cell.fragments[fragmentIndex]!;
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) {
|
||||
cellParagraphs[fragmentIndex] = null;
|
||||
continue;
|
||||
}
|
||||
const paragraph =
|
||||
cached?.[fragmentIndex] ??
|
||||
createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
textStyles: store.textStyles,
|
||||
});
|
||||
store.ownedParagraphs.add(paragraph);
|
||||
return paragraph;
|
||||
});
|
||||
retainedParagraphs.add(paragraph);
|
||||
cellParagraphs[fragmentIndex] = paragraph;
|
||||
}
|
||||
store.paragraphsByCell.set(cell, cellParagraphs);
|
||||
return cellParagraphs;
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const paragraph of store.ownedParagraphs) {
|
||||
if (retainedParagraphs.has(paragraph)) continue;
|
||||
paragraph.dispose();
|
||||
store.ownedParagraphs.delete(paragraph);
|
||||
}
|
||||
for (const cell of store.paragraphsByCell.keys()) {
|
||||
if (!retainedCells.has(cell)) store.paragraphsByCell.delete(cell);
|
||||
}
|
||||
return { font: store.font, paragraphs };
|
||||
}
|
||||
|
||||
@@ -164,23 +201,25 @@ export function createNativeTextMeasurer(input: {
|
||||
measure: (text: string) => font.getTextWidth(text),
|
||||
};
|
||||
const asciiMetrics = createCachedAsciiTextMetrics(primary);
|
||||
const fallbackAware = createFallbackAwareTextMeasurer({
|
||||
primary,
|
||||
measureWithSystemFallback(text) {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
},
|
||||
});
|
||||
const black = Skia.Color("black");
|
||||
const measureParagraph = (text: string) => {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: black,
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
};
|
||||
return {
|
||||
...fallbackAware,
|
||||
measure(text) {
|
||||
return requiresRetainedParagraph(text, asciiMetrics)
|
||||
? measureParagraph(text)
|
||||
: asciiMetrics.measure(text);
|
||||
},
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics),
|
||||
measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes),
|
||||
@@ -190,7 +229,7 @@ export function createNativeTextMeasurer(input: {
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
color: black,
|
||||
});
|
||||
let end = 0;
|
||||
const advances = graphemes.map((grapheme) => {
|
||||
@@ -219,23 +258,21 @@ function createFragmentParagraph(input: {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
textStyles: NativeTextLayoutStore["textStyles"];
|
||||
}): SkParagraph {
|
||||
const builder = Skia.ParagraphBuilder.Make(
|
||||
paragraphStyle(input.families, input.fontSize, input.lineHeight),
|
||||
);
|
||||
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||
const color = codeTextColor(input.cell, input.palette);
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, color))
|
||||
.addText(input.fragment.text)
|
||||
.pop();
|
||||
builder.pushStyle(retainedTextStyle(input, color)).addText(input.fragment.text).pop();
|
||||
} else {
|
||||
for (const run of input.cell.tokens) {
|
||||
const start = Math.max(input.fragment.start, run.start);
|
||||
const end = Math.min(input.fragment.end, run.end);
|
||||
if (end <= start) continue;
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, run.color))
|
||||
.pushStyle(retainedTextStyle(input, run.color))
|
||||
.addText(fragmentTextForRange(input.fragment, start, end))
|
||||
.pop();
|
||||
}
|
||||
@@ -281,6 +318,18 @@ function paragraphStyle(families: string[], fontSize: number, lineHeight: number
|
||||
};
|
||||
}
|
||||
|
||||
function retainedTextStyle(
|
||||
input: { families: string[]; fontSize: number; textStyles: NativeTextLayoutStore["textStyles"] },
|
||||
color: string,
|
||||
) {
|
||||
let style = input.textStyles.get(color);
|
||||
if (!style) {
|
||||
style = textStyle(input.families, input.fontSize, color);
|
||||
input.textStyles.set(color, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function textStyle(families: string[], fontSize: number, color: string) {
|
||||
return { fontFamilies: families, fontSize, color: Skia.Color(color) };
|
||||
}
|
||||
|
||||
@@ -78,6 +78,40 @@ function modelInput(
|
||||
}
|
||||
|
||||
describe("diff document workspace cache", () => {
|
||||
it("keeps text prepared in each visited window of the same file", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: `const value${index} = ${index};`,
|
||||
}));
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const input = modelInput([source], measureText);
|
||||
for (const top of [30, 300, 600]) {
|
||||
cache.buildModel({ ...input, materializationWindow: { top, height: 54 } });
|
||||
}
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, materializationWindow: { top: 300, height: 54 } });
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
const files = [diffFile(), { ...diffFile(), path: "b.ts" }];
|
||||
const first = cache.buildModel(modelInput(files, measureText));
|
||||
const replacement = structuredClone(files[0]!);
|
||||
replacement.hunks[0]!.lines[0]!.content = "changed text";
|
||||
const next = cache.buildModel(modelInput([replacement, files[1]!], measureText));
|
||||
const oldRow = first.rows[first.files[1]!.rowStart]!;
|
||||
const nextRow = next.rows[next.files[1]!.rowStart]!;
|
||||
expect(oldRow.kind).toBe("line");
|
||||
expect(nextRow.kind).toBe("line");
|
||||
if (oldRow.kind !== "line" || nextRow.kind !== "line") throw new Error("Missing file text");
|
||||
expect(nextRow.cells).toBe(oldRow.cells);
|
||||
expect(next.files[0]!.file).toBe(replacement);
|
||||
});
|
||||
|
||||
it("reuses measured rows until a model-building input changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const files = [diffFile()];
|
||||
@@ -115,7 +149,7 @@ describe("diff document workspace cache", () => {
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, files: [...files] });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds retained geometry variants for one diff payload", () => {
|
||||
@@ -152,7 +186,7 @@ describe("diff document workspace cache", () => {
|
||||
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const first = cache.buildModel(
|
||||
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
|
||||
modelInput(files, measureText, { materializationWindow: { top: 30, height: 20 } }),
|
||||
);
|
||||
const firstCalls = stats.calls;
|
||||
const secondFile = first.files[1]!;
|
||||
|
||||
@@ -13,6 +13,7 @@ const MAX_MODEL_VARIANTS = 4;
|
||||
const MAX_TYPOGRAPHY_RESOURCES = 4;
|
||||
|
||||
interface ModelVariant {
|
||||
measureText: TextMeasurer;
|
||||
key: string;
|
||||
exactKey: string;
|
||||
model: DiffDocumentModel;
|
||||
@@ -38,23 +39,29 @@ export interface DiffDocumentWorkspaceCache {
|
||||
}
|
||||
|
||||
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], ModelVariant[]>();
|
||||
let variants: ModelVariant[] = [];
|
||||
const typographyResources = new Map<string, DiffTypographyResource>();
|
||||
|
||||
return {
|
||||
buildModel(input) {
|
||||
const key = modelVariantKey(input);
|
||||
const exactKey = exactModelKey(input);
|
||||
const variants = variantsByFiles.get(input.files) ?? [];
|
||||
const variantIndex = variants.findIndex((candidate) => candidate.key === key);
|
||||
const variantIndex = variants.findIndex(
|
||||
(candidate) => candidate.key === key && candidate.measureText === input.measureText,
|
||||
);
|
||||
const variant = variantIndex === -1 ? undefined : variants[variantIndex];
|
||||
if (variant?.exactKey === exactKey) {
|
||||
if (
|
||||
variant?.exactKey === exactKey &&
|
||||
variant.model.files.length === input.files.length &&
|
||||
variant.model.files.every((file, index) => file.file === input.files[index])
|
||||
) {
|
||||
variants.splice(variantIndex, 1);
|
||||
variants.unshift(variant);
|
||||
return variant.model;
|
||||
}
|
||||
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
|
||||
const nextVariant = {
|
||||
measureText: input.measureText,
|
||||
key,
|
||||
exactKey,
|
||||
model,
|
||||
@@ -62,7 +69,7 @@ export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
};
|
||||
if (variantIndex !== -1) variants.splice(variantIndex, 1);
|
||||
variants.unshift(nextVariant);
|
||||
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
|
||||
variants = variants.slice(0, MAX_MODEL_VARIANTS);
|
||||
return model;
|
||||
},
|
||||
typography(input) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retainDiffFiles, shareCheckoutDiff, shareCommitFileDiff } from "./diff-sharing";
|
||||
|
||||
const file: ParsedDiffFile = {
|
||||
path: "a.ts",
|
||||
oldPath: "old-a.ts",
|
||||
status: "ok",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{
|
||||
type: "add",
|
||||
content: "const a",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " a", style: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exhaustive keys make a protocol-field addition require a sharing-policy test.
|
||||
const fileChanges = {
|
||||
path: (value) => {
|
||||
value.path = "b.ts";
|
||||
},
|
||||
oldPath: (value) => {
|
||||
delete value.oldPath;
|
||||
},
|
||||
status: (value) => {
|
||||
value.status = "binary";
|
||||
},
|
||||
isNew: (value) => {
|
||||
value.isNew = true;
|
||||
},
|
||||
isDeleted: (value) => {
|
||||
value.isDeleted = true;
|
||||
},
|
||||
additions: (value) => {
|
||||
value.additions = 2;
|
||||
},
|
||||
deletions: (value) => {
|
||||
value.deletions = 2;
|
||||
},
|
||||
hunks: (value) => {
|
||||
value.hunks = [];
|
||||
},
|
||||
} satisfies Record<keyof ParsedDiffFile, (value: ParsedDiffFile) => void>;
|
||||
type Hunk = ParsedDiffFile["hunks"][number];
|
||||
const hunkChanges = {
|
||||
oldStart: (value) => {
|
||||
value.oldStart = 2;
|
||||
},
|
||||
oldCount: (value) => {
|
||||
value.oldCount = 2;
|
||||
},
|
||||
newStart: (value) => {
|
||||
value.newStart = 2;
|
||||
},
|
||||
newCount: (value) => {
|
||||
value.newCount = 2;
|
||||
},
|
||||
lines: (value) => {
|
||||
value.lines = [];
|
||||
},
|
||||
} satisfies Record<keyof Hunk, (value: Hunk) => void>;
|
||||
type Line = Hunk["lines"][number];
|
||||
const lineChanges = {
|
||||
type: (value) => {
|
||||
value.type = "remove";
|
||||
},
|
||||
content: (value) => {
|
||||
value.content = "const b";
|
||||
},
|
||||
tokens: (value) => {
|
||||
delete value.tokens;
|
||||
},
|
||||
} satisfies Record<keyof Line, (value: Line) => void>;
|
||||
type Token = NonNullable<Line["tokens"]>[number];
|
||||
const tokenChanges = {
|
||||
text: (value) => {
|
||||
value.text = "class";
|
||||
},
|
||||
style: (value) => {
|
||||
value.style = null;
|
||||
},
|
||||
} satisfies Record<keyof Token, (value: Token) => void>;
|
||||
|
||||
describe("diff sharing", () => {
|
||||
it.each(Object.entries(fileChanges))("replaces a file when %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(hunkChanges))("replaces a file when hunk %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(lineChanges))("replaces a file when line %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!.lines[0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(tokenChanges))("replaces a file when token %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!.lines[0]!.tokens![0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it("retains moved files and removes missing files", () => {
|
||||
const second = { ...file, path: "b.ts" };
|
||||
const reordered = retainDiffFiles([file, second], structuredClone([second, file]));
|
||||
expect(reordered[0]).toBe(second);
|
||||
expect(reordered[1]).toBe(file);
|
||||
expect(retainDiffFiles(reordered, [structuredClone(file)])[0]).toBe(file);
|
||||
});
|
||||
it("updates response metadata without invalidating unchanged text", () => {
|
||||
const previous = { cwd: "/repo", files: [file], error: null, requestId: "a" };
|
||||
expect(shareCheckoutDiff(previous, structuredClone(previous))).toBe(previous);
|
||||
const next = shareCheckoutDiff(previous, { ...structuredClone(previous), requestId: "b" });
|
||||
expect(next.requestId).toBe("b");
|
||||
expect(next.files).toBe(previous.files);
|
||||
const rejected = {
|
||||
...previous,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Too large" },
|
||||
diffTooLarge: true,
|
||||
};
|
||||
expect(shareCheckoutDiff(next, rejected)).toEqual(rejected);
|
||||
});
|
||||
it("preserves cached commit bodies and publishes null transitions", () => {
|
||||
const previous = { file };
|
||||
expect(shareCommitFileDiff(previous, structuredClone(previous))).toBe(previous);
|
||||
expect(shareCommitFileDiff(previous, { file: null })).toEqual({ file: null });
|
||||
expect(shareCommitFileDiff({ file: null }, previous)).toBe(previous);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
previous: ParsedDiffFile[],
|
||||
incoming: ParsedDiffFile[],
|
||||
): ParsedDiffFile[] {
|
||||
if (previous === incoming) return previous;
|
||||
const byPath = new Map(previous.map((file) => [file.path, file]));
|
||||
let unchanged = previous.length === incoming.length;
|
||||
const files = incoming.map((file, index) => {
|
||||
const old = byPath.get(file.path);
|
||||
const retained = old && equalFile(old, file) ? old : file;
|
||||
unchanged &&= retained === previous[index];
|
||||
return retained;
|
||||
});
|
||||
return unchanged ? previous : files;
|
||||
}
|
||||
|
||||
export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPayload {
|
||||
const next = incoming as DiffPayload;
|
||||
const old = previous as DiffPayload | undefined;
|
||||
if (!old || old === next) return next;
|
||||
const files = retainDiffFiles(old.files, next.files);
|
||||
const error = replaceEqualDeep(old.error, next.error);
|
||||
return files === old.files &&
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
}
|
||||
|
||||
export function shareCommitFileDiff(
|
||||
previous: unknown,
|
||||
incoming: unknown,
|
||||
): { file: ParsedDiffFile | null } {
|
||||
const next = incoming as { file: ParsedDiffFile | null };
|
||||
const old = previous as typeof next | undefined;
|
||||
return old &&
|
||||
(old.file === next.file || (old.file && next.file && equalFile(old.file, next.file)))
|
||||
? old
|
||||
: next;
|
||||
}
|
||||
|
||||
function equalFile(left: ParsedDiffFile, right: ParsedDiffFile): boolean {
|
||||
if (left === right) return true;
|
||||
if (
|
||||
left.path !== right.path ||
|
||||
left.oldPath !== right.oldPath ||
|
||||
left.status !== right.status ||
|
||||
left.isNew !== right.isNew ||
|
||||
left.isDeleted !== right.isDeleted ||
|
||||
left.additions !== right.additions ||
|
||||
left.deletions !== right.deletions ||
|
||||
left.hunks.length !== right.hunks.length
|
||||
)
|
||||
return false;
|
||||
for (let h = 0; h < left.hunks.length; h++) {
|
||||
const a = left.hunks[h]!;
|
||||
const b = right.hunks[h]!;
|
||||
if (a === b) continue;
|
||||
if (
|
||||
a.oldStart !== b.oldStart ||
|
||||
a.oldCount !== b.oldCount ||
|
||||
a.newStart !== b.newStart ||
|
||||
a.newCount !== b.newCount ||
|
||||
a.lines.length !== b.lines.length
|
||||
)
|
||||
return false;
|
||||
if (!equalLines(a.lines, b.lines)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalLines(
|
||||
left: ParsedDiffFile["hunks"][number]["lines"],
|
||||
right: ParsedDiffFile["hunks"][number]["lines"],
|
||||
): boolean {
|
||||
for (let l = 0; l < left.length; l++) {
|
||||
const x = left[l]!;
|
||||
const y = right[l]!;
|
||||
if (x === y) continue;
|
||||
if (x.type !== y.type || x.content !== y.content) return false;
|
||||
if (x.tokens === y.tokens) continue;
|
||||
if (!x.tokens || !y.tokens || x.tokens.length !== y.tokens.length) return false;
|
||||
for (let t = 0; t < x.tokens.length; t++) {
|
||||
const p = x.tokens[t]!;
|
||||
const q = y.tokens[t]!;
|
||||
if (p.text !== q.text || p.style !== q.style) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
|
||||
import { commitFileDiffQueryOptions } from "./commit-file-diff-query";
|
||||
import { useCheckoutCommitsQuery } from "@/git/use-commits-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -79,18 +79,16 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles
|
||||
Boolean(client) &&
|
||||
isConnected;
|
||||
const fileDiffResults = useFetchQueries(
|
||||
commitFiles.map((file) => ({
|
||||
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
|
||||
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.getCommitFileDiff(cwd, sha, file.path);
|
||||
},
|
||||
enabled: fileDiffsEnabled,
|
||||
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
dataShape: "value" as const,
|
||||
})),
|
||||
commitFiles.map((file) =>
|
||||
commitFileDiffQueryOptions({
|
||||
serverId,
|
||||
cwd,
|
||||
sha,
|
||||
path: file.path,
|
||||
client,
|
||||
enabled: fileDiffsEnabled,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading";
|
||||
const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { shareCheckoutDiff } from "./diff-sharing";
|
||||
import { useMemo } from "react";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
@@ -74,6 +75,7 @@ export function useCheckoutDiffQuery({
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
structuralSharing: shareCheckoutDiff,
|
||||
enabled: routeEnabled,
|
||||
pushEvent: "checkout_diff_update",
|
||||
meta: checkoutDiffPushRoute({
|
||||
|
||||
@@ -79,6 +79,8 @@ export function useProvidersSnapshot(
|
||||
|
||||
const snapshotQuery = useReplicaQuery({
|
||||
queryKey,
|
||||
// The content-hash cache already owns catalog and model identity.
|
||||
structuralSharing: false,
|
||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -149,6 +151,7 @@ export function prefetchProvidersSnapshot(
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: Infinity,
|
||||
structuralSharing: false,
|
||||
queryFn: ({ signal }) =>
|
||||
fetchProvidersSnapshot({ client, serverId, cwd, queryClient: singletonQueryClient, signal }),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user