feat(editor): add file editor word wrap preference (#8423)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
gatsby74 2026-07-13 00:17:06 +02:00 committed by GitHub
parent 2306f82113
commit 9dc1f253ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 242 additions and 15 deletions

View File

@ -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,

View File

@ -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' })
})
})

View File

@ -0,0 +1,8 @@
import type { editor } from 'monaco-editor'
export function buildFileEditorWordWrapOptions(
editorWordWrap: boolean | undefined
): Pick<editor.IStandaloneEditorConstructionOptions, 'wordWrap'> {
// Why: profiles saved before this preference existed must retain Orca's previous wrapped default.
return { wordWrap: editorWordWrap === false ? 'off' : 'on' }
}

View File

@ -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(
<EditorWordWrapSetting
settings={{ ...getDefaultSettings(join('test', 'home')), editorWordWrap }}
updateSettings={updateSettings}
/>
)
})
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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="radio"]')].find(
(button) => button.textContent === 'On'
)
act(() => on?.click())
expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: true })
})
})

View File

@ -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<GlobalSettings>) => void
}
export function EditorWordWrapSetting({
settings,
updateSettings
}: EditorWordWrapSettingProps): React.JSX.Element {
return (
<SearchableSetting
title={translate(
'auto.components.settings.GeneralEditorSettingsSection.7ddd66fede',
'Editor Word Wrap'
)}
description={translate(
'auto.components.settings.GeneralEditorSettingsSection.9b18de6eea',
'Wrap long lines in file editors instead of requiring horizontal scrolling.'
)}
keywords={['editor', 'code', 'word wrap', 'wrap', 'horizontal scroll', 'long lines']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label>
{translate(
'auto.components.settings.GeneralEditorSettingsSection.7ddd66fede',
'Editor Word Wrap'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.GeneralEditorSettingsSection.9b18de6eea',
'Wrap long lines in file editors instead of requiring horizontal scrolling.'
)}
</p>
</div>
<SettingsSegmentedControl
ariaLabel={translate(
'auto.components.settings.GeneralEditorSettingsSection.7ddd66fede',
'Editor Word Wrap'
)}
value={settings.editorWordWrap === false ? 'off' : 'on'}
onChange={(option) => 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'
)
}
]}
/>
</SearchableSetting>
)
}

View File

@ -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({
/>
</SearchableSetting>
<EditorWordWrapSetting settings={settings} updateSettings={updateSettings} />
<SearchableSetting
title={translate(
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',

View File

@ -95,6 +95,13 @@ describe('GeneralPane search entries', () => {
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()

View File

@ -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(

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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)
})

View File

@ -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(),

View File

@ -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. */