fix(run): preserve console output selection (#854)

The console virtualizes its rows, so a selection that scrolled out of the rendered window lost its rows and copying returned only part of it. Selected rows (and, while dragging, the rows between anchor and pointer) now stay mounted, auto-follow pauses while selecting, and each row ends with a line break so multi-line copies keep their newlines. Covered by unit tests and Playwright selection tests.

Fixes #839

Co-authored-by: kkMihai <[email protected]>
Цей коміт міститься в:
kkmihaiтаkkMihai authored and GitHub committed 2026-11-02 20:13:44 +08:00
1 джерело 2a6cc686eb
коміт f6fbc0e5db
4 files changed
+256

No files matched your search

+5
Переглянути файл
@@ -21,6 +21,7 @@ import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
import { segmentLine, type SegmentMark } from "./segments";
import styles from "./Run.module.css";
import { useConsoleSelection } from "./useConsoleSelection";
import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { ConsoleFindBar } from "./FindBar";
@@ -211,7 +212,9 @@ export function ConsoleView({
});
const programmatic = useRef(false);
const { rangeExtractor, selecting } = useConsoleSelection(scrollRef, () => onFollowChange(false));
const virtualizer = useVirtualizer({
rangeExtractor,
count,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW,
@@ -241,6 +244,7 @@ export function ConsoleView({
const el = scrollRef.current;
if (!el || programmatic.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW;
if (atBottom && selecting) return;
if (atBottom !== follow) onFollowChange(atBottom);
};
@@ -537,6 +541,7 @@ export function ConsoleView({
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
{"\n"}
</div>
);
})}
+94
Переглянути файл
@@ -0,0 +1,94 @@
import { act, fireEvent, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useConsoleSelection } from "./useConsoleSelection";
const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 };
afterEach(() => {
window.getSelection()?.removeAllRanges();
document.body.replaceChildren();
});
function setup() {
const host = document.createElement("div");
document.body.append(host);
const rows = [5, 6, 7].map((index) => {
const row = document.createElement("div");
row.dataset.index = String(index);
row.textContent = `output ${index}`;
host.append(row);
return row;
});
const ref = { current: host };
const pause = vi.fn();
const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } });
const select = (backward = false) => {
act(() => {
const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!];
window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3);
document.dispatchEvent(new Event("selectionchange"));
});
};
return { ...hook, host, rows, pause, select };
}
describe("console selection retention", () => {
it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => {
const { result, select, pause } = setup();
select(backward);
const indices = result.current.rangeExtractor(viewport);
expect(indices.slice(0, 3)).toEqual([5, 6, 7]);
expect(indices).not.toContain(50);
expect(indices).toContain(100);
expect(pause).toHaveBeenCalled();
});
it("renders the intervening text when dragging beyond the viewport", () => {
const { result, rows, select } = setup();
fireEvent.mouseDown(rows[0]!, { button: 0 });
select();
const indices = result.current.rangeExtractor(viewport);
expect(indices[0]).toBe(5);
expect(indices).toHaveLength(108);
fireEvent.mouseUp(document);
expect(result.current.rangeExtractor(viewport)).not.toContain(50);
expect(result.current.rangeExtractor(viewport)).toContain(5);
});
it("releases pinned rows when the selection is cleared or moved outside the console", () => {
const { result, select } = setup();
select();
act(() => {
window.getSelection()!.removeAllRanges();
document.dispatchEvent(new Event("selectionchange"));
});
expect(result.current.selecting).toBe(false);
expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
select();
const other = document.createTextNode("other output");
document.body.append(other);
act(() => {
window.getSelection()!.setBaseAndExtent(other, 0, other, 5);
document.dispatchEvent(new Event("selectionchange"));
});
expect(result.current.selecting).toBe(false);
expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
});
it("keeps a drag active when incoming output rerenders the parent", () => {
const { result, rows, select, rerender } = setup();
fireEvent.mouseDown(rows[0]!, { button: 0 });
const pause = vi.fn();
rerender({ pause });
select();
expect(result.current.rangeExtractor(viewport)).toContain(50);
expect(pause).toHaveBeenCalled();
});
it("does not pin rows or pause output for a context click", () => {
const { result, rows, pause } = setup();
fireEvent.mouseDown(rows[0]!, { button: 2 });
expect(result.current.selecting).toBe(false);
expect(pause).not.toHaveBeenCalled();
});
});
+73
Переглянути файл
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react";
import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual";
type SelectedRows = { start: number; end: number; dragging: boolean };
/** Keep the browser's selection endpoints, and the text between them, mounted. */
export function useConsoleSelection(host: RefObject<HTMLDivElement | null>, pauseFollow: () => void) {
const pause = useEffectEvent(pauseFollow);
const [selected, setSelected] = useState<SelectedRows | null>(null);
useEffect(() => {
const el = host.current;
if (!el) return;
let dragging = false;
const indexOf = (node: Node | null) => {
const element = node instanceof Element ? node : node?.parentElement;
const row = element?.closest<HTMLElement>("[data-index]");
return row && el.contains(row) ? Number(row.dataset.index) : null;
};
const update = () => {
const selection = window.getSelection();
const anchor = indexOf(selection?.anchorNode ?? null);
const focus = indexOf(selection?.focusNode ?? null);
const next =
selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null
? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging }
: null;
setSelected((prev) =>
prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next,
);
if (next && !selection!.isCollapsed) pause();
};
const down = (event: MouseEvent) => {
if (event.button !== 0) return;
const index = indexOf(event.target as Node);
if (index === null) return;
dragging = true;
// Pin the anchor before a scroll can recycle it, even before selectionchange fires.
setSelected({ start: index, end: index, dragging: true });
pause();
};
const up = () => {
dragging = false;
update();
};
el.addEventListener("mousedown", down);
document.addEventListener("mouseup", up);
document.addEventListener("selectionchange", update);
return () => {
el.removeEventListener("mousedown", down);
document.removeEventListener("mouseup", up);
document.removeEventListener("selectionchange", update);
};
}, [host]);
const rangeExtractor = useCallback(
(range: Range) => {
const visible = defaultRangeExtractor(range);
if (!selected || !visible.length) return visible;
// During a drag, scrolling must also render every line up to the pointer.
const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start);
const end = Math.min(
range.count - 1,
selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end,
);
const indices = new Set(visible);
for (let i = start; i <= end; i++) indices.add(i);
return [...indices].sort((a, b) => a - b);
},
[selected],
);
return { rangeExtractor, selecting: selected !== null };
}