feat(ui): make preview panes selectable and copyable (#1127)

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>
This commit is contained in:
Qing
2026-11-04 08:54:25 +08:00
committed by GitHub
co-authored by qtvbwfn
parent 53b8cdeb51
commit e5718d41d8
3 changed files with 70 additions and 9 deletions
+7
View File
@@ -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);
+29
View File
@@ -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(
<SourcePreview
path="/p/a.ts"
line={1}
load={loadOf("one\ntwo\nthree")}
highlights={[{ line: 1, ranges: [[0, 3]] }]}
/>,
);
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(<SourcePreview path="/p/a.ts" line={0} load={loadOf("// TODO")} />);
await screen.findByText("// TODO");
// The scroll pane is a selection scope: drags stay inside, text stays copyable.
expect(container.querySelector("[data-selection-scope]")).toBeTruthy();
});
});
+34 -9
View File
@@ -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<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(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<HTMLElement>(`[data-line="${line}"]`);
if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2;
}, [current, line]);
if (!path)
return (
<div className={clsx(styles.preview, styles.empty, className)}>
@@ -71,12 +65,43 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0,
if (error && !current) return <div className={clsx(styles.preview, styles.empty, className)}>{error}</div>;
if (!current) return <div className={clsx(styles.preview, className)} />;
return <PreviewBody path={path} line={line} lines={current.lines} highlights={highlights} className={className} />;
}
/**
* 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<HTMLDivElement>(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<HTMLElement>(`[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(
<div key={n} data-line={n} className={clsx(styles.codeLine, n === line && styles.currentLine)}>