diff --git a/packages/app-core/src/components/TitleBar.test.ts b/packages/app-core/src/components/TitleBar.test.ts
index e50d38fe5..f86404521 100644
--- a/packages/app-core/src/components/TitleBar.test.ts
+++ b/packages/app-core/src/components/TitleBar.test.ts
@@ -71,14 +71,27 @@ describe('window title bar', () => {
expect(close).toHaveBeenCalledOnce()
})
- it('keeps the browser layout and old preferences unchanged', () => {
+ it('hides in the browser too, and old preferences keep it', () => {
runtime = 'web'
state.showWindowTitleBar = false
act(() => root.render(createElement(TitleBar)))
+ expect(host.childElementCount).toBe(0)
+ state.showWindowTitleBar = undefined
+ act(() => root.render(createElement(TitleBar)))
expect(host.textContent).toContain('Demo vault')
runtime = 'desktop'
- state.showWindowTitleBar = undefined
act(() => root.render(createElement(TitleBar)))
expect(host.textContent).toContain('Demo vault')
})
+
+ it('leaves onboarding a strip to move the desktop window by', () => {
+ state.showWindowTitleBar = false
+ act(() => root.render(createElement(TitleBar, { standalone: true })))
+ const strip = host.firstElementChild
+ expect(strip?.classList.contains('drag-region')).toBe(true)
+ expect(host.textContent).toBe('')
+ runtime = 'web'
+ act(() => root.render(createElement(TitleBar, { standalone: true })))
+ expect(host.childElementCount).toBe(0)
+ })
})
diff --git a/packages/app-core/src/components/TitleBar.tsx b/packages/app-core/src/components/TitleBar.tsx
index a96fce074..4b447bad9 100644
--- a/packages/app-core/src/components/TitleBar.tsx
+++ b/packages/app-core/src/components/TitleBar.tsx
@@ -9,7 +9,7 @@ import { isTrashTabPath } from '@shared/trash'
import { isQuickNotesTabPath } from '@shared/quick-notes'
import { resolveSystemFolderLabels } from '../lib/system-folder-labels'
-export function TitleBar(): JSX.Element | null {
+export function TitleBar({ standalone = false }: { standalone?: boolean }): JSX.Element | null {
const showWindowTitleBar = useStore((s) => s.showWindowTitleBar)
const vault = useStore((s) => s.vault)
const activeNote = useStore((s) => s.activeNote)
@@ -19,7 +19,13 @@ export function TitleBar(): JSX.Element | null {
const isMac = window.zen.platformSync() === 'darwin'
const labels = resolveSystemFolderLabels(systemFolderLabels)
- if (window.zen.getAppInfo().runtime === 'desktop' && showWindowTitleBar === false) return null
+ if (showWindowTitleBar === false) {
+ // Onboarding and the no-vault screen have no other chrome to move the
+ // window by; in the workspace the sidebar header and tab strip carry it.
+ return standalone && window.zen.getAppInfo().runtime === 'desktop' ? (
+
+ ) : null
+ }
const title = activeNote
? activeNote.title
diff --git a/packages/app-core/src/lib/cm-code-fence-indent.ts b/packages/app-core/src/lib/cm-code-fence-indent.ts
index d940b88ef..32999a8c2 100644
--- a/packages/app-core/src/lib/cm-code-fence-indent.ts
+++ b/packages/app-core/src/lib/cm-code-fence-indent.ts
@@ -1,6 +1,12 @@
import { syntaxTree } from '@codemirror/language'
-import { EditorSelection, countColumn, type Line } from '@codemirror/state'
-import type { EditorView } from '@codemirror/view'
+import {
+ EditorSelection,
+ countColumn,
+ type EditorState,
+ type Line,
+ type StateCommand,
+ type Transaction
+} from '@codemirror/state'
import type { SyntaxNode } from '@lezer/common'
import { hasPendingMarkdownBlockSnippet } from './cm-markdown-snippets'
@@ -24,9 +30,11 @@ import { hasPendingMarkdownBlockSnippet } from './cm-markdown-snippets'
*
* The one exception is a fence inside a quote or callout: there the content
* and closing lines are taken over too (see `continueQuotedFenceLine`).
+ *
+ * A state command, not a view one: Vim's `o`/`O` run it on a scratch state to
+ * read the prefix Enter would give a quoted line (`quotedOpenLinePrefix`).
*/
-export function insertNewlineContinueFencedCodeIndent(view: EditorView): boolean {
- const { state } = view
+export const insertNewlineContinueFencedCodeIndent: StateCommand = ({ state, dispatch }) => {
const range = state.selection.main
if (!range.empty) return false
@@ -46,7 +54,7 @@ export function insertNewlineContinueFencedCodeIndent(view: EditorView): boolean
// Past the opener, content lines already copy their own leading whitespace
// correctly, unless the fence is in a quote.
if (fenceLine.number !== caretLine.number) {
- return continueQuotedFenceLine(view, node, prefix, caretLine)
+ return continueQuotedFenceLine(state, dispatch, node, prefix, caretLine)
}
// Whitespace-only (or empty) indent is already handled by the default
@@ -54,12 +62,14 @@ export function insertNewlineContinueFencedCodeIndent(view: EditorView): boolean
if (!/\S/.test(prefix)) return false
const insert = state.lineBreak + fenceContinuationPrefix(prefix)
- view.dispatch(
- state.changeByRange((r) => ({
- changes: { from: r.from, to: r.to, insert },
- range: EditorSelection.cursor(r.from + insert.length)
- })),
- { scrollIntoView: true, userEvent: 'input' }
+ dispatch(
+ state.update(
+ state.changeByRange((r) => ({
+ changes: { from: r.from, to: r.to, insert },
+ range: EditorSelection.cursor(r.from + insert.length)
+ })),
+ { scrollIntoView: true, userEvent: 'input' }
+ )
)
return true
}
@@ -90,12 +100,12 @@ function fenceContinuationPrefix(openerPrefix: string): string {
* applied: it measures columns from a line start that holds the `>`.
*/
function continueQuotedFenceLine(
- view: EditorView,
+ state: EditorState,
+ dispatch: (tr: Transaction) => void,
fence: SyntaxNode,
openerPrefix: string,
line: Line
): boolean {
- const { state } = view
if (!openerPrefix.includes('>') || state.selection.ranges.length > 1) return false
const head = state.selection.main.head
@@ -120,11 +130,13 @@ function continueQuotedFenceLine(
let to = head
while (to < line.to && /[\t ]/.test(line.text[to - line.from])) to++
const from = /\S/.test(state.sliceDoc(markupEnd, head)) ? head : markupEnd
- view.dispatch({
- changes: { from, to, insert },
- selection: EditorSelection.cursor(from + insert.length),
- scrollIntoView: true,
- userEvent: 'input'
- })
+ dispatch(
+ state.update({
+ changes: { from, to, insert },
+ selection: EditorSelection.cursor(from + insert.length),
+ scrollIntoView: true,
+ userEvent: 'input'
+ })
+ )
return true
}
diff --git a/packages/app-core/src/lib/cm-vim-open-line.test.ts b/packages/app-core/src/lib/cm-vim-open-line.test.ts
new file mode 100644
index 000000000..c1dfa0aa7
--- /dev/null
+++ b/packages/app-core/src/lib/cm-vim-open-line.test.ts
@@ -0,0 +1,169 @@
+// @vitest-environment jsdom
+//
+// Vim `o` / `O` pressed for real, mounted like EditorPane's editor (note
+// grammar, Vim-aware markdown Enter, ordered-list renumbering): on a line in a
+// quote or callout the new line keeps the prefix Enter would give it.
+import { afterEach, describe, expect, it } from 'vitest'
+import { EditorState } from '@codemirror/state'
+import { EditorView, keymap } from '@codemirror/view'
+import { vim } from '@replit/codemirror-vim'
+import { noteMarkdown } from './cm-markdown-language'
+import { orderedListRenumber } from './cm-ordered-list-renumber'
+import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap'
+import { quotedOpenLinePrefix, registerOpenLineContinuation } from './cm-vim-open-line'
+
+const views: EditorView[] = []
+afterEach(() => views.splice(0).forEach((v) => v.destroy()))
+
+/** `doc` with the caret at the start of the first line equal to `lineText`. */
+function mount(doc: string, lineText?: string): EditorView {
+ registerOpenLineContinuation()
+ const lines = doc.split('\n')
+ const at = lineText === undefined ? 0 : lines.indexOf(lineText)
+ if (at < 0) throw new Error(`no line ${JSON.stringify(lineText)}`)
+ const anchor = lines.slice(0, at).reduce((n, line) => n + line.length + 1, 0)
+ const view = new EditorView({
+ state: EditorState.create({
+ doc,
+ selection: { anchor },
+ extensions: [
+ vim(),
+ noteMarkdown(),
+ vimAwareMarkdownKeymap,
+ orderedListRenumber,
+ keymap.of([...vimAwareDefaultKeymap(true)])
+ ]
+ }),
+ parent: document.body
+ })
+ views.push(view)
+ view.focus()
+ return view
+}
+
+function press(view: EditorView, ...keys: string[]): void {
+ for (const key of keys) {
+ view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
+ }
+}
+
+function typeInput(view: EditorView, text: string): void {
+ const pos = view.state.selection.main.head
+ view.dispatch({
+ changes: { from: pos, insert: text },
+ selection: { anchor: pos + text.length },
+ userEvent: 'input.type'
+ })
+}
+
+/** Press `keys` in normal mode, type `zz`, leave insert mode. */
+function openLine(doc: string, lineText: string, ...keys: string[]): string {
+ const view = mount(doc, lineText)
+ press(view, ...keys)
+ typeInput(view, 'zz')
+ press(view, 'Escape')
+ return view.state.doc.toString()
+}
+
+const prefixOf = (doc: string, lineText: string): string | null => {
+ const view = mount(doc, lineText)
+ return quotedOpenLinePrefix(view.state, view.state.doc.lineAt(view.state.selection.main.head).number)
+}
+
+describe('quotedOpenLinePrefix', () => {
+ it('is what Enter continues a quoted line with', () => {
+ expect(prefixOf('> hello', '> hello')).toBe('> ')
+ expect(prefixOf('> > deep', '> > deep')).toBe('> > ')
+ expect(prefixOf('> [!tip] Title\n> body', '> [!tip] Title')).toBe('> ')
+ expect(prefixOf('> - item', '> - item')).toBe('> - ')
+ expect(prefixOf('> * [x] done', '> * [x] done')).toBe('> * [ ] ')
+ expect(prefixOf('> 1. one', '> 1. one')).toBe('> 2. ')
+ expect(prefixOf('> - item\n> more', '> more')).toBe('> ')
+ expect(prefixOf('- > quoted in a list', '- > quoted in a list')).toBe(' > ')
+ expect(prefixOf('> hello\nlazy', 'lazy')).toBe('> ')
+ })
+
+ it('follows the quoted-fence rule on code lines (#961)', () => {
+ const fence = '> ```js\n> if (a) {\n> b()\n> ```'
+ expect(prefixOf(fence, '> ```js')).toBe('> ')
+ expect(prefixOf(fence, '> if (a) {')).toBe('> ')
+ expect(prefixOf(fence, '> b()')).toBe('> ')
+ expect(prefixOf(fence, '> ```')).toBe('> ')
+ expect(prefixOf('> - ```js\n> xy\n> ```', '> xy')).toBe('> ')
+ })
+
+ it('continues the markup where Enter would end it', () => {
+ // Enter on an empty item drops its marker; `o` opens another item, as it
+ // does on an empty item outside a quote.
+ expect(prefixOf('> - ', '> - ')).toBe('> - ')
+ expect(prefixOf('> 1. a\n> 2. ', '> 2. ')).toBe('> 3. ')
+ // Enter on a second blank `>` line leaves the quote.
+ expect(prefixOf('> a\n>\n>', '>')).toBe('> ')
+ const view = mount('> a\n>\n>')
+ expect(quotedOpenLinePrefix(view.state, 3)).toBe('> ')
+ })
+
+ it('is null outside a quote', () => {
+ expect(prefixOf('plain', 'plain')).toBeNull()
+ expect(prefixOf('- item', '- item')).toBeNull()
+ expect(prefixOf('```\n> prompt\n```', '> prompt')).toBeNull()
+ expect(prefixOf('> a\n\nafter', 'after')).toBeNull()
+ expect(prefixOf('> a\n', '')).toBeNull()
+ })
+})
+
+describe('Vim o / O inside a quote or callout', () => {
+ it('opens a quoted line below and above', () => {
+ expect(openLine('> hello', '> hello', 'o')).toBe('> hello\n> zz')
+ expect(openLine('> hello', '> hello', 'O')).toBe('> zz\n> hello')
+ expect(openLine('> > deep', '> > deep', 'o')).toBe('> > deep\n> > zz')
+ })
+
+ it('stays in a callout', () => {
+ const callout = '> [!tip] Title\n> body\n\nafter'
+ expect(openLine(callout, '> body', 'o')).toBe('> [!tip] Title\n> body\n> zz\n\nafter')
+ expect(openLine(callout, '> [!tip] Title', 'o')).toBe('> [!tip] Title\n> zz\n> body\n\nafter')
+ })
+
+ it('continues a list inside a quote', () => {
+ expect(openLine('> - item', '> - item', 'o')).toBe('> - item\n> - zz')
+ expect(openLine('> - item', '> - item', 'O')).toBe('> - zz\n> - item')
+ expect(openLine('> - [x] done', '> - [x] done', 'o')).toBe('> - [x] done\n> - [ ] zz')
+ expect(openLine('> - item\n> more', '> more', 'o')).toBe('> - item\n> more\n> zz')
+ })
+
+ it('renumbers an ordered list inside a quote', () => {
+ expect(openLine('> 1. one\n> 2. two', '> 1. one', 'o')).toBe('> 1. one\n> 2. zz\n> 3. two')
+ expect(openLine('> 1. one\n> 2. two', '> 2. two', 'O')).toBe('> 1. one\n> 2. zz\n> 3. two')
+ })
+
+ it('keeps a quoted code block whole', () => {
+ const fence = '> [!tip] Code\n> ```js\n> xy\n> ```'
+ expect(openLine(fence, '> xy', 'o')).toBe('> [!tip] Code\n> ```js\n> xy\n> zz\n> ```')
+ expect(openLine(fence, '> xy', 'O')).toBe('> [!tip] Code\n> ```js\n> zz\n> xy\n> ```')
+ })
+
+ it('repeats with a count and with .', () => {
+ expect(openLine('> hello', '> hello', '2', 'o')).toBe('> hello\n> zz\n> zz')
+ const view = mount('> hello', '> hello')
+ press(view, 'o')
+ typeInput(view, 'zz')
+ press(view, 'Escape', '.')
+ expect(view.state.doc.toString()).toBe('> hello\n> zz\n> zz')
+ })
+
+ it('leaves the current line as it is', () => {
+ expect(openLine('> - a\n> - \n', '> - ', 'o')).toBe('> - a\n> - \n> - zz\n')
+ expect(openLine('> a\n>\n> b', '>', 'o')).toBe('> a\n>\n> zz\n> b')
+ })
+})
+
+describe('Vim o / O outside a quote (unchanged)', () => {
+ it('continues a list (#320) and opens a plain line elsewhere', () => {
+ expect(openLine('- item', '- item', 'o')).toBe('- item\n- zz')
+ expect(openLine('1. one\n2. two', '1. one', 'o')).toBe('1. one\n2. zz\n3. two')
+ expect(openLine('plain', 'plain', 'o')).toBe('plain\nzz')
+ expect(openLine('plain', 'plain', 'O')).toBe('zz\nplain')
+ expect(openLine('```\n> prompt\n```', '> prompt', 'o')).toBe('```\n> prompt\nzz\n```')
+ })
+})
diff --git a/packages/app-core/src/lib/cm-vim-open-line.ts b/packages/app-core/src/lib/cm-vim-open-line.ts
new file mode 100644
index 000000000..830d83410
--- /dev/null
+++ b/packages/app-core/src/lib/cm-vim-open-line.ts
@@ -0,0 +1,142 @@
+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 { 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
+]
+
+function lineInQuote(state: EditorState, lineNumber: number): boolean {
+ const line = state.doc.line(lineNumber)
+ const end = line.from + line.text.trimEnd().length
+ for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(end, -1); node; node = node.parent) {
+ if (node.name === 'Blockquote') return true
+ }
+ return false
+}
+
+/** What the caret's line starts with after Enter at the end of line
+ * `lineNumber`, or null when Enter opens no line below it there. */
+function enterPrefix(state: EditorState, lineNumber: number): string | null {
+ const line = state.doc.line(lineNumber)
+ const atEnd = state.update({ selection: { anchor: line.to } }).state
+ for (const command of enterContinuations) {
+ const out: { tr?: Transaction } = {}
+ const handled = command({
+ state: atEnd,
+ dispatch: (tr) => {
+ out.tr = tr
+ }
+ })
+ if (!handled || !out.tr) continue
+ 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.
+ const own = next.doc.lineAt(changes.mapPos(line.from, 1))
+ return landed.number > own.number ? next.sliceDoc(landed.from, head) : null
+ }
+ return null
+}
+
+/**
+ * The prefix for a line Vim's `o`/`O` open from line `lineNumber` when it sits
+ * in a blockquote or callout, or null outside one.
+ *
+ * Vim's own open-line copies only leading whitespace, which drops the `>` and
+ * puts the new line outside the quote. The prefix is read off Enter's own
+ * commands, run on a scratch state, so `o` and Enter cannot disagree:
+ * `> - item` opens `> - `, `> 1. a` opens `> 2. `, `> more` opens `> `,
+ * and a code line in a quoted fence opens `> ` plus its indentation (#961).
+ *
+ * On an empty list item or a second blank `>` line Enter ends the markup
+ * instead; `o` still continues it there, as on an empty list item outside a
+ * quote, so the line is read with placeholder content.
+ */
+export function quotedOpenLinePrefix(state: EditorState, lineNumber: number): string | null {
+ if (!lineInQuote(state, lineNumber)) return null
+ const prefix = enterPrefix(state, lineNumber)
+ if (prefix != null) return prefix
+ const line = state.doc.line(lineNumber)
+ const filler = /\s$/.test(line.text) ? 'x' : ' x'
+ return enterPrefix(state.update({ changes: { from: line.to, insert: filler } }).state, lineNumber)
+}
+
+// The handler runs as `actions[name]`, so `this` is Vim's action table.
+type VimActionTable = {
+ newLineAndEnterInsertMode: (cm: unknown, args: unknown, vim: unknown) => void
+ enterInsertMode: (cm: unknown, args: unknown, vim: unknown) => void
+}
+
+function zenOpenLineContinuingList(
+ this: VimActionTable,
+ cm: { cm6?: EditorView },
+ actionArgs: { after?: boolean; repeat?: number },
+ vim: { insertMode?: boolean }
+): void {
+ const view = cm.cm6
+ const line = view?.state.doc.lineAt(view.state.selection.main.head)
+ const prefix =
+ view && line
+ ? (quotedOpenLinePrefix(view.state, line.number) ?? listContinuationPrefix(line.text))
+ : null
+ if (!view || !line || prefix == null) {
+ this.newLineAndEnterInsertMode(cm, actionArgs, vim)
+ return
+ }
+ vim.insertMode = true
+ if (actionArgs.after) {
+ view.dispatch({
+ changes: { from: line.to, insert: `\n${prefix}` },
+ selection: { anchor: line.to + 1 + prefix.length }
+ })
+ } else {
+ view.dispatch({
+ changes: { from: line.from, insert: `${prefix}\n` },
+ selection: { anchor: line.from + prefix.length }
+ })
+ }
+ this.enterInsertMode(cm, { repeat: actionArgs.repeat }, vim)
+}
+
+let openLineRegistered = false
+
+/**
+ * `o` / `O` that carry the line's markup forward, like pressing Enter (#320):
+ * on a list item bullets repeat, ordered numbers advance (the renumber pass
+ * fixes the exact value for both `o` and `O`), indentation is preserved, and a
+ * checkbox continues as a fresh unchecked box; inside a quote or callout the
+ * new line keeps its `>` (see `quotedOpenLinePrefix`). Other lines fall back
+ * to Vim's built-in open-line. Idempotent, so it is safe on HMR.
+ */
+export function registerOpenLineContinuation(): void {
+ if (openLineRegistered) return
+ openLineRegistered = true
+ Vim.defineAction(
+ 'zenOpenLineContinuingList',
+ zenOpenLineContinuingList as unknown as Parameters
[1]
+ )
+ Vim.mapCommand(
+ 'o',
+ 'action',
+ 'zenOpenLineContinuingList',
+ { after: true },
+ { context: 'normal', isEdit: true, interlaceInsertRepeat: true }
+ )
+ Vim.mapCommand(
+ 'O',
+ 'action',
+ 'zenOpenLineContinuingList',
+ { after: false },
+ { context: 'normal', isEdit: true, interlaceInsertRepeat: true }
+ )
+}
diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts
index 3687974ab..efc74d366 100644
--- a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts
+++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts
@@ -2,7 +2,7 @@
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { forceParsing } from '@codemirror/language'
-import { EditorState } from '@codemirror/state'
+import { Compartment, EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { describe, expect, it } from 'vitest'
import { wysiwygBlocksPlugin } from './cm-wysiwyg-blocks'
@@ -33,6 +33,9 @@ function mount(doc: string): EditorView {
forceParsing(view, doc.length, 5000)
view.dispatch({ changes: { from: doc.length, insert: ' ' } })
view.dispatch({ changes: { from: doc.length, to: doc.length + 1 } })
+ // Each change reparses under a time budget, which a slow runner can cut;
+ // finish it so the assertions read the decorations of the whole tree.
+ forceParsing(view, doc.length, 5000)
return view
}
@@ -60,6 +63,30 @@ describe('wysiwygBlocksPlugin', () => {
view.destroy()
})
+ it('redraws when only the syntax tree changes, as when a cut parse finishes', () => {
+ // Swapping the language in changes the tree and nothing else: no edit, no
+ // selection, no viewport. A parse that finishes after its time budget ran
+ // out reaches the plugin the same way.
+ const language = new Compartment()
+ const doc = '# T\n\n> [!warning] Log\n> ```\n> fetch: done\n> ```\n\nEnd.'
+ const parent = document.createElement('div')
+ document.body.append(parent)
+ const view = new EditorView({
+ parent,
+ state: EditorState.create({
+ doc,
+ selection: { anchor: doc.length },
+ extensions: [language.of([]), wysiwygBlocksPlugin]
+ })
+ })
+ expect(view.dom.querySelector('.cm-callout')).toBeNull()
+ view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) })
+ forceParsing(view, doc.length, 5000)
+ expect(view.dom.querySelector('.cm-callout-warning')).toBeTruthy()
+ expect(view.dom.textContent).not.toContain('```')
+ view.destroy()
+ })
+
it('reveals the raw list marker on the active line', () => {
const view = mount(DOC)
const firstItem = DOC.indexOf('- first')
diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts
index 000f47014..e7efcc5cb 100644
--- a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts
+++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts
@@ -270,6 +270,10 @@ export const wysiwygBlocksPlugin = ViewPlugin.fromClass(
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
+ // A parse cut short by its time budget finishes in a later update that
+ // changes nothing else; without this the cards and hidden fences stay
+ // as the partial tree drew them until the next edit or cursor move.
+ syntaxTree(update.startState) !== syntaxTree(update.state) ||
update.transactions.some((tr) =>
tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect))
)
diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts
index 15b580f65..e7f10dba1 100644
--- a/packages/app-core/src/lib/help.ts
+++ b/packages/app-core/src/lib/help.ts
@@ -1122,7 +1122,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [
title: 'Appearance',
items: [
{ label: 'Theme, mode, and variant', detail: 'Pick a theme family: Apple, Gruvbox, Catppuccin, GitHub, Solarized, One, Nord, Tokyo Night, Kanagawa (Wave / Dragon / Paper Ink (Custom) / Lotus), Rosé Pine (Rosé Pine / Moon / Dawn), or the monochrome, true-black (OLED-friendly) Black Metal, plus light or dark mode and the active flavor or contrast where the theme supports it.' },
- { label: 'Window title bar', detail: 'Under Settings → Appearance → Chrome, turn off Window title bar to remove the main window title and controls on macOS, Windows, and Linux, including tiling desktops such as Hyprland. The sidebar and editor stay visible, and the change applies to open vault windows without a restart. Drag blank space around the sidebar header to move the window, or use your window manager shortcuts. Press Cmd/Ctrl+, to restore the setting. The preference is saved as [appearance].show_window_title_bar in config.toml.' },
+ { label: 'Window title bar', detail: 'Under Settings → Appearance → Chrome, turn off Window title bar to remove the main window title and controls on macOS, Windows, and Linux, including tiling desktops such as Hyprland. The sidebar and editor stay visible, and the change applies to open vault windows without a restart. Drag blank space in the tab strip, the sidebar header or the note list header to move the window, or use your window manager shortcuts. Press Cmd/Ctrl+, to restore the setting. The preference is saved as [appearance].show_window_title_bar in config.toml. In the browser app the same switch is called Title bar: it hides the bar with the open note’s title, in that browser.' },
{ label: 'Keep running in the tray', detail: 'Off by default. Under Settings → Appearance → Window, turn it on and closing the last window hides it instead of ending the session: a ZenNotes icon in the system tray (the menu bar on macOS) stays, Cloud sync and the Quick capture hotkey keep working, and the icon\'s tooltip and menu show the sync state while a Cloud vault is linked. Click the icon, or choose Open ZenNotes in its menu, to bring the window back; on macOS the Dock icon does too. Quit from the icon\'s menu, with Quit ZenNotes in the command palette, or with Cmd+Q on macOS. On a desktop with no tray host the icon does not appear: open ZenNotes again and the window comes back. `Space b` (Hide Window to Tray in the palette) hides the window on purpose, which a tiling window manager cannot do with minimise. The setting belongs to this computer and is not part of config.toml. While ZenNotes runs it also checks for updates every six hours, not only at launch.' },
{ label: 'Dark sidebar', detail: 'Tint the sidebar slightly darker than the canvas so the chrome reads as a distinct surface.' },
{ label: 'Sidebar arrows', detail: 'Show or hide disclosure arrows for collapsible sidebar folders and sections.' },
@@ -1269,9 +1269,9 @@ export const HELP_CLI: HelpCard[] = [
'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.'
},
{
- title: 'CLI 0.9.0 in desktop 2.72.0',
+ title: 'CLI 0.9.1 in desktop 2.73.0',
body:
- 'On macOS and Linux, desktop 2.72.0 bundles CLI 0.9.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.'
+ 'On macOS and Linux, desktop 2.73.0 bundles CLI 0.9.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.'
},
{
title: 'Updates arrive without a ZenNotes update',
diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts
index fd0d82f0b..139377c95 100644
--- a/packages/app-core/src/lib/whats-new.ts
+++ b/packages/app-core/src/lib/whats-new.ts
@@ -39,6 +39,23 @@ export const CLOUD_ANNOUNCEMENT = {
} as const
export const WHATS_NEW: readonly WhatsNewEntry[] = [
+ {
+ version: '2.73.0',
+ highlights: [
+ {
+ title: 'Move the window without a title bar',
+ body: 'With Window title bar turned off under Settings → Appearance → Chrome, drag blank space in the tab strip to move the window, as you would a title bar, even with the sidebar closed. The browser app gets the same switch, called Title bar.'
+ },
+ {
+ title: 'Settings in a narrow window',
+ body: 'Text fields, choices, font pickers, sliders and key bindings in Settings move under their label when the window or tablet is narrow, instead of squeezing the label into a column of single words.'
+ },
+ {
+ title: 'Vim o and O in quotes',
+ body: 'With Vim on, o and O on a line inside a quote or callout open the new line inside it, with the same quote marker and list marker Enter would give.'
+ }
+ ]
+ },
{
version: '2.72.0',
highlights: [
diff --git a/packages/app-core/src/lib/window-chrome.test.ts b/packages/app-core/src/lib/window-chrome.test.ts
new file mode 100644
index 000000000..f8d22a517
--- /dev/null
+++ b/packages/app-core/src/lib/window-chrome.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import { makeLeaf, type PaneLayout, type PaneLeaf } from './pane-layout'
+import { chromeCarriesWindowDrag, isPaneOnTopEdge } from './window-chrome'
+
+function split(direction: 'row' | 'column', children: PaneLayout[]): PaneLayout {
+ return {
+ kind: 'split',
+ id: `split-${direction}`,
+ direction,
+ children,
+ sizes: children.map(() => 1 / children.length)
+ }
+}
+
+describe('chromeCarriesWindowDrag', () => {
+ it('only when a desktop window hides its title bar', () => {
+ expect(chromeCarriesWindowDrag(false, 'desktop')).toBe(true)
+ expect(chromeCarriesWindowDrag(true, 'desktop')).toBe(false)
+ })
+
+ it('never in a browser, whose page cannot move its window', () => {
+ expect(chromeCarriesWindowDrag(false, 'web')).toBe(false)
+ expect(chromeCarriesWindowDrag(true, 'web')).toBe(false)
+ })
+})
+
+describe('isPaneOnTopEdge', () => {
+ it('a lone pane is on the top edge', () => {
+ const only = makeLeaf()
+ expect(isPaneOnTopEdge(only, only.id)).toBe(true)
+ })
+
+ it('every pane of a side-by-side split is on the top edge', () => {
+ const left = makeLeaf()
+ const right = makeLeaf()
+ const root = split('row', [left, right])
+ expect(isPaneOnTopEdge(root, left.id)).toBe(true)
+ expect(isPaneOnTopEdge(root, right.id)).toBe(true)
+ })
+
+ it('only the first pane of a stacked split is', () => {
+ const top = makeLeaf()
+ const bottom = makeLeaf()
+ const root = split('column', [top, bottom])
+ expect(isPaneOnTopEdge(root, top.id)).toBe(true)
+ expect(isPaneOnTopEdge(root, bottom.id)).toBe(false)
+ })
+
+ it('follows nesting: side by side on top of a stack, stacked under a row', () => {
+ const topLeft: PaneLeaf = makeLeaf()
+ const topRight: PaneLeaf = makeLeaf()
+ const below: PaneLeaf = makeLeaf()
+ const rightTop: PaneLeaf = makeLeaf()
+ const rightBottom: PaneLeaf = makeLeaf()
+ const root = split('row', [
+ split('column', [split('row', [topLeft, topRight]), below]),
+ split('column', [rightTop, rightBottom])
+ ])
+ expect(isPaneOnTopEdge(root, topLeft.id)).toBe(true)
+ expect(isPaneOnTopEdge(root, topRight.id)).toBe(true)
+ expect(isPaneOnTopEdge(root, below.id)).toBe(false)
+ expect(isPaneOnTopEdge(root, rightTop.id)).toBe(true)
+ expect(isPaneOnTopEdge(root, rightBottom.id)).toBe(false)
+ })
+
+ it('an unknown pane is not on the top edge', () => {
+ expect(isPaneOnTopEdge(makeLeaf(), 'missing')).toBe(false)
+ })
+})
diff --git a/packages/app-core/src/lib/window-chrome.ts b/packages/app-core/src/lib/window-chrome.ts
new file mode 100644
index 000000000..20829c0ef
--- /dev/null
+++ b/packages/app-core/src/lib/window-chrome.ts
@@ -0,0 +1,23 @@
+import type { PaneLayout } from './pane-layout'
+
+/**
+ * Whether the workspace chrome has to carry the window drag itself: the user
+ * turned the title bar off in a desktop window, so nothing else moves it (the
+ * traffic lights go with the title bar on macOS). In a browser the page never
+ * moves its window, so the answer there is always no.
+ */
+export function chromeCarriesWindowDrag(showWindowTitleBar: boolean, runtime: string): boolean {
+ return runtime === 'desktop' && showWindowTitleBar === false
+}
+
+/**
+ * Whether pane `paneId` touches the window's top edge: every column split
+ * above it holds it in its first slot. Only those panes' tab strips stand in
+ * for the title bar, as the top tab headers do in Obsidian; a tab strip in the
+ * middle of the window is not a title bar.
+ */
+export function isPaneOnTopEdge(root: PaneLayout, paneId: string): boolean {
+ if (root.kind === 'leaf') return root.id === paneId
+ const children = root.direction === 'column' ? root.children.slice(0, 1) : root.children
+ return children.some((child) => isPaneOnTopEdge(child, paneId))
+}
diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css
index 95dc13c35..1ff1fb035 100644
--- a/packages/app-core/src/styles/index.css
+++ b/packages/app-core/src/styles/index.css
@@ -1510,6 +1510,9 @@ textarea,
[role="button"] {
-webkit-app-region: no-drag;
}
+.zn-app-dragging .drag-region {
+ -webkit-app-region: no-drag;
+}
/* --- CodeMirror editor theme ------------------------------------------- */
diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json
index 3917ad879..7b0afc16a 100644
--- a/packages/bridge-contract/package.json
+++ b/packages/bridge-contract/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/bridge-contract",
"private": true,
- "version": "2.72.0",
+ "version": "2.73.0",
"type": "module",
"exports": {
"./bridge": "./src/bridge.ts",
diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json
index a4fb88330..41b2242b3 100644
--- a/packages/shared-domain/package.json
+++ b/packages/shared-domain/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-domain",
"private": true,
- "version": "2.72.0",
+ "version": "2.73.0",
"type": "module",
"exports": {
"./*": "./src/*.ts"
diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json
index b943668a4..0ad7a594c 100644
--- a/packages/shared-ui/package.json
+++ b/packages/shared-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-ui",
"private": true,
- "version": "2.72.0",
+ "version": "2.73.0",
"type": "module",
"exports": {
".": "./src/index.ts"
diff --git a/tooling/scripts/perf-desktop-runtime.mjs b/tooling/scripts/perf-desktop-runtime.mjs
index 47f0baac5..3e3551b31 100644
--- a/tooling/scripts/perf-desktop-runtime.mjs
+++ b/tooling/scripts/perf-desktop-runtime.mjs
@@ -87,8 +87,6 @@ const deferredNormalFlowChunkPatterns = [
/^Preview-/,
/^NoteHoverPreview-/,
/^wardley-/,
- /^vendor-markdown-/,
- /^vendor-highlight-/,
/^vendor-d3-/,
// Pele draws mermaid fences; its runtime chunk loads before any diagram type.
/^pele-runtime-/,
@@ -100,6 +98,18 @@ const deferredNormalFlowChunkPatterns = [
/^vendor-jsxgraph-/,
/^vendor-function-plot-/
]
+// The markdown stack is the editor's, so it loads in the normal flow but must
+// never be fetched before the app first renders. The live preview draws table
+// cells (cm-table's renderInlineCell, #559) and comment bodies (CommentsPanel)
+// through renderMarkdown, and formulas through KaTeX (cm-math-render,
+// cm-latex-completions), all synchronously in a widget's toDOM or a render, so
+// both chunks sit in the Editor chunk's static graph, and App warms that module
+// during boot (renderer.workspace.ready waits for it). Deferring them would
+// paint raw source for a frame on the first table, formula or comment. Fetched
+// before the app mounts, though, they are in the ENTRY's static graph, ahead of
+// first paint, the way a manualChunks rule once hoisted mermaid there and
+// brought vendor-markdown along (e7a9879e).
+const editorChunkPatterns = [/^vendor-markdown-/, /^vendor-highlight-/]
function parsePositiveInt(raw, fallback) {
const parsed = Number.parseInt(raw ?? '', 10)
@@ -609,10 +619,31 @@ function budgetStatus(label, actual, budget) {
}
}
-function findDeferredNormalFlowChunks(scripts) {
- return scripts.filter((script) =>
- deferredNormalFlowChunkPatterns.some((pattern) => pattern.test(script))
- )
+function matchingChunks(scripts, patterns) {
+ return scripts.filter((script) => patterns.some((pattern) => pattern.test(script)))
+}
+
+// Scripts the reloaded document asked for before App's first render. Only the
+// entry's static graph and the HTML's own modulepreloads can be that early:
+// App's first dynamic import waits for an effect. Timed from the CDP request's
+// wallTime because the renderer loads over file://, which Resource Timing never
+// records. Null when the app has no renderer.app.mounted sample (an older
+// ZEN_PERF_DESKTOP_APP_PATH build) or when the entry chunk itself falls outside
+// the window, which means the two clocks do not line up. Either way the guard
+// cannot run, so the report says unavailable instead of printing an empty list
+// that looks like a pass, and ZEN_PERF_ENFORCE fails the run.
+function scriptsRequestedBeforeMount(requests, timeOrigin, appMountedSample) {
+ const mount = sampleWindow(appMountedSample, timeOrigin)
+ if (!mount || typeof timeOrigin !== 'number') return null
+ const mountedAt = timeOrigin + mount.start
+ const scripts = [
+ ...new Set(
+ requests
+ .filter((request) => request.wallTimeMs >= timeOrigin && request.wallTimeMs < mountedAt)
+ .map((request) => request.script)
+ )
+ ]
+ return scripts.some((script) => /^index-/.test(script)) ? scripts : null
}
function printMetric(label, value, suffix = 'ms') {
@@ -686,9 +717,12 @@ async function main() {
consoleMessages.push({ type: event.type, text })
}
})
+ const networkScriptRequests = []
client.on('Network.requestWillBeSent', (event) => {
const script = scriptNameFromUrl(event.request?.url ?? '')
- if (script) networkScripts.push(script)
+ if (!script) return
+ networkScripts.push(script)
+ networkScriptRequests.push({ script, wallTimeMs: (event.wallTime ?? 0) * 1000 })
})
await Promise.all([
@@ -747,6 +781,7 @@ async function main() {
refreshApply: samples.find((sample) => sample.name === 'store.refreshNotes.apply') ?? null,
workspaceRestore: samples.find((sample) => sample.name.startsWith('workspace.restore')) ?? null,
samples,
+ timeOrigin: performance.timeOrigin,
rowCounts: rowCounts(),
nav: nav ? {
domContentLoaded: Math.round(nav.domContentLoadedEventEnd * 100) / 100,
@@ -1055,7 +1090,13 @@ async function main() {
const mainListNotes = mainPerfSamples.find((sample) => sample.name === 'main.vault.listNotes') ?? null
const longTaskSummary = summarizeLongTasks(finalSnapshot.longTasks)
const loadedScripts = [...new Set([...finalSnapshot.scripts, ...networkScripts])]
- const deferredNormalFlowChunks = findDeferredNormalFlowChunks(loadedScripts)
+ const deferredNormalFlowChunks = matchingChunks(loadedScripts, deferredNormalFlowChunkPatterns)
+ const scriptsBeforeMount = scriptsRequestedBeforeMount(
+ networkScriptRequests,
+ startup.timeOrigin,
+ startup.appMounted
+ )
+ const editorChunksBeforeMount = matchingChunks(scriptsBeforeMount ?? [], editorChunkPatterns)
const statuses = [
...(mainReady ? [budgetStatus('main ready-to-show', mainReady.durationMs, budgets.mainReadyMs)] : []),
budgetStatus('renderer workspace ready', startup.ready.durationMs, budgets.rendererReadyMs),
@@ -1186,6 +1227,21 @@ async function main() {
}
}
+ if (scriptsBeforeMount === null) {
+ console.log('\nJS chunks fetched before the app mounted: unavailable')
+ } else {
+ console.log('\nJS chunks fetched before the app mounted')
+ for (const script of scriptsBeforeMount) {
+ console.log(`- ${script}`)
+ }
+ }
+ if (editorChunksBeforeMount.length > 0) {
+ console.log('\nEditor chunks fetched before the app mounted')
+ for (const script of editorChunksBeforeMount) {
+ console.log(`- ${script}`)
+ }
+ }
+
const failed = statuses.filter((status) => !status.ok)
if (failed.length > 0) {
console.log('\nBudget warnings')
@@ -1220,6 +1276,16 @@ async function main() {
if (enforceDeferredChunks && deferredNormalFlowChunks.length > 0) {
throw new Error('Desktop runtime benchmark loaded deferred-heavy chunks during normal flow')
}
+ if (enforceDeferredChunks && scriptsBeforeMount === null) {
+ throw new Error(
+ startup.appMounted
+ ? 'Desktop runtime benchmark could not time chunk fetches against the app mount (the entry chunk fell outside the before-mount window); the before-mount guard cannot run'
+ : 'Desktop runtime benchmark could not time chunk fetches against the app mount (no renderer.app.mounted sample); the before-mount guard cannot run'
+ )
+ }
+ if (enforceDeferredChunks && editorChunksBeforeMount.length > 0) {
+ throw new Error('Desktop runtime benchmark fetched editor chunks before the app mounted')
+ }
if (enforceBudgets && failed.length > 0) {
throw new Error('Desktop runtime benchmark exceeded enforced budgets')
}