feat(vcs): soft-wrap long lines in the diff viewer (#2286)

The diff viewer never soft-wrapped, so a long changed line ran off the right edge. Diff panes now follow Settings › Editor › General › Soft-wrap files for the file, and the viewer's settings menu has a Use Soft Wraps checkbox that overrides it for all diffs and is remembered with the other viewer options. View › Active Editor › Soft-Wrap (editor.toggleSoftWrap) also toggles it when a diff pane has focus. Toggling reconfigures both panes in place, keeping the selection and the scroll position, including aligned split diffs.

Closes #2263

Co-authored-by: atlas-u <[email protected]>
Цей коміт міститься в:
ovra authored and GitHub committed 2026-11-09 17:03:21 +08:00
1 джерело 4bc08a1084
коміт e86134ce83
13 files changed
+388 -5

No files matched your search

+8 -3
Переглянути файл
@@ -6,6 +6,7 @@ import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { focusedSoftWrapSurface } from "@/workbench/softWrap";
import { resetSurfaceFontZoom, stepFocusedSurfaceFontZoom } from "@/workbench/contributions";
import { editorController, useGotoLine, useTabs } from "./controller";
import {
@@ -738,9 +739,13 @@ export function registerEditorActions(): () => void {
category: "View",
icon: "soft-wrap",
keywords: ["word wrap", "line wrap"],
when: "textEditorActive",
toggled: () => activeSlot()?.model.softWrapOn() ?? false,
run: () => activeSlot()?.model.toggleSoftWrap(),
when: "textEditorActive || softWrapSurfaceFocus",
toggled: () => focusedSoftWrapSurface()?.enabled() ?? activeSlot()?.model.softWrapOn() ?? false,
run: () => {
const surface = focusedSoftWrapSurface();
if (surface) surface.toggle();
else activeSlot()?.model.toggleSoftWrap();
},
},
text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }),
text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }),
+62 -1
Переглянути файл
@@ -13,7 +13,7 @@ import {
} from "react";
import clsx from "clsx";
import { EditorView, keymap } from "@codemirror/view";
import { Prec, Text, type Extension } from "@codemirror/state";
import { Compartment, Prec, Text, type Extension } from "@codemirror/state";
import { history, historyKeymap } from "@codemirror/commands";
import {
MergeView,
@@ -27,12 +27,16 @@ import {
} from "@codemirror/merge";
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
import { setFindOpen } from "@/editor/core/cm/find";
import { softWrapActive } from "@/editor/core/cm/prefs";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
import { getContext, setContext } from "@/platform/context";
import { editorPrefs } from "@/platform/editorSettings";
import { call, commands } from "@/platform/ipc";
import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { registerSoftWrapSurface } from "@/workbench/softWrap";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { changedLineGutters, diffOverview, diffWords, languageFor, viewerBase } from "./cm";
import { applyChunk, arrowControls, chunkAt, hoverRevert, revertUnifiedChunk } from "./diffControls";
@@ -365,6 +369,24 @@ export function DiffEditor({
const editableB = !binary && !!attached?.b && !attached.b.readOnly;
const ready = !!attached && !!statics && lang !== null && binary !== null;
// Soft wraps follow the editor's (Settings › Editor › General) unless toggled in the viewer settings.
const editorSoftWrap = useProject((s) => softWrapActive(s.settings?.editor?.softWrap, editorPrefs(s.settings), path));
const softWrap = opts.softWrap ?? editorSoftWrap;
const wrapSlot = useMemo(() => new Compartment(), []);
const softWrapRef = useRef(softWrap);
softWrapRef.current = softWrap;
const toggleSoftWrapRef = useRef(() => {});
toggleSoftWrapRef.current = () => setOpts({ softWrap: !softWrapRef.current });
useEffect(() => {
const host = hostRef.current;
if (!host || !ready || binary) return;
return registerSoftWrapSurface(host, {
enabled: () => softWrapRef.current,
toggle: () => toggleSoftWrapRef.current(),
});
}, [ready, binary]);
// Read when the views are built; toggling it later goes through `setSync`.
const syncScroll = useRef(opts.syncScroll);
syncScroll.current = opts.syncScroll;
@@ -419,6 +441,7 @@ export function DiffEditor({
const inclusion = includable ? inclusionGutter(path, textA) : [];
const side = (att: DocumentAttachment | null, editable: boolean, notes?: "left" | "right"): Extension[] => [
viewerBase({ readOnly: !editable, findBar: true }),
wrapSlot.of(softWrapRef.current ? EditorView.lineWrapping : []),
// Esc closes the find bar before it collapses the selection (a found match); open popups go first.
Prec.high(keymap.of([{ key: "Escape", run: () => findEscape.current() }])),
lang,
@@ -573,6 +596,7 @@ export function DiffEditor({
opts.collapse,
opts.ignore,
opts.highlight,
wrapSlot,
editableA,
editableB,
updateChunkInfo,
@@ -581,6 +605,37 @@ export function DiffEditor({
path,
]);
useEffect(() => {
const views = viewsRef.current;
if (!views) return;
// Aligned panes scroll in MergeView's outer element, which scrollSnapshot does not cover.
const outer = views.merge && !views.panes ? views.merge.dom : null;
const top = outer?.getBoundingClientRect().top;
const block = top == null ? null : views.b.lineBlockAtHeight(Math.max(0, top - views.b.documentTop));
const anchor =
block && top != null
? EditorView.scrollIntoView(block.from, { y: "start", yMargin: block.top + views.b.documentTop - top })
: null;
for (const view of [views.a, views.b]) {
if (view)
view.dispatch({
effects: [
wrapSlot.reconfigure(softWrap ? EditorView.lineWrapping : []),
...(outer ? (view === views.b && anchor ? [anchor] : []) : [view.scrollSnapshot()]),
],
});
}
// MergeView inserts alignment spacers on its next measure, after the panes have reflowed.
if (anchor) {
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (viewsRef.current === views) views.b.dispatch({ effects: anchor });
});
});
return () => cancelAnimationFrame(frame);
}
}, [softWrap, wrapSlot]);
useEffect(() => viewsRef.current?.panes?.setSync(opts.syncScroll), [opts.syncScroll]);
useEffect(
@@ -927,6 +982,12 @@ export function DiffEditor({
checked: opts.collapse,
run: () => setOpts({ collapse: !opts.collapse }),
},
{
id: "softWrap",
label: "Use Soft Wraps",
checked: softWrap,
run: () => setOpts({ softWrap: !softWrap }),
},
{
id: "sync",
label: "Synchronize Scrolling",
+12 -1
Переглянути файл
@@ -32,6 +32,7 @@ describe("diff options", () => {
collapse: false,
syncScroll: false,
alignChanges: false,
softWrap: true,
}),
).toEqual({
mode: "split",
@@ -40,8 +41,18 @@ describe("diff options", () => {
collapse: false,
syncScroll: false,
alignChanges: false,
softWrap: true,
});
expect(parseDiffOptions({ ignore: "bogus", highlight: 3, collapse: "x" })).toEqual(DEFAULT_DIFF_OPTIONS);
expect(parseDiffOptions({ ignore: "bogus", highlight: 3, collapse: "x", softWrap: "yes" })).toEqual(
DEFAULT_DIFF_OPTIONS,
);
// Options saved before soft wraps existed follow the editor's setting.
expect(parseDiffOptions({ mode: "unified" }).softWrap).toBeNull();
});
it.each([true, false, null])("round-trips the soft-wrap override %s", (softWrap) => {
const options = { ...DEFAULT_DIFF_OPTIONS, softWrap };
expect(parseDiffOptions(JSON.parse(JSON.stringify(options)))).toEqual(options);
});
it("ignore whitespace policies", () => {
+100
Переглянути файл
@@ -49,6 +49,7 @@ import { DiffEditor, mapLine } from "../DiffEditor";
import { DiffEditorTab } from "../diff-editor-tab";
import { diffInput, withChain, type DiffData } from "../diffInput";
import { useToolWindowLayout } from "@/workbench/toolwindows/store";
import { useProject } from "@/platform/project";
/** Git Log opens a commit's file: both sides are revisions. */
const input = {
@@ -202,6 +203,105 @@ describe("Diff toolbar", () => {
localStorage.clear();
});
describe("soft wraps", () => {
const wrapped = () =>
[...document.querySelectorAll(".cm-editor .cm-content")].map((c) => c.classList.contains("cm-lineWrapping"));
const editorSettings = (softWrap: boolean, softWrapFiles?: string) =>
useProject.setState({
settings: { editor: { softWrap, ...(softWrapFiles ? { general: { softWrapFiles } } : {}) } } as never,
});
beforeEach(() => {
localStorage.clear();
texts.HEAD = "a\nb\n";
texts.abc123 = "a\nnew\nb\n";
});
it.each([
{ name: "the file matches Soft-wrap files", softWrap: true, files: "*.ts", mode: "split", expected: true },
{ name: "the file matches, unified viewer", softWrap: true, files: "*.ts", mode: "unified", expected: true },
{ name: "the file is not in Soft-wrap files", softWrap: true, files: undefined, mode: "split", expected: false },
{ name: "soft wraps are off in the editor", softWrap: false, files: "*.ts", mode: "split", expected: false },
] as const)("follow the editor's setting when $name", async ({ softWrap, files, mode, expected }) => {
editorSettings(softWrap, files);
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ mode }));
render(<DiffEditor input={input} />);
const panes = mode === "split" ? 2 : 1;
await waitFor(() => expect(document.querySelectorAll(".cm-editor")).toHaveLength(panes));
expect(wrapped()).toEqual(Array(panes).fill(expected));
useProject.setState({ settings: null as never });
});
it.each([
{ mode: "split", alignChanges: false },
{ mode: "split", alignChanges: true },
{ mode: "unified", alignChanges: false },
])("reconfigures $mode (aligned: $alignChanges) without losing selection", async ({ mode, alignChanges }) => {
editorSettings(true, "*.ts");
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ mode, alignChanges }));
const mounted = render(
<>
<DiffEditor input={input} />
<ContextMenuHost />
</>,
);
const count = mode === "split" ? 2 : 1;
await waitFor(() => expect(document.querySelectorAll(".cm-editor")).toHaveLength(count));
const views = [...document.querySelectorAll<HTMLElement>(".cm-editor")].map((dom) =>
EditorView.findFromDOM(dom)!,
);
for (const view of views) view.dispatch({ selection: { anchor: 1 } });
expect(wrapped()).toEqual(Array(count).fill(true));
// Settings changes still apply until an explicit viewer override is made.
editorSettings(false);
await waitFor(() => expect(wrapped()).toEqual(Array(count).fill(false)));
editorSettings(true, "*.ts");
await waitFor(() => expect(wrapped()).toEqual(Array(count).fill(true)));
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Use Soft Wraps" }));
await waitFor(() => expect(wrapped()).toEqual(Array(count).fill(false)));
expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).softWrap).toBe(false);
editorSettings(true, "*");
await waitFor(() => expect(wrapped()).toEqual(Array(count).fill(false)));
for (const view of views) {
expect(view.dom.isConnected).toBe(true);
expect(EditorView.findFromDOM(view.dom)).toBe(view);
expect(view.state.selection.main.head).toBe(1);
}
mounted.unmount();
render(<DiffEditor input={input} />);
await waitFor(() => expect(document.querySelectorAll(".cm-editor")).toHaveLength(count));
expect(wrapped()).toEqual(Array(count).fill(false));
useProject.setState({ settings: null as never });
localStorage.clear();
});
it("turn on from the settings menu and stay on", async () => {
editorSettings(true);
const mounted = render(
<>
<DiffEditor input={input} />
<ContextMenuHost />
</>,
);
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2));
expect(wrapped()).toEqual([false, false]);
(await screen.findByRole("button", { name: "Settings" })).click();
const item = await screen.findByRole("menuitemcheckbox", { name: "Use Soft Wraps" });
expect(item.getAttribute("aria-checked")).toBe("false");
item.click();
await waitFor(() => expect(wrapped()).toEqual([true, true]));
expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).softWrap).toBe(true);
// The next diff opens wrapped although the editor would not wrap this file.
mounted.unmount();
render(<DiffEditor input={input} />);
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2));
expect(wrapped()).toEqual([true, true]);
useProject.setState({ settings: null as never });
localStorage.clear();
});
});
it("has no Jump to Source when the file is gone from the working tree", async () => {
probe.mockRejectedValue(new Error("no such file"));
render(<DiffEditor input={input} />);
+4
Переглянути файл
@@ -26,6 +26,8 @@ export interface DiffViewOptions {
* keeps its own height and a curved divider joins the changes (IDEA's default).
*/
alignChanges: boolean;
/** Long lines wrap; null follows Settings › Editor › General › Soft-wrap files for the file. */
softWrap: boolean | null;
}
export const IGNORE_OPTIONS: ReadonlyArray<{ value: IgnorePolicy; label: string }> = [
@@ -48,6 +50,7 @@ export const DEFAULT_DIFF_OPTIONS: DiffViewOptions = {
collapse: true,
syncScroll: true,
alignChanges: false,
softWrap: null,
};
const KEY = "openidea.vcs.diffOptions";
@@ -66,6 +69,7 @@ export function parseDiffOptions(raw: unknown): DiffViewOptions {
collapse: typeof o.collapse === "boolean" ? o.collapse : d.collapse,
syncScroll: typeof o.syncScroll === "boolean" ? o.syncScroll : d.syncScroll,
alignChanges: typeof o.alignChanges === "boolean" ? o.alignChanges : d.alignChanges,
softWrap: typeof o.softWrap === "boolean" ? o.softWrap : d.softWrap,
};
}
+2
Переглянути файл
@@ -97,6 +97,8 @@
"common.close": "Close",
"common.copied": "Copied",
"common.copy": "Copy",
"menu.activeEditor": "Active Editor",
"menu.editor.toggleSoftWrap": "Soft-Wrap",
"menu.app": "OpenIDEA",
"menu.app.about": "About OpenIDEA",
"menu.app.checkForUpdates": "Check for Updates…",
+3
Переглянути файл
@@ -1,4 +1,6 @@
{
"Active Editor": "Active Editor",
"Soft-Wrap": "Soft-Wrap",
"The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.",
"Input queue is full (4 MiB or 1024 writes); input was not sent.": "Input queue is full (4 MiB or 1024 writes); input was not sent.",
"Cannot write to terminal": "Cannot write to terminal",
@@ -602,6 +604,7 @@
"Database connection failed": "Database connection failed",
"Database service unavailable": "Database service unavailable",
"Trust this project to connect plugin data sources": "Trust this project to connect plugin data sources",
"Use Soft Wraps": "Use Soft Wraps",
"Language server runs on": "Language server runs on",
"Requires a container root for path mapping": "Requires a container root for path mapping",
"Target unavailable; language server runs on the host": "Target unavailable; language server runs on the host",
+2
Переглянути файл
@@ -1,4 +1,5 @@
{
"Active Editor": "活动编辑器",
"The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "语言服务器的 Java 可执行文件({path})无法在此机器上运行。请在 {setting} 中选择可正常运行的 JDK,或设为自动。",
"Input queue is full (4 MiB or 1024 writes); input was not sent.": "输入队列已满(4 MiB 或 1024 次写入);本次输入未发送。",
"Cannot write to terminal": "无法写入终端",
@@ -2388,6 +2389,7 @@
"Soft": "软",
"Soft Wraps": "软换行",
"Soft-Wrap": "软换行",
"Use Soft Wraps": "使用软换行",
"Soft-wrap": "软换行",
"Soft-wrap files": "软换行文件",
"Some items could not be copied": "部分项目无法复制",
+2
Переглянути файл
@@ -97,6 +97,8 @@
"common.close": "关闭",
"common.copied": "已复制",
"common.copy": "复制",
"menu.activeEditor": "活动编辑器",
"menu.editor.toggleSoftWrap": "软换行",
"menu.app": "OpenIDEA",
"menu.app.about": "关于 OpenIDEA",
"menu.app.checkForUpdates": "检查更新…",
+39
Переглянути файл
@@ -0,0 +1,39 @@
/** Soft-wrap action targets outside the regular editor, retained while its menus are open. */
import { getContext, setContext } from "@/platform/context";
interface SoftWrapSurface {
enabled(): boolean;
toggle(): void;
}
let focused: SoftWrapSurface | null = null;
export function focusedSoftWrapSurface(): SoftWrapSurface | null {
return focused;
}
export function registerSoftWrapSurface(host: HTMLElement, surface: SoftWrapSurface): () => void {
const focus = (target: Element | null) => {
if (target && host.contains(target)) {
focused = surface;
setContext("softWrapSurfaceFocus", true);
} else if (
focused === surface &&
!getContext().popupOpen &&
!target?.closest('[role="menu"], [role="menubar"], [aria-haspopup="menu"]')
) {
focused = null;
setContext("softWrapSurfaceFocus", false);
}
};
const onFocus = (event: FocusEvent) => focus(event.target instanceof Element ? event.target : null);
document.addEventListener("focusin", onFocus);
focus(document.activeElement);
return () => {
document.removeEventListener("focusin", onFocus);
if (focused === surface) {
focused = null;
setContext("softWrapSurfaceFocus", false);
}
};
}