From 7c7d656a50ee579393c322c5580cd0a90ca258a6 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:21 +0700 Subject: [PATCH] fix(run): select and copy the whole console output with Select All (#903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Cmd/Ctrl+A in the Run console fell through to the browser and selected the whole window, and copying only saw the rows the virtualizer had mounted. Select All now selects the console only (also in its context menu), and copying after it — keyboard, context menu or native copy — takes the whole buffer. copySelectionOnKeyDown accepts a textOf callback for other views that do not mount all their text. Refs #746 Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- e2e/console-selection.spec.ts | 21 ++++++++++ .../app/__tests__/consoleCopy.test.ts | 27 +++++++++++++ src/features/run/ConsoleView.tsx | 40 +++++++++++++++++-- src/ui/selectableText.ts | 4 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts index cfec2c29..c0e9c070 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 3f449702..b82d12f6 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 24cc2287..8c5aad24 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 d285d3bf..e8489a48 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; }