diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts index cfec2c291..c0e9c070e 100644 --- a/e2e/console-selection.spec.ts +++ b/e2e/console-selection.spec.ts @@ -82,3 +82,24 @@ test("preserves all lines in a drag selection across virtual windows", async ({ expect(text).toContain("output 250\noutput 251"); expect(text).toContain("output 284"); }); + +test("Select All takes the whole buffer, not just the mounted rows", async ({ page }) => { + const log = await consoleOutput(page); + await page.evaluate(() => { + (window as unknown as { copied: string[] }).copied = []; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async (text: string) => (window as unknown as { copied: string[] }).copied.push(text) }, + }); + }); + await log.focus(); + await page.keyboard.press("Meta+A"); + // Selection stays inside the console: nothing from the workbench behind it is selected. + const selected = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selected).not.toContain("Project"); + await page.keyboard.press("Meta+C"); + const [copied] = await page.evaluate(() => (window as unknown as { copied: string[] }).copied); + expect(copied?.split("\n")).toHaveLength(500); + expect(copied).toContain("output 0\n"); + expect(copied).toMatch(/output 499$/); +}); diff --git a/src/features/app/__tests__/consoleCopy.test.ts b/src/features/app/__tests__/consoleCopy.test.ts index 3f449702f..b82d12f62 100644 --- a/src/features/app/__tests__/consoleCopy.test.ts +++ b/src/features/app/__tests__/consoleCopy.test.ts @@ -108,6 +108,33 @@ describe("view-local console copy with the Windows keymap (#372)", () => { expect(writeText).toHaveBeenCalledWith("selected application output"); }); + it("Ctrl+A selects only the console and copies the whole buffer, not just the mounted rows (#746)", () => { + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: "first\nsecond\n" }); + vi.spyOn(model, "text").mockReturnValue("whole buffer, including unmounted rows"); + const outside = document.createElement("p"); + outside.textContent = "workbench chrome"; + document.body.append(outside); + render( + createElement(ConsoleView, { + model, + version: 1, + softWrap: false, + follow: false, + onFollowChange: () => {}, + cwd: "/", + }), + ); + const output = screen.getByRole("log"); + const selectAll = copy(output, { key: "a", code: "KeyA", metaKey: true }); + expect(selectAll.defaultPrevented).toBe(true); + const selection = window.getSelection()!; + expect(output.contains(selection.anchorNode) && output.contains(selection.focusNode)).toBe(true); + expect(selection.toString()).not.toContain("workbench chrome"); + copy(output); + expect(writeText).toHaveBeenCalledWith("whole buffer, including unmounted rows"); + }); + it.each([false, true])("copies build output and selected message detail (detail=%s)", (detail) => { useBuildRun.setState({ status: "failed", diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 24cc2287d..8c5aad24b 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -179,6 +179,7 @@ export function ConsoleView({ ...aria }: ConsoleViewProps) { const scrollRef = useRef(null); + const innerRef = useRef(null); const lines = model.lines; const patterns = useWorkflowSettings((s) => s.console.foldPatterns); const [expanded, setExpanded] = useState>(() => new Set()); @@ -377,9 +378,30 @@ export function ConsoleView({ scrolledMatch.current = key; }, [findOpen, activeMatch, rowOfLine, rows, virtualizer]); + /** Rows are virtualized, so "everything" is a selection of the container, not of the mounted rows. */ + const selectAll = () => { + const inner = innerRef.current; + if (inner) window.getSelection()?.selectAllChildren(inner); + }; + const allSelected = () => { + const inner = innerRef.current; + const s = window.getSelection(); + return ( + !!inner && + !!s && + !s.isCollapsed && + s.anchorNode === inner && + s.focusNode === inner && + s.anchorOffset === 0 && + s.focusOffset === inner.childNodes.length + ); + }; + /** The selected text; after Select All the whole buffer, including rows that are not mounted. */ + const selectedOutput = () => (allSelected() ? model.text() : (window.getSelection()?.toString() ?? "")); + const onContextMenu = (e: MouseEvent) => { e.preventDefault(); - const selection = window.getSelection()?.toString() ?? ""; + const selection = selectedOutput(); showContextMenu(e, [ { label: "Copy", @@ -388,6 +410,7 @@ export function ConsoleView({ disabled: !selection, run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined), }, + { label: "Select All", shortcut: formatBinding(hostBinding("Meta+A")), run: selectAll }, { label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) }, { label: "Find…", @@ -410,7 +433,12 @@ export function ConsoleView({ openFind(); return; } - if (copySelectionOnKeyDown(e)) return; + if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "a") { + e.preventDefault(); + selectAll(); + return; + } + if (copySelectionOnKeyDown(e, selectedOutput)) return; if (!input) return; if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); @@ -496,10 +524,16 @@ export function ConsoleView({ onScroll={onScroll} onContextMenu={onContextMenu} onKeyDown={onKeyDown} + onCopy={(e) => { + // Native copy (menu, macOS) would take only the mounted rows. + if (!allSelected()) return; + e.preventDefault(); + e.clipboardData.setData("text/plain", model.text()); + }} onPaste={onPaste} data-autofocus="" > -
+
{items.map((item) => { const row = rows[item.index]; if (!row) { diff --git a/src/ui/selectableText.ts b/src/ui/selectableText.ts index d285d3bfd..e8489a48e 100644 --- a/src/ui/selectableText.ts +++ b/src/ui/selectableText.ts @@ -70,6 +70,8 @@ export function copySelectionOnKeyDown( | "preventDefault" | "stopPropagation" >, + /** Text to copy instead of `selection.toString()`, for views that do not mount all their text. */ + textOf: (selection: Selection) => string = (selection) => selection.toString(), ): boolean { if (e.defaultPrevented || e.nativeEvent.isComposing || e.altKey || e.shiftKey) return false; const copy = @@ -87,6 +89,6 @@ export function copySelectionOnKeyDown( return false; e.preventDefault(); e.stopPropagation(); - void copyText(selection.toString()); + void copyText(textOf(selection)); return true; }