mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8032a1502 | ||
|
|
dc07efe937 | ||
|
|
36892d5be7 | ||
|
|
b9f624f72e | ||
|
|
50623965b4 |
@@ -273,6 +273,26 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
|
||||
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
||||
measurements so profiling overhead does not contaminate them.
|
||||
|
||||
For diff layout, two benchmarks share one corpus
|
||||
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
|
||||
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
|
||||
shared document model with the web and native measurers replaced by counters, so it reports how
|
||||
many strings each backend would have to shape, which is the cost that survives the move to canvas
|
||||
or Skia:
|
||||
|
||||
```bash
|
||||
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
```
|
||||
|
||||
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
|
||||
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
|
||||
React commit time for cold and warm toggles, then scroll frame times in both modes:
|
||||
|
||||
```bash
|
||||
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
|
||||
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
|
||||
```
|
||||
|
||||
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
||||
That report includes input and composer height changes plus React work grouped into composer,
|
||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
|
||||
sha256-zgyG4roTTTOnNkqOu10gnwwG8GXcjrKH6XlqOvvqKQo=
|
||||
|
||||
Generated
+9
-9
@@ -37625,8 +37625,8 @@
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
@@ -38535,12 +38535,12 @@
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.20.0-beta.212",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.212.tgz",
|
||||
"integrity": "sha512-RzaTYeukTXTJth+Sl7FzCmb8AqiyDrNWNnqG+/300qJbfHYfCMmBlBVZ0yCTSZIWMdgFbgkhmnmBN7Yzs6xoBA==",
|
||||
"version": "0.20.0-beta.291",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz",
|
||||
"integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.213"
|
||||
"@xterm/xterm": "^6.1.0-beta.292"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/headless": {
|
||||
@@ -38554,9 +38554,9 @@
|
||||
]
|
||||
},
|
||||
"packages/app/node_modules/@xterm/xterm": {
|
||||
"version": "6.1.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.213.tgz",
|
||||
"integrity": "sha512-bq5nJkgoZDSg6Ma4zgiQ7jlooGNo7U+HDa6vnhx43yXsVEK2laIF7R08bMYAuU0B+C9xX0gwSrcsW18GxjUoJQ==",
|
||||
"version": "6.1.0-beta.292",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz",
|
||||
"integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -296,8 +297,187 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
|
||||
test.setTimeout(300_000);
|
||||
const corpus = await createPullRequestCorpusRepository();
|
||||
const client = await connectSeedClient();
|
||||
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: corpus.repo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to create wrap performance workspace");
|
||||
}
|
||||
|
||||
await configureUnwrappedUnifiedDiff(page);
|
||||
await instrumentDiffLatency(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const openStartedAt = performance.now();
|
||||
await openChangesPanel(page, 90_000);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
const open = {
|
||||
readyMs: Math.round(performance.now() - openStartedAt),
|
||||
...(await readDiffDocumentRenders(page)),
|
||||
};
|
||||
|
||||
const toggles: WrapToggleMeasurement[] = [];
|
||||
for (const name of [
|
||||
"wrap on (cold)",
|
||||
"wrap off (cold)",
|
||||
"wrap on (warm)",
|
||||
"wrap off (warm)",
|
||||
]) {
|
||||
toggles.push(await measureWrapToggle(page, name));
|
||||
}
|
||||
const unwrappedScroll = await continuouslyScrollCanvas(page);
|
||||
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
|
||||
const wrappedScroll = await continuouslyScrollCanvas(page);
|
||||
|
||||
const report = {
|
||||
scenario: "wrap-toggle",
|
||||
sourceFiles: corpus.fileCount,
|
||||
sourceLines: corpus.lineCount,
|
||||
open,
|
||||
toggles,
|
||||
scroll: {
|
||||
unwrapped: summarizeScroll(unwrappedScroll),
|
||||
wrapped: summarizeScroll(wrappedScroll),
|
||||
},
|
||||
};
|
||||
await attachPerformanceReport(testInfo, report);
|
||||
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
|
||||
|
||||
const [wrapOn, wrapOff] = toggles;
|
||||
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
|
||||
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await corpus.repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface WrapToggleMeasurement {
|
||||
name: string;
|
||||
/** Pointer down on the toolbar button to two painted frames after the document resized. */
|
||||
latencyMs: number;
|
||||
scrollHeightBefore: number;
|
||||
scrollHeightAfter: number;
|
||||
longestTaskMs: number;
|
||||
longTaskTotalMs: number;
|
||||
diffDocumentCommits: number;
|
||||
diffDocumentRenderMs: number;
|
||||
}
|
||||
|
||||
interface LongTask {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface DiffLatencyScope {
|
||||
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
|
||||
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
|
||||
__PASEO_RESET_RENDER_PROFILE__?: () => void;
|
||||
__PASEO_LONG_TASKS__?: LongTask[];
|
||||
__PASEO_WRAP_TOGGLE_AT__?: number;
|
||||
}
|
||||
|
||||
async function createPullRequestCorpusRepository() {
|
||||
const files = pullRequestCorpus();
|
||||
const repo = await createTempGitRepo("diff-wrap-perf-", {
|
||||
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
|
||||
});
|
||||
for (const file of files) {
|
||||
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
|
||||
}
|
||||
return {
|
||||
repo,
|
||||
fileCount: files.length,
|
||||
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function instrumentDiffLatency(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
scope.__PASEO_LONG_TASKS__ = [];
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
|
||||
}
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function readDiffDocumentRenders(
|
||||
page: Page,
|
||||
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
|
||||
return page.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
|
||||
(sample) => sample.id === "DiffDocument",
|
||||
);
|
||||
return {
|
||||
diffDocumentCommits: samples.length,
|
||||
diffDocumentRenderMs: Math.round(
|
||||
samples.reduce((total, sample) => total + sample.actualDuration, 0),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
|
||||
await page.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
scope.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||
scope.__PASEO_LONG_TASKS__ = [];
|
||||
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
() => {
|
||||
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await page.getByTestId("changes-toggle-wrap-lines").click();
|
||||
const measured = await scroll.evaluate(async (element, before) => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
const deadline = performance.now() + 240_000;
|
||||
while (element.scrollHeight === before && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const doneAt = performance.now();
|
||||
// Long task entries are delivered after the task ends; give the observer a turn.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
|
||||
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
|
||||
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
|
||||
);
|
||||
return {
|
||||
latencyMs: Math.round(doneAt - startedAt),
|
||||
scrollHeightAfter: element.scrollHeight,
|
||||
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
|
||||
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
|
||||
};
|
||||
}, scrollHeightBefore);
|
||||
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
|
||||
}
|
||||
|
||||
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
|
||||
const { frameSignatures: _frameSignatures, ...summary } = report;
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureAdditionalPerformanceScenario(
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -459,6 +460,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -466,8 +468,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
|
||||
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,12 +14,23 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* The diff shapes the layout benchmarks measure, shared by the node bench
|
||||
* (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`)
|
||||
* so both report against the same text.
|
||||
*/
|
||||
export interface BenchmarkCorpusFile {
|
||||
path: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function repeatToLength(unit: string, length: number): string {
|
||||
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length);
|
||||
}
|
||||
|
||||
/** A real source file: short lines, a few with ligature punctuation. */
|
||||
export function realCodeCorpusFile(): BenchmarkCorpusFile {
|
||||
const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8");
|
||||
return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") };
|
||||
}
|
||||
|
||||
/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */
|
||||
export function changelogCorpusFile(): BenchmarkCorpusFile {
|
||||
const sentence =
|
||||
"- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. ";
|
||||
return {
|
||||
path: "CHANGELOG.md",
|
||||
lines: Array.from({ length: 42 }, (_, index) =>
|
||||
repeatToLength(sentence, 200 + ((index * 97) % 398)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */
|
||||
export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||
return {
|
||||
path: "src/generated.ts",
|
||||
lines: Array.from({ length: lineCount }, (_, index) => {
|
||||
const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`;
|
||||
return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialized data: no ligatures, every line wraps several times. */
|
||||
export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||
return {
|
||||
path: "fixtures/data.json",
|
||||
lines: Array.from({ length: lineCount }, (_, index) =>
|
||||
repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A pull-request-sized working diff: one of each shape above. */
|
||||
export function pullRequestCorpus(): BenchmarkCorpusFile[] {
|
||||
return [
|
||||
realCodeCorpusFile(),
|
||||
changelogCorpusFile(),
|
||||
generatedCodeCorpusFile(1200),
|
||||
jsonCorpusFile(300),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { bench, describe } from "vitest";
|
||||
import {
|
||||
changelogCorpusFile,
|
||||
generatedCodeCorpusFile,
|
||||
jsonCorpusFile,
|
||||
pullRequestCorpus,
|
||||
realCodeCorpusFile,
|
||||
type BenchmarkCorpusFile,
|
||||
} from "./benchmark-corpus";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import {
|
||||
createAdditiveAdvances,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createChunkedWidthMeasurer,
|
||||
createMeasuredAdvances,
|
||||
requiresShaping,
|
||||
} from "./text-measurement";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
// The pure layout work the diff surface does when a diff opens or line wrap is
|
||||
// toggled, without a browser or Skia. Counts are the backend-independent cost
|
||||
// owners (how many strings a shaper has to see), timings are the JS overhead
|
||||
// around them:
|
||||
// npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1400, height: 900 };
|
||||
const PHONE_VIEWPORT = { width: 390, height: 800 };
|
||||
const CELL_WIDTH = 7.8;
|
||||
|
||||
interface MeasurementStats {
|
||||
/** Whole strings handed to the backend. */
|
||||
calls: number;
|
||||
/** Characters inside those strings; canvas and Skia cost scales with this. */
|
||||
characters: number;
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
measurer: TextMeasurer;
|
||||
stats: MeasurementStats;
|
||||
}
|
||||
|
||||
function monospaceWidth(text: string): number {
|
||||
let width = 0;
|
||||
for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1;
|
||||
return width * CELL_WIDTH;
|
||||
}
|
||||
|
||||
function countingMeasure(stats: MeasurementStats): (text: string) => number {
|
||||
return (text) => {
|
||||
stats.calls += 1;
|
||||
stats.characters += text.length;
|
||||
return monospaceWidth(text);
|
||||
};
|
||||
}
|
||||
|
||||
/** Previous web backend, retained to isolate the cost of width-only probes. */
|
||||
function webBackendWithoutWidthProbe(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const measure = countingMeasure(stats);
|
||||
return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter.
|
||||
* Width-only probes shape one string per chunk, like the native measurer; the
|
||||
* widths are the same sums, in the same order, as the last cumulative advance.
|
||||
*/
|
||||
function webBackend(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const measure = countingMeasure(stats);
|
||||
const additive = createAdditiveAdvances(measure);
|
||||
return {
|
||||
stats,
|
||||
measurer: {
|
||||
measure,
|
||||
measureAdvances: createMeasuredAdvances(measure),
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */
|
||||
function nativeBackend(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const widths = new Map<string, number>();
|
||||
const additive = (graphemes: readonly string[]) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
stats.calls += 1;
|
||||
stats.characters += grapheme.length;
|
||||
width = monospaceWidth(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
return (advance += width);
|
||||
});
|
||||
};
|
||||
const shape = countingMeasure(stats);
|
||||
return {
|
||||
stats,
|
||||
measurer: {
|
||||
measure(text) {
|
||||
return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0);
|
||||
},
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping,
|
||||
measureAdditive: additive,
|
||||
measureShaped(graphemes) {
|
||||
shape(graphemes.join(""));
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme)));
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile {
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: file.lines.length,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: file.lines.length,
|
||||
lines: file.lines.map((content) => ({
|
||||
type: "add" as const,
|
||||
content,
|
||||
tokens: [{ text: content, style: null }],
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
name: string;
|
||||
files: ParsedDiffFile[];
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{ name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] },
|
||||
{ name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] },
|
||||
{ name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] },
|
||||
{ name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] },
|
||||
{ name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) },
|
||||
];
|
||||
|
||||
function input(
|
||||
scenario: Scenario,
|
||||
options: {
|
||||
wrapLines: boolean;
|
||||
viewport: { width: number; height: number };
|
||||
measureText: TextMeasurer;
|
||||
},
|
||||
): BuildDiffDocumentModelInput {
|
||||
return {
|
||||
files: scenario.files,
|
||||
collapsedFilePaths: new Set(),
|
||||
layout: "unified",
|
||||
wrapLines: options.wrapLines,
|
||||
viewportWidth: options.viewport.width,
|
||||
typography: { family: "monospace", size: 13, lineHeight: 20 },
|
||||
measureText: options.measureText,
|
||||
palette: {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
// What the surfaces pass at scrollTop 0: five viewports tall.
|
||||
materializationWindow: { top: 0, height: options.viewport.height * 5 },
|
||||
};
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
name: string;
|
||||
wrapLines: boolean;
|
||||
viewport: { width: number; height: number };
|
||||
backend: () => Backend;
|
||||
}
|
||||
|
||||
const VARIANTS: Variant[] = [
|
||||
{
|
||||
name: "unwrapped desktop | web",
|
||||
wrapLines: false,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | web",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | web without width probe",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackendWithoutWidthProbe,
|
||||
},
|
||||
{
|
||||
name: "wrapped phone | web",
|
||||
wrapLines: true,
|
||||
viewport: PHONE_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "unwrapped desktop | native",
|
||||
wrapLines: false,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | native",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped phone | native",
|
||||
wrapLines: true,
|
||||
viewport: PHONE_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
];
|
||||
|
||||
function countModel(scenario: Scenario, variant: Variant) {
|
||||
const { measurer, stats } = variant.backend();
|
||||
const started = performance.now();
|
||||
const model = buildDiffDocumentModel(
|
||||
input(scenario, {
|
||||
wrapLines: variant.wrapLines,
|
||||
viewport: variant.viewport,
|
||||
measureText: measurer,
|
||||
}),
|
||||
);
|
||||
const elapsedMs = performance.now() - started;
|
||||
let materializedCells = 0;
|
||||
let fragments = 0;
|
||||
let graphemes = 0;
|
||||
for (const row of model.rows) {
|
||||
if (row.kind !== "line") continue;
|
||||
for (const cell of row.cells) {
|
||||
if (!cell || cell.fragments.length === 0) continue;
|
||||
materializedCells += 1;
|
||||
fragments += cell.fragments.length;
|
||||
for (const fragment of cell.fragments) graphemes += fragment.graphemes.length;
|
||||
}
|
||||
}
|
||||
const sourceLines = scenario.files.reduce(
|
||||
(count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
sourceLines,
|
||||
materializedCells,
|
||||
fragments,
|
||||
graphemeObjects: graphemes,
|
||||
measureCalls: stats.calls,
|
||||
measuredCharacters: stats.characters,
|
||||
coldMs: Number(elapsedMs.toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
const report: Record<string, ReturnType<typeof countModel>> = {};
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const variant of VARIANTS) {
|
||||
report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant);
|
||||
}
|
||||
}
|
||||
console.table(report);
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
describe(scenario.name, () => {
|
||||
for (const variant of VARIANTS) {
|
||||
if (variant.backend === nativeBackend) continue;
|
||||
bench(variant.name, () => {
|
||||
buildDiffDocumentModel(
|
||||
input(scenario, {
|
||||
wrapLines: variant.wrapLines,
|
||||
viewport: variant.viewport,
|
||||
measureText: variant.backend().measurer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveRelayoutScrollTop,
|
||||
shouldApplyRelayoutScroll,
|
||||
} from "./model";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types";
|
||||
|
||||
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
|
||||
|
||||
@@ -639,17 +639,119 @@ describe("diff document model", () => {
|
||||
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
|
||||
});
|
||||
|
||||
it("keeps wrapped geometry fully measured", () => {
|
||||
it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => {
|
||||
const changed = fileWithChangedLine("x".repeat(100));
|
||||
const files = [changed, file("later.ts")];
|
||||
// Deliberately inaccurate M width makes the estimate differ from exact layout.
|
||||
const measureText = {
|
||||
measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10),
|
||||
};
|
||||
const options = input({ files, measureText });
|
||||
const first = buildDiffDocumentModel({
|
||||
...options,
|
||||
materializationWindow: { top: 30, height: 1 },
|
||||
});
|
||||
const eager = buildDiffDocumentModel(options);
|
||||
const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] });
|
||||
expect(first.height).not.toBe(eager.height);
|
||||
expect(exact.rows).toEqual(eager.rows);
|
||||
expect(exact.height).toBe(eager.height);
|
||||
expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path));
|
||||
const scrollTop = first.files[1]!.bodyTop + 3;
|
||||
const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop);
|
||||
expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3);
|
||||
expect(captureScrollAnchor(exact, nextScrollTop)).toEqual(
|
||||
captureScrollAnchor(first, scrollTop),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => {
|
||||
const source = file();
|
||||
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const options = input({ files: [source], viewportWidth: 500 });
|
||||
const before = buildDiffDocumentModel(options);
|
||||
const scrollTop = before.rows[100]!.top + 36;
|
||||
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||
expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 });
|
||||
const resizedOptions = { ...options, viewportWidth: 180 };
|
||||
const estimated = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: {
|
||||
top: Math.floor(scrollTop / 800) * 800 - 800,
|
||||
height: 2000,
|
||||
},
|
||||
});
|
||||
const estimatedRow = estimated.rows[100]!;
|
||||
expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]);
|
||||
const restored = resolveRelayoutScrollTop(before, estimated, scrollTop);
|
||||
const recaptured = captureScrollAnchor(estimated, restored)!;
|
||||
expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 });
|
||||
|
||||
const materialized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: restored - 800, height: 2000 },
|
||||
reuseFrom: [estimated],
|
||||
});
|
||||
const eager = buildDiffDocumentModel(resizedOptions);
|
||||
const expected = resolveRelayoutScrollTop(before, eager, scrollTop);
|
||||
expect(expected).toBe(28_938);
|
||||
expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight);
|
||||
expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected);
|
||||
});
|
||||
|
||||
it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => {
|
||||
const source = file();
|
||||
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const options = input({ files: [source], viewportWidth: 500 });
|
||||
const before = buildDiffDocumentModel(options);
|
||||
const scrollTop = before.rows[100]!.top + 38;
|
||||
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||
const resizedOptions = {
|
||||
...options,
|
||||
viewportWidth: 180,
|
||||
measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) },
|
||||
};
|
||||
const resized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: scrollTop - 800, height: 2000 },
|
||||
materializationAnchor: anchor,
|
||||
});
|
||||
const row = resized.rows[100]!;
|
||||
expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0);
|
||||
const restored = resolveRelayoutScrollTop(before, resized, scrollTop);
|
||||
const materialized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: restored - 800, height: 2000 },
|
||||
materializationAnchor: captureScrollAnchor(resized, restored),
|
||||
reuseFrom: [resized],
|
||||
});
|
||||
const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored);
|
||||
const nextAnchor = captureScrollAnchor(materialized, nextScrollTop);
|
||||
// Offset 90 belongs to the fragment beginning at 78 after narrowing.
|
||||
expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 });
|
||||
expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor));
|
||||
});
|
||||
|
||||
it("materializes wrapped text only inside the window", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
files: [file("a.ts"), file("b.ts")],
|
||||
wrapLines: true,
|
||||
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
|
||||
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
|
||||
expect(measuredFragments(model, "b.ts")).toBe(0);
|
||||
expect(
|
||||
model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -798,3 +900,7 @@ function reviewActionsWithEditor(
|
||||
onDeleteComment() {},
|
||||
};
|
||||
}
|
||||
|
||||
function hasFragments(cell: DiffCell | null): boolean {
|
||||
return cell !== null && cell.fragments.length > 0;
|
||||
}
|
||||
|
||||
@@ -154,27 +154,38 @@ function appendReusableFileRows(candidate: {
|
||||
}): FileRowsResult {
|
||||
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
||||
const retainsRowGeometry =
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
candidate.file.rowStart === candidate.rows.length;
|
||||
let top = candidate.bodyTop;
|
||||
for (const reusedRow of reusedRows) {
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
let nextRow =
|
||||
retainsRowGeometry && reusedRow.top === top
|
||||
? reusedRow
|
||||
: repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
(intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) ||
|
||||
containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
const cells = materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
);
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
cells,
|
||||
height:
|
||||
Math.max(
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map(
|
||||
(cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight,
|
||||
),
|
||||
) + nextRow.reviewHeight,
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
@@ -240,6 +251,7 @@ function appendNewFileRows(candidate: {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
candidate.gutterWidth,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
@@ -252,8 +264,8 @@ function appendNewFileRows(candidate: {
|
||||
let maximumHorizontalOverflow = 0;
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height) ||
|
||||
containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
@@ -275,7 +287,7 @@ function appendNewFileRows(candidate: {
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||
);
|
||||
const height = textHeight + line.reviewHeight;
|
||||
const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -412,13 +424,14 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean):
|
||||
};
|
||||
}
|
||||
|
||||
function geometryCell(source: CellSource): DiffCell {
|
||||
function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell {
|
||||
return {
|
||||
type: source.type,
|
||||
content: source.content,
|
||||
lineNumber: source.lineNumber,
|
||||
tokens: [],
|
||||
fragments: [],
|
||||
estimatedWrapColumns,
|
||||
reviewTarget: source.reviewTarget,
|
||||
sourceIdentity: source.sourceIdentity,
|
||||
};
|
||||
@@ -427,20 +440,87 @@ function geometryCell(source: CellSource): DiffCell {
|
||||
function geometryLines(
|
||||
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
gutterWidth: number,
|
||||
): GeometryLine[] {
|
||||
const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0;
|
||||
return sources.map((sourceCells) => {
|
||||
const columns = input.wrapLines
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) /
|
||||
characterWidth,
|
||||
),
|
||||
)
|
||||
: Infinity;
|
||||
const cells = sourceCells.map((source) =>
|
||||
source ? geometryCell(source) : null,
|
||||
source ? geometryCell(source, columns) : null,
|
||||
) as DiffLineRow["cells"];
|
||||
const reviewHeight = reviewHeightForCells(cells, input);
|
||||
return {
|
||||
cells,
|
||||
reviewHeight,
|
||||
height: input.typography.lineHeight + reviewHeight,
|
||||
// Unseen wrapped rows use a monospace estimate; materialization replaces
|
||||
// it with the exact fragment count without changing source identity.
|
||||
height:
|
||||
input.typography.lineHeight *
|
||||
Math.max(
|
||||
1,
|
||||
...cells.map((cell) => {
|
||||
return cell && input.wrapLines
|
||||
? Math.ceil(estimatedDisplayOffset(cell.content) / columns)
|
||||
: 1;
|
||||
}),
|
||||
) +
|
||||
reviewHeight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Use the same tab expansion and column count for estimated heights and anchors.
|
||||
function estimatedDisplayOffset(text: string): number {
|
||||
return text.length + (text.match(/\t/g)?.length ?? 0) * 3;
|
||||
}
|
||||
|
||||
function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number {
|
||||
return Math.floor(
|
||||
estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) /
|
||||
(cell.estimatedWrapColumns ?? Infinity),
|
||||
);
|
||||
}
|
||||
|
||||
function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) {
|
||||
const columns = cell.estimatedWrapColumns ?? Infinity;
|
||||
const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1);
|
||||
const index = Math.min(visualIndex, lastIndex);
|
||||
const displayOffset = index === 0 ? 0 : index * columns;
|
||||
let start = Math.min(displayOffset, cell.content.length);
|
||||
if (cell.content.includes("\t")) {
|
||||
let offset = 0;
|
||||
start = 0;
|
||||
while (start < cell.content.length && offset < displayOffset) {
|
||||
offset += cell.content[start] === "\t" ? 4 : 1;
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
return { start, top: index * lineHeight };
|
||||
}
|
||||
|
||||
// A resize can move the anchor outside the old window. Measure that row in the
|
||||
// same pass: estimated columns cannot identify exact shaped-text fragments.
|
||||
function containsMaterializationAnchor(
|
||||
input: BuildDiffDocumentModelInput,
|
||||
path: string,
|
||||
cells: DiffLineRow["cells"],
|
||||
): boolean {
|
||||
const anchor = input.materializationAnchor;
|
||||
return (
|
||||
anchor?.kind === "text" &&
|
||||
anchor.path === path &&
|
||||
cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity))
|
||||
);
|
||||
}
|
||||
|
||||
function intersectsMaterializationWindow(
|
||||
input: BuildDiffDocumentModelInput,
|
||||
fileTop: number,
|
||||
@@ -795,17 +875,27 @@ export function captureScrollAnchor(
|
||||
}
|
||||
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
||||
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
||||
let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex]));
|
||||
let cellIndex = row.cells.findIndex(
|
||||
(cell) =>
|
||||
cell &&
|
||||
(cell.fragments.length > 0
|
||||
? Boolean(cell.fragments[visualIndex])
|
||||
: estimatedFragment(cell, visualIndex, model.lineHeight).top ===
|
||||
visualIndex * model.lineHeight),
|
||||
);
|
||||
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
||||
const cell = row.cells[cellIndex];
|
||||
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
||||
const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)];
|
||||
const anchorY = row.top + (fragment?.top ?? 0);
|
||||
const fragment =
|
||||
cell.fragments.length > 0
|
||||
? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]!
|
||||
: estimatedFragment(cell, visualIndex, model.lineHeight);
|
||||
const anchorY = row.top + fragment.top;
|
||||
return {
|
||||
kind: "text",
|
||||
path: file.path,
|
||||
sourceIdentity: cell.sourceIdentity,
|
||||
cellOffset: fragment?.start ?? 0,
|
||||
cellOffset: fragment.start,
|
||||
viewportDelta: anchorY - scrollTop,
|
||||
};
|
||||
}
|
||||
@@ -831,8 +921,10 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
|
||||
const fragment =
|
||||
cell.fragments.find(
|
||||
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
||||
) ?? cell.fragments.at(-1);
|
||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
||||
) ??
|
||||
cell.fragments.at(-1) ??
|
||||
estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight);
|
||||
return Math.max(0, row.top + fragment.top - anchor.viewportDelta);
|
||||
}
|
||||
|
||||
export function resolveRelayoutScrollTop(
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "./horizontal-offsets";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
resolveRelayoutScrollTop,
|
||||
retainReusableModels,
|
||||
shouldApplyRelayoutScroll,
|
||||
@@ -138,6 +139,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
materializationAnchor: previousModelRef.current
|
||||
? captureScrollAnchor(previousModelRef.current, scrollTop.get())
|
||||
: null,
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
@@ -148,6 +152,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}, [
|
||||
measurement,
|
||||
fileWindowTop,
|
||||
scrollTop,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
|
||||
@@ -28,10 +28,20 @@ import {
|
||||
} from "./hit-testing";
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
FILE_HEADER_HEIGHT,
|
||||
resolveRelayoutScrollTop,
|
||||
} from "./model";
|
||||
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import { createMeasuredAdvances } from "./text-measurement";
|
||||
import {
|
||||
createMeasuredAdvances,
|
||||
createAdditiveAdvances,
|
||||
createChunkedWidthMeasurer,
|
||||
requiresShaping,
|
||||
} from "./text-measurement";
|
||||
import { retainDiffViewport } from "./viewport";
|
||||
import type {
|
||||
DiffHit,
|
||||
@@ -183,6 +193,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
materializationAnchor: previousModelRef.current
|
||||
? captureScrollAnchor(previousModelRef.current, scrollTopRef.current)
|
||||
: null,
|
||||
});
|
||||
return next;
|
||||
}, [
|
||||
@@ -1166,5 +1179,13 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
|
||||
};
|
||||
// Canvas exposes no glyph coverage, so `requiresShaping` alone decides which
|
||||
// runs a shaper has to see -- there is no `glyphIds` to fall back on.
|
||||
return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) };
|
||||
const additive = createAdditiveAdvances(measure);
|
||||
return {
|
||||
measure,
|
||||
measureAdvances: createMeasuredAdvances(measure),
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface DiffCell {
|
||||
lineNumber: number | null;
|
||||
tokens: DiffTokenRun[];
|
||||
fragments: DiffFragment[];
|
||||
/** Monospace columns per visual line until this cell is measured. */
|
||||
estimatedWrapColumns?: number;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
sourceIdentity: DiffSourceIdentity;
|
||||
}
|
||||
@@ -185,6 +187,8 @@ export interface BuildDiffDocumentModelInput {
|
||||
reviewActions?: InlineReviewActions;
|
||||
labels: { binary: string; tooLarge: string };
|
||||
materializationWindow?: { top: number; height: number };
|
||||
/** Also measure the anchored row so relayout can restore its exact fragment. */
|
||||
materializationAnchor?: DiffScrollAnchor | null;
|
||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||
reuseFrom?: readonly DiffDocumentModel[];
|
||||
}
|
||||
|
||||
@@ -95,6 +95,37 @@ describe("diff document workspace cache", () => {
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("materializes a new anchor when the viewport window is unchanged", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const { measureText } = countingMeasurer();
|
||||
const input = modelInput([source], measureText, {
|
||||
wrapLines: true,
|
||||
materializationWindow: { top: 30, height: 54 },
|
||||
});
|
||||
const first = cache.buildModel(input);
|
||||
const anchored = cache.buildModel({
|
||||
...input,
|
||||
materializationAnchor: {
|
||||
kind: "text",
|
||||
path: source.path,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" },
|
||||
cellOffset: 90,
|
||||
viewportDelta: -2,
|
||||
},
|
||||
});
|
||||
const firstRow = first.rows[50]!;
|
||||
const anchoredRow = anchored.rows[50]!;
|
||||
expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0);
|
||||
expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
|
||||
@@ -128,7 +128,9 @@ function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): s
|
||||
const materializationWindow = input.materializationWindow
|
||||
? [input.materializationWindow.top, input.materializationWindow.height]
|
||||
: null;
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
|
||||
const anchor = input.materializationAnchor;
|
||||
const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null;
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]);
|
||||
}
|
||||
|
||||
function typographyKey(typography: DiffTypography): string {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -63,6 +63,7 @@ export const en = {
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
daemonClientDisconnected: "Daemon client is disconnected",
|
||||
noFileFound: "No file found for {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const es: TranslationResources = {
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const fr: TranslationResources = {
|
||||
daemonClientUnavailable: "Client du daemon indisponible",
|
||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||
noFileFound: "{{token}}のファイルが見つかりません",
|
||||
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ptBR: TranslationResources = {
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
daemonClientDisconnected: "Daemon клиента отключен",
|
||||
noFileFound: "Файл для {{token}} не найден",
|
||||
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||
noFileFound: "未找到 {{token}} 对应的文件",
|
||||
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -64,6 +64,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -3682,6 +3683,7 @@ function WorkspaceScreenContent({
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
|
||||
const markdownPathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -3689,8 +3691,15 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
openFile: openMarkdownFile,
|
||||
openDirectory: openMarkdownDirectory,
|
||||
openOutsideDirectory: openMarkdownOutsideDirectory,
|
||||
}),
|
||||
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
|
||||
[
|
||||
workspaceDirectory,
|
||||
client,
|
||||
openMarkdownFile,
|
||||
openMarkdownDirectory,
|
||||
openMarkdownOutsideDirectory,
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) markdownPathOpener.cancel();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { page } from "vitest/browser";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
|
||||
interface TerminalView {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
terminal: Terminal;
|
||||
}
|
||||
|
||||
interface RowImage {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
const views: TerminalView[] = [];
|
||||
const header = "HEADER_0123456789 한글 日本語";
|
||||
|
||||
function mountTerminal(left: number): TerminalView {
|
||||
const root = document.createElement("div");
|
||||
Object.assign(root.style, {
|
||||
position: "fixed",
|
||||
left: `${left}px`,
|
||||
top: "0",
|
||||
width: "440px",
|
||||
height: "360px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
Object.assign(host.style, { width: "100%", height: "100%" });
|
||||
root.appendChild(host);
|
||||
document.body.appendChild(root);
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 16,
|
||||
theme: { background: "#101010", foreground: "#eeeeee", cursor: "#eeeeee" },
|
||||
});
|
||||
const terminal = window.__paseoTerminal;
|
||||
if (!terminal) throw new Error("Terminal did not mount");
|
||||
const view = { root, host, runtime, terminal };
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
function write(view: TerminalView, text: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
view.runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve });
|
||||
});
|
||||
}
|
||||
|
||||
function webglCanvas(view: TerminalView): HTMLCanvasElement {
|
||||
for (const canvas of view.host.querySelectorAll(".xterm-screen canvas")) {
|
||||
if (canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2")) return canvas;
|
||||
}
|
||||
throw new Error("Real WebGL is required; DOM fallback cannot test glyph corruption");
|
||||
}
|
||||
|
||||
async function mountPair() {
|
||||
await page.viewport(1000, 600);
|
||||
const active = mountTerminal(0);
|
||||
const sibling = mountTerminal(460);
|
||||
await document.fonts.ready;
|
||||
await expect
|
||||
.poll(() => active.host.querySelectorAll(".xterm-screen canvas").length)
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(() => sibling.host.querySelectorAll(".xterm-screen canvas").length)
|
||||
.toBeGreaterThan(0);
|
||||
webglCanvas(active);
|
||||
webglCanvas(sibling);
|
||||
await write(active, "\x1b[?25l");
|
||||
await write(sibling, "\x1b[?25l");
|
||||
return { active, sibling };
|
||||
}
|
||||
|
||||
function captureHeader(input: { view: TerminalView; output?: string }): Promise<RowImage> {
|
||||
const { view, output } = input;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
rendered.dispose();
|
||||
reject(new Error("Terminal did not render within 2 seconds"));
|
||||
}, 2000);
|
||||
const rendered = view.terminal.onRender(() => {
|
||||
rendered.dispose();
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
// Copy during onRender, before WebGL discards its default drawing buffer.
|
||||
const source = webglCanvas(view);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = source.width;
|
||||
canvas.height = Math.round(source.height / view.terminal.rows);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D pixel capture is unavailable");
|
||||
context.drawImage(source, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
resolve({ width: canvas.width, height: canvas.height, pixels });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
if (output === undefined) view.terminal.refresh(0, view.terminal.rows - 1);
|
||||
else view.runtime.write({ data: encodeTerminalOutput(output) });
|
||||
});
|
||||
}
|
||||
|
||||
function expectSamePixels(input: { expected: RowImage; actual: RowImage; label: string }): void {
|
||||
const { expected, actual, label } = input;
|
||||
expect(actual.width, label).toBe(expected.width);
|
||||
expect(actual.height, label).toBe(expected.height);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < expected.pixels.length; index += 1) {
|
||||
if (expected.pixels[index] !== actual.pixels[index]) changed += 1;
|
||||
}
|
||||
expect(changed, label).toBe(0);
|
||||
}
|
||||
|
||||
function coloredOutput(input: { cells: number; offset: number }): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let output = "";
|
||||
for (let index = 0; index < input.cells; index += 1) {
|
||||
const value = index + input.offset;
|
||||
const foreground = `${value % 256};${(value >> 8) % 256};${(value * 13) % 256}`;
|
||||
const background = (value * 7) % 256;
|
||||
output += `\x1b[38;2;${foreground}m\x1b[48;5;${background}m${alphabet[value % alphabet.length]}`;
|
||||
}
|
||||
return `${output}\x1b[0m`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const view of views.splice(0)) {
|
||||
view.runtime.unmount();
|
||||
view.root.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves another terminal's glyph pixels when a shared atlas is cleared", async () => {
|
||||
const { active, sibling } = await mountPair();
|
||||
await write(sibling, header);
|
||||
const before = await captureHeader({ view: sibling });
|
||||
const visibleChannels = before.pixels.filter(
|
||||
(channel, index) => index % 4 !== 3 && channel > 100,
|
||||
);
|
||||
expect(visibleChannels.length).toBeGreaterThan(100);
|
||||
|
||||
active.terminal.clearTextureAtlas();
|
||||
await captureHeader({ view: active, output: coloredOutput({ cells: 2000, offset: 0 }) });
|
||||
const after = await captureHeader({
|
||||
view: sibling,
|
||||
output: `\x1b[${sibling.terminal.rows};1Hx`,
|
||||
});
|
||||
expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" });
|
||||
});
|
||||
|
||||
it("preserves both headers while ordinary output grows the shared glyph atlas", async () => {
|
||||
const { active, sibling } = await mountPair();
|
||||
const cells = active.terminal.cols * (active.terminal.rows - 2);
|
||||
// Startup glyphs can stay on an unmerged page and hide this bug. Allocate the
|
||||
// reference after some output, as a real long-running TUI does.
|
||||
for (let batch = 0; batch < 16; batch += 1) {
|
||||
await captureHeader({
|
||||
view: active,
|
||||
output: `\x1b[H${coloredOutput({ cells, offset: batch * cells })}`,
|
||||
});
|
||||
}
|
||||
const lateHeader = `\x1b[H\x1b[0m\x1b[2K\x1b[38;2;253;251;247m${header}\x1b[0m`;
|
||||
await write(active, lateHeader);
|
||||
await write(sibling, lateHeader);
|
||||
const activeBefore = await captureHeader({ view: active });
|
||||
const siblingBefore = await captureHeader({ view: sibling });
|
||||
|
||||
for (let batch = 0; batch < 256; batch += 1) {
|
||||
const activeFrame = await captureHeader({
|
||||
view: active,
|
||||
output: `\x1b[2;1H${coloredOutput({ cells, offset: (batch + 16) * cells })}`,
|
||||
});
|
||||
const siblingFrame = await captureHeader({
|
||||
view: sibling,
|
||||
output: `\x1b[${sibling.terminal.rows};1H${batch % 10}`,
|
||||
});
|
||||
expect(active.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expectSamePixels({
|
||||
expected: activeBefore,
|
||||
actual: activeFrame,
|
||||
label: `active frame ${batch}`,
|
||||
});
|
||||
expectSamePixels({
|
||||
expected: siblingBefore,
|
||||
actual: siblingFrame,
|
||||
label: `sibling frame ${batch}`,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
/**
|
||||
* Opens a folder outside the workspace in the system file manager. The Files
|
||||
* panel only lists the workspace, so hosts without a local file manager get a
|
||||
* toast instead.
|
||||
*/
|
||||
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isLocalExecution = useIsLocalDaemon(serverId);
|
||||
const { targets } = useDesktopOpenTargets({ isLocalExecution });
|
||||
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
|
||||
|
||||
return useCallback(
|
||||
(folderPath: string) => {
|
||||
const showOutsideWorkspace = () =>
|
||||
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
|
||||
if (!fileManagerTarget) {
|
||||
showOutsideWorkspace();
|
||||
return;
|
||||
}
|
||||
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("[open-outside-workspace-folder] open failed", error);
|
||||
showOutsideWorkspace();
|
||||
},
|
||||
);
|
||||
},
|
||||
[fileManagerTarget, t, toast],
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,10 @@ export default defineConfig({
|
||||
include: ["src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
provider: playwright({
|
||||
// Exercise real WebGL glyph rendering on GPU-less CI runners.
|
||||
launchOptions: { args: ["--enable-unsafe-swiftshader"] },
|
||||
}),
|
||||
headless: true,
|
||||
connectTimeout: 180_000,
|
||||
instances: [{ browser: "chromium" }],
|
||||
|
||||
@@ -86,6 +86,10 @@ class FakeEditorTargets implements EditorTargetRuntime {
|
||||
return this.paths.has(targetPath);
|
||||
}
|
||||
|
||||
expandHomePath(targetPath: string): string {
|
||||
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
|
||||
}
|
||||
|
||||
isAbsolutePath(targetPath: string): boolean {
|
||||
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
|
||||
}
|
||||
@@ -513,6 +517,19 @@ describe("editor target registry", () => {
|
||||
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
|
||||
});
|
||||
|
||||
it("opens a home-relative folder in the file manager", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.addPath("/home/me/Downloads/contracts");
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
|
||||
runtime,
|
||||
[fileManagerTarget],
|
||||
);
|
||||
|
||||
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
|
||||
});
|
||||
|
||||
it("keeps the platform file-manager ids used by stored preferences", async () => {
|
||||
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
|
||||
finderTarget,
|
||||
|
||||
@@ -97,10 +97,16 @@ export function getEditorTarget(
|
||||
}
|
||||
|
||||
export async function openEditorTarget(
|
||||
input: EditorTargetLaunchInput & { editorId: string },
|
||||
request: EditorTargetLaunchInput & { editorId: string },
|
||||
runtime: EditorTargetRuntime,
|
||||
targets: readonly EditorTarget[] = EDITOR_TARGETS,
|
||||
): Promise<void> {
|
||||
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
|
||||
const input = {
|
||||
...request,
|
||||
workspacePath: runtime.expandHomePath(request.workspacePath),
|
||||
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
|
||||
};
|
||||
if (!runtime.isAbsolutePath(input.workspacePath)) {
|
||||
throw new Error("Editor target workspace path must be an absolute local path");
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string, homeDirectory: string): string {
|
||||
if (value === "~") return homeDirectory;
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(homeDirectory, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveExecutable(
|
||||
commands: readonly string[],
|
||||
input: {
|
||||
@@ -166,6 +174,7 @@ export function createEditorTargetRuntime(
|
||||
pathExists,
|
||||
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
|
||||
listDirectory,
|
||||
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
|
||||
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
|
||||
async spawnDetached({ command, args }) {
|
||||
const commandScript = isWindowsCommandScript(command, platform);
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface EditorTargetRuntime {
|
||||
pathExists(path: string): boolean;
|
||||
isAbsolutePath(path: string): boolean;
|
||||
listDirectory(path: string): EditorTargetDirectoryEntry[];
|
||||
/** Resolves a leading `~` to the user's home directory. */
|
||||
expandHomePath(path: string): string;
|
||||
resolveCommand(commands: readonly string[]): string | null;
|
||||
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
|
||||
openPath(path: string): Promise<void>;
|
||||
|
||||
@@ -997,7 +997,9 @@ class AcpRuntime {
|
||||
): string {
|
||||
if (messageId) {
|
||||
this.fallbackChunkIds.delete(kind);
|
||||
return messageId;
|
||||
// Native IDs identify a message within its chunk kind. Timeline IDs must
|
||||
// also distinguish reasoning from replies and from generated fallback IDs.
|
||||
return JSON.stringify([kind, messageId]);
|
||||
}
|
||||
let id = this.fallbackChunkIds.get(kind);
|
||||
if (!id) {
|
||||
|
||||
@@ -1307,7 +1307,15 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
const items = events.flatMap((event) =>
|
||||
event.type === "timeline.item" &&
|
||||
(event.item.type === "assistant_message" || event.item.type === "reasoning")
|
||||
? [{ id: event.item.id, text: event.item.text }]
|
||||
? [
|
||||
{
|
||||
id: event.item.id,
|
||||
text: event.item.text,
|
||||
...(event.item.type === "assistant_message" && event.item.messageId
|
||||
? { messageId: event.item.messageId }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(items).toEqual([
|
||||
@@ -1315,7 +1323,11 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
{ id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" },
|
||||
{ id: "agent_message_chunk:2", text: "next turn" },
|
||||
{ id: "agent_thought_chunk:3", text: "think A" },
|
||||
{ id: "m-explicit", text: "say" },
|
||||
{
|
||||
id: JSON.stringify(["agent_message_chunk", "m-explicit"]),
|
||||
messageId: "m-explicit",
|
||||
text: "say",
|
||||
},
|
||||
{ id: "agent_thought_chunk:4", text: "think B" },
|
||||
]);
|
||||
await connection.close();
|
||||
@@ -1402,6 +1414,21 @@ async function streamChunkTurns(
|
||||
}
|
||||
|
||||
describe("ACP streamed message boundaries", () => {
|
||||
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
textChunk("private ", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("reasoning", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("public ", "agent_message_chunk", "shared-id"),
|
||||
textChunk("reply", "agent_message_chunk", "shared-id"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "reasoning", text: "private reasoning" },
|
||||
{ type: "assistant_message", text: "public reply" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "tool call",
|
||||
@@ -1584,8 +1611,12 @@ describe("ACP session history replay", () => {
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user-1", text: "What is the " },
|
||||
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
|
||||
{ type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " },
|
||||
{
|
||||
type: "user_message",
|
||||
id: '["user_message_chunk","user-1"]',
|
||||
text: "What is the temperature?",
|
||||
},
|
||||
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
|
||||
{ type: "tool_call", id: "tool-1", status: "running" },
|
||||
{ type: "tool_call", id: "tool-1", status: "completed" },
|
||||
@@ -1599,6 +1630,48 @@ describe("ACP session history replay", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("What is the ", "shared-id"),
|
||||
userChunk("temperature?", "shared-id"),
|
||||
textChunk("Checking ", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("the sensor", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("It is ", "agent_message_chunk", "shared-id"),
|
||||
textChunk("25°C", "agent_message_chunk", "shared-id"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
const items = new Map(
|
||||
events.flatMap((event) =>
|
||||
event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [],
|
||||
),
|
||||
);
|
||||
expect([...items.values()]).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
id: '["user_message_chunk","shared-id"]',
|
||||
messageId: "shared-id",
|
||||
text: "What is the temperature?",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: '["agent_thought_chunk","shared-id"]',
|
||||
text: "Checking the sensor",
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
id: '["agent_message_chunk","shared-id"]',
|
||||
messageId: "shared-id",
|
||||
text: "It is 25°C",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user