feat(app): make Explorer a first-class pane host (#3826)

This commit is contained in:
Mohamed Boudra
2026-08-25 13:25:57 +02:00
committed by GitHub
parent 54299a2e6c
commit c914bcb44b
191 changed files with 8634 additions and 4730 deletions
@@ -37,6 +37,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
onActivate={activate}
onSelect={onSelectPath}
onOpenFile={working?.onOpenFile}
onOpenToSide={working?.onOpenToSide}
onAddToChat={working?.onAddToChat}
onCopyPath={working?.onCopyPath}
onCopyRelativePath={working?.onCopyRelativePath}
@@ -60,6 +61,7 @@ function documentFileHeaderPropsEqual(
previous.mode.onFilePress === next.mode.onFilePress &&
previous.mode.workspaceFileDragScope === next.mode.workspaceFileDragScope &&
previous.mode.onOpenFile === next.mode.onOpenFile &&
previous.mode.onOpenToSide === next.mode.onOpenToSide &&
previous.mode.onAddToChat === next.mode.onAddToChat &&
previous.mode.onCopyPath === next.mode.onCopyPath &&
previous.mode.onCopyRelativePath === next.mode.onCopyRelativePath &&
+6 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { withUnistyles } from "react-native-unistyles";
import { RenderProfile } from "@/utils/render-profiler";
import { createDiffPalette, retainDiffPalette } from "./palette";
import { DiffSurface } from "./surface";
import type { DiffDocumentProps, DiffPalette } from "./types";
@@ -47,5 +48,9 @@ const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
}));
export function DiffDocument(props: DiffDocumentProps) {
return <StyledDiffDocument {...props} />;
return (
<RenderProfile id="DiffDocument">
<StyledDiffDocument {...props} />
</RenderProfile>
);
}
@@ -8,12 +8,7 @@ import { DocumentFileHeader } from "./document-file-header";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import {
buildDiffDocumentModel,
FILE_HEADER_HEIGHT,
resolveRelayoutScrollTop,
retainReusableModels,
} from "./model";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
import { paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import { createMeasuredAdvances } from "./text-measurement";
@@ -25,22 +20,20 @@ import type {
DiffTypography,
TextMeasurer,
} from "./types";
import { useDiffDocumentWorkspaceCache } from "./workspace-cache";
const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
const RESIZE_SETTLE_DELAY_MS = 120;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
dependencies: readonly unknown[];
models: ReturnType<typeof buildDiffDocumentModel>[];
} | null>(null);
const consumedFocusRef = useRef<string | null>(null);
const scrollTopRef = useRef(0);
const horizontalOffsetsRef = useRef(new Map<string, number>());
@@ -60,7 +53,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
@@ -81,10 +73,28 @@ export function DiffSurface(props: DiffSurfaceProps) {
}),
[family, props.displayPreferences.codeFontSize],
);
const measurement = useMemo(
() => (loadedTypography ? createWebTextMeasurer(loadedTypography) : null),
[loadedTypography],
const typographyResource = useMemo(
() =>
workspaceCache.typography({
typography: desiredTypography,
load: async () => {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
]);
await document.fonts.ready;
},
createMeasurer: () => createWebTextMeasurer(desiredTypography),
}),
[desiredTypography, workspaceCache],
);
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
typeof workspaceCache.typography
> | null>(() => (typographyResource.isReady() ? typographyResource : null));
const loadedTypography =
readyTypographyResource === typographyResource ? typographyResource.typography : null;
const measurement =
readyTypographyResource === typographyResource ? typographyResource.measureText : null;
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
const model = useMemo(() => {
if (!loadedTypography || !measurement) {
@@ -95,22 +105,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
lineHeight: desiredTypography.lineHeight,
});
}
const dependencies = [
props.files,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
viewport.width,
loadedTypography,
measurement,
props.palette,
t,
] as const;
const previous = reusableModelRef.current;
const canReuse = previous?.dependencies.every(
(dependency, index) => dependency === dependencies[index],
);
const reuseFrom = canReuse ? previous?.models : undefined;
const next = buildDiffDocumentModel({
return workspaceCache.buildModel({
files: props.files,
collapsedFilePaths: props.collapsedFilePaths,
layout: props.displayPreferences.layout,
@@ -124,13 +119,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
reuseFrom,
});
reusableModelRef.current = {
dependencies,
models: retainReusableModels(reuseFrom, next),
};
return next;
}, [
measurement,
props.collapsedFilePaths,
@@ -143,6 +132,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
desiredTypography.lineHeight,
loadedTypography,
viewport.width,
workspaceCache,
]);
modelRef.current = model;
@@ -280,18 +270,19 @@ export function DiffSurface(props: DiffSurfaceProps) {
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
]);
await document.fonts.ready;
if (!cancelled) setLoadedTypography(desiredTypography);
})();
if (typographyResource.isReady()) {
setReadyTypographyResource(typographyResource);
return;
}
setReadyTypographyResource(null);
void typographyResource.load().then(() => {
if (!cancelled) setReadyTypographyResource(typographyResource);
return undefined;
});
return () => {
cancelled = true;
};
}, [desiredTypography]);
}, [typographyResource]);
useLayoutEffect(() => {
const previous = previousModelRef.current;
const scroll = scrollRef.current;
@@ -20,6 +20,7 @@ export interface WorkingDiffMode {
focusRequestId?: number;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
onOpenFile?: (path: string) => void;
onOpenToSide?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onCopyRelativePath?: (path: string) => void;
@@ -0,0 +1,173 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
const palette: DiffPalette = {
surface: "#000",
headerSurface: "#111",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
addition: "green",
deletion: "red",
additionBackground: "#010",
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
syntax: { keyword: "purple" },
};
function diffFile(): ParsedDiffFile {
return {
path: "src/a.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "const answer = 42;" }],
},
],
};
}
function countingMeasurer() {
const stats = { calls: 0 };
const measureText: TextMeasurer = {
measure(text) {
stats.calls += 1;
return text.length * 8;
},
};
return { measureText, stats };
}
function modelInput(
files: readonly ParsedDiffFile[],
measureText: TextMeasurer,
overrides: Partial<BuildDiffDocumentModelInput> = {},
): BuildDiffDocumentModelInput {
return {
files,
collapsedFilePaths: new Set(),
layout: "unified",
wrapLines: false,
viewportWidth: 800,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText,
palette,
labels: { binary: "Binary", tooLarge: "Too large" },
...overrides,
};
}
describe("diff document workspace cache", () => {
it("reuses measured rows until a model-building input changes", () => {
const cache = createDiffDocumentWorkspaceCache();
const files = [diffFile()];
const { measureText, stats } = countingMeasurer();
const input = modelInput(files, measureText);
const first = cache.buildModel(input);
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
const second = cache.buildModel(input);
expect(stats.calls).toBe(0);
expect(second).toBe(first);
const collapsed = cache.buildModel({
...input,
collapsedFilePaths: new Set(["src/a.ts"]),
});
expect(collapsed).not.toBe(first);
expect(collapsed.files[0]?.isCollapsed).toBe(true);
cache.buildModel({ ...input, viewportWidth: 640 });
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
cache.buildModel({
...input,
typography: { family: "monospace", size: 13, lineHeight: 20 },
});
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
cache.buildModel({ ...input, palette: { ...palette, foreground: "#eee" } });
expect(stats.calls).toBeGreaterThan(0);
stats.calls = 0;
cache.buildModel({ ...input, files: [...files] });
expect(stats.calls).toBeGreaterThan(0);
});
it("bounds retained geometry variants for one diff payload", () => {
const cache = createDiffDocumentWorkspaceCache();
const files = [diffFile()];
const { measureText, stats } = countingMeasurer();
for (const viewportWidth of [600, 700, 800, 900, 1000]) {
cache.buildModel(modelInput(files, measureText, { viewportWidth }));
}
stats.calls = 0;
cache.buildModel(modelInput(files, measureText, { viewportWidth: 600 }));
expect(stats.calls).toBeGreaterThan(0);
});
it("invalidates status rows when their translated labels change", () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
const files: ParsedDiffFile[] = [{ ...diffFile(), status: "binary", hunks: [] }];
const first = cache.buildModel(modelInput(files, measureText));
const second = cache.buildModel(
modelInput(files, measureText, {
labels: { binary: "Binary blob", tooLarge: "Too large" },
}),
);
expect(first.rows[0]).toMatchObject({ kind: "status", label: "Binary" });
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
});
it("shares loaded typography and its text measurer across mounts", async () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
let loadCount = 0;
let measurerCount = 0;
const typography = { family: "monospace", size: 12, lineHeight: 18 };
const resource = cache.typography({
typography,
load: async () => {
loadCount += 1;
},
createMeasurer: () => {
measurerCount += 1;
return measureText;
},
});
const reused = cache.typography({
typography: { ...typography },
load: async () => {
loadCount += 1;
},
createMeasurer: () => {
measurerCount += 1;
return measureText;
},
});
expect(reused).toBe(resource);
expect(measurerCount).toBe(1);
await Promise.all([resource.load(), reused.load()]);
expect(loadCount).toBe(1);
expect(resource.isReady()).toBe(true);
});
});
@@ -0,0 +1,168 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import invariant from "tiny-invariant";
import { buildDiffDocumentModel, retainReusableModels } from "./model";
import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
DiffPalette,
DiffTypography,
TextMeasurer,
} from "./types";
const MAX_MODEL_VARIANTS = 4;
const MAX_TYPOGRAPHY_RESOURCES = 4;
interface ModelVariant {
key: string;
exactKey: string;
model: DiffDocumentModel;
models: DiffDocumentModel[];
}
interface TypographyResourceInput {
typography: DiffTypography;
load: () => Promise<void>;
createMeasurer: () => TextMeasurer;
}
export interface DiffTypographyResource {
typography: DiffTypography;
measureText: TextMeasurer;
isReady(): boolean;
load(): Promise<DiffTypography>;
}
export interface DiffDocumentWorkspaceCache {
buildModel(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): DiffDocumentModel;
typography(input: TypographyResourceInput): DiffTypographyResource;
}
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], 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 variant = variantIndex === -1 ? undefined : variants[variantIndex];
if (variant?.exactKey === exactKey) {
variants.splice(variantIndex, 1);
variants.unshift(variant);
return variant.model;
}
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
const nextVariant = {
key,
exactKey,
model,
models: retainReusableModels(variant?.models, model),
};
if (variantIndex !== -1) variants.splice(variantIndex, 1);
variants.unshift(nextVariant);
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
return model;
},
typography(input) {
const key = typographyKey(input.typography);
const existing = typographyResources.get(key);
if (existing) {
typographyResources.delete(key);
typographyResources.set(key, existing);
return existing;
}
const resource = createTypographyResource(input);
typographyResources.set(key, resource);
while (typographyResources.size > MAX_TYPOGRAPHY_RESOURCES) {
const oldestKey = typographyResources.keys().next().value;
if (oldestKey === undefined) break;
typographyResources.delete(oldestKey);
}
return resource;
},
};
}
function createTypographyResource(input: TypographyResourceInput): DiffTypographyResource {
let ready = false;
let pending: Promise<DiffTypography> | null = null;
return {
typography: input.typography,
measureText: input.createMeasurer(),
isReady: () => ready,
load() {
if (ready) return Promise.resolve(input.typography);
pending ??= input.load().then(() => {
ready = true;
return input.typography;
});
return pending;
},
};
}
function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
return JSON.stringify([
input.layout,
input.wrapLines,
input.viewportWidth,
typographyKey(input.typography),
paletteKey(input.palette),
input.labels.binary,
input.labels.tooLarge,
]);
}
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
const reviewGeometry = input.reviewActions
? {
comments: [...input.reviewActions.commentsByTarget.entries()]
.map(([target, comments]) => [target, comments.map((comment) => comment.id).sort()])
.sort(([left], [right]) => String(left).localeCompare(String(right))),
editor: input.reviewActions.editor
? [input.reviewActions.editor.target.key, input.reviewActions.editor.commentId]
: null,
}
: null;
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
}
function typographyKey(typography: DiffTypography): string {
return JSON.stringify([typography.family, typography.size, typography.lineHeight]);
}
function paletteKey(palette: DiffPalette): string {
const syntax = Object.keys(palette.syntax)
.sort()
.map((name) => [name, palette.syntax[name]]);
return JSON.stringify([
palette.surface,
palette.headerSurface,
palette.border,
palette.foreground,
palette.foregroundMuted,
palette.addition,
palette.deletion,
palette.additionBackground,
palette.deletionBackground,
palette.emptyBackground,
palette.selection,
syntax,
]);
}
const WorkspaceCacheContext = createContext<DiffDocumentWorkspaceCache | null>(null);
export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) {
const [cache] = useState(createDiffDocumentWorkspaceCache);
return <WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>;
}
export function useDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
const cache = useContext(WorkspaceCacheContext);
invariant(cache, "DiffDocumentWorkspaceCacheProvider is required");
return cache;
}