diff --git a/e2e/diff.spec.ts b/e2e/diff.spec.ts index 4878eb7f5..daea8f688 100644 --- a/e2e/diff.spec.ts +++ b/e2e/diff.spec.ts @@ -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(".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); + }); + } +}); diff --git a/e2e/diffRepo.ts b/e2e/diffRepo.ts index b2a1ddaa5..08c347971 100644 --- a/e2e/diffRepo.ts +++ b/e2e/diffRepo.ts @@ -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 }, { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 1515424ea..f0f5a790a 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -395,6 +395,7 @@ pub fn spec(os: Os) -> Vec { 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(); diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 0288c81a1..587c11c9b 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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" }), diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index c5ddb9cef..4c435181d 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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", diff --git a/src/features/vcs/__tests__/diffOptions.test.ts b/src/features/vcs/__tests__/diffOptions.test.ts index f89e1897c..00cd3c87e 100644 --- a/src/features/vcs/__tests__/diffOptions.test.ts +++ b/src/features/vcs/__tests__/diffOptions.test.ts @@ -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", () => { diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index be9fe6a9d..df2357084 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -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(); + 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( + <> + + + , + ); + const count = mode === "split" ? 2 : 1; + await waitFor(() => expect(document.querySelectorAll(".cm-editor")).toHaveLength(count)); + const views = [...document.querySelectorAll(".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(); + 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( + <> + + + , + ); + 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(); + 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(); diff --git a/src/features/vcs/diffOptions.ts b/src/features/vcs/diffOptions.ts index b07837983..f1b6e4d64 100644 --- a/src/features/vcs/diffOptions.ts +++ b/src/features/vcs/diffOptions.ts @@ -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, }; } diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index df89808e0..eeb0b3001 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -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…", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index c5c79cea9..bcf4f7005 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4bcf9c13e..cdc3ef9d3 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "部分项目无法复制", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 9afe2d366..ff39e3157 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -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": "检查更新…", diff --git a/src/workbench/softWrap.ts b/src/workbench/softWrap.ts new file mode 100644 index 000000000..a99b49e14 --- /dev/null +++ b/src/workbench/softWrap.ts @@ -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); + } + }; +}