From afa7839acbccb83d6f31480074ac7d5b0359efd9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 11:25:49 -0600 Subject: [PATCH 01/28] Fix(editor): Enter in a list opens the next bullet right below, always A blank line between the first two bullets of a list spaced out every bullet typed after it. SLP showed it on Discord: Shift+Enter at the end of "one" left an empty line above "two", and from then on each Enter put a blank line before the new bullet, "three" and "four" included. A fresh list under the next heading stayed tight, which made it look random. CodeMirror's markdown Enter calls a list loose when a blank line separates its first two items, and continues a loose list with a blank line of its own every time. Replayed over CDP, the released 2.80.1 saved `- one\n\n- two\n\n- three\n\n- four`. Enter now runs upstream's command and takes that one blank line back out of the finished transaction, so ordered-list renumbering, quoted lists and task boxes stay upstream's. The blank line upstream inserts is the only one its Enter ever adds: a line break, a line of nothing but indentation and `>` marks, then the line break before the marker. With `nonTightLists: false`, Enter on an empty second bullet ends the list, as it already did on an empty third one, instead of pushing the bullet down under a blank line. Vim's `o` already opened the next item right below; its quoted-line prefix now reads this command, so `o` and Enter still agree. A blank line the writer typed stays where it is. The same replay now saves `- one\n\n- two\n- three\n- four`. --- .../src/lib/cm-tight-list-enter.test.ts | 119 ++++++++++++++++++ .../app-core/src/lib/cm-tight-list-enter.ts | 56 +++++++++ .../app-core/src/lib/cm-vim-default-keymap.ts | 15 ++- packages/app-core/src/lib/cm-vim-open-line.ts | 8 +- 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 packages/app-core/src/lib/cm-tight-list-enter.test.ts create mode 100644 packages/app-core/src/lib/cm-tight-list-enter.ts diff --git a/packages/app-core/src/lib/cm-tight-list-enter.test.ts b/packages/app-core/src/lib/cm-tight-list-enter.test.ts new file mode 100644 index 000000000..fbc20710e --- /dev/null +++ b/packages/app-core/src/lib/cm-tight-list-enter.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +// +// Enter pressed for real in an editor mounted like EditorPane's (note grammar, +// the Vim-aware markdown keymap, ordered-list renumbering): a list never grows +// blank lines of its own, even once one sits between its first two items. +import { afterEach, describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { noteMarkdown } from './cm-markdown-language' +import { orderedListRenumber } from './cm-ordered-list-renumber' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap' + +const views: EditorView[] = [] +afterEach(() => views.splice(0).forEach((v) => v.destroy())) + +/** `doc` with the caret where `|` is. */ +function mount(marked: string): EditorView { + const anchor = marked.indexOf('|') + if (anchor < 0) throw new Error('no caret in doc') + const view = new EditorView({ + state: EditorState.create({ + doc: marked.replace('|', ''), + selection: { anchor }, + extensions: [ + noteMarkdown(), + vimAwareMarkdownKeymap, + orderedListRenumber, + keymap.of([...vimAwareDefaultKeymap(false)]) + ] + }), + parent: document.body + }) + views.push(view) + view.focus() + return view +} + +function pressEnter(view: EditorView, shiftKey = false): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, shiftKey, bubbles: true, cancelable: true }) + ) +} + +/** The doc with `|` at the caret. */ +function marked(view: EditorView): string { + const head = view.state.selection.main.head + const doc = view.state.doc.toString() + return doc.slice(0, head) + '|' + doc.slice(head) +} + +function type(view: EditorView, text: string): void { + const head = view.state.selection.main.head + view.dispatch({ changes: { from: head, insert: text }, selection: { anchor: head + text.length } }) +} + +describe('Enter in a list keeps it tight', () => { + it('opens the next bullet right below once a blank line sits between the first two', () => { + const view = mount('- one\n\n- two|') + pressEnter(view) + expect(marked(view)).toBe('- one\n\n- two\n- |') + type(view, 'three') + pressEnter(view) + expect(marked(view)).toBe('- one\n\n- two\n- three\n- |') + }) + + it('replays the Discord report: Shift+Enter after the first bullet spaces only that gap', () => { + const view = mount('- one|\n- two') + pressEnter(view, true) + expect(marked(view)).toBe('- one\n|\n- two') + view.dispatch({ selection: { anchor: view.state.doc.length } }) + pressEnter(view) + type(view, 'three') + pressEnter(view) + type(view, 'four') + expect(view.state.doc.toString()).toBe('- one\n\n- two\n- three\n- four') + }) + + it('leaves a tight list as it was', () => { + const view = mount('- one\n- two|') + pressEnter(view) + expect(marked(view)).toBe('- one\n- two\n- |') + }) + + it('splits an item mid-text without a gap', () => { + const view = mount('- one\n\n- two|three') + pressEnter(view) + expect(marked(view)).toBe('- one\n\n- two\n- |three') + }) + + it('still renumbers the ordered items after the new one', () => { + const view = mount('1. a\n\n2. b|\n3. c') + pressEnter(view) + expect(marked(view)).toBe('1. a\n\n2. b\n3. |\n4. c') + }) + + it('keeps the quote marks of a quoted list', () => { + const view = mount('> - a\n>\n> - b|') + pressEnter(view) + expect(marked(view)).toBe('> - a\n>\n> - b\n> - |') + }) + + it('continues a task as a fresh box without a gap', () => { + const view = mount('- [x] one\n\n- [ ] two|') + pressEnter(view) + expect(marked(view)).toBe('- [x] one\n\n- [ ] two\n- [ ] |') + }) + + it('ends the list on an empty second item instead of pushing it down', () => { + const view = mount('- one\n- |') + pressEnter(view) + expect(marked(view)).toBe('- one\n|') + }) + + it('ends the list on an empty later item, as before', () => { + const view = mount('- one\n- two\n- |') + pressEnter(view) + expect(marked(view)).toBe('- one\n- two\n|') + }) +}) diff --git a/packages/app-core/src/lib/cm-tight-list-enter.ts b/packages/app-core/src/lib/cm-tight-list-enter.ts new file mode 100644 index 000000000..4c3105887 --- /dev/null +++ b/packages/app-core/src/lib/cm-tight-list-enter.ts @@ -0,0 +1,56 @@ +import { insertNewlineContinueMarkupCommand } from '@codemirror/lang-markdown' +import { ChangeSet, type ChangeSpec, type StateCommand, type Transaction } from '@codemirror/state' + +// Off, Enter on an empty second item ends the list the way it already does on +// an empty third one, instead of pushing the item down under a blank line. +const continueMarkup = insertNewlineContinueMarkupCommand({ nonTightLists: false }) + +/** + * The markdown Enter command, without the blank lines CodeMirror gives a + * "loose" list. + * + * CodeMirror calls a list loose when a blank line separates its first two + * items, and from then on opens every new item in it under a blank line of its + * own. So one Shift+Enter between the first two bullets spaced out every bullet + * typed after it (SLP on Discord, 2026-10-31). Here Enter opens the next item + * right below, as Vim's `o` already did; a blank line the writer typed stays + * where it is, and nothing adds more. + * + * Upstream inserts that blank line, and no other, as a line break, a line of + * nothing but indentation and `>` marks, then the line break before the new + * marker. It is taken out of the finished transaction, so ordered-list + * renumbering and the rest of the edit stay upstream's. + * + * A state command, like the fence one: Vim's `o`/`O` run it on a scratch state. + */ +export const insertNewlineContinueMarkupTight: StateCommand = ({ state, dispatch }) => { + const out: { tr?: Transaction } = {} + const handled = continueMarkup({ + state, + dispatch: (tr) => { + out.tr = tr + } + }) + if (!handled || !out.tr) return handled + const { tr } = out + const gaps: ChangeSpec[] = [] + tr.changes.iterChanges((_fromA, _toA, fromB, _toB, inserted) => { + if (inserted.lines !== 3 || inserted.line(1).length > 0) return + const blank = inserted.line(2) + if (/^[\s>]*$/.test(blank.text)) gaps.push({ from: fromB + 1, to: fromB + blank.length + 2 }) + }) + if (gaps.length === 0) { + dispatch(tr) + return true + } + const drop = ChangeSet.of(gaps, tr.newDoc.length) + dispatch( + state.update({ + changes: tr.changes.compose(drop), + selection: tr.newSelection.map(drop), + scrollIntoView: true, + userEvent: 'input' + }) + ) + return true +} diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts index 8cc1d5999..6455c84a8 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -12,6 +12,7 @@ import { import { getCM } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' import { insertNewlineContinueFrontmatterList } from './cm-frontmatter' +import { insertNewlineContinueMarkupTight } from './cm-tight-list-enter' import { selectNoteText } from './cm-select-note' import { isMacPlatform } from './keymaps' @@ -277,10 +278,22 @@ const frontmatterListEnter: KeyBinding = { run: insertNewlineContinueFrontmatterList } +// Upstream's Enter, except that a list never grows blank lines of its own +// (see `insertNewlineContinueMarkupTight`). +const tightListEnter: KeyBinding = { + key: 'Enter', + run: insertNewlineContinueMarkupTight +} + export const vimAwareMarkdownKeymap: Extension = Prec.high( keymap.of( deferKeysToVim( - [frontmatterListEnter, fencedCodeIndentEnter, ...markdownKeymap], + [ + frontmatterListEnter, + fencedCodeIndentEnter, + tightListEnter, + ...markdownKeymap.filter((binding) => binding.key !== 'Enter') + ], new Set(['Enter', 'Backspace']) ) ) diff --git a/packages/app-core/src/lib/cm-vim-open-line.ts b/packages/app-core/src/lib/cm-vim-open-line.ts index 830d83410..ee871d4a3 100644 --- a/packages/app-core/src/lib/cm-vim-open-line.ts +++ b/packages/app-core/src/lib/cm-vim-open-line.ts @@ -1,17 +1,17 @@ import { syntaxTree } from '@codemirror/language' -import { insertNewlineContinueMarkup } from '@codemirror/lang-markdown' import type { EditorState, StateCommand, Transaction } from '@codemirror/state' import type { EditorView } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' import { Vim } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' +import { insertNewlineContinueMarkupTight } from './cm-tight-list-enter' import { listContinuationPrefix } from './list-continuation' // Enter's markup continuations, in vimAwareMarkdownKeymap's order. The // frontmatter list one is left out: the frontmatter is never in a quote. const enterContinuations: readonly StateCommand[] = [ insertNewlineContinueFencedCodeIndent, - insertNewlineContinueMarkup + insertNewlineContinueMarkupTight ] function lineInQuote(state: EditorState, lineNumber: number): boolean { @@ -40,8 +40,8 @@ function enterPrefix(state: EditorState, lineNumber: number): string | null { const { changes, state: next } = out.tr const head = next.selection.main.head const landed = next.doc.lineAt(head) - // An empty item's Enter can push it down a line (loosening the list) - // and leave the caret on it: that line is this one, not a new one. + // An empty item's Enter ends the list and leaves the caret on that + // line: it is this one, not a new one. const own = next.doc.lineAt(changes.mapPos(line.from, 1)) return landed.number > own.number ? next.sliceDoc(landed.from, head) : null } From c6b968920a33443f680d235386513429eb43c0e2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 11:26:02 -0600 Subject: [PATCH 02/28] Fix(vault): New Drawing opens the drawing in a Vault root vault set to assets/drawings charles reported on Discord that New Drawing does nothing on his Mac. In a default vault it works from every entry point, but in a Vault root vault whose drawings location is the folder `assets/drawings` (the example the Settings field itself gave) the file was written and then never opened: `vault:read-note` refused it with "Note not in a known folder". Each try left one more invisible `Untitled N.excalidraw` behind. In a root vault a Specific folder's first segment is a top-level directory, and `assets/`, the other attachment folders and dot folders hold files the vault never lists or opens as notes. Databases and task files resolve their Specific folder the same way, so they had the same trap. resolveCreateLocation now sends such a location to the primary location instead, and reservedCreateFolderTop names the folder that caused it, so the Settings row can say why ("`assets/` holds attachments, so new drawings go to the vault root."). The examples become `Drawings` and `Databases`. An Inbox vault is untouched: there `assets/drawings` lives in `inbox/` and always worked. A system folder named as the first segment (`archive/x` in a root vault) keeps its old behavior; this only stops creating files where they cannot be opened. Over CDP in a root vault set to `assets/drawings`, the released 2.80.1 wrote the drawing and opened nothing; this build opens it at the vault root from Home and from the command palette. --- .../app-core/src/components/SettingsModal.tsx | 25 ++++++++++++--- packages/app-core/src/lib/help.ts | 2 +- .../app-core/src/lib/vault-layout.test.ts | 32 +++++++++++++++++++ packages/app-core/src/lib/vault-layout.ts | 28 ++++++++++++++-- packages/bridge-contract/src/ipc.ts | 2 +- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index f67c4aced..c00094e29 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -131,6 +131,7 @@ import { normalizeMonthlyNoteLocale, normalizeMonthlyNoteTitlePattern, normalizeMonthlyNotesDirectory, + reservedCreateFolderTop, specificFolderDestination, vaultRelativeFolderPath, } from "../lib/vault-layout"; @@ -376,6 +377,20 @@ function specificFolderDestinationLabel( return dir ? `\`${dir}/\`` : "the vault root"; } +/** Where a `Specific folder` row's new files go, and why when a reserved + * folder sends them to the primary location instead. */ +function specificFolderOutcome( + folder: string | undefined, + settings: VaultSettings, + files: string, +): string { + const where = specificFolderDestinationLabel(folder, settings); + const reserved = reservedCreateFolderTop(folder, settings); + if (!reserved) return `New ${files} go to ${where}.`; + const why = reserved.startsWith(".") ? "is hidden" : "holds attachments"; + return `\`${reserved}/\` ${why}, so new ${files} go to ${where}.`; +} + function resolveVaultTextSearchBackend( preferred: VaultTextSearchBackendPreference, capabilities: VaultTextSearchCapabilities | null, @@ -4952,9 +4967,9 @@ export function SettingsModal(): JSX.Element { {vaultSettings.drawingsLocation?.mode === "folder" && ( @@ -4991,9 +5006,9 @@ export function SettingsModal(): JSX.Element { {vaultSettings.databasesLocation?.mode === "folder" && ( @@ -5027,7 +5042,7 @@ export function SettingsModal(): JSX.Element { {vaultSettings.tasksLocation?.mode === "folder" && (