From e5718d41d873fdb3fe53d995c618e09ce5dbc9c7 Mon Sep 17 00:00:00 2001 From: Qing Date: Wed, 4 Nov 2026 08:54:25 +0800 Subject: [PATCH] feat(ui): make preview panes selectable and copyable (#1127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workbench disables selection globally, so the read-only preview panes beside result trees (TODO, Usages, Problems…) could not be selected or copied. The pane opts back in (user-select: text) and uses useSelectionBoundary so drags stay inside it; the line-number gutter stays unselectable so copied text is code only. Co-authored-by: qtvbwfn <5848026+qtvbwfn@users.noreply.github.com> --- src/ui/SourcePreview.module.css | 7 ++++++ src/ui/SourcePreview.test.tsx | 29 ++++++++++++++++++++++ src/ui/SourcePreview.tsx | 43 ++++++++++++++++++++++++++------- 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/ui/SourcePreview.test.tsx diff --git a/src/ui/SourcePreview.module.css b/src/ui/SourcePreview.module.css index 53e45f39..93d2e7b1 100644 --- a/src/ui/SourcePreview.module.css +++ b/src/ui/SourcePreview.module.css @@ -11,6 +11,10 @@ font-size: var(--fs-ui); line-height: 19px; color: var(--fg); + /* The workbench disables selection globally; preview text stays selectable/copyable. */ + -webkit-user-select: text; + user-select: text; + cursor: text; } .empty { display: flex; @@ -41,6 +45,9 @@ text-align: right; color: var(--fg-gutter); background-color: var(--bg-app); + /* Copying the code leaves the line numbers out. */ + -webkit-user-select: none; + user-select: none; } .currentLine .gutter { color: var(--fg-muted); diff --git a/src/ui/SourcePreview.test.tsx b/src/ui/SourcePreview.test.tsx new file mode 100644 index 00000000..46e9a408 --- /dev/null +++ b/src/ui/SourcePreview.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SourcePreview } from "./SourcePreview"; + +/** A stable loader reference (a new function reloads, like the real module-level ones). */ +const loadOf = (content: string) => () => Promise.resolve(content); + +describe("SourcePreview", () => { + it("renders the loaded lines around the current one", async () => { + const { container } = render( + , + ); + await screen.findByText("two"); + expect(container.querySelector('[data-line="1"] mark')?.textContent).toBe("two"); + expect(container.querySelector('[data-line="0"]')?.textContent).toContain("one"); + }); + + it("makes its text selectable as a selection boundary", async () => { + const { container } = render(); + await screen.findByText("// TODO"); + // The scroll pane is a selection scope: drags stay inside, text stays copyable. + expect(container.querySelector("[data-selection-scope]")).toBeTruthy(); + }); +}); diff --git a/src/ui/SourcePreview.tsx b/src/ui/SourcePreview.tsx index 7a42b1e9..fe4cbf21 100644 --- a/src/ui/SourcePreview.tsx +++ b/src/ui/SourcePreview.tsx @@ -7,6 +7,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "re import clsx from "clsx"; import { T } from "./T"; import styles from "./SourcePreview.module.css"; +import { useSelectionBoundary } from "./useSelectionBoundary"; import { tx } from "@/platform/i18n"; export interface SourceHighlight { @@ -33,7 +34,6 @@ export interface SourcePreviewProps { export function SourcePreview({ path, line, highlights = [], load, version = 0, className }: SourcePreviewProps) { const [text, setText] = useState<{ path: string; lines: string[] } | null>(null); const [error, setError] = useState(null); - const scrollRef = useRef(null); const current = text && text.path === path ? text : null; useEffect(() => { @@ -56,12 +56,6 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0, }; }, [path, version, load]); - useLayoutEffect(() => { - const box = scrollRef.current; - const el = box?.querySelector(`[data-line="${line}"]`); - if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2; - }, [current, line]); - if (!path) return (
@@ -71,12 +65,43 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0, if (error && !current) return
{error}
; if (!current) return
; + return ; +} + +/** + * The rendered lines. A separate component so the selection boundary mounts + * with the scroll element: the pane's own `path` may go null → set, after + * this component's effects already ran, and the ref would stay empty. + */ +function PreviewBody({ + path, + line, + lines, + highlights, + className, +}: { + path: string; + line: number; + lines: string[]; + highlights: readonly SourceHighlight[]; + className?: string; +}) { + const scrollRef = useRef(null); + useSelectionBoundary(scrollRef); + // Re-run on the loaded lines (not the parent's `current` object): the body + // remounts per text, so recentring must fire when the new lines arrive. + useLayoutEffect(() => { + const box = scrollRef.current; + const el = box?.querySelector(`[data-line="${line}"]`); + if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2; + }, [line, path, lines]); + const byLine = new Map(highlights.map((h) => [h.line, h.ranges])); const start = Math.max(0, line - WINDOW); - const end = Math.min(current.lines.length, line + WINDOW); + const end = Math.min(lines.length, line + WINDOW); const rows: ReactNode[] = []; for (let n = start; n < end; n++) { - const content = current.lines[n] ?? ""; + const content = lines[n] ?? ""; const ranges = byLine.get(n); rows.push(