mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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]>
This commit is contained in:
1 parent
2a6cc686eb
commit
f6fbc0e5db
4 files changed
+256
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/** Mount the real console with enough output to recycle rows while selecting. */
|
||||
async function consoleOutput(page: Page, follow = false) {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async (follow) => {
|
||||
const reactPath = "/node_modules/.vite/deps/react.js";
|
||||
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
|
||||
const viewPath = "/src/features/run/ConsoleView.tsx";
|
||||
const modelPath = "/src/features/run/console.ts";
|
||||
const react = await import(reactPath);
|
||||
const dom = await import(domPath);
|
||||
const { createElement, useState } = react.default ?? react;
|
||||
const { createRoot } = dom.default ?? dom;
|
||||
const { ConsoleView } = await import(viewPath);
|
||||
const { ConsoleModel } = await import(modelPath);
|
||||
const model = new ConsoleModel();
|
||||
model.append({ kind: "stdout", text: Array.from({ length: 500 }, (_, i) => `output ${i}`).join("\n") });
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white";
|
||||
document.body.append(host);
|
||||
function Console() {
|
||||
const [following, setFollowing] = useState(follow);
|
||||
return createElement(ConsoleView, {
|
||||
model,
|
||||
version: 1,
|
||||
softWrap: false,
|
||||
follow: following,
|
||||
onFollowChange: setFollowing,
|
||||
cwd: "/",
|
||||
});
|
||||
}
|
||||
createRoot(host).render(createElement(Console));
|
||||
}, follow);
|
||||
const log = page.getByRole("log", { name: "Console", exact: true });
|
||||
await expect(log.locator("[data-index]").first()).toBeVisible();
|
||||
return log;
|
||||
}
|
||||
|
||||
test("keeps a console selection intact when scrolling beyond the virtual window", async ({ page }) => {
|
||||
const log = await consoleOutput(page);
|
||||
await log.evaluate((el) => {
|
||||
el.scrollTop = 200 * 20;
|
||||
});
|
||||
const start = log.locator('[data-index="205"]');
|
||||
await expect(start).toBeVisible();
|
||||
await start.evaluate((el) => {
|
||||
const selection = window.getSelection()!;
|
||||
selection.setBaseAndExtent(el.firstChild!.firstChild!, 2, el.nextElementSibling!.firstChild!.firstChild!, 5);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
|
||||
await log.evaluate((el) => {
|
||||
el.scrollTop = 350 * 20;
|
||||
});
|
||||
await expect(log.locator('[data-index="350"]')).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await expect(start).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("preserves all lines in a drag selection across virtual windows", async ({ page }) => {
|
||||
const log = await consoleOutput(page);
|
||||
await log.evaluate((el) => {
|
||||
el.scrollTop = 200 * 20;
|
||||
});
|
||||
const start = log.locator('[data-index="205"]');
|
||||
await expect(start).toBeVisible();
|
||||
const box = (await start.boundingBox())!;
|
||||
await page.mouse.move(box.x + 20, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 60, box.y + 30, { steps: 5 });
|
||||
await log.evaluate((el) => {
|
||||
el.scrollTop = 280 * 20;
|
||||
});
|
||||
const end = log.locator('[data-index="285"]');
|
||||
await expect(end).toBeVisible();
|
||||
const endBox = (await end.boundingBox())!;
|
||||
await page.mouse.move(endBox.x + 60, endBox.y + 10, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const text = await page.evaluate(() => window.getSelection()?.toString());
|
||||
expect(text).toContain("output 206\noutput 207");
|
||||
expect(text).toContain("output 250\noutput 251");
|
||||
expect(text).toContain("output 284");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user