diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 6c608ceefa1..d3cfc60fc32 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -52,6 +52,7 @@ import { } from './monaco-markdown-selection-annotation' import { translate } from '@/i18n/i18n' import { handleMonacoLargeTextPaste } from './monaco-large-text-paste' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' import { clampMonacoAutoHeight, getMonacoAutoHeightForContent, @@ -146,6 +147,8 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) + const editorFontFamily = settings?.terminalFontFamily || 'monospace' + const editorWordWrap = settings?.editorWordWrap const estimatedAutoHeight = useMemo(() => { if (!autoHeight) { return null @@ -707,14 +710,15 @@ export default function MonacoEditor({ // Update editor options when settings change useEffect(() => { - if (!editorRef.current || !settings) { + if (!editorRef.current) { return } editorRef.current.updateOptions({ fontSize: editorFontSize, - fontFamily: settings.terminalFontFamily || 'monospace' + fontFamily: editorFontFamily, + ...buildFileEditorWordWrapOptions(editorWordWrap) }) - }, [editorFontSize, settings]) + }, [editorFontFamily, editorFontSize, editorWordWrap]) useEffect(() => { markdownDocLinkDecorationsRef.current?.refresh() @@ -833,9 +837,9 @@ export default function MonacoEditor({ // setting into DiffViewer/DiffSectionItem would have no effect. minimap: { enabled: settings?.editorMinimapEnabled ?? false }, scrollBeyondLastLine: false, - wordWrap: 'on', + ...buildFileEditorWordWrapOptions(editorWordWrap), fontSize: editorFontSize, - fontFamily: settings?.terminalFontFamily || 'monospace', + fontFamily: editorFontFamily, lineNumbers: 'on', renderLineHighlight: 'line', automaticLayout: true, diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts new file mode 100644 index 00000000000..c50d4389ab3 --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' + +describe('buildFileEditorWordWrapOptions', () => { + it('keeps wrapping enabled for existing profiles without the preference', () => { + expect(buildFileEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'on' }) + expect(buildFileEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' }) + }) + + it('disables wrapping for horizontal file-editor scrolling', () => { + expect(buildFileEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' }) + }) +}) diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts new file mode 100644 index 00000000000..af9bedfb2ef --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts @@ -0,0 +1,8 @@ +import type { editor } from 'monaco-editor' + +export function buildFileEditorWordWrapOptions( + editorWordWrap: boolean | undefined +): Pick { + // Why: profiles saved before this preference existed must retain Orca's previous wrapped default. + return { wordWrap: editorWordWrap === false ? 'off' : 'on' } +} diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx new file mode 100644 index 00000000000..63a963c215e --- /dev/null +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { join } from 'node:path' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) + +import { EditorWordWrapSetting } from './EditorWordWrapSetting' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null +}) + +function renderSetting(editorWordWrap: boolean | undefined, updateSettings = vi.fn()) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + ) + }) + return { container, updateSettings } +} + +describe('EditorWordWrapSetting', () => { + it('shows wrapping as on for profiles saved before the preference existed', () => { + const { container } = renderSetting(undefined) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + expect(on?.getAttribute('aria-checked')).toBe('true') + }) + + it('shows wrapping as off when the preference is disabled', () => { + const { container } = renderSetting(false) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + expect(off?.getAttribute('aria-checked')).toBe('true') + }) + + it('persists the off choice for horizontal scrolling', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(true, updateSettings) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + act(() => off?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: false }) + }) + + it('persists the on choice without changing the diff preference', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(false, updateSettings) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + act(() => on?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: true }) + }) +}) diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx new file mode 100644 index 00000000000..b3dfbb853c4 --- /dev/null +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx @@ -0,0 +1,69 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { Label } from '../ui/label' +import { SettingsSegmentedControl } from './SettingsFormControls' + +type EditorWordWrapSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function EditorWordWrapSetting({ + settings, + updateSettings +}: EditorWordWrapSettingProps): React.JSX.Element { + return ( + +
+ +

+ {translate( + 'auto.components.settings.GeneralEditorSettingsSection.9b18de6eea', + 'Wrap long lines in file editors instead of requiring horizontal scrolling.' + )} +

+
+ updateSettings({ editorWordWrap: option === 'on' })} + options={[ + { + value: 'off', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.bf16ef0af2', + 'Off' + ) + }, + { + value: 'on', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.3f6892f307', + 'On' + ) + } + ]} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index a4a33f1102a..9cccdf166ee 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -17,6 +17,7 @@ import { } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' +import { EditorWordWrapSetting } from './EditorWordWrapSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -248,6 +249,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes file-editor word-wrap and horizontal-scroll keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('editor word wrap', entries)).toBe(true) + expect(matchesSettingsSearch('horizontal scroll', entries)).toBe(true) + }) + it('includes rich Markdown spellcheck keywords', () => { const entries = getGeneralPaneSearchEntries() diff --git a/src/renderer/src/components/settings/general-editor-search.ts b/src/renderer/src/components/settings/general-editor-search.ts index 8f93eea9544..c7ba4653d32 100644 --- a/src/renderer/src/components/settings/general-editor-search.ts +++ b/src/renderer/src/components/settings/general-editor-search.ts @@ -29,6 +29,17 @@ export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [ ) ] }, + { + title: translate('auto.components.settings.general.search.e61157e926', 'Editor Word Wrap'), + description: translate( + 'auto.components.settings.general.search.005be5c699', + 'Wrap long lines in file editors instead of requiring horizontal scrolling.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.e1ee631696', 'editor'), + ...translateSearchKeyword('auto.components.settings.general.search.3ca5ab78a5', 'code') + ] + }, { title: translate('auto.components.settings.general.search.2760c9933f', 'Default Diff View'), description: translate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91d1c051fbf..6a98f7b81cd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5401,7 +5401,9 @@ "bf16ef0af2": "Off", "3f6892f307": "On", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "Editor Word Wrap", + "9b18de6eea": "Wrap long lines in file editors instead of requiring horizontal scrolling." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7716,7 +7718,9 @@ "defaultProjectRuntime": "Default Project Runtime", "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "Editor Word Wrap", + "005be5c699": "Wrap long lines in file editors instead of requiring horizontal scrolling." } }, "git": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 202deadeef2..5ea1e331e18 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5401,7 +5401,9 @@ "bf16ef0af2": "Desactivado", "3f6892f307": "Activado", "b82f86d7d2": "Corrector ortográfico de Rich Markdown", - "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." + "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", + "7ddd66fede": "Ajuste de línea en el editor", + "9b18de6eea": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7679,7 +7681,9 @@ "defaultProjectRuntime": "Runtime de proyecto predeterminado", "defaultProjectRuntimeDescription": "Elige el runtime que heredarán los proyectos locales de Windows.", "d2d2d929c0": "Corrector ortográfico de Rich Markdown", - "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." + "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", + "e61157e926": "Ajuste de línea en el editor", + "005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." } }, "git": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 19e8945071f..ea6894df23e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5386,7 +5386,9 @@ "bf16ef0af2": "オフ", "3f6892f307": "オン", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "エディターのワードラップ", + "9b18de6eea": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "ローカルホスト、127.0.0.1、*.internal", @@ -7701,7 +7703,9 @@ "defaultProjectRuntime": "Default Project Runtime", "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "エディターのワードラップ", + "005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" } }, "git": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 48782e1f3c0..1a5da97b828 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5386,7 +5386,9 @@ "bf16ef0af2": "끄다", "3f6892f307": "~에", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "편집기 자동 줄 바꿈", + "9b18de6eea": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "로컬호스트, 127.0.0.1, *.internal", @@ -7664,7 +7666,9 @@ "defaultProjectRuntime": "기본 프로젝트 런타임", "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "편집기 자동 줄 바꿈", + "005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." } }, "git": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f967af9d20a..9fc7fc0cee3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5386,7 +5386,9 @@ "bf16ef0af2": "关闭", "3f6892f307": "开启", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" + "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", + "7ddd66fede": "编辑器自动换行", + "9b18de6eea": "在文件编辑器中自动换行长行,而无需水平滚动。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "本地主机,127.0.0.1,*.internal", @@ -7664,7 +7666,9 @@ "defaultProjectRuntime": "默认项目运行时", "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" + "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", + "e61157e926": "编辑器自动换行", + "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。" } }, "git": { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e185c4d073f..343e0ffe173 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -52,6 +52,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) + it('keeps file-editor word wrapping enabled by default', () => { + expect(getDefaultSettings('/tmp').editorWordWrap).toBe(true) + }) + it('keeps rich Markdown spellcheck enabled by default', () => { expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 26c547ad20a..950cc999477 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -208,6 +208,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + editorWordWrap: true, richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), diff --git a/src/shared/types.ts b/src/shared/types.ts index bc6bcfc38a3..7b0c9b5ca84 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2512,6 +2512,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Defaults on for profiles saved before file-editor wrapping became configurable. */ + editorWordWrap?: boolean /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ richMarkdownSpellcheckEnabled?: boolean /** Whether local markdown review note controls and the review panel are shown. */