дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
13 files changed
+388
-5
No files matched your search
@@ -18,6 +18,7 @@ interface Opts {
|
||||
alignChanges?: boolean;
|
||||
syncScroll?: boolean;
|
||||
collapse?: boolean;
|
||||
softWrap?: boolean;
|
||||
ignore?: "none" | "trim" | "whitespace" | "whitespaceAndLines";
|
||||
}
|
||||
|
||||
@@ -350,3 +351,126 @@ test.describe("10,000-line files", () => {
|
||||
await shot(page, `rewrite-${mode.replace(" ", "-")}`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("soft wrapping", () => {
|
||||
for (const [mode, opts] of [
|
||||
["split", { alignChanges: false }],
|
||||
["aligned", { alignChanges: true }],
|
||||
["unified", { mode: "unified" }],
|
||||
] as [string, Opts][]) {
|
||||
test(`toggles in place, retains the visible line and routes the editor action (${mode})`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openCase(page, "src/soft-wrap.ts", { ...opts, softWrap: false });
|
||||
const editors = page.locator(".cm-editor.cm-merge-a, .cm-editor.cm-merge-b");
|
||||
const count = mode === "unified" ? 1 : 2;
|
||||
await expect(editors).toHaveCount(count);
|
||||
await editors.evaluateAll((eds) => {
|
||||
for (const ed of eds) (ed as HTMLElement).dataset.wrapIdentity = "original";
|
||||
});
|
||||
const right = page.locator(".cm-editor.cm-merge-b");
|
||||
await right.evaluate(async (ed) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
const view = EditorView.findFromDOM(ed)!;
|
||||
const pos = view.state.doc.line(125).from;
|
||||
view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) });
|
||||
view.focus();
|
||||
});
|
||||
const topLine = () =>
|
||||
right.evaluate(async (ed) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
const v = EditorView.findFromDOM(ed)!;
|
||||
const outer = ed.closest<HTMLElement>(".cm-mergeView");
|
||||
const top = Math.max(v.scrollDOM.getBoundingClientRect().top, outer?.getBoundingClientRect().top ?? 0);
|
||||
return v.state.doc.lineAt(v.lineBlockAtHeight(Math.max(0, top - v.documentTop)).from).number;
|
||||
});
|
||||
await expect.poll(topLine).toBeGreaterThan(80);
|
||||
const before = await topLine();
|
||||
await toolbar(page).getByRole("button", { name: "Settings", exact: true }).click();
|
||||
const checkbox = page.getByRole("menuitemcheckbox", { name: "Use Soft Wraps", exact: true });
|
||||
await expect(checkbox).toHaveAttribute("aria-checked", "false");
|
||||
await checkbox.click();
|
||||
await expect(page.locator(".cm-lineWrapping")).toHaveCount(count);
|
||||
await expect.poll(async () => Math.abs((await topLine()) - before)).toBeLessThanOrEqual(3);
|
||||
await expect(page.locator('[data-wrap-identity="original"]')).toHaveCount(count);
|
||||
await right.evaluate(async (ed) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
const v = EditorView.findFromDOM(ed)!;
|
||||
if (v.state.doc.lineAt(v.state.selection.main.head).number !== 125) throw new Error("Selection lost");
|
||||
v.dispatch({ effects: EditorView.scrollIntoView(0, { y: "start" }) });
|
||||
});
|
||||
const first = right.locator(".cm-content > .cm-line").first();
|
||||
await expect.poll(() => first.evaluate((line) => line.getBoundingClientRect().height)).toBeGreaterThan(100);
|
||||
// Line-number backgrounds cover the entire wrapped logical line.
|
||||
if (mode !== "unified") {
|
||||
await expect
|
||||
.poll(() =>
|
||||
right.evaluate((ed) =>
|
||||
Math.abs(
|
||||
ed.querySelector(".cm-content > .cm-line")!.getBoundingClientRect().height -
|
||||
ed.querySelector(".cm-lineNumbers .cm-diffGutter-modified")!.getBoundingClientRect().height,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBeLessThan(2);
|
||||
if (mode === "aligned") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tops = await editors.evaluateAll(async (eds) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
return eds.map((ed) => {
|
||||
const view = EditorView.findFromDOM(ed)!;
|
||||
return view.documentTop + view.lineBlockAt(view.state.doc.line(2).from).bottom;
|
||||
});
|
||||
});
|
||||
return Math.abs(tops[0]! - tops[1]!);
|
||||
})
|
||||
.toBeLessThan(2);
|
||||
} else {
|
||||
const path = await page.locator(".cm-diffDivider svg path").first().getAttribute("d");
|
||||
expect(path).toBeTruthy();
|
||||
expect(path).not.toMatch(/NaN|Infinity/);
|
||||
}
|
||||
} else {
|
||||
await expect
|
||||
.poll(() =>
|
||||
right
|
||||
.locator(".cm-deletedLine")
|
||||
.first()
|
||||
.evaluate((line) => line.getBoundingClientRect().height),
|
||||
)
|
||||
.toBeGreaterThan(100);
|
||||
}
|
||||
// The standard action toggles both panes, whether the read-only left or right is focused.
|
||||
for (let i = 0; i < count; i++) {
|
||||
await editors.nth(i).locator(".cm-content").focus();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/platform/actions.ts";
|
||||
const { executeAction, isActionEnabled, useActions } = await import(/* @vite-ignore */ path);
|
||||
if (!isActionEnabled(useActions.getState().actions.get("editor.toggleSoftWrap")!))
|
||||
throw new Error("Soft-Wrap action disabled");
|
||||
if (document.activeElement?.closest(".cm-merge-a")) {
|
||||
const path = "/src/ui/index.ts";
|
||||
const { showContextMenu } = await import(/* @vite-ignore */ path);
|
||||
showContextMenu({ x: 100, y: 100 }, [{ kind: "action", action: "editor.toggleSoftWrap" }]);
|
||||
} else await executeAction("editor.toggleSoftWrap");
|
||||
});
|
||||
if (count === 2 && i === 0) {
|
||||
const action = page.getByRole("menuitemcheckbox", { name: "Soft-Wrap", exact: true });
|
||||
await expect(action).toBeEnabled();
|
||||
await expect(action).toHaveAttribute("aria-checked", "true");
|
||||
await action.click();
|
||||
}
|
||||
await expect(page.locator(".cm-lineWrapping")).toHaveCount(i % 2 === 0 ? 0 : count);
|
||||
}
|
||||
const persisted = await page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).softWrap,
|
||||
);
|
||||
expect(persisted).toBe(count === 2);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -61,6 +61,17 @@ const FUNCS = ["parse", "format", "validate", "render", "save"].map(
|
||||
const MINIFIED = Array.from({ length: 2_000 }, (_, i) => `var a${i}=f(${i},"k${i}");`).join("");
|
||||
|
||||
export const CASES: readonly DiffCase[] = [
|
||||
{
|
||||
path: "src/soft-wrap.ts",
|
||||
before:
|
||||
`// ${"long original text ".repeat(100)}\n` +
|
||||
lines(150, (i) => `const value${i} = ${i};`) +
|
||||
`// ${"old tail ".repeat(100)}\n`,
|
||||
after:
|
||||
`// ${"long changed text ".repeat(160)}\n` +
|
||||
lines(150, (i) => `const value${i} = ${i};`) +
|
||||
`// ${"new changed tail ".repeat(180)}\n`,
|
||||
},
|
||||
// A word edited and a comment added: one modified block (the reported case).
|
||||
{ path: "src/options.ts", before: OPTIONS_BEFORE, after: OPTIONS_AFTER },
|
||||
{
|
||||
|
||||
@@ -395,6 +395,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)]),
|
||||
],
|
||||
),
|
||||
sub("activeEditor", "Active Editor", vec![act("editor.toggleSoftWrap", "Soft-Wrap")]),
|
||||
SEP,
|
||||
act("code.quickDefinition", "Quick Definition"),
|
||||
act("code.quickTypeDefinition", "Quick Type Definition"),
|
||||
@@ -1652,6 +1653,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_wrap_is_available_under_view_active_editor_on_every_os() {
|
||||
for os in ALL_OS {
|
||||
let menus = spec(os);
|
||||
let Entry::Submenu { children: view, .. } =
|
||||
menus.iter().find(|e| matches!(e, Entry::Submenu { id: "view", .. })).unwrap()
|
||||
else {
|
||||
panic!("View menu missing")
|
||||
};
|
||||
let Entry::Submenu { children: active, .. } =
|
||||
view.iter().find(|e| matches!(e, Entry::Submenu { id: "activeEditor", .. })).unwrap()
|
||||
else {
|
||||
panic!("Active Editor menu missing")
|
||||
};
|
||||
assert!(action_ids(active).contains(&"editor.toggleSoftWrap"), "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_menu_config_is_not_rebuilt() {
|
||||
let state = MenuState::default();
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "部分项目无法复制",
|
||||
|
||||
@@ -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": "检查更新…",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача