mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Restore copy actions in web diffs (#4229)
* fix(app): restore diff copy actions on web The canvas-backed diff owns selection without creating a DOM range, so browser context menus cannot expose copy actions themselves. Route keyboard and context-menu actions through the renderer's canonical source selection instead. * fix(app): harden web diff copy actions * fix(app): default split header selection to new pane * fix(app): isolate nested diff context menus * fix(app): select available split diff pane
This commit is contained in:
@@ -1019,6 +1019,16 @@ test("autofocusing an inline review keeps the Changes tab focused", async ({ pag
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("inline reviews keep the browser text context menu", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1142,6 +1152,33 @@ test("canvas diff copies a dragged character selection without opening a review"
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-selection").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-line").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
|
||||
});
|
||||
|
||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithTwoSelectionDiffs();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }, 1);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await rightClickFirstChangedLine(page, 1);
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1539,6 +1576,29 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-shift-", {
|
||||
files: [
|
||||
{ path: "src/first.ts", content: "" },
|
||||
{ path: "src/second.ts", content: "" },
|
||||
],
|
||||
});
|
||||
await Promise.all([
|
||||
writeFile(path.join(repo.path, "src/first.ts"), "FIRST\n"),
|
||||
writeFile(path.join(repo.path, "src/second.ts"), "ABCDEFGHIJ\n"),
|
||||
]);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
@@ -1753,6 +1813,20 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
|
||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
||||
}
|
||||
|
||||
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5, {
|
||||
button: "right",
|
||||
});
|
||||
}
|
||||
|
||||
async function hoverFirstChangedGutter(page: Page): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
@@ -1793,8 +1867,9 @@ async function deleteInlineReview(page: Page): Promise<void> {
|
||||
async function dragExactAddedText(
|
||||
page: Page,
|
||||
offsets: { startOffset: number; endOffset: number },
|
||||
fileIndex = 0,
|
||||
): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, metrics] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
characterOffsetAtPoint,
|
||||
hitTestDiffDocument,
|
||||
orderedSelection,
|
||||
selectAllSource,
|
||||
selectCellSource,
|
||||
selectedSourceText,
|
||||
} from "./hit-testing";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
@@ -104,6 +106,64 @@ describe("diff hit testing", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("selects one source line for context-menu copy", () => {
|
||||
const model = buildModel("copy this", { wrapLines: false });
|
||||
const cell = changedCell(model);
|
||||
|
||||
expect(selectedSourceText(model, selectCellSource(model, position(model, cell, 4)))).toBe(
|
||||
"copy this",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects all diff source in document order", () => {
|
||||
const model = buildModel("first", { wrapLines: false });
|
||||
|
||||
expect(selectedSourceText(model, selectAllSource(model)!)).toBe("@@ -1,0 +1,2 @@\nfirst\ntail");
|
||||
});
|
||||
|
||||
it("selects all source from one split side without interleaving panes", () => {
|
||||
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
|
||||
const changedRow = model.rows.find(
|
||||
(row) => row.kind === "line" && row.cells.length === 2 && row.cells.every(Boolean),
|
||||
);
|
||||
expect(changedRow?.kind).toBe("line");
|
||||
|
||||
const left = changedRow!.kind === "line" ? changedRow!.cells[0]! : null;
|
||||
const right = changedRow!.kind === "line" ? changedRow!.cells[1]! : null;
|
||||
expect(selectedSourceText(model, selectAllSource(model, position(model, left!, 0))!)).toBe(
|
||||
"@@ -1,2 +1,2 @@\nold-one\nold-two",
|
||||
);
|
||||
expect(selectedSourceText(model, selectAllSource(model, position(model, right!, 0))!)).toBe(
|
||||
"new-one\nnew-two",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults split hunk-header select-all to the new pane", () => {
|
||||
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
|
||||
const header = model.rows
|
||||
.filter((row) => row.kind === "line")
|
||||
.flatMap((row) => row.cells)
|
||||
.find((cell) => cell?.type === "header");
|
||||
expect(header).toBeDefined();
|
||||
|
||||
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
|
||||
"new-one\nnew-two",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the old pane for a deletion-only split hunk", () => {
|
||||
const model = buildModel("unused", { files: [deletedSplitFile()], layout: "split" });
|
||||
const header = model.rows
|
||||
.filter((row) => row.kind === "line")
|
||||
.flatMap((row) => row.cells)
|
||||
.find((cell) => cell?.type === "header");
|
||||
expect(header).toBeDefined();
|
||||
|
||||
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
|
||||
"@@ -1,2 +1,0 @@\nold-one\nold-two",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildModel(changedContent: string, overrides: Partial<BuildDiffDocumentModelInput> = {}) {
|
||||
@@ -212,6 +272,29 @@ function splitFile(): ParsedDiffFile {
|
||||
};
|
||||
}
|
||||
|
||||
function deletedSplitFile(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/deleted.ts",
|
||||
isNew: false,
|
||||
isDeleted: true,
|
||||
additions: 0,
|
||||
deletions: 2,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 2,
|
||||
newStart: 1,
|
||||
newCount: 0,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,2 +1,0 @@" },
|
||||
{ type: "remove", content: "old-one" },
|
||||
{ type: "remove", content: "old-two" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewActionsForFirstAddition(): NonNullable<
|
||||
BuildDiffDocumentModelInput["reviewActions"]
|
||||
> {
|
||||
|
||||
@@ -105,6 +105,64 @@ export function selectedSourceText(model: DiffDocumentModel, selection: DiffSele
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function selectCellSource(
|
||||
model: DiffDocumentModel,
|
||||
position: DiffCharacterPosition,
|
||||
): DiffSelection {
|
||||
const row = model.rows[position.rowIndex];
|
||||
const cell = row?.kind === "line" ? row.cells[position.cellIndex] : null;
|
||||
const end = cell?.content.length ?? position.sourceOffset;
|
||||
return {
|
||||
anchor: { ...position, sourceOffset: 0 },
|
||||
focus: { ...position, sourceOffset: end },
|
||||
};
|
||||
}
|
||||
|
||||
export function selectAllSource(
|
||||
model: DiffDocumentModel,
|
||||
preferredPosition?: DiffCharacterPosition,
|
||||
): DiffSelection | null {
|
||||
let first: DiffCharacterPosition | null = null;
|
||||
let last: DiffCharacterPosition | null = null;
|
||||
const preferredRow = preferredPosition ? model.rows[preferredPosition.rowIndex] : null;
|
||||
const preferredCell =
|
||||
preferredPosition && preferredRow?.kind === "line"
|
||||
? preferredRow.cells[preferredPosition.cellIndex]
|
||||
: null;
|
||||
let splitCellIndex: number | null = null;
|
||||
if (model.layout === "split") {
|
||||
const hasNewSource = model.rows.some(
|
||||
(row) => row.kind === "line" && row.cells[1] && row.cells[1].type !== "header",
|
||||
);
|
||||
splitCellIndex = hasNewSource ? 1 : 0;
|
||||
if (preferredPosition && preferredCell && preferredCell.type !== "header") {
|
||||
splitCellIndex = preferredPosition.cellIndex;
|
||||
}
|
||||
}
|
||||
for (const row of model.rows) {
|
||||
if (row.kind !== "line") continue;
|
||||
row.cells.forEach((cell, cellIndex) => {
|
||||
if (!cell || (splitCellIndex !== null && cellIndex !== splitCellIndex)) return;
|
||||
const side = cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new");
|
||||
first ??= {
|
||||
fileIndex: row.fileIndex,
|
||||
rowIndex: row.index,
|
||||
cellIndex,
|
||||
side,
|
||||
sourceOffset: 0,
|
||||
};
|
||||
last = {
|
||||
fileIndex: row.fileIndex,
|
||||
rowIndex: row.index,
|
||||
cellIndex,
|
||||
side,
|
||||
sourceOffset: cell.content.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
return first && last ? { anchor: first, focus: last } : null;
|
||||
}
|
||||
|
||||
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
|
||||
return (
|
||||
left.rowIndex - right.rowIndex ||
|
||||
|
||||
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
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 { 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 {
|
||||
@@ -11,7 +20,12 @@ import {
|
||||
diffMaterializationWindow,
|
||||
resolveVisibleFileSections,
|
||||
} from "./header-layout";
|
||||
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||
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";
|
||||
@@ -33,6 +47,7 @@ const RESIZE_SETTLE_DELAY_MS = 120;
|
||||
|
||||
export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const workspaceCache = useDiffDocumentWorkspaceCache();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -75,6 +90,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
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;
|
||||
@@ -488,13 +505,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
},
|
||||
[schedulePaint],
|
||||
);
|
||||
const pointHit = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
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 = event.clientX - bounds.left;
|
||||
const documentY = event.clientY - bounds.top + scrollTopRef.current;
|
||||
const x = clientX - bounds.left;
|
||||
const documentY = clientY - bounds.top + scrollTopRef.current;
|
||||
const file = currentModel.files.find(
|
||||
(entry) => entry.top <= documentY && documentY < entry.bottom,
|
||||
);
|
||||
@@ -505,6 +522,27 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
|
||||
});
|
||||
}, []);
|
||||
const pointHit = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
|
||||
[pointHitAt],
|
||||
);
|
||||
const setSelection = useCallback(
|
||||
(selection: DiffSelection | null) => {
|
||||
selectionRef.current = selection;
|
||||
const currentModel = modelRef.current;
|
||||
setHasSelection(
|
||||
Boolean(
|
||||
selection && currentModel && selectedSourceText(currentModel, selection).length > 0,
|
||||
),
|
||||
);
|
||||
schedulePaint();
|
||||
},
|
||||
[schedulePaint],
|
||||
);
|
||||
useEffect(() => {
|
||||
dragRef.current = null;
|
||||
setSelection(null);
|
||||
}, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]);
|
||||
const pointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
@@ -518,8 +556,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
return;
|
||||
const dismissSelectionOnClick = selectionRef.current !== null;
|
||||
if (dismissSelectionOnClick) {
|
||||
selectionRef.current = null;
|
||||
schedulePaint();
|
||||
setSelection(null);
|
||||
}
|
||||
const hit = pointHit(event);
|
||||
if (hit?.kind !== "cell") return;
|
||||
@@ -530,12 +567,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
moved: false,
|
||||
dismissSelectionOnClick,
|
||||
};
|
||||
selectionRef.current = { anchor: hit.position, focus: hit.position };
|
||||
setSelection({ anchor: hit.position, focus: hit.position });
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
schedulePaint();
|
||||
},
|
||||
[pointHit, schedulePaint],
|
||||
[pointHit, setSelection],
|
||||
);
|
||||
const updateActiveHeader = useCallback(
|
||||
(target: EventTarget | null) => {
|
||||
@@ -589,10 +625,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
setHoveredAffordance(null);
|
||||
}
|
||||
if (!drag || hit?.kind !== "cell") return;
|
||||
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
|
||||
schedulePaint();
|
||||
setSelection({ anchor: drag.anchor, focus: hit.position });
|
||||
},
|
||||
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
|
||||
[pointHit, setSelection, updateActiveHeader, viewport.width],
|
||||
);
|
||||
const pointerUp = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -612,8 +647,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
})
|
||||
: false;
|
||||
if (drag && !moved && drag.dismissSelectionOnClick) {
|
||||
selectionRef.current = null;
|
||||
schedulePaint();
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -624,12 +658,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
hit.target &&
|
||||
reviewActions
|
||||
) {
|
||||
selectionRef.current = null;
|
||||
setSelection(null);
|
||||
reviewActions.onStartComment(hit.target);
|
||||
schedulePaint();
|
||||
}
|
||||
},
|
||||
[pointHit, reviewActions, schedulePaint],
|
||||
[pointHit, reviewActions, setSelection],
|
||||
);
|
||||
const cancelPointer = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
@@ -643,6 +676,61 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
},
|
||||
[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],
|
||||
@@ -672,7 +760,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[desiredTypography, loadedTypography],
|
||||
);
|
||||
|
||||
return (
|
||||
const surface = (
|
||||
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -685,7 +773,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
onPointerCancel={cancelPointer}
|
||||
onLostPointerCapture={cancelPointer}
|
||||
onPointerLeave={pointerLeave}
|
||||
onContextMenu={prepareContextMenu}
|
||||
onCopy={copy}
|
||||
onKeyDown={keyDown}
|
||||
style={SCROLL_STYLE}
|
||||
>
|
||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||
@@ -752,6 +842,34 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
) : 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 shiftCanvasPixels(input: {
|
||||
@@ -820,7 +938,7 @@ function WebFileHeaderSection({
|
||||
[file.bottom, file.top],
|
||||
);
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={style} onContextMenu={stopContextMenuPropagation}>
|
||||
<div
|
||||
data-diff-header="true"
|
||||
data-diff-header-path={file.path}
|
||||
@@ -864,7 +982,7 @@ function WebReviewThread({
|
||||
[height, left, top, width],
|
||||
);
|
||||
return (
|
||||
<div style={style} data-diff-review="true">
|
||||
<div style={style} data-diff-review="true" onContextMenu={stopContextMenuPropagation}>
|
||||
<InlineReviewThread
|
||||
reviewTarget={target}
|
||||
reviewActions={actions}
|
||||
@@ -876,12 +994,17 @@ function WebReviewThread({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -10,10 +10,12 @@ export const ar: TranslationResources = {
|
||||
cancel: "يلغي",
|
||||
close: "يغلق",
|
||||
copy: "ينسخ",
|
||||
copyLine: "نسخ السطر",
|
||||
dismiss: "رفض",
|
||||
retry: "أعد المحاولة",
|
||||
search: "يبحث",
|
||||
select: "يختار",
|
||||
selectAll: "تحديد الكل",
|
||||
},
|
||||
placeholders: {
|
||||
search: "يبحث...",
|
||||
@@ -33,6 +35,7 @@ export const ar: TranslationResources = {
|
||||
errors: {
|
||||
error: "خطأ",
|
||||
unableToSave: "غير قادر على الحفظ",
|
||||
unableToCopy: "تعذر النسخ",
|
||||
nameRequired: "الاسم مطلوب",
|
||||
daemonUnavailable: "Daemon غير متوفر",
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
|
||||
@@ -7,10 +7,12 @@ export const en = {
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
copy: "Copy",
|
||||
copyLine: "Copy line",
|
||||
dismiss: "Dismiss",
|
||||
retry: "Retry",
|
||||
search: "Search",
|
||||
select: "Select",
|
||||
selectAll: "Select all",
|
||||
},
|
||||
placeholders: {
|
||||
search: "Search...",
|
||||
@@ -30,6 +32,7 @@ export const en = {
|
||||
errors: {
|
||||
error: "Error",
|
||||
unableToSave: "Unable to save",
|
||||
unableToCopy: "Unable to copy",
|
||||
nameRequired: "Name is required",
|
||||
daemonUnavailable: "Daemon unavailable",
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const es: TranslationResources = {
|
||||
cancel: "Cancelar",
|
||||
close: "Cerrar",
|
||||
copy: "Copiar",
|
||||
copyLine: "Copiar línea",
|
||||
dismiss: "Despedir",
|
||||
retry: "Rever",
|
||||
search: "Buscar",
|
||||
select: "Seleccionar",
|
||||
selectAll: "Seleccionar todo",
|
||||
},
|
||||
placeholders: {
|
||||
search: "Buscar...",
|
||||
@@ -33,6 +35,7 @@ export const es: TranslationResources = {
|
||||
errors: {
|
||||
error: "Error",
|
||||
unableToSave: "No se puede guardar",
|
||||
unableToCopy: "No se pudo copiar",
|
||||
nameRequired: "El nombre es obligatorio",
|
||||
daemonUnavailable: "Daemonno disponible",
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const fr: TranslationResources = {
|
||||
cancel: "Annuler",
|
||||
close: "Fermer",
|
||||
copy: "Copie",
|
||||
copyLine: "Copier la ligne",
|
||||
dismiss: "Rejeter",
|
||||
retry: "Réessayer",
|
||||
search: "Recherche",
|
||||
select: "Sélectionner",
|
||||
selectAll: "Tout sélectionner",
|
||||
},
|
||||
placeholders: {
|
||||
search: "Recherche...",
|
||||
@@ -33,6 +35,7 @@ export const fr: TranslationResources = {
|
||||
errors: {
|
||||
error: "Erreur",
|
||||
unableToSave: "Impossible d'enregistrer",
|
||||
unableToCopy: "Impossible de copier",
|
||||
nameRequired: "Le nom est requis",
|
||||
daemonUnavailable: "Daemonindisponible",
|
||||
daemonClientUnavailable: "ClientDaemonindisponible",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const ja: TranslationResources = {
|
||||
cancel: "キャンセル",
|
||||
close: "閉じる",
|
||||
copy: "コピー",
|
||||
copyLine: "行をコピー",
|
||||
dismiss: "閉じる",
|
||||
retry: "再試行",
|
||||
search: "検索",
|
||||
select: "選択",
|
||||
selectAll: "すべて選択",
|
||||
},
|
||||
placeholders: {
|
||||
search: "検索...",
|
||||
@@ -33,6 +35,7 @@ export const ja: TranslationResources = {
|
||||
errors: {
|
||||
error: "エラー",
|
||||
unableToSave: "保存できません",
|
||||
unableToCopy: "コピーできません",
|
||||
nameRequired: "名前は必須です",
|
||||
daemonUnavailable: "デーモンが利用できません",
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const ko: TranslationResources = {
|
||||
cancel: "취소",
|
||||
close: "닫기",
|
||||
copy: "복사",
|
||||
copyLine: "줄 복사",
|
||||
dismiss: "닫기",
|
||||
retry: "다시 시도",
|
||||
search: "검색",
|
||||
select: "선택",
|
||||
selectAll: "모두 선택",
|
||||
},
|
||||
placeholders: {
|
||||
search: "검색...",
|
||||
@@ -33,6 +35,7 @@ export const ko: TranslationResources = {
|
||||
errors: {
|
||||
error: "오류",
|
||||
unableToSave: "저장할 수 없습니다",
|
||||
unableToCopy: "복사할 수 없습니다",
|
||||
nameRequired: "이름을 입력하세요",
|
||||
daemonUnavailable: "데몬을 사용할 수 없습니다",
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const ptBR: TranslationResources = {
|
||||
cancel: "Cancelar",
|
||||
close: "Fechar",
|
||||
copy: "Copiar",
|
||||
copyLine: "Copiar linha",
|
||||
dismiss: "Dispensar",
|
||||
retry: "Tentar novamente",
|
||||
search: "Buscar",
|
||||
select: "Selecionar",
|
||||
selectAll: "Selecionar tudo",
|
||||
},
|
||||
placeholders: {
|
||||
search: "Buscar...",
|
||||
@@ -33,6 +35,7 @@ export const ptBR: TranslationResources = {
|
||||
errors: {
|
||||
error: "Erro",
|
||||
unableToSave: "Não foi possível salvar",
|
||||
unableToCopy: "Não foi possível copiar",
|
||||
nameRequired: "O nome é obrigatório",
|
||||
daemonUnavailable: "Daemon indisponível",
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const ru: TranslationResources = {
|
||||
cancel: "Отмена",
|
||||
close: "Закрыть",
|
||||
copy: "Копировать",
|
||||
copyLine: "Копировать строку",
|
||||
dismiss: "Отклонить",
|
||||
retry: "Повторить",
|
||||
search: "Поиск",
|
||||
select: "Выбрать",
|
||||
selectAll: "Выбрать все",
|
||||
},
|
||||
placeholders: {
|
||||
search: "Поиск...",
|
||||
@@ -33,6 +35,7 @@ export const ru: TranslationResources = {
|
||||
errors: {
|
||||
error: "Ошибка",
|
||||
unableToSave: "Не удалось сохранить",
|
||||
unableToCopy: "Не удалось скопировать",
|
||||
nameRequired: "Требуется имя",
|
||||
daemonUnavailable: "Daemon недоступен",
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
|
||||
@@ -10,10 +10,12 @@ export const zhCN: TranslationResources = {
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
copy: "复制",
|
||||
copyLine: "复制行",
|
||||
dismiss: "关闭",
|
||||
retry: "重试",
|
||||
search: "搜索",
|
||||
select: "选择",
|
||||
selectAll: "全选",
|
||||
},
|
||||
placeholders: {
|
||||
search: "搜索...",
|
||||
@@ -33,6 +35,7 @@ export const zhCN: TranslationResources = {
|
||||
errors: {
|
||||
error: "错误",
|
||||
unableToSave: "无法保存",
|
||||
unableToCopy: "无法复制",
|
||||
nameRequired: "名称必填",
|
||||
daemonUnavailable: "Daemon 不可用",
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
|
||||
Reference in New Issue
Block a user