mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
* feat(app): give compact Changes an overview and a straight rail Three fixes to the Explorer's Changes tab on phones, where there is no tree rail and the diff is the only view of what changed. No placeholder stat while the sidebar slides in. `useCheckoutDiffQuery` reported `isLoading` only when the query was enabled and connected, so an inactive retained panel settled on `files: []`. The comparison toolbar summed that into "+0 -0" and the body rendered "No changes" for a diff nobody had fetched yet, both replaced a moment later by real numbers. The result now derives from the payload alone: no payload means loading, whatever the query is doing. Callers already handle loading correctly, so nothing downstream needed a special case. Jump to file. A floating action button over the diff opens a sheet with the same changed-files tree the desktop rail renders; tapping a file closes the sheet and scrolls the diff there. `ChangedFilesTree` moved out of `diff-pane.tsx` into its own module so both surfaces own one tree, and its list is now the sheet-aware one so it scrolls with the sheet gesture on native. `DiffDocument` takes a `contentInsetBottom` so the button never traps the last line. Rails on the compact header. The tab labels sat at 20dp and the close X at 16dp while every toolbar row below them used 12dp leading and 8dp trailing. The close button was also a hand-rolled Pressable rather than a ToolbarButton. `paneContentToolbarTrailingPadding` now takes whether the row's last control is a bare glyph or a framed one: a glyph's hitbox overhangs its ink so the row pads by less than the rail, but the compact git-actions trigger paints its own frame and was landing flush against the screen edge. The commits header chevron moved to the depth-0 tree rail. Desktop geometry is unchanged. * fix(app): preserve Explorer touch targets and reset file overview * fix(app): preserve diff selections across repaints Header typography refreshes and height resizes changed the repaint callback and inadvertently cleared active source selections. Keep selection updates stable while retaining explicit source and selection-layout invalidation. * test(app): express diff copy regression as user actions * test(app): express file overview flows as user actions
1171 lines
41 KiB
TypeScript
1171 lines
41 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { ViewStyle } from "react-native";
|
|
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
|
|
import { copyToClipboard } from "@/utils/copy-to-clipboard";
|
|
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
|
import { DocumentFileHeader } from "./document-file-header";
|
|
import {
|
|
diffInteractionWindowTop,
|
|
diffMaterializationWindow,
|
|
resolveVisibleFileSections,
|
|
} from "./header-layout";
|
|
import {
|
|
hitTestDiffDocument,
|
|
selectAllSource,
|
|
selectCellSource,
|
|
selectedSourceText,
|
|
} from "./hit-testing";
|
|
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
|
import { HorizontalScroll } from "./horizontal-scroll.web";
|
|
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
|
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
|
import { hasPointerDragStarted } from "./pointer-gesture";
|
|
import { createMeasuredAdvances } from "./text-measurement";
|
|
import { retainDiffViewport } from "./viewport";
|
|
import type {
|
|
DiffHit,
|
|
DiffFileSection,
|
|
DiffSelection,
|
|
DiffSurfaceProps,
|
|
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;
|
|
|
|
interface StickyHeaderCanvasSlot {
|
|
section: HTMLDivElement | null;
|
|
canvas: HTMLCanvasElement | null;
|
|
file: DiffFileSection | null;
|
|
active: boolean;
|
|
width: number;
|
|
ratio: number;
|
|
palette: DiffSurfaceProps["palette"] | null;
|
|
typography: DiffSurfaceProps["headerTypography"] | null;
|
|
}
|
|
|
|
function emptyStickyHeaderCanvasSlot(): StickyHeaderCanvasSlot {
|
|
return {
|
|
section: null,
|
|
canvas: null,
|
|
file: null,
|
|
active: false,
|
|
width: 0,
|
|
ratio: 0,
|
|
palette: null,
|
|
typography: null,
|
|
};
|
|
}
|
|
|
|
export function DiffSurface(props: DiffSurfaceProps) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const workspaceCache = useDiffDocumentWorkspaceCache();
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const stickyHeaderSlotsRef = useRef<[StickyHeaderCanvasSlot, StickyHeaderCanvasSlot]>([
|
|
emptyStickyHeaderCanvasSlot(),
|
|
emptyStickyHeaderCanvasSlot(),
|
|
]);
|
|
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 consumedFocusRef = useRef<string | null>(null);
|
|
const scrollTopRef = useRef(0);
|
|
const horizontalOffsetsRef = useRef(new Map<string, number>());
|
|
const selectionRef = useRef<DiffSelection | null>(null);
|
|
const dragRef = useRef<{
|
|
anchor: DiffSelection["anchor"];
|
|
startX: number;
|
|
startY: number;
|
|
moved: boolean;
|
|
dismissSelectionOnClick: boolean;
|
|
} | null>(null);
|
|
const frameRef = useRef<number | null>(null);
|
|
const activeHeaderPathRef = useRef<string | null>(null);
|
|
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const resizeReleaseFrameRef = useRef<number | null>(null);
|
|
const resizePointerActiveRef = useRef(false);
|
|
const pendingViewportRef = useRef({ width: 0, height: 0 });
|
|
const forcePaintRef = useRef(true);
|
|
const canvasWindowRef = useRef({ top: 0, height: 0 });
|
|
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
const [fileWindowTop, setFileWindowTop] = useState(0);
|
|
const [interactionFiles, setInteractionFiles] = useState<
|
|
ReturnType<typeof buildDiffDocumentModel>["files"]
|
|
>([]);
|
|
const [hasSelection, setHasSelection] = useState(false);
|
|
const [contextHit, setContextHit] = useState<Extract<DiffHit, { kind: "cell" }> | null>(null);
|
|
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
|
hit: Extract<DiffHit, { kind: "cell" }>;
|
|
left: number;
|
|
top: number;
|
|
} | null>(null);
|
|
const hasHoveredAffordanceRef = useRef(false);
|
|
const family = props.displayPreferences.monoFontFamily.trim() || DEFAULT_MONO_STACK;
|
|
useLayoutEffect(() => {
|
|
const stats = (window as typeof window & { __PASEO_DIFF_REACT_STATS__?: { commits: number } })
|
|
.__PASEO_DIFF_REACT_STATS__;
|
|
if (stats) stats.commits += 1;
|
|
});
|
|
const desiredTypography = useMemo<DiffTypography>(
|
|
() => ({
|
|
family,
|
|
size: props.displayPreferences.codeFontSize,
|
|
lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5),
|
|
}),
|
|
[family, props.displayPreferences.codeFontSize],
|
|
);
|
|
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}`),
|
|
document.fonts.load(
|
|
`400 ${props.headerTypography.size}px ${props.headerTypography.family}`,
|
|
),
|
|
document.fonts.load(
|
|
`400 ${props.headerTypography.statSize}px ${props.headerTypography.family}`,
|
|
),
|
|
]);
|
|
await document.fonts.ready;
|
|
},
|
|
createMeasurer: () => createWebTextMeasurer(desiredTypography),
|
|
}),
|
|
[desiredTypography, props.headerTypography, 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) {
|
|
return emptyDiffDocumentModel({
|
|
layout: props.displayPreferences.layout,
|
|
wrapLines: props.displayPreferences.wrapLines,
|
|
viewportWidth: viewport.width,
|
|
lineHeight: desiredTypography.lineHeight,
|
|
});
|
|
}
|
|
const next = workspaceCache.buildModel({
|
|
files: props.files,
|
|
collapsedFilePaths: props.collapsedFilePaths,
|
|
layout: props.displayPreferences.layout,
|
|
wrapLines: props.displayPreferences.wrapLines,
|
|
viewportWidth: viewport.width,
|
|
typography: loadedTypography,
|
|
measureText: measurement,
|
|
palette: props.palette,
|
|
reviewActions,
|
|
labels: {
|
|
binary: t("workspace.git.diff.binaryFile"),
|
|
tooLarge: t("workspace.git.diff.tooLarge"),
|
|
},
|
|
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
|
});
|
|
return next;
|
|
}, [
|
|
fileWindowTop,
|
|
measurement,
|
|
props.collapsedFilePaths,
|
|
props.displayPreferences.layout,
|
|
props.displayPreferences.wrapLines,
|
|
props.files,
|
|
props.palette,
|
|
reviewActions,
|
|
t,
|
|
desiredTypography.lineHeight,
|
|
loadedTypography,
|
|
viewport.width,
|
|
viewport.height,
|
|
workspaceCache,
|
|
]);
|
|
modelRef.current = model;
|
|
|
|
const paintStickyHeaderPool = useCallback(
|
|
(currentModel: ReturnType<typeof buildDiffDocumentModel>, scrollTop: number) => {
|
|
const stickyFile = resolveVisibleFileSections({
|
|
files: currentModel.files,
|
|
scrollTop,
|
|
viewportHeight: Math.max(1, viewport.height),
|
|
overscan: 0,
|
|
}).sticky?.file;
|
|
const pooledFiles = stickyFile
|
|
? [stickyFile, currentModel.files[stickyFile.fileIndex + 1]].filter(
|
|
(file): file is DiffFileSection => file !== undefined,
|
|
)
|
|
: [];
|
|
const ratio = window.devicePixelRatio || 1;
|
|
|
|
for (const [slotIndex, slot] of stickyHeaderSlotsRef.current.entries()) {
|
|
const file = pooledFiles.find((candidate) => candidate.fileIndex % 2 === slotIndex) ?? null;
|
|
const { section, canvas } = slot;
|
|
if (!section || !canvas) continue;
|
|
if (!file) {
|
|
section.style.display = "none";
|
|
slot.file = null;
|
|
continue;
|
|
}
|
|
|
|
section.style.display = "block";
|
|
section.style.top = `${file.top}px`;
|
|
section.style.height = `${file.bottom - file.top}px`;
|
|
section.dataset.diffStickyHeaderPath = file.path;
|
|
const active = activeHeaderPathRef.current === file.path;
|
|
const canReusePaint =
|
|
slot.file === file &&
|
|
slot.active === active &&
|
|
slot.width === viewport.width &&
|
|
slot.ratio === ratio &&
|
|
slot.palette === props.palette &&
|
|
slot.typography === props.headerTypography;
|
|
if (canReusePaint) continue;
|
|
|
|
const pixelWidth = Math.ceil(viewport.width * ratio);
|
|
const pixelHeight = Math.ceil(FILE_HEADER_HEIGHT * ratio);
|
|
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
|
canvas.style.width = `${viewport.width}px`;
|
|
canvas.style.height = `${FILE_HEADER_HEIGHT}px`;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) continue;
|
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
context.clearRect(0, 0, viewport.width, FILE_HEADER_HEIGHT);
|
|
paintWebFileHeader({
|
|
context,
|
|
file,
|
|
palette: props.palette,
|
|
typography: props.headerTypography,
|
|
viewportWidth: viewport.width,
|
|
y: 0,
|
|
activePath: activeHeaderPathRef.current,
|
|
});
|
|
slot.file = file;
|
|
slot.active = active;
|
|
slot.width = viewport.width;
|
|
slot.ratio = ratio;
|
|
slot.palette = props.palette;
|
|
slot.typography = props.headerTypography;
|
|
}
|
|
},
|
|
[props.headerTypography, props.palette, viewport.height, viewport.width],
|
|
);
|
|
|
|
const paint = useCallback(() => {
|
|
frameRef.current = null;
|
|
const canvas = canvasRef.current;
|
|
const currentModel = modelRef.current;
|
|
if (!canvas || !currentModel || currentModel.viewportWidth <= 0 || viewport.height <= 0) return;
|
|
const desiredHeight = Math.min(
|
|
Math.max(currentModel.height, viewport.height),
|
|
viewport.height * 3,
|
|
);
|
|
const currentWindow = canvasWindowRef.current;
|
|
const viewportTop = scrollTopRef.current;
|
|
const viewportBottom = viewportTop + viewport.height;
|
|
const safeInset = viewport.height / 2;
|
|
const mustRecenter =
|
|
currentWindow.height !== desiredHeight ||
|
|
viewportTop < currentWindow.top + safeInset ||
|
|
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
|
|
if (!forcePaintRef.current && !mustRecenter) return;
|
|
const forceFullPaint = forcePaintRef.current;
|
|
forcePaintRef.current = false;
|
|
const canvasHeight = desiredHeight;
|
|
const ratio = window.devicePixelRatio || 1;
|
|
const requestedCanvasTop = mustRecenter
|
|
? Math.min(
|
|
Math.max(0, viewportTop - viewport.height),
|
|
Math.max(0, currentModel.height - canvasHeight),
|
|
)
|
|
: currentWindow.top;
|
|
const canvasTop = Math.round(requestedCanvasTop * ratio) / ratio;
|
|
canvasWindowRef.current = { top: canvasTop, height: canvasHeight };
|
|
const pixelWidth = Math.ceil(currentModel.viewportWidth * ratio);
|
|
const pixelHeight = Math.ceil(canvasHeight * ratio);
|
|
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
|
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
|
canvas.style.width = `${currentModel.viewportWidth}px`;
|
|
canvas.style.top = `${canvasTop}px`;
|
|
canvas.style.height = `${canvasHeight}px`;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return;
|
|
if (!measurement || !loadedTypography) return;
|
|
const { paintTop, paintHeight } = shiftCanvasPixels({
|
|
canvas,
|
|
scratch:
|
|
canvasScratchRef.current ?? (canvasScratchRef.current = document.createElement("canvas")),
|
|
context,
|
|
currentTop: currentWindow.top,
|
|
nextTop: canvasTop,
|
|
canvasHeight,
|
|
pixelWidth,
|
|
pixelHeight,
|
|
ratio,
|
|
canReuse: !forceFullPaint && !resized && currentWindow.height === canvasHeight,
|
|
});
|
|
paintWebViewport({
|
|
context,
|
|
model: currentModel,
|
|
palette: props.palette,
|
|
typography: loadedTypography,
|
|
headerTypography: props.headerTypography,
|
|
measureText: measurement,
|
|
scrollTop: canvasTop,
|
|
viewportWidth: currentModel.viewportWidth,
|
|
viewportHeight: canvasHeight,
|
|
horizontalOffsets: horizontalOffsetsRef.current,
|
|
selection: selectionRef.current,
|
|
activeHeaderPath: activeHeaderPathRef.current,
|
|
devicePixelRatio: ratio,
|
|
paintTop,
|
|
paintHeight,
|
|
});
|
|
}, [loadedTypography, measurement, props.headerTypography, props.palette, viewport.height]);
|
|
const schedulePaint = useCallback(
|
|
(force = true) => {
|
|
if (force) forcePaintRef.current = true;
|
|
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
|
},
|
|
[paint],
|
|
);
|
|
useLayoutEffect(() => {
|
|
const root = rootRef.current;
|
|
if (!root) return;
|
|
const commitPendingViewport = () =>
|
|
setViewport((current) => retainDiffViewport(current, pendingViewportRef.current));
|
|
const clearResizeSettleTimer = () => {
|
|
if (resizeSettleTimerRef.current === null) return;
|
|
clearTimeout(resizeSettleTimerRef.current);
|
|
resizeSettleTimerRef.current = null;
|
|
};
|
|
const handlePointerDown = () => {
|
|
resizePointerActiveRef.current = true;
|
|
clearResizeSettleTimer();
|
|
};
|
|
const handlePointerEnd = () => {
|
|
if (!resizePointerActiveRef.current) return;
|
|
resizePointerActiveRef.current = false;
|
|
if (resizeReleaseFrameRef.current !== null) {
|
|
cancelAnimationFrame(resizeReleaseFrameRef.current);
|
|
}
|
|
resizeReleaseFrameRef.current = requestAnimationFrame(() => {
|
|
resizeReleaseFrameRef.current = null;
|
|
commitPendingViewport();
|
|
});
|
|
};
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
if (!entry) return;
|
|
const nextViewport = { width: entry.contentRect.width, height: entry.contentRect.height };
|
|
pendingViewportRef.current = nextViewport;
|
|
if (resizePointerActiveRef.current) return;
|
|
setViewport((currentViewport) =>
|
|
currentViewport.width > 0 && currentViewport.height > 0
|
|
? currentViewport
|
|
: retainDiffViewport(currentViewport, nextViewport),
|
|
);
|
|
clearResizeSettleTimer();
|
|
resizeSettleTimerRef.current = setTimeout(() => {
|
|
resizeSettleTimerRef.current = null;
|
|
commitPendingViewport();
|
|
}, RESIZE_SETTLE_DELAY_MS);
|
|
});
|
|
observer.observe(root);
|
|
window.addEventListener("pointerdown", handlePointerDown, { capture: true });
|
|
window.addEventListener("pointerup", handlePointerEnd, { capture: true });
|
|
window.addEventListener("pointercancel", handlePointerEnd, { capture: true });
|
|
return () => {
|
|
observer.disconnect();
|
|
clearResizeSettleTimer();
|
|
if (resizeReleaseFrameRef.current !== null)
|
|
cancelAnimationFrame(resizeReleaseFrameRef.current);
|
|
window.removeEventListener("pointerdown", handlePointerDown, { capture: true });
|
|
window.removeEventListener("pointerup", handlePointerEnd, { capture: true });
|
|
window.removeEventListener("pointercancel", handlePointerEnd, { capture: true });
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (typographyResource.isReady()) {
|
|
setReadyTypographyResource(typographyResource);
|
|
return;
|
|
}
|
|
setReadyTypographyResource(null);
|
|
void typographyResource.load().then(() => {
|
|
if (!cancelled) setReadyTypographyResource(typographyResource);
|
|
return undefined;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [typographyResource]);
|
|
useLayoutEffect(() => {
|
|
const previous = previousModelRef.current;
|
|
const scroll = scrollRef.current;
|
|
if (previous && scroll && previous !== model) {
|
|
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop);
|
|
scrollTopRef.current = nextScrollTop;
|
|
scroll.scrollTop = nextScrollTop;
|
|
}
|
|
previousModelRef.current = model;
|
|
}, [model]);
|
|
useLayoutEffect(() => {
|
|
horizontalOffsetsRef.current = retainHorizontalOffsetMapForPaths(
|
|
horizontalOffsetsRef.current,
|
|
props.files.map((file) => file.path),
|
|
);
|
|
}, [props.files]);
|
|
const updateInteractionFiles = useCallback(
|
|
(scrollTop: number) => {
|
|
const windowTop = diffInteractionWindowTop(scrollTop, viewport.height);
|
|
setFileWindowTop((current) => (current === windowTop ? current : windowTop));
|
|
const next = resolveVisibleFileSections({
|
|
files: model.files,
|
|
scrollTop: windowTop,
|
|
viewportHeight: viewport.height,
|
|
overscan: viewport.height * 2,
|
|
}).files;
|
|
setInteractionFiles((current) =>
|
|
current.length === next.length && current[0] === next[0] && current.at(-1) === next.at(-1)
|
|
? current
|
|
: next,
|
|
);
|
|
},
|
|
[model.files, viewport.height],
|
|
);
|
|
useLayoutEffect(() => {
|
|
schedulePaint();
|
|
paintStickyHeaderPool(model, scrollTopRef.current);
|
|
updateInteractionFiles(scrollTopRef.current);
|
|
}, [model, paintStickyHeaderPool, schedulePaint, updateInteractionFiles]);
|
|
useEffect(
|
|
() => () => {
|
|
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
|
// Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
|
|
// request so the refreshed renderer can schedule its first paint.
|
|
frameRef.current = null;
|
|
},
|
|
[],
|
|
);
|
|
const mode = props.mode;
|
|
const collapsedFilePaths = props.collapsedFilePaths;
|
|
const onToggleFile = props.onToggleFile;
|
|
useEffect(() => {
|
|
if (mode.kind !== "working") return;
|
|
const focusPath = mode.focusPath;
|
|
if (!focusPath) return;
|
|
const requestKey = `${mode.focusRequestId ?? "initial"}:${focusPath}`;
|
|
if (consumedFocusRef.current === requestKey) return;
|
|
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
|
|
const file = model.files.find((entry) => entry.path === focusPath);
|
|
if (file && scrollRef.current) {
|
|
scrollRef.current.scrollTop = file.top;
|
|
consumedFocusRef.current = requestKey;
|
|
}
|
|
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
|
|
|
const handleVerticalScroll = useCallback(
|
|
(scrollElement: HTMLDivElement) => {
|
|
const scrollTop = scrollElement.scrollTop;
|
|
scrollTopRef.current = scrollTop;
|
|
const clearedActiveHeader = activeHeaderPathRef.current !== null;
|
|
if (clearedActiveHeader) {
|
|
activeHeaderPathRef.current = null;
|
|
schedulePaint();
|
|
}
|
|
updateInteractionFiles(scrollTop);
|
|
if (hasHoveredAffordanceRef.current) {
|
|
hasHoveredAffordanceRef.current = false;
|
|
setHoveredAffordance(null);
|
|
}
|
|
const currentModel = modelRef.current;
|
|
if (currentModel) paintStickyHeaderPool(currentModel, scrollTop);
|
|
const currentWindow = canvasWindowRef.current;
|
|
if (!currentModel || currentWindow.height === 0) {
|
|
schedulePaint(false);
|
|
return;
|
|
}
|
|
const viewportBottom = scrollTop + viewport.height;
|
|
const safeInset = viewport.height / 2;
|
|
const crossedSafeInset =
|
|
scrollTop < currentWindow.top + safeInset ||
|
|
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
|
|
if (!crossedSafeInset) return;
|
|
const requestedTop = Math.min(
|
|
Math.max(0, scrollTop - viewport.height),
|
|
Math.max(0, currentModel.height - currentWindow.height),
|
|
);
|
|
const ratio = window.devicePixelRatio || 1;
|
|
const nextTop = Math.round(requestedTop * ratio) / ratio;
|
|
if (nextTop !== currentWindow.top) schedulePaint(false);
|
|
},
|
|
[paintStickyHeaderPool, schedulePaint, updateInteractionFiles, viewport.height],
|
|
);
|
|
useEffect(() => {
|
|
const scroll = scrollRef.current;
|
|
if (!scroll) return;
|
|
const onScroll = () => handleVerticalScroll(scroll);
|
|
scroll.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => scroll.removeEventListener("scroll", onScroll);
|
|
}, [handleVerticalScroll]);
|
|
const handleHorizontalScroll = useCallback(
|
|
(path: string, offset: number) => {
|
|
horizontalOffsetsRef.current.set(path, offset);
|
|
schedulePaint();
|
|
},
|
|
[schedulePaint],
|
|
);
|
|
const pointHitAt = useCallback((clientX: number, clientY: number) => {
|
|
const currentModel = modelRef.current;
|
|
const root = rootRef.current;
|
|
if (!currentModel || !root) return null;
|
|
const bounds = root.getBoundingClientRect();
|
|
const x = clientX - bounds.left;
|
|
const documentY = clientY - bounds.top + scrollTopRef.current;
|
|
const file = currentModel.files.find(
|
|
(entry) => entry.top <= documentY && documentY < entry.bottom,
|
|
);
|
|
return hitTestDiffDocument({
|
|
model: currentModel,
|
|
x,
|
|
documentY,
|
|
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
|
|
});
|
|
}, []);
|
|
const pointHit = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
|
|
[pointHitAt],
|
|
);
|
|
const setSelection = useStableEvent((selection: DiffSelection | null) => {
|
|
selectionRef.current = selection;
|
|
const currentModel = modelRef.current;
|
|
setHasSelection(
|
|
Boolean(selection && currentModel && selectedSourceText(currentModel, selection).length > 0),
|
|
);
|
|
schedulePaint();
|
|
});
|
|
// Source and code-layout changes invalidate selection coordinates. A new
|
|
// repaint callback (header typography, palette, viewport height) does not.
|
|
useEffect(() => {
|
|
dragRef.current = null;
|
|
setSelection(null);
|
|
}, [
|
|
props.collapsedFilePaths,
|
|
props.displayPreferences.layout,
|
|
props.files,
|
|
desiredTypography,
|
|
setSelection,
|
|
]);
|
|
const pointerDown = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (event.button !== 0) return;
|
|
const target = event.target;
|
|
if (
|
|
target instanceof Element &&
|
|
target.closest(
|
|
'[data-diff-header="true"], [data-diff-review="true"], [role="menuitem"], button, input, textarea',
|
|
)
|
|
)
|
|
return;
|
|
const dismissSelectionOnClick = selectionRef.current !== null;
|
|
if (dismissSelectionOnClick) {
|
|
setSelection(null);
|
|
}
|
|
const hit = pointHit(event);
|
|
if (hit?.kind !== "cell") return;
|
|
dragRef.current = {
|
|
anchor: hit.position,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
moved: false,
|
|
dismissSelectionOnClick,
|
|
};
|
|
setSelection({ anchor: hit.position, focus: hit.position });
|
|
event.currentTarget.focus();
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
},
|
|
[pointHit, setSelection],
|
|
);
|
|
const updateActiveHeader = useCallback(
|
|
(target: EventTarget | null) => {
|
|
const header =
|
|
target instanceof Element ? target.closest<HTMLElement>("[data-diff-header]") : null;
|
|
const activeHeaderPath = header?.dataset.diffHeaderPath ?? null;
|
|
if (activeHeaderPathRef.current === activeHeaderPath) return;
|
|
activeHeaderPathRef.current = activeHeaderPath;
|
|
const currentModel = modelRef.current;
|
|
if (currentModel) paintStickyHeaderPool(currentModel, scrollTopRef.current);
|
|
schedulePaint();
|
|
},
|
|
[paintStickyHeaderPool, schedulePaint],
|
|
);
|
|
const pointerMove = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
updateActiveHeader(event.target);
|
|
const drag = dragRef.current;
|
|
if (drag) {
|
|
drag.moved = hasPointerDragStarted({
|
|
startX: drag.startX,
|
|
startY: drag.startY,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
alreadyDragging: drag.moved,
|
|
});
|
|
}
|
|
const hit = pointHit(event);
|
|
if (hit?.kind === "cell") {
|
|
const row = modelRef.current?.rows[hit.position.rowIndex];
|
|
const file = modelRef.current?.files[hit.position.fileIndex];
|
|
const sideIndex =
|
|
row?.kind === "line" && row.cells.length === 2 && hit.position.side === "new" ? 1 : 0;
|
|
if (row && file) {
|
|
const columnWidth = viewport.width / (row.kind === "line" ? row.cells.length : 1);
|
|
const gutterBorder = sideIndex * columnWidth + file.gutterWidth;
|
|
const rootBounds = rootRef.current?.getBoundingClientRect();
|
|
const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN;
|
|
if (hit.target && Number.isFinite(pointerX)) {
|
|
hasHoveredAffordanceRef.current = true;
|
|
setHoveredAffordance({
|
|
hit,
|
|
left: gutterBorder - 12,
|
|
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2,
|
|
});
|
|
} else {
|
|
hasHoveredAffordanceRef.current = false;
|
|
setHoveredAffordance(null);
|
|
}
|
|
}
|
|
} else {
|
|
hasHoveredAffordanceRef.current = false;
|
|
setHoveredAffordance(null);
|
|
}
|
|
if (!drag || hit?.kind !== "cell") return;
|
|
setSelection({ anchor: drag.anchor, focus: hit.position });
|
|
},
|
|
[pointHit, setSelection, updateActiveHeader, viewport.width],
|
|
);
|
|
const pointerUp = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
const drag = dragRef.current;
|
|
const hit = pointHit(event);
|
|
dragRef.current = null;
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
const moved = drag
|
|
? hasPointerDragStarted({
|
|
startX: drag.startX,
|
|
startY: drag.startY,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
alreadyDragging: drag.moved,
|
|
})
|
|
: false;
|
|
if (drag && !moved && drag.dismissSelectionOnClick) {
|
|
setSelection(null);
|
|
return;
|
|
}
|
|
if (
|
|
event.pointerType === "touch" &&
|
|
drag &&
|
|
!moved &&
|
|
hit?.kind === "cell" &&
|
|
hit.target &&
|
|
reviewActions
|
|
) {
|
|
setSelection(null);
|
|
reviewActions.onStartComment(hit.target);
|
|
}
|
|
},
|
|
[pointHit, reviewActions, setSelection],
|
|
);
|
|
const cancelPointer = useCallback(() => {
|
|
dragRef.current = null;
|
|
}, []);
|
|
const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]);
|
|
const copy = useCallback(
|
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
if (!selectionRef.current) return;
|
|
event.clipboardData.setData("text/plain", selectedSourceText(model, selectionRef.current));
|
|
event.preventDefault();
|
|
},
|
|
[model],
|
|
);
|
|
const prepareContextMenu = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
const hit = pointHitAt(event.clientX, event.clientY);
|
|
setContextHit(hit?.kind === "cell" ? hit : null);
|
|
},
|
|
[pointHitAt],
|
|
);
|
|
const writeSourceText = useCallback(
|
|
(text: string) => {
|
|
void copyToClipboard(text)
|
|
.then(() => toast.copied(t("common.states.copied")))
|
|
.catch(() => toast.error(t("common.errors.unableToCopy")));
|
|
},
|
|
[t, toast],
|
|
);
|
|
const copySelectedSource = useCallback(() => {
|
|
const currentModel = modelRef.current;
|
|
const selection = selectionRef.current;
|
|
if (currentModel && selection) writeSourceText(selectedSourceText(currentModel, selection));
|
|
}, [writeSourceText]);
|
|
const copyContextLine = useCallback(() => {
|
|
const currentModel = modelRef.current;
|
|
if (!currentModel || !contextHit) return;
|
|
const selection = selectCellSource(currentModel, contextHit.position);
|
|
writeSourceText(selectedSourceText(currentModel, selection));
|
|
}, [contextHit, writeSourceText]);
|
|
const selectAll = useCallback(() => {
|
|
const currentModel = modelRef.current;
|
|
if (currentModel) {
|
|
setSelection(
|
|
selectAllSource(currentModel, contextHit?.position ?? selectionRef.current?.focus),
|
|
);
|
|
}
|
|
}, [contextHit?.position, setSelection]);
|
|
const keyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const target = event.target;
|
|
if (
|
|
target instanceof Element &&
|
|
target.closest('input, textarea, [contenteditable="true"]')
|
|
) {
|
|
return;
|
|
}
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {
|
|
event.preventDefault();
|
|
selectAll();
|
|
return;
|
|
}
|
|
if (event.key === "Escape" && selectionRef.current) {
|
|
event.preventDefault();
|
|
setSelection(null);
|
|
}
|
|
},
|
|
[selectAll, setSelection],
|
|
);
|
|
const rootStyle = useMemo<React.CSSProperties>(
|
|
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
|
|
[props.palette.surface],
|
|
);
|
|
const contentInsetBottom = props.contentInsetBottom ?? 0;
|
|
const contentStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
...CONTENT_STYLE,
|
|
height: Math.max(model.height, viewport.height) + contentInsetBottom,
|
|
}),
|
|
[contentInsetBottom, model.height, viewport.height],
|
|
);
|
|
const affordanceStyle = useMemo<ViewStyle>(
|
|
() => ({
|
|
...AFFORDANCE_STYLE,
|
|
left: hoveredAffordance?.left ?? 0,
|
|
top: hoveredAffordance?.top ?? 0,
|
|
}),
|
|
[hoveredAffordance?.left, hoveredAffordance?.top],
|
|
);
|
|
const addHoveredComment = useCallback(() => {
|
|
const target = hoveredAffordance?.hit.target;
|
|
if (target) reviewActions?.onStartComment(target);
|
|
}, [hoveredAffordance?.hit.target, reviewActions]);
|
|
const canvasStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
...CANVAS_STYLE,
|
|
fontFamily: (loadedTypography ?? desiredTypography).family,
|
|
fontSize: (loadedTypography ?? desiredTypography).size,
|
|
}),
|
|
[desiredTypography, loadedTypography],
|
|
);
|
|
|
|
const surface = (
|
|
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
|
|
<div
|
|
ref={scrollRef}
|
|
data-testid="git-diff-scroll"
|
|
data-overlay-scrollbar="true"
|
|
tabIndex={0}
|
|
onPointerDown={pointerDown}
|
|
onPointerMove={pointerMove}
|
|
onPointerUp={pointerUp}
|
|
onPointerCancel={cancelPointer}
|
|
onLostPointerCapture={cancelPointer}
|
|
onPointerLeave={pointerLeave}
|
|
onContextMenu={prepareContextMenu}
|
|
onCopy={copy}
|
|
onKeyDown={keyDown}
|
|
style={SCROLL_STYLE}
|
|
>
|
|
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
|
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
|
<StickyHeaderCanvasSlotView slotsRef={stickyHeaderSlotsRef} slotIndex={0} />
|
|
<StickyHeaderCanvasSlotView slotsRef={stickyHeaderSlotsRef} slotIndex={1} />
|
|
{interactionFiles.map((file) => (
|
|
<WebFileHeaderSection key={file.path} file={file}>
|
|
<DocumentFileHeader
|
|
file={file}
|
|
selectedPath={props.selectedPath}
|
|
mode={props.mode}
|
|
onToggleFile={props.onToggleFile}
|
|
onSelectPath={props.onSelectPath}
|
|
canvasRendered
|
|
/>
|
|
</WebFileHeaderSection>
|
|
))}
|
|
{interactionFiles
|
|
.filter((file) => !file.isCollapsed && !model.wrapLines)
|
|
.map((file) => (
|
|
<HorizontalScroll
|
|
key={file.path}
|
|
file={file}
|
|
initialOffset={horizontalOffsetsRef.current.get(file.path) ?? 0}
|
|
onScroll={handleHorizontalScroll}
|
|
/>
|
|
))}
|
|
{props.mode.kind === "working" && reviewActions
|
|
? model.rows.map((row) => {
|
|
if (row.kind !== "line" || row.reviewHeight === 0) return null;
|
|
const columnWidth = model.viewportWidth / row.cells.length;
|
|
return row.cells.map((cell, index) => {
|
|
if (!cell?.reviewTarget) return null;
|
|
const comments = reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? [];
|
|
const hasEditor =
|
|
reviewActions.editor?.target.filePath === cell.reviewTarget.filePath &&
|
|
reviewActions.editor.target.side === cell.reviewTarget.side &&
|
|
reviewActions.editor.target.lineNumber === cell.reviewTarget.lineNumber;
|
|
if (comments.length === 0 && !hasEditor) return null;
|
|
return (
|
|
<WebReviewThread
|
|
key={cell.reviewTarget.key}
|
|
target={cell.reviewTarget}
|
|
actions={reviewActions}
|
|
top={row.top + row.height - row.reviewHeight}
|
|
left={index * columnWidth}
|
|
width={columnWidth}
|
|
height={row.reviewHeight}
|
|
pinToViewport={!model.wrapLines}
|
|
/>
|
|
);
|
|
});
|
|
})
|
|
: null}
|
|
</div>
|
|
</div>
|
|
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
|
{hoveredAffordance?.hit.target && reviewActions ? (
|
|
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger contextOnly style={CONTEXT_TRIGGER_STYLE}>
|
|
{surface}
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent align="start" minWidth={180} testID="diff-source-context-menu">
|
|
<ContextMenuItem
|
|
disabled={!hasSelection}
|
|
onSelect={copySelectedSource}
|
|
testID="diff-source-copy-selection"
|
|
>
|
|
{t("common.actions.copy")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
disabled={!contextHit}
|
|
onSelect={copyContextLine}
|
|
testID="diff-source-copy-line"
|
|
>
|
|
{t("common.actions.copyLine")}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onSelect={selectAll} testID="diff-source-select-all">
|
|
{t("common.actions.selectAll")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
function StickyHeaderCanvasSlotView({
|
|
slotsRef,
|
|
slotIndex,
|
|
}: {
|
|
slotsRef: React.RefObject<[StickyHeaderCanvasSlot, StickyHeaderCanvasSlot]>;
|
|
slotIndex: 0 | 1;
|
|
}) {
|
|
const setSection = useCallback(
|
|
(section: HTMLDivElement | null) => {
|
|
slotsRef.current[slotIndex].section = section;
|
|
},
|
|
[slotIndex, slotsRef],
|
|
);
|
|
const setCanvas = useCallback(
|
|
(canvas: HTMLCanvasElement | null) => {
|
|
slotsRef.current[slotIndex].canvas = canvas;
|
|
},
|
|
[slotIndex, slotsRef],
|
|
);
|
|
return (
|
|
<div ref={setSection} style={STICKY_CANVAS_SECTION_STYLE}>
|
|
<canvas
|
|
ref={setCanvas}
|
|
data-testid={`git-diff-sticky-header-${slotIndex}`}
|
|
style={STICKY_CANVAS_STYLE}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function shiftCanvasPixels(input: {
|
|
canvas: HTMLCanvasElement;
|
|
scratch: HTMLCanvasElement;
|
|
context: CanvasRenderingContext2D;
|
|
currentTop: number;
|
|
nextTop: number;
|
|
canvasHeight: number;
|
|
pixelWidth: number;
|
|
pixelHeight: number;
|
|
ratio: number;
|
|
canReuse: boolean;
|
|
}): { paintTop: number; paintHeight: number } {
|
|
const delta = input.nextTop - input.currentTop;
|
|
if (!input.canReuse || delta === 0 || Math.abs(delta) >= input.canvasHeight) {
|
|
return { paintTop: 0, paintHeight: input.canvasHeight };
|
|
}
|
|
const deltaPixels = Math.round(Math.abs(delta) * input.ratio);
|
|
const overlapPixels = input.pixelHeight - deltaPixels;
|
|
if (input.scratch.width !== input.pixelWidth) input.scratch.width = input.pixelWidth;
|
|
if (input.scratch.height !== input.pixelHeight) input.scratch.height = input.pixelHeight;
|
|
const scratchContext = input.scratch.getContext("2d");
|
|
if (!scratchContext) return { paintTop: 0, paintHeight: input.canvasHeight };
|
|
scratchContext.setTransform(1, 0, 0, 1, 0, 0);
|
|
scratchContext.globalCompositeOperation = "copy";
|
|
scratchContext.drawImage(input.canvas, 0, 0);
|
|
input.context.setTransform(1, 0, 0, 1, 0, 0);
|
|
if (delta > 0) {
|
|
input.context.drawImage(
|
|
input.scratch,
|
|
0,
|
|
deltaPixels,
|
|
input.pixelWidth,
|
|
overlapPixels,
|
|
0,
|
|
0,
|
|
input.pixelWidth,
|
|
overlapPixels,
|
|
);
|
|
return { paintTop: input.canvasHeight - Math.abs(delta), paintHeight: Math.abs(delta) };
|
|
}
|
|
input.context.drawImage(
|
|
input.scratch,
|
|
0,
|
|
0,
|
|
input.pixelWidth,
|
|
overlapPixels,
|
|
0,
|
|
deltaPixels,
|
|
input.pixelWidth,
|
|
overlapPixels,
|
|
);
|
|
return { paintTop: 0, paintHeight: Math.abs(delta) };
|
|
}
|
|
|
|
function WebFileHeaderSection({
|
|
file,
|
|
children,
|
|
}: {
|
|
file: ReturnType<typeof buildDiffDocumentModel>["files"][number];
|
|
children: React.ReactNode;
|
|
}) {
|
|
const style = useMemo<React.CSSProperties>(
|
|
() => ({ ...FILE_SECTION_STYLE, top: file.top, height: file.bottom - file.top }),
|
|
[file.bottom, file.top],
|
|
);
|
|
return (
|
|
<div style={style} onContextMenu={stopContextMenuPropagation}>
|
|
<div
|
|
data-diff-header="true"
|
|
data-diff-header-path={file.path}
|
|
style={STICKY_HEADER_STYLE}
|
|
onMouseDown={preventDocumentMouseSelection}
|
|
>
|
|
{children}
|
|
</div>
|
|
<div data-testid={`diff-file-${file.fileIndex}-body`} style={BODY_MARKER_STYLE} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function preventDocumentMouseSelection(event: React.MouseEvent): void {
|
|
if (event.detail < 2) return;
|
|
const target = event.target;
|
|
if (target instanceof Element && target.closest('[data-diff-review="true"]')) return;
|
|
event.preventDefault();
|
|
window.getSelection()?.removeAllRanges();
|
|
}
|
|
|
|
function WebReviewThread({
|
|
target,
|
|
actions,
|
|
top,
|
|
left,
|
|
width,
|
|
height,
|
|
pinToViewport,
|
|
}: {
|
|
target: ReviewableDiffTarget;
|
|
actions: NonNullable<Extract<DiffSurfaceProps["mode"], { kind: "working" }>["reviewActions"]>;
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
height: number;
|
|
pinToViewport: boolean;
|
|
}) {
|
|
const style = useMemo<React.CSSProperties>(
|
|
() => ({ ...REVIEW_STYLE, top, left, width, height }),
|
|
[height, left, top, width],
|
|
);
|
|
return (
|
|
<div style={style} data-diff-review="true" onContextMenu={stopContextMenuPropagation}>
|
|
<InlineReviewThread
|
|
reviewTarget={target}
|
|
reviewActions={actions}
|
|
height={height}
|
|
viewportWidth={width}
|
|
pinToViewport={pinToViewport}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function stopContextMenuPropagation(event: React.MouseEvent): void {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
const ROOT_STYLE: React.CSSProperties = {
|
|
position: "relative",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflow: "hidden",
|
|
};
|
|
const CONTEXT_TRIGGER_STYLE: ViewStyle = { flex: 1, minHeight: 0 };
|
|
const SCROLL_STYLE: React.CSSProperties = {
|
|
position: "absolute",
|
|
inset: 0,
|
|
zIndex: 2,
|
|
overflowY: "auto",
|
|
overflowX: "hidden",
|
|
scrollbarWidth: "none",
|
|
cursor: "text",
|
|
};
|
|
|
|
function noop(): void {}
|
|
const CONTENT_STYLE: React.CSSProperties = {
|
|
position: "relative",
|
|
width: "100%",
|
|
userSelect: "none",
|
|
};
|
|
const FILE_SECTION_STYLE: React.CSSProperties = {
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 3,
|
|
pointerEvents: "none",
|
|
userSelect: "none",
|
|
};
|
|
const STICKY_CANVAS_SECTION_STYLE: React.CSSProperties = {
|
|
position: "absolute",
|
|
display: "none",
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 2,
|
|
pointerEvents: "none",
|
|
};
|
|
const STICKY_CANVAS_STYLE: React.CSSProperties = {
|
|
position: "sticky",
|
|
top: 0,
|
|
display: "block",
|
|
pointerEvents: "none",
|
|
};
|
|
const STICKY_HEADER_STYLE: React.CSSProperties = {
|
|
position: "sticky",
|
|
top: 0,
|
|
pointerEvents: "auto",
|
|
userSelect: "none",
|
|
};
|
|
const BODY_MARKER_STYLE: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: FILE_HEADER_HEIGHT,
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
pointerEvents: "none",
|
|
};
|
|
const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" };
|
|
const CANVAS_STYLE: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
zIndex: 1,
|
|
pointerEvents: "none",
|
|
};
|
|
const AFFORDANCE_STYLE: ViewStyle = {
|
|
position: "absolute",
|
|
zIndex: 5,
|
|
};
|
|
|
|
function emptyDiffDocumentModel(input: {
|
|
layout: "unified" | "split";
|
|
wrapLines: boolean;
|
|
viewportWidth: number;
|
|
lineHeight: number;
|
|
}): ReturnType<typeof buildDiffDocumentModel> {
|
|
return {
|
|
files: [],
|
|
rows: [],
|
|
height: 0,
|
|
lineHeight: input.lineHeight,
|
|
layout: input.layout,
|
|
wrapLines: input.wrapLines,
|
|
viewportWidth: input.viewportWidth,
|
|
reviewGeometryKey: "",
|
|
};
|
|
}
|
|
|
|
function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
|
|
const canvas = document.createElement("canvas");
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return { measure: () => 0 };
|
|
const measure = (text: string, weight: "regular" | "semibold" = "regular") => {
|
|
context.font = `${weight === "semibold" ? 600 : 400} ${typography.size}px ${typography.family}`;
|
|
return context.measureText(text).width;
|
|
};
|
|
// 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)) };
|
|
}
|