Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ describe('TOML serialization', () => {
editorLineHeight: 1.6,
themeFamily: 'nord',
themeMode: 'dark',
autoPairs: false,
autoPairQuotesInProse: true,
vaultTextSearchBackend: 'ripgrep',
ripgrepBinaryPath: null,
interfaceFont: null,
Expand All @@ -117,6 +119,8 @@ describe('TOML serialization', () => {
expect(round.editorFontSize).toBe(18)
expect(round.editorLineHeight).toBeCloseTo(1.6)
expect(round.themeFamily).toBe('nord')
expect(round.autoPairs).toBe(false)
expect(round.autoPairQuotesInProse).toBe(true)
expect(round.vaultTextSearchBackend).toBe('ripgrep')
expect(round.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+P' })
expect(round.kanbanColumnTitles).toEqual({ 'status:todo': 'To Do' })
Expand All @@ -137,6 +141,10 @@ describe('TOML serialization', () => {
expect(text).toContain('theme_mode = "dark" # light | dark | auto')
expect(text).toContain('backend = "auto" # auto | builtin | ripgrep | fzf')
expect(text).toContain('font_size = 16')
expect(text).toContain('auto_pairs = true # auto-insert matching [] () and {} while typing')
expect(text).toContain(
'auto_pair_quotes_in_prose = false # also auto-insert matching quotes outside Markdown code'
)
expect(text).toContain('[vim]')
expect(text).toContain('[view]')
// Keymaps: every action listed as a commented, grouped default reference.
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
tomlKey: 'markdown_snippets',
comment: 'auto-close markdown delimiters while typing'
},
autoPairs: {
section: 'editor',
tomlKey: 'auto_pairs',
comment: 'auto-insert matching [] () and {} while typing'
},
autoPairQuotesInProse: {
section: 'editor',
tomlKey: 'auto_pair_quotes_in_prose',
comment: 'also auto-insert matching quotes outside Markdown code'
},
hideBuiltinTemplates: {
section: 'editor',
tomlKey: 'hide_builtin_templates',
Expand Down
48 changes: 48 additions & 0 deletions packages/app-core/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,12 @@ export function SettingsModal(): JSX.Element {
);
const markdownSnippets = useStore((s) => s.markdownSnippets);
const setMarkdownSnippets = useStore((s) => s.setMarkdownSnippets);
const autoPairs = useStore((s) => s.autoPairs);
const setAutoPairs = useStore((s) => s.setAutoPairs);
const autoPairQuotesInProse = useStore((s) => s.autoPairQuotesInProse);
const setAutoPairQuotesInProse = useStore(
(s) => s.setAutoPairQuotesInProse,
);
const tabsEnabled = useStore((s) => s.tabsEnabled);
const setTabsEnabled = useStore((s) => s.setTabsEnabled);
const wrapTabs = useStore((s) => s.wrapTabs);
Expand Down Expand Up @@ -1832,6 +1838,30 @@ export function SettingsModal(): JSX.Element {
"completion",
],
},
{
id: "auto-pairs",
title: "Auto-pair brackets and delimiters",
description:
"Insert matching [] () and {} as you type; quotes pair in Markdown code.",
keywords: [
"auto pair",
"autopair",
"parentheses",
"braces",
"brackets",
"quotes",
"code blocks",
"completion",
"vim",
],
},
{
id: "auto-pair-quotes-in-prose",
title: "Auto-pair quotes in prose",
description:
"Also insert matching quotes outside Markdown code.",
keywords: ["auto pair", "autopair", "quotes", "prose", "code blocks"],
},
{
id: "note-tabs",
title: "Note tabs",
Expand Down Expand Up @@ -2090,6 +2120,8 @@ export function SettingsModal(): JSX.Element {
"live-preview",
"render-tables",
"markdown-overrides",
"auto-pairs",
"auto-pair-quotes-in-prose",
"note-tabs",
"wrap-note-tabs",
"word-wrap",
Expand Down Expand Up @@ -2165,6 +2197,22 @@ export function SettingsModal(): JSX.Element {
settingId="markdown-overrides"
onChange={setMarkdownSnippets}
/>
<ToggleRow
label="Auto-pair brackets and delimiters"
description="Insert matching [] () and {} as you type, wrap selected text, and skip over a closing delimiter that is already present. Quotes pair inside inline code and fenced code blocks. In Vim mode this only applies in insert mode."
value={autoPairs}
settingId="auto-pairs"
onChange={setAutoPairs}
/>
{autoPairs && (
<ToggleRow
label="Auto-pair quotes in prose"
description={'Also insert matching "" and \'\' outside inline code and fenced code blocks.'}
value={autoPairQuotesInProse}
settingId="auto-pair-quotes-in-prose"
onChange={setAutoPairQuotesInProse}
/>
)}
<ToggleRow
label="Note tabs"
description="Open notes in tabs and allow split-friendly tab workflows. Turn off to keep the simpler single-note behavior."
Expand Down
150 changes: 150 additions & 0 deletions packages/app-core/src/lib/cm-auto-pairs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { EditorSelection, EditorState } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { vim } from '@replit/codemirror-vim'
import {
autoPairBackspaceTransaction,
autoPairExtension,
autoPairInputTransaction,
isInMarkdownCode
} from './cm-auto-pairs'
import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap'

function state(doc: string, anchor = doc.length, head = anchor): EditorState {
return EditorState.create({ doc, selection: EditorSelection.range(anchor, head) })
}

describe('autoPairInputTransaction', () => {
it.each([
['(', ')'],
['[', ']'],
['{', '}']
])('inserts %s with its matching closer', (open, close) => {
const next = state('').update(autoPairInputTransaction(state(''), 0, 0, open)!).state

expect(next.doc.toString()).toBe(open + close)
expect(next.selection.main.head).toBe(1)
})

it('wraps the selected text', () => {
const current = state('note', 0, 4)
const next = current.update(autoPairInputTransaction(current, 0, 4, '{')!).state

expect(next.doc.toString()).toBe('{note}')
expect(next.selection.main.from).toBe(1)
expect(next.selection.main.to).toBe(5)
})

it('moves over an existing closer instead of inserting another', () => {
const current = state('()', 1)
const next = current.update(autoPairInputTransaction(current, 1, 1, ')')!).state

expect(next.doc.toString()).toBe('()')
expect(next.selection.main.head).toBe(2)
})

it.each(['"', "'"])('pairs %s only when quotes are enabled', (quote) => {
const current = state('')
expect(autoPairInputTransaction(current, 0, 0, quote)).toBeNull()

const next = current.update(autoPairInputTransaction(current, 0, 0, quote, true)!).state
expect(next.doc.toString()).toBe(quote + quote)
expect(next.selection.main.head).toBe(1)
})

it('skips an existing generated quote and leaves contractions alone', () => {
const quoted = state('""', 1)
const skipped = quoted.update(autoPairInputTransaction(quoted, 1, 1, '"', true)!).state
expect(skipped.selection.main.head).toBe(2)

expect(autoPairInputTransaction(state('don', 3), 3, 3, "'", true)).toBeNull()
})

it('does not handle unrelated or multi-character input', () => {
const current = state('')
expect(autoPairInputTransaction(current, 0, 0, 'x')).toBeNull()
expect(autoPairInputTransaction(current, 0, 0, '()')).toBeNull()
})
})

describe('autoPairBackspaceTransaction', () => {
it('removes an empty pair around the cursor', () => {
const current = state('{}', 1)
const next = current.update(autoPairBackspaceTransaction(current)!).state

expect(next.doc.toString()).toBe('')
expect(next.selection.main.head).toBe(0)
})

it('leaves non-empty pairs to normal backspace behavior', () => {
expect(autoPairBackspaceTransaction(state('{x}', 1))).toBeNull()
})

it('removes empty quotes when quote pairing is enabled', () => {
const current = state("''", 1)
const next = current.update(autoPairBackspaceTransaction(current, true)!).state
expect(next.doc.toString()).toBe('')
})
})

describe('isInMarkdownCode', () => {
it('identifies fenced and inline code but not Markdown prose', () => {
const doc = 'Prose\n\n```ts\nconst fenced = \n```\n\nInline `const inline = `'
const current = EditorState.create({
doc,
extensions: [markdown({ base: markdownLanguage, addKeymap: false })]
})

expect(isInMarkdownCode(current, 2)).toBe(false)
expect(isInMarkdownCode(current, doc.indexOf('const fenced') + 'const fenced = '.length)).toBe(true)
expect(isInMarkdownCode(current, doc.indexOf('const inline') + 'const inline = '.length)).toBe(true)
})
})

describe('disabled auto pairs', () => {
const views: EditorView[] = []
afterEach(() => views.splice(0).forEach((view) => view.destroy()))

function mount(withDisabledAutoPairs: boolean): EditorView {
const view = new EditorView({
state: EditorState.create({
doc: '{',
selection: { anchor: 1 },
extensions: [
vim(),
markdown({ base: markdownLanguage, addKeymap: false }),
vimAwareMarkdownKeymap,
...(withDisabledAutoPairs ? [autoPairExtension({ shouldHandle: () => false })] : []),
keymap.of([...vimAwareDefaultKeymap(true)])
]
}),
parent: document.body
})
views.push(view)
view.focus()
view.contentDOM.dispatchEvent(
new KeyboardEvent('keydown', { key: 'i', keyCode: 73, bubbles: true, cancelable: true })
)
return view
}

function pressEnter(view: EditorView): void {
view.contentDOM.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true })
)
}

it('leaves Enter behavior unchanged from the editor without the extension', () => {
const baseline = mount(false)
const disabled = mount(true)

pressEnter(baseline)
pressEnter(disabled)

expect(baseline.state.doc.toString()).toBe('{\n')
expect(disabled.state.doc.toString()).toBe(baseline.state.doc.toString())
expect(disabled.state.selection.main.head).toBe(baseline.state.selection.main.head)
})
})
Loading