diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts new file mode 100644 index 000000000..cfec2c291 --- /dev/null +++ b/e2e/console-selection.spec.ts @@ -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"); +}); diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 8997d4196..24cc2287d 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -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({ {row.fold && foldButton(row.fold, true)} {inputInline && item.index === rows.length - 1 && pendingInput} + {"\n"} ); })} diff --git a/src/features/run/useConsoleSelection.test.tsx b/src/features/run/useConsoleSelection.test.tsx new file mode 100644 index 000000000..9e0058cf9 --- /dev/null +++ b/src/features/run/useConsoleSelection.test.tsx @@ -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(); + }); +}); diff --git a/src/features/run/useConsoleSelection.ts b/src/features/run/useConsoleSelection.ts new file mode 100644 index 000000000..6fa897240 --- /dev/null +++ b/src/features/run/useConsoleSelection.ts @@ -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, pauseFollow: () => void) { + const pause = useEffectEvent(pauseFollow); + const [selected, setSelected] = useState(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("[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 }; +}