From 61eac765cd47999c7476acc6e754b073513b6527 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 08:50:30 -0500 Subject: [PATCH 01/16] Fix(vim): Ctrl+A increments, and Settings and sidebar keys stay in place (#993) Three Vim mode reports from one issue. Ctrl+A selected the whole note in normal mode on Linux and Windows instead of incrementing the number. CodeMirror's defaultKeymap binds Mod-a to select all, and Mod is Ctrl there; the keymap's handler runs ahead of the Vim plugin's (measured in the built app's plugin order), so Vim never saw . Ctrl+X, its decrement, has no binding and always worked. In Vim mode off macOS, Mod-a now defers to Vim outside insert mode, the way the arrows and Enter already do (#287): normal and visual mode increment, and insert mode keeps select all. macOS, where select all is Cmd+A, and Vim off are unchanged. In Settings, Ctrl+J/K and the arrows walked the categories out of order. The list shows them grouped by section, but the keys step through the results list, which followed the order the categories are defined in (Appearance, Editor, Keymap, Tasks, Typography, and so on). The results are now built from the categories in the navigation's order, so the keys and the list agree, and a search lists its matches in that order too. In the sidebar, the first `l` on a collapsed folder moved the Vim cursor to the open note. Opening a folder clears selectedPath, and the expand prefetches the folder's notes; the prefetch, like most store updates, rebuilds the active fields from the open tab, which puts selectedPath back. The Sidebar's reveal effect read that as a new selection and moved the cursor onto the note. A second expand found the notes cached and nothing happened, hence "only the first time", and a vault small enough to be prefetched at boot never shows it. The reveal now moves the cursor only when the selection really changes (sidebarSelectionChanged): the last note counts through the gap a folder selection leaves, and a re-run for the same selection, such as a listing refresh, leaves the cursor where the keys put it. Focusing the sidebar still lands on the current item. What this does not fix: the cursor is a render-order row number, so a refresh that adds or removes a row numbered before it (a file written from outside the app) still shifts it onto a neighbor. That needs the cursor to track its row, and is left for its own change. On Linux (beelink: an Ubuntu 26.04 container under Xvfb, driven over CDP), against the released 2.76.0 tarball and a tarball of this branch, same vault and keys: Ctrl+A in normal mode selected all before and turned 41 into 42 after, and insert mode selected all in both; Settings Ctrl+J went Appearance, Editor, Keymap, Tasks, Typography, Cloud, Vault before and followed the list after; `l` on a 30-note folder moved the cursor to the open note before and kept it on the folder after, with `j` stepping into the folder. The sidebar and Settings checks also pass on the macOS dev build. Tests: Ctrl+A dispatched through the keymap and Vim in the editor's key order (fails on the old keymap), the selection comparison, and the navigation ordering. app-core 3,445 tests and the sidebar Vim smoke test pass; typecheck clean. --- .../app-core/src/components/SettingsModal.tsx | 10 ++- packages/app-core/src/components/Sidebar.tsx | 34 +++++++++- .../src/lib/cm-vim-default-keymap.test.ts | 65 +++++++++++++++++++ .../app-core/src/lib/cm-vim-default-keymap.ts | 27 ++++++-- .../app-core/src/lib/settings-search.test.ts | 31 ++++++++- packages/app-core/src/lib/settings-search.ts | 19 ++++++ .../app-core/src/lib/sidebar-scroll.test.ts | 28 +++++++- packages/app-core/src/lib/sidebar-scroll.ts | 15 +++++ 8 files changed, 221 insertions(+), 8 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 7870d6d23..f946f3beb 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -134,6 +134,7 @@ import { TemplateEditorModal } from "./TemplateEditorModal"; import type { NoteTemplate } from "@bridge-contract/templates"; import { getSettingsSearchResults, + inNavigationOrder, type SettingsSearchCategory, } from "../lib/settings-search"; import { useAppUpdateState } from "../lib/app-update-state"; @@ -315,6 +316,10 @@ const SETTINGS_SECTIONS: { { id: "system", title: "System", categoryIds: ["mcp", "cli", "about"] }, ]; +const SETTINGS_NAVIGATION_ORDER = SETTINGS_SECTIONS.flatMap( + (section) => section.categoryIds, +); + function settingsSearchTargetProps(settingId: string | undefined): { "data-settings-search-id"?: string; } { @@ -5885,7 +5890,10 @@ export function SettingsModal(): JSX.Element { ]; const query = navQuery.trim().toLowerCase(); - const searchResults = getSettingsSearchResults(categories, query); + const searchResults = getSettingsSearchResults( + inNavigationOrder(categories, SETTINGS_NAVIGATION_ORDER), + query, + ); const visibleSearchResult = searchResults.find((result) => result.id === activeSearchResultId) ?? searchResults.find((result) => result.category.id === activeCategory) ?? diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index d9443b32c..21d230d11 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -133,6 +133,7 @@ import { import { getScrollTopForPreservedSidebarAnchor, isRecentSidebarPointerInteraction, + sidebarSelectionChanged, } from "../lib/sidebar-scroll"; import { buildVaultSwitcherEntries } from "../lib/vault-switcher"; import { appUpdateBadgeLabel, useAppUpdateState } from "../lib/app-update-state"; @@ -702,6 +703,10 @@ export function Sidebar(): JSX.Element { const selectedSidebarCountRef = useRef(selectedSidebarCount); const sidebarScrollRef = useRef(null); const lastSidebarPointerAtRef = useRef(0); + // What the reveal effect below last put the Vim cursor on, and the last + // note that was selected; see that effect. + const revealedSelectionRef = useRef(null); + const lastSelectedNoteRef = useRef(null); const pendingSidebarScrollAnchorRef = useRef<{ selector: string; top: number; @@ -2797,7 +2802,32 @@ export function Sidebar(): JSX.Element { }; useEffect(() => { - if (!isSidebarFocused) return; + if (!isSidebarFocused) { + // Coming back into the sidebar always lands on the current item. + revealedSelectionRef.current = null; + return; + } + + // Follow a new selection, never a re-run for the same one. Selecting a + // folder clears selectedPath, and almost any store update after it (the + // first expand's note prefetch landing, a listing refresh) sets it back + // to the open tab. That is the same note coming back, not a new pick, so + // the last note counts through the gap; and a refresh that leaves the + // selection alone is no reason to move. Both used to drag the cursor off + // the row Vim keys had moved it to and onto the open note. (#993) + if (selectedPath) lastSelectedNoteRef.current = selectedPath; + const selection = [ + lastSelectedNoteRef.current, + view, + tagsViewActive, + selectedTags, + trashViewActive, + archiveViewActive, + quickNotesViewActive, + ]; + if (!sidebarSelectionChanged(revealedSelectionRef.current, selection)) { + return; + } const findTarget = (): HTMLElement | null => { if ( @@ -2900,8 +2930,10 @@ export function Sidebar(): JSX.Element { return null; }; + // No row yet (the listing is still loading): the next run tries again. const target = findTarget(); if (!target) return; + revealedSelectionRef.current = selection; const idx = Number(target.dataset.sidebarIdx); if (Number.isFinite(idx) && idx !== sidebarCursorIndex) { diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts index 0ec1b09a2..7ee0ad22d 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts @@ -224,6 +224,71 @@ describe('vimAwareMarkdownKeymap (Enter in Vim normal mode acts as )', () => }) }) +// #993: on Linux/Windows defaultKeymap's Mod-a (select all) is Ctrl+A, Vim's +// increment, and it ran ahead of the Vim plugin. jsdom's navigator is not +// a Mac, so CodeMirror reads Mod as Ctrl here, exactly as it does there. +describe('Ctrl+A reaches Vim where Mod is Ctrl (#993)', () => { + const views: EditorView[] = [] + afterEach(() => { + views.splice(0).forEach((v) => v.destroy()) + }) + + const mount = (doc: string): EditorView => { + const view = new EditorView({ + state: EditorState.create({ + doc, + // Keymap first: in the real editors CodeMirror's keymap handler runs + // before the Vim plugin's (measured in the app), which is the order + // that let select all win. + extensions: [keymap.of([...vimAwareDefaultKeymap(true, false)]), vim()] + }), + parent: document.body + }) + views.push(view) + view.focus() + return view + } + + const press = (view: EditorView, key: string, keyCode: number, ctrlKey = false): void => { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, keyCode, ctrlKey, bubbles: true, cancelable: true }) + ) + } + + const selectAllRun = (mac: boolean): NonNullable => { + const binding = vimAwareDefaultKeymap(true, mac).find((b) => b.key === 'Mod-a') + if (!binding?.run) throw new Error('no Mod-a binding') + return binding.run + } + + it('increments the number on the line in normal mode instead of selecting the note', () => { + const view = mount('count 41 apples') + press(view, 'a', 65, true) + expect(view.state.doc.toString()).toBe('count 42 apples') + expect(view.state.selection.main.empty).toBe(true) + }) + + it('defers in visual mode too, and keeps select all in insert mode', () => { + const visual = mount('hello world') + press(visual, 'v', 86) + expect(selectAllRun(false)(visual)).toBe(false) + + const insert = mount('hello world') + press(insert, 'i', 73) + expect(selectAllRun(false)(insert)).toBe(true) + expect(insert.state.selection.main.from).toBe(0) + expect(insert.state.selection.main.to).toBe(11) + }) + + it('leaves Cmd+A on macOS and Ctrl+A with Vim off as plain select all', () => { + const mac = vimAwareDefaultKeymap(true, true).find((b) => b.key === 'Mod-a') + expect(mac?.preventDefault).toBeUndefined() + const view = mount('hello world') + expect(mac?.run?.(view)).toBe(true) // normal mode, and still selects + expect(vimAwareDefaultKeymap(false, false)).toBe(vimAwareDefaultKeymap(false, true)) + }) +}) + // #510: on Linux/Windows the search panel's Mod-f is Ctrl+F, which Vim wants // for page-forward; the keymap runs ahead of the Vim plugin, so the panel won. describe('vimAwareSearchKeymap', () => { 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 9745cd9fd..9b9177b96 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -159,15 +159,34 @@ const vimModeKeymap: readonly KeyBinding[] = deferEscapeToVim( deferKeysToVim(defaultKeymapWithoutMacEmacs, VIM_MOTION_KEYS) ) +/** + * Ctrl+A belongs to Vim where Mod is Ctrl (#993). `defaultKeymap` binds + * `Mod-a` to select all, which on Linux and Windows is Ctrl+A, Vim's + * `` increment; it ran ahead of the Vim plugin, so normal mode selected + * the whole note instead of bumping the number. (Ctrl+X, the decrement, has + * no binding here and always reached Vim.) Like the motion keys it defers to + * Vim outside insert mode, so insert mode keeps select all. macOS's Cmd+A + * never collided. + */ +const vimModeKeymapModIsCtrl: readonly KeyBinding[] = deferKeysToVim( + vimModeKeymap, + new Set(['Mod-a']) +) + /** * CodeMirror's `defaultKeymap`, made Vim-aware: in Vim mode the macOS * emacs-style control chords are stripped so Vim's ``/``/``/… * bindings work, the arrow keys defer to Vim in normal/visual mode so they - * move/extend like `hjkl`, and Escape defers to Vim in insert/visual mode so it - * leaves the mode; with Vim off the full keymap is used unchanged. + * move/extend like `hjkl`, Escape defers to Vim in insert/visual mode so it + * leaves the mode, and off macOS Ctrl+A defers to Vim outside insert mode; + * with Vim off the full keymap is used unchanged. */ -export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { - return vimMode ? vimModeKeymap : defaultKeymap +export function vimAwareDefaultKeymap( + vimMode: boolean, + mac: boolean = isMacPlatform() +): readonly KeyBinding[] { + if (!vimMode) return defaultKeymap + return mac ? vimModeKeymap : vimModeKeymapModIsCtrl } /** diff --git a/packages/app-core/src/lib/settings-search.test.ts b/packages/app-core/src/lib/settings-search.test.ts index 22fa37d86..7aa2a4bd8 100644 --- a/packages/app-core/src/lib/settings-search.test.ts +++ b/packages/app-core/src/lib/settings-search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getSettingsSearchResults } from './settings-search' +import { getSettingsSearchResults, inNavigationOrder } from './settings-search' const categories = [ { @@ -118,3 +118,32 @@ describe('getSettingsSearchResults', () => { }) }) }) + +// #993: Ctrl+J/K walk the results list, which with an empty search used to +// follow the definition order while the nav showed the section order. +describe('inNavigationOrder', () => { + const cat = (id: string) => ({ id, title: id, description: '', keywords: [] }) + const defined = ['appearance', 'editor', 'keymaps', 'tasks', 'typography', 'cloud', 'vault'].map(cat) + const nav = ['appearance', 'typography', 'editor', 'tasks', 'keymaps', 'vault', 'cloud'] + + it('puts the categories in the order the navigation shows them', () => { + expect(inNavigationOrder(defined, nav).map((c) => c.id)).toEqual(nav) + }) + + it('so the empty-search results Ctrl+J walks match the nav', () => { + const results = getSettingsSearchResults(inNavigationOrder(defined, nav), '') + expect(results.map((r) => r.category.id)).toEqual(nav) + }) + + it('keeps categories the order does not name, after the named ones', () => { + const ids = inNavigationOrder([cat('extra'), ...defined], ['vault', 'appearance']).map((c) => c.id) + expect(ids.slice(0, 2)).toEqual(['vault', 'appearance']) + expect(ids.slice(2)).toEqual(['extra', 'editor', 'keymaps', 'tasks', 'typography', 'cloud']) + }) + + it('leaves the input untouched', () => { + const input = [...defined] + inNavigationOrder(input, nav) + expect(input.map((c) => c.id)).toEqual(defined.map((c) => c.id)) + }) +}) diff --git a/packages/app-core/src/lib/settings-search.ts b/packages/app-core/src/lib/settings-search.ts index 70f3cd256..edbbc52b7 100644 --- a/packages/app-core/src/lib/settings-search.ts +++ b/packages/app-core/src/lib/settings-search.ts @@ -85,6 +85,25 @@ function settingResult( } } +/** + * Categories in the order the navigation shows them, which groups them by + * section and so differs from the order they are defined in. The results + * list is built from this, and Ctrl+J/K (and the arrows) walk that list even + * while the search is empty, so a definition-order list sent the keys + * skipping back and forth through the categories on screen. (#993) + * Categories the order does not name follow, in their own order. + */ +export function inNavigationOrder( + categories: readonly TCategory[], + order: readonly string[] +): TCategory[] { + const rank = (id: string): number => { + const at = order.indexOf(id) + return at === -1 ? order.length : at + } + return [...categories].sort((a, b) => rank(a.id) - rank(b.id)) +} + export function getSettingsSearchResults( categories: TCategory[], query: string diff --git a/packages/app-core/src/lib/sidebar-scroll.test.ts b/packages/app-core/src/lib/sidebar-scroll.test.ts index 0689384b8..368a04800 100644 --- a/packages/app-core/src/lib/sidebar-scroll.test.ts +++ b/packages/app-core/src/lib/sidebar-scroll.test.ts @@ -1,9 +1,35 @@ import { describe, expect, it } from 'vitest' import { getScrollTopForPreservedSidebarAnchor, - isRecentSidebarPointerInteraction + isRecentSidebarPointerInteraction, + sidebarSelectionChanged } from './sidebar-scroll' +// #993: the reveal re-ran on every listing refresh and on the open note +// coming back after a folder selection, and each run pulled the Vim cursor +// onto the open note. +describe('sidebarSelectionChanged', () => { + const folderView = { kind: 'folder', folder: 'inbox', subpath: 'Projects' } + const tags: string[] = [] + + it('reveals the first time, after focus comes back (no previous selection)', () => { + expect(sidebarSelectionChanged(null, ['inbox/a.md', folderView, false, tags])).toBe(true) + }) + + it('stays put when nothing the person selects has changed', () => { + const selection = ['inbox/a.md', folderView, false, tags] + expect(sidebarSelectionChanged(selection, ['inbox/a.md', folderView, false, tags])).toBe(false) + }) + + it('follows a different note, a new view, or a toggled special view', () => { + const selection = ['inbox/a.md', folderView, false, tags] + expect(sidebarSelectionChanged(selection, ['inbox/b.md', folderView, false, tags])).toBe(true) + expect(sidebarSelectionChanged(selection, ['inbox/a.md', { ...folderView }, false, tags])).toBe(true) + expect(sidebarSelectionChanged(selection, ['inbox/a.md', folderView, true, tags])).toBe(true) + expect(sidebarSelectionChanged(selection, ['inbox/a.md', folderView, false, ['x']])).toBe(true) + }) +}) + describe('sidebar scroll anchoring', () => { it('keeps the interacted row at the same visual offset', () => { expect( diff --git a/packages/app-core/src/lib/sidebar-scroll.ts b/packages/app-core/src/lib/sidebar-scroll.ts index fd16378ce..04cf3b9c3 100644 --- a/packages/app-core/src/lib/sidebar-scroll.ts +++ b/packages/app-core/src/lib/sidebar-scroll.ts @@ -29,3 +29,18 @@ export function isRecentSidebarPointerInteraction( if (!Number.isFinite(now) || now < lastPointerAt) return false return now - lastPointerAt <= windowMs } + +/** + * Whether the sidebar's reveal has a new selection to follow. The parts are + * the things a person selects (the note, the view, the special views), and + * they compare by identity. A reveal that ran for the same parts already put + * the Vim cursor there, so re-running it can only pull the cursor back from + * where the keys have since moved it. (#993) + */ +export function sidebarSelectionChanged( + previous: readonly unknown[] | null, + next: readonly unknown[] +): boolean { + if (!previous || previous.length !== next.length) return true + return next.some((part, i) => !Object.is(part, previous[i])) +} From 69f34c0f6efc2037da42bf59caa90f052b365487 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 09:37:40 -0500 Subject: [PATCH 02/16] Feat(tags): the Tags view lists the tagged blocks inside each note (#837) uNyanda jots ideas down to develop later and tags them, several unrelated ideas to a note. Searching a tag returned the whole note, so finding one idea meant reading through all of them. Tags were a flat per-note list (NoteMeta.tags), and nothing recorded where in a note a tag sat. Opening a tag (a sidebar tag, a #tag clicked in a note, :tag) now lists each matching note with its tagged blocks under the title: the list item, paragraph, quote or table row that carries the tag. A list item brings its nested items along, and a tag on a heading tags its whole section, shown as the heading with the first lines under it. Enter, o or a click on a block opens the note at that line with the line highlighted, recorded as a jump so Ctrl+O comes back; Enter on the note row opens the note at the top as before. The header counts both, and the filter narrows to the blocks whose text matches. A title match keeps all of a note's blocks; an excerpt match keeps only the note row, since the excerpt is the opening of the body and letting it keep every block undid the narrowing whenever the idea sat near the top. With Vim mode on, } and { hop from note to note past the blocks (tags.nextNote / tags.previousNote). Not ] and [: VimNav takes [ as the prefix of [b / ]b from every panel, and claiming it here would break buffer switching from the Tags view. With Vim off they do nothing, as the other single keys there. The block scan is new pure logic in shared-domain (tag-blocks.ts). It reads tags by the exact rules of the note-level scan, so a block lists for a tag only when that scan counted the tag from that line: the same frontmatter cut, the same code mask, the same pattern. To share the mask without a sixth copy of stripCodeContent, app-core's copy (with extractTags) moved to shared-domain/tag-scan.ts and app-core re-exports it. There are still five copies; the cross-references in the others now name the new home (the Go copy in ZenNotes/znserver still names the old path). Block structure is read from the lines scanMarkdownLines owns, so a fence inside a list item stays part of the item and a # line inside a fence never ends a section. CRLF bodies lose the CR before LF first, so line numbers do not move. The occurrences are indexed on demand, not at boot. NoteMeta.tags already says which notes carry a tag, so the Tags view reads only the notes it lists, through the readNote bridge every host implements, and caches each note's blocks stamped with its updatedAt and size; a watcher update changes the stamp, so a stale entry is never served. Nothing is read while the view is closed, and a note open in the editor is read from its buffer, so a block typed a moment ago lists right away. Because readNote is the only dependency, local vaults, remote workspaces and the web client all get blocks with no new IPC, and the Go server and MCP are untouched. Deliberately unchanged, as Adib decided: the workflows `tag #x` condition stays note-level; the search palette stays a note finder; frontmatter tags tag the whole note, so a note tagged only there stays a plain row; block rows are always shown, with no new setting. openNoteAtOffset gains a highlightLine option, the line flash task jumps already use. Verified over CDP on the built app (tooling/scripts/tag-blocks-smoke.mjs, isolated userData and config dir, Vim on and off, 24 checks): blocks under their notes with code ignored and the frontmatter-only note a plain row; j, } and { over the rows; Enter landing on the highlighted line; Ctrl+O back to the view; a freshly typed block listing; the filter; Vim off leaving } and j inert while the arrows and Enter work; no console errors. A tag on 500 notes lists all 500 blocks about 340 ms after Enter. Perf harness (5000 notes, 2 runs before and 3 after): note switch 33.7 vs 33.4 ms, virtual scroll 34.1 vs 34.0 ms, heap 21.3 MB, no long tasks, no budget failures; main-process listing read 422 to 428 ms before and 432 to 517 ms after, back to 432 ms on a third run, so noise on a path this change does not touch. Tests: block extraction (list items and children, continuation and lazy lines, paragraphs, heading sections, quotes, table rows, code ignored, frontmatter staying note-level, CRLF, the jump offset) and the Tags view rows and note hops. shared-domain 2,091 tests, app-core 293 files and desktop 75 files pass; typecheck clean. Docs: the in-app manual (a "Tags find the idea, not just the note" card and the Tags keys) here; the website mirror lands separately. --- apps/desktop/src/main/vault.ts | 5 +- apps/desktop/src/mcp/vault-ops.ts | 2 +- packages/app-core/src/components/TagView.tsx | 251 +++++-- packages/app-core/src/lib/help.ts | 12 +- packages/app-core/src/lib/keymaps.ts | 26 + packages/app-core/src/lib/tag-block-index.ts | 199 ++++++ .../app-core/src/lib/tag-view-rows.test.ts | 123 ++++ packages/app-core/src/lib/tag-view-rows.ts | 77 +++ packages/app-core/src/lib/tags.ts | 76 +-- packages/app-core/src/lib/wikilinks.ts | 2 +- packages/app-core/src/store.ts | 5 +- packages/shared-domain/src/keymaps-catalog.ts | 2 + packages/shared-domain/src/tag-blocks.test.ts | 261 +++++++ packages/shared-domain/src/tag-blocks.ts | 340 +++++++++ packages/shared-domain/src/tag-scan.ts | 80 +++ .../shared-domain/src/workflows/apply-ops.ts | 4 +- tooling/scripts/tag-blocks-smoke.mjs | 644 ++++++++++++++++++ 17 files changed, 1976 insertions(+), 133 deletions(-) create mode 100644 packages/app-core/src/lib/tag-block-index.ts create mode 100644 packages/app-core/src/lib/tag-view-rows.test.ts create mode 100644 packages/app-core/src/lib/tag-view-rows.ts create mode 100644 packages/shared-domain/src/tag-blocks.test.ts create mode 100644 packages/shared-domain/src/tag-blocks.ts create mode 100644 packages/shared-domain/src/tag-scan.ts create mode 100644 tooling/scripts/tag-blocks-smoke.mjs diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index a1b403d46..4b638f269 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -1979,8 +1979,9 @@ async function folderOf(root: string, absPath: string): Promise { * never read code as content. Line-based and indentation-tolerant: a fence * nested under a list item is still a code block (#293). Mirrors * `stripCodeContent` in apps/desktop/src/main/vault.ts, - * packages/app-core/src/lib/{tags,wikilinks}.ts, and + * packages/shared-domain/src/tag-scan.ts, packages/app-core/src/lib/wikilinks.ts, and * internal/vault/parse.go in ZenNotes/znserver — keep all five in sync. */ function stripCodeContent(body: string): string { diff --git a/packages/app-core/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx index 73a05175b..de15cfc7a 100644 --- a/packages/app-core/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -1,7 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTagsViewActive, useStore } from '../store' import type { NoteMeta } from '@shared/ipc' +import { tagBlockJumpOffset, type TagBlock } from '@shared/tag-blocks' import { buildTagTree, flattenTagTree, matchesSelectedTags, noteTagsForCount } from '../lib/tags' +import { useTagBlockIndex } from '../lib/tag-block-index' +import { + buildTagViewRows, + nextNoteRowIndex, + previousNoteRowIndex, + type TagViewRow +} from '../lib/tag-view-rows' import { resolveTypstPreambleFolder } from '../lib/typst-preamble' import { TagIcon, CloseIcon, DocumentIcon } from './icons' import { ContextMenu, type ContextMenuItem } from './ContextMenu' @@ -51,7 +59,13 @@ export function TagView(): JSX.Element { const amActive = useStore(isTagsViewActive) const [filter, setFilter] = useState('') - const [cursorIndex, setCursorIndex] = useState(0) + // The cursor follows a row's key, not its position: block rows fill in + // under notes as their bodies are read, and the selected row must not + // drift while they do. `index` is the fallback when the key leaves the list. + const [cursor, setCursor] = useState<{ key: string | null; index: number }>({ + key: null, + index: 0 + }) const [exOpen, setExOpen] = useState(false) const [exValue, setExValue] = useState('') // Right-click menu for a selected tag chip (deselect / keep only / clear). (#356) @@ -100,47 +114,98 @@ export function TagView(): JSX.Element { .sort((a, b) => b.updatedAt - a.updatedAt) }, [notes, activeNote, selectedTags, tagMatchMode, preambleFolder]) - const filtered = useMemo(() => { - const q = filter.trim().toLowerCase() - if (!q) return matching - return matching.filter( - (n) => - n.title.toLowerCase().includes(q) || - n.excerpt.toLowerCase().includes(q) || - n.path.toLowerCase().includes(q) - ) - }, [matching, filter]) + // Block-level results (#837): the blocks inside each matching note that + // carry a selected tag, read on demand for the listed notes only. + const tagBlocks = useTagBlockIndex(matching) + const { rows, blockCount } = useMemo( + () => buildTagViewRows(matching, tagBlocks.blocksByPath, selectedTags, filter), + [matching, tagBlocks.blocksByPath, selectedTags, filter] + ) + const matchingBlockCount = useMemo( + () => + filter.trim() + ? buildTagViewRows(matching, tagBlocks.blocksByPath, selectedTags, '').blockCount + : blockCount, + [filter, matching, tagBlocks.blocksByPath, selectedTags, blockCount] + ) - const safeCursor = Math.min(cursorIndex, Math.max(0, filtered.length - 1)) - const current = filtered[safeCursor] + const cursorIndex = useMemo(() => { + if (rows.length === 0) return 0 + if (cursor.key !== null) { + const at = rows.findIndex((row) => row.key === cursor.key) + if (at >= 0) return at + } + return Math.min(cursor.index, rows.length - 1) + }, [rows, cursor]) + const current: TagViewRow | undefined = rows[cursorIndex] - useEffect(() => { - if (safeCursor !== cursorIndex) setCursorIndex(safeCursor) - }, [safeCursor, cursorIndex]) + const moveTo = useCallback( + (index: number) => { + const row = rows[index] + if (row) setCursor({ key: row.key, index }) + }, + [rows] + ) useEffect(() => { if (!current) return const el = rootRef.current?.querySelector( - `[data-tag-row="${cssEscape(current.path)}"]` + `[data-tag-view-index="${cursorIndex}"]` ) if (el) el.scrollIntoView({ block: 'nearest' }) - }, [current]) + }, [current, cursorIndex]) - const openCurrent = useCallback(async () => { - if (!current) return - await selectNote(current.path) - useStore.getState().setFocusedPanel('editor') + const openNote = useCallback( + async (note: NoteMeta) => { + await selectNote(note.path) + useStore.getState().setFocusedPanel('editor') + requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) + }, + [selectNote] + ) + + // Jump to the block's first line, highlighted, the way a task opens from + // the Tasks view. The offset comes from the body as it is now (the open + // buffer when there is one), so an edit since the list was built cannot + // send the caret to a stale position. Recorded as a jump, so Ctrl+O comes + // back here. + const openBlock = useCallback(async (note: NoteMeta, block: TagBlock) => { + const state = useStore.getState() + let body = + state.activeNote?.path === note.path + ? state.activeNote.body + : state.noteContents[note.path]?.body + if (body == null) { + try { + body = (await window.zen.readNote(note.path)).body + } catch (err) { + console.error('open tagged block: readNote failed', err) + return + } + } + await useStore + .getState() + .openNoteAtOffset(note.path, tagBlockJumpOffset(body, block.startLine), { + scrollMode: 'center', + highlightLine: true + }) requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) - }, [current, selectNote]) + }, []) + + const openRow = useCallback( + async (row: TagViewRow) => { + if (row.kind === 'block') await openBlock(row.note, row.block) + else await openNote(row.note) + }, + [openBlock, openNote] + ) const moveCursor = useCallback( (delta: number) => { - if (filtered.length === 0) return - setCursorIndex((i) => - Math.max(0, Math.min(filtered.length - 1, i + delta)) - ) + if (rows.length === 0) return + moveTo(Math.max(0, Math.min(rows.length - 1, cursorIndex + delta))) }, - [filtered.length] + [rows.length, cursorIndex, moveTo] ) const runExCommand = useCallback( @@ -300,7 +365,20 @@ export function TagView(): JSX.Element { } if (seq('nav.jumpBottom')) { consume() - setCursorIndex(filtered.length - 1) + moveTo(rows.length - 1) + return + } + // `}` / `{` hop between notes, past a note's block rows (vim-gated). + if (seq('tags.nextNote')) { + consume() + const next = nextNoteRowIndex(rows, cursorIndex) + if (next >= 0) moveTo(next) + return + } + if (seq('tags.previousNote')) { + consume() + const previous = previousNoteRowIndex(rows, cursorIndex) + if (previous >= 0) moveTo(previous) return } if ( @@ -310,7 +388,7 @@ export function TagView(): JSX.Element { getKeymapBinding(overrides, 'nav.jumpTop'), gPending, gTimer, - () => setCursorIndex(0), + () => moveTo(0), consume, 500 ) @@ -332,7 +410,7 @@ export function TagView(): JSX.Element { } if ((k === 'Enter' || seq('nav.openResult')) && current) { consume() - void openCurrent() + void openRow(current) } } window.addEventListener('keydown', handler, true) @@ -341,11 +419,13 @@ export function TagView(): JSX.Element { amActive, filter, moveCursor, - filtered.length, + moveTo, + rows, + cursorIndex, current, keymapOverrides, vimMode, - openCurrent, + openRow, closeTagView, setSelectedTags ]) @@ -366,7 +446,12 @@ export function TagView(): JSX.Element {

Tags

{matching.length} {matching.length === 1 ? 'note' : 'notes'} + {matchingBlockCount > 0 && + ` · ${matchingBlockCount} ${matchingBlockCount === 1 ? 'block' : 'blocks'}`} + {tagBlocks.pending > 0 && ( + Finding tagged blocks… + )}
{selectedTags.length >= 2 && (
@@ -575,7 +660,7 @@ export function TagView(): JSX.Element {
Pick one or more tags above to see matching notes.
- ) : filtered.length === 0 ? ( + ) : rows.length === 0 ? (
{matching.length === 0 ? tagMatchMode === 'all' && selectedTags.length >= 2 @@ -584,22 +669,47 @@ export function TagView(): JSX.Element { : `No notes match "${filter}".`}
) : ( - filtered.map((note, i) => { - const isCursor = i === safeCursor + rows.map((row, i) => { + const isCursor = i === cursorIndex + if (row.kind === 'block') { + const { note, block } = row + return ( + + ) + } + const { note } = row return ( + ) + } + if (row.kind === 'more') { + return ( + + ) + } + const n = row.note const dir = noteFolderLabel(n, vaultSettings) return (
+ {/* Not held by a running sync: a draft is saved before every run and + cannot change during one, so leaving never waits on that run. */} +
+ )} )} @@ -990,6 +1025,15 @@ function safeId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); } +function loadTooSlow(conflict: CloudSyncPendingConflict): CloudErrorCopy { + return { + message: + "ZenNotes is taking too long to open the two versions of this file. Both versions are safe, and nothing was changed. Choose Retry, or Finish later to come back to it.", + detail: `Loading conflict ${conflict.id} (${conflict.path}) did not finish within ${CLOUD_CONFLICT_LOAD_TIMEOUT_MS / 1000} seconds.`, + kind: "other", + }; +} + /** Without this the resolver showed "Error invoking remote method * 'cloud-vault-conflict:resolve': Error: ..." whole. */ function message(error: unknown): CloudErrorCopy { diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index cc5db432a..54ba846e4 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -108,6 +108,10 @@ export interface CloudSyncRepository { applyStagedCloudContent?(change: CloudSyncChange, previous: CloudSyncTrackedItem | undefined, file: CloudSyncStagedFile): Promise resolveStagedCloudConflict?(input: CloudSyncStagedConflict): Promise scan(): Promise + /** The file scan() would list at exactly this path and spelling, or null + * when none is there. Reviewing a conflict needs only its own file, and a + * phone hashes every file a scan lists. */ + readLocalItem?(path: string): Promise /** Finish filesystem work an earlier run was stopped in the middle of. Runs * first in every sync run, before anything is pulled or scanned. */ recoverInterruptedWork?(): Promise @@ -1234,8 +1238,15 @@ export class CloudSyncCoordinator { private async currentLocalSnapshot( conflict: CloudSyncStoredConflict ): Promise { - const items = await this.repository.scan() const expectedPath = conflict.local.path ?? conflict.base.path + // Opening one conflict scanned the whole vault, and a phone reads and + // hashes every file it lists: on a vault just pulled from a computer the + // review waited on that for as long as the vault was big. Only a file + // that is no longer at its path (moved, renamed by case) needs the scan. + const direct = expectedPath && this.repository.readLocalItem + ? await this.repository.readLocalItem(expectedPath) + : null + const items = direct ? [direct] : await this.repository.scan() const exact = expectedPath ? items.find((item) => cloudSyncPathKey(item.path) === cloudSyncPathKey(expectedPath)) : undefined diff --git a/packages/shared-domain/src/cloud-sync-host-service.test.ts b/packages/shared-domain/src/cloud-sync-host-service.test.ts index 29d6961b0..9c721c2a3 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.test.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.test.ts @@ -360,6 +360,65 @@ describe('CloudSyncHostService', () => { }) }) + // Discord, 2026-10-26: queued behind a run waiting on a dropped connection, + // a phone's review showed "Loading the versions kept for you" until that + // request timed out minutes later. + it('opens a waiting conflict without waiting for a sync that is stuck', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 1, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const local = textContent('latest local edit') + const cloud = textContent('older cloud edit') + const { client, hostVault, service } = setup( + [remoteVault], + [{ path: 'Note.md', kind: 'text', content: local }] + ) + client.manifest.mockResolvedValue({ + data: [ + { + item_id: 'item-remote', + path: 'Note.md', + kind: 'text', + revision: 3, + sha256: cloud.sha256, + byte_length: cloud.byte_length, + media_type: cloud.media_type, + content: cloud + } + ], + cursor: 1, + next_page: null + }) + await service.link(hostVault, remoteVault.id) + const conflict = (await service.sync(hostVault)).pending_conflicts![0]! + + let releaseChanges!: (value: { data: []; cursor: number; has_more: false }) => void + client.changes.mockReset().mockImplementationOnce( + () => + new Promise((resolve) => { + releaseChanges = resolve + }) + ) + const syncing = service.sync(hostVault) + await vi.waitFor(() => expect(client.changes).toHaveBeenCalledOnce()) + + const opened = await Promise.race([ + service.getConflict(hostVault, conflict.id), + new Promise<'still waiting'>((resolve) => setTimeout(() => resolve('still waiting'), 500)) + ]) + expect(opened).toMatchObject({ + local: { text: 'latest local edit' }, + cloud: { text: 'older cloud edit' } + }) + + releaseChanges({ data: [], cursor: 1, has_more: false }) + await syncing + }) + it('does not start a sync while a conflict decision is being saved', async () => { const remoteVault: CloudSyncVault = { id: 'vault-1', diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index 77dd1cadc..dda11c1e5 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.ts @@ -372,16 +372,21 @@ export class CloudSyncHostService { vault: CloudSyncHostVault, conflictId: string ): Promise { - return await this.exclusive(vault.key, async () => { - const { account, client, link } = await this.linkedConnection(vault) - return await new CloudSyncCoordinator( - link.vault_id, - client, - vault.repository, - this.stateStore(vault.key, account.base_url, link.vault_id), - this.ids - ).getConflict(conflictId) - }) + // Reading a waiting conflict changes nothing, so it does not queue behind + // the vault's other work. Queued, the review waited out whatever sync was + // running: a phone's long first sync, or a request on a dropped + // connection that holds a run for minutes until it times out (Discord, + // 2026-10-26: "Loading the versions kept for you" never finished). A + // draft or a decision still waits its turn, and a choice made on what + // this read showed is checked again when it is saved. + const { account, client, link } = await this.linkedConnection(vault) + return await new CloudSyncCoordinator( + link.vault_id, + client, + vault.repository, + this.stateStore(vault.key, account.base_url, link.vault_id), + this.ids + ).getConflict(conflictId) } async saveConflictDraft( diff --git a/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts b/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts index f0b63a9f1..c69b72fea 100644 --- a/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts +++ b/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts @@ -875,6 +875,103 @@ describe('PortableCloudSyncRepository: an interrupted case-only rename', () => { }) }) +/** Counts the files read, the way a phone pays for each one in native work. */ +class CountingMemoryFileSystem extends MemoryFileSystem { + readonly reads: string[] = [] + + override async readBase64(path: string): Promise { + this.reads.push(path) + return super.readBase64(path) + } +} + +// Discord, 2026-10-26: on a phone that had just pulled a computer's vault, +// opening one first-sync conflict read and hashed every note in the vault. +describe('PortableCloudSyncRepository: opening one waiting conflict', () => { + async function firstSyncWithConflict() { + const statePath = 'zennotes-cloud-sync/state.json' + const notes: Record = {} + for (let index = 1; index <= 200; index++) notes[`inbox/Note ${index}.md`] = `note ${index}` + const fs = new CountingMemoryFileSystem({ ...notes, 'inbox/Shopping.md': 'Eggs (from the phone)' }) + const cloudFiles = { ...notes, 'inbox/Shopping.md': 'Coffee (from the PC)' } + const items = await Promise.all(Object.entries(cloudFiles).map(async ([path, text], index) => { + const content = await textContent(text) + return { item_id: `item-${index}`, path, kind: 'text' as const, revision: 1, sha256: content.sha256, + byte_length: content.byte_length, media_type: content.media_type, content } + })) + const remote: CloudSyncRemote = { + manifest: async () => ({ data: items, cursor: items.length, next_page: null }), + changes: async () => ({ data: [], cursor: items.length, has_more: false }), + mutate: async () => { throw new Error('nothing should be pushed') } + } + let id = 0 + const coordinator = new CloudSyncCoordinator('vault-1', remote, new PortableCloudSyncRepository(fs), { + load: async () => (fs.text(statePath) ? JSON.parse(fs.text(statePath)!) as CloudSyncState : null), + save: async (state) => fs.writeText(statePath, JSON.stringify(state)) + }, { itemId: () => `new-${++id}`, operationId: () => `operation-${++id}` }) + const run = await coordinator.sync() + expect(run.pendingConflicts.map((conflict) => conflict.path)).toEqual(['inbox/Shopping.md']) + fs.reads.length = 0 + return { fs, coordinator, conflictId: run.pendingConflicts[0]!.id } + } + + it('reads only that file, not the whole vault', async () => { + const { fs, coordinator, conflictId } = await firstSyncWithConflict() + + await expect(coordinator.getConflict(conflictId)).resolves.toMatchObject({ + local: { path: 'inbox/Shopping.md', text: 'Eggs (from the phone)' }, + cloud: { path: 'inbox/Shopping.md', text: 'Coffee (from the PC)' } + }) + expect(fs.reads).toEqual(['inbox/Shopping.md']) + }) + + it('still finds this device\'s version after it moved', async () => { + const { fs, coordinator, conflictId } = await firstSyncWithConflict() + await fs.rename('inbox/Shopping.md', 'Errands/Shopping.md') + + await expect(coordinator.getConflict(conflictId)).resolves.toMatchObject({ + local: { path: 'Errands/Shopping.md', text: 'Eggs (from the phone)' } + }) + expect(fs.reads.length).toBeGreaterThan(200) + }) +}) + +describe('PortableCloudSyncRepository.readLocalItem', () => { + it('reads the file at exactly that path', async () => { + const repository = new PortableCloudSyncRepository(new MemoryFileSystem({ 'inbox/Plan.md': 'plan' })) + + await expect(repository.readLocalItem('inbox/Plan.md')).resolves.toMatchObject({ + path: 'inbox/Plan.md', kind: 'text', content: { data: 'plan' } + }) + }) + + // The scan reports the spelling stored on disk, so any other spelling is + // left to it rather than read under the asked-for name. + it('leaves a name stored in other letter case to the scan', async () => { + const repository = new PortableCloudSyncRepository( + new CaseInsensitiveMemoryFileSystem({ 'inbox/plan.md': 'plan' }) + ) + + await expect(repository.readLocalItem('inbox/Plan.md')).resolves.toBeNull() + await expect(repository.readLocalItem('Inbox/plan.md')).resolves.toBeNull() + await expect(repository.readLocalItem('inbox/plan.md')).resolves.toMatchObject({ path: 'inbox/plan.md' }) + }) + + it('finds nothing where the scan would list nothing', async () => { + const repository = new PortableCloudSyncRepository(new MemoryFileSystem({ + 'inbox/Plan.md/inside.md': 'a folder named like a note', + '.zennotes/sync/downloads/staged.md': 'sync work', + 'zennotes-cloud-sync/state.json': '{}' + })) + + await expect(repository.readLocalItem('inbox/Plan.md')).resolves.toBeNull() + await expect(repository.readLocalItem('inbox/Missing.md')).resolves.toBeNull() + await expect(repository.readLocalItem('Missing/Plan.md')).resolves.toBeNull() + await expect(repository.readLocalItem('.zennotes/sync/downloads/staged.md')).resolves.toBeNull() + await expect(repository.readLocalItem('zennotes-cloud-sync/state.json')).resolves.toBeNull() + }) +}) + async function tracked(path: string, data: string) { const content = await textContent(data) return { diff --git a/packages/shared-domain/src/cloud-sync-portable-filesystem.ts b/packages/shared-domain/src/cloud-sync-portable-filesystem.ts index 6838756cd..12e908bbd 100644 --- a/packages/shared-domain/src/cloud-sync-portable-filesystem.ts +++ b/packages/shared-domain/src/cloud-sync-portable-filesystem.ts @@ -142,6 +142,33 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { return items.sort((left, right) => left.path.localeCompare(right.path)) } + /** + * The file at exactly this path, read as scan() reads it, found with one + * listing per folder on the way down. Every name must match its listing + * exactly; a case-only difference anywhere is left to the scan, which + * reports the spelling on disk. + */ + async readLocalItem(path: string): Promise { + const normalized = this.path(path) + if (!shouldSyncVaultPath(normalized)) return null + const segments = normalized.split('/') + let directory = '' + for (const [index, segment] of segments.entries()) { + const last = index === segments.length - 1 + let entries: CloudSyncFileEntry[] + try { + entries = await this.fs.readdir(directory) + } catch { + return null + } + const entry = entries.find((candidate) => candidate.name.normalize('NFC') === segment) + if (!entry || entry.type !== (last ? 'file' : 'directory')) return null + directory = directory ? `${directory}/${segment}` : segment + if (!last && !shouldTraverseCloudSyncDirectory(directory)) return null + } + return this.readItem(normalized) + } + async pendingConflictPaths(): Promise { return (await this.fs.stat(CLOUD_SYNC_SETTINGS_CONFLICT_PATH)) === 'file' ? [CLOUD_SYNC_VAULT_SETTINGS_PATH] From 0000cc4c9556f64cddc3160a58ce9a85667fc8e1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 12:20:00 -0500 Subject: [PATCH 07/16] Perf(cloud): a pulled change costs the same in a vault of any size While reproducing yeminn's conflict review report (Discord, 2026-10-26) a first sync of a 2,500-note vault looked like it pulled about one note a second on Android. That did not reproduce: from a clean app on an API 37 emulator a first sync pulls a flat ~65 notes a second at 600, 2,500 and 5,000 notes, in the foreground or the background, and the change feed ~70. The time per file there is ~16 native bridge calls, the same in any vault. One cost did grow with the vault. pullChanges reduced each change with reduceCloudSyncChange, which copies every tracked item, so catching up on m changes in an n-note vault cost n x m. In Node the pull loop took 176 us a change at 600 notes and 1,749 us at 10,000. A pull now keeps one working copy of the items and changes it in place (reduceCloudSyncChangeInto): 18 to 21 us a change at every size. The state saved when a later change fails (#813) stays exactly what it was: what each item held at the landed point is kept until the next landing and put back before that save. On the phone the gain is lost under the bridge calls (2,000 changes into 5,000 notes: 38.1 s before, 39.2 s after); it matters on the desktop, which runs this same loop over a fast disk, and for very large vaults. New tests: the in-place reducer matches reduceCloudSyncChange step by step and leaves the items untouched whenever it throws; a failing change while a coalesced revision is still owed saves only what landed (passes on the old code too, so the behaviour is pinned); and a change into a 20,000-note vault costs no more than five times one into a 200-note vault (old code: 3.57 ms against 0.14 ms, fails). shared-domain 2,101 tests pass, typecheck clean; the Android shell's typecheck and 312 tests pass on a rehearsal core with this change. --- .../src/cloud-sync-coordinator.test.ts | 125 ++++++++++++++++++ .../src/cloud-sync-coordinator.ts | 36 ++++- .../src/cloud-sync-engine.test.ts | 43 +++++- .../shared-domain/src/cloud-sync-engine.ts | 23 +++- 4 files changed, 218 insertions(+), 9 deletions(-) diff --git a/packages/shared-domain/src/cloud-sync-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index 792a111e7..25ba7c6f0 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -2032,6 +2032,131 @@ describe('CloudSyncCoordinator: catching up on a file this device never touched' expect(server.mutations).toEqual([]) }) + it('saves only what landed when a change fails while a coalesced revision is still owed', async () => { + // Fresh.md lands on its own. Plan.md v2 is then skipped in favour of v3, + // and New.md lands while v3 is still owed, so the newest state that can + // stand on its own is the one right after Fresh.md. The failing move must + // not save Plan.md as v2, nor New.md as tracked. + const fs = memoryFileSystem({ [path]: 'v1', 'Other.md': 'other' }) + const rename = fs.rename + let refuse = true + fs.rename = async (from, to) => { + if (refuse) throw new Error('rename failed: Already exists') + return rename(from, to) + } + const states = memoryState({ + version: 1, + vault_id: 'vault-1', + cursor: 1, + items: { + plan: tracked('plan', path, 1, 'v1'), + other: tracked('other', 'Other.md', 1, 'other') + } + }) + const server = remote({ + changes: [ + upsert(2, 'fresh', 'Fresh.md', 'fresh'), + upsert(3, 'plan', path, 'v2'), + upsert(4, 'new', 'New.md', 'new'), + { + sequence: 5, + item_id: 'other', + type: 'move', + path: 'archive/Other.md', + previous_path: 'Other.md', + revision: 5 + }, + upsert(6, 'plan', path, 'v3') + ] + }) + const coordinator = new CloudSyncCoordinator( + 'vault-1', + server, + new PortableCloudSyncRepository(fs), + states, + ids() + ) + + await expect(coordinator.sync()).rejects.toThrow('Already exists') + expect(states.current).toEqual({ + version: 1, + vault_id: 'vault-1', + cursor: 2, + items: { + plan: tracked('plan', path, 1, 'v1'), + other: tracked('other', 'Other.md', 1, 'other'), + fresh: tracked('fresh', 'Fresh.md', 2, 'fresh') + } + }) + + refuse = false + const retry = await coordinator.sync() + + expect(retry.localConflicts).toEqual([]) + expect([...fs.files.keys()].sort()).toEqual(['Fresh.md', 'New.md', 'archive/Other.md', path]) + expect(fs.files.get(path)).toBe('v3') + expect(states.current?.cursor).toBe(6) + expect(states.current?.items.new).toEqual(tracked('new', 'New.md', 4, 'new')) + expect(states.current?.pending_conflicts ?? {}).toEqual({}) + expect(server.mutations).toEqual([]) + }) + + it('pulls each change at the same cost in a large vault as in a small one', async () => { + // Every pulled change used to copy the whole tracked state, so a phone + // catching up on a big vault paid for its size on every file: 1.7 ms a + // change at 10,000 notes against 0.18 ms at 600 (18 µs at both now). + async function perChange(vaultSize: number, arriving: number): Promise { + const items: CloudSyncState['items'] = {} + const files = new Map() + for (let index = 0; index < vaultSize; index++) { + const notePath = `inbox/Note ${index}.md` + const body = content(`note ${index}`) + items[`note-${index}`] = { + item_id: `note-${index}`, path: notePath, kind: 'text', revision: 1, + sha256: body.sha256, byte_length: body.byte_length, media_type: body.media_type + } + files.set(notePath, { path: notePath, kind: 'text', content: body }) + } + const changes = Array.from({ length: arriving }, (_, index): CloudSyncChange => ({ + sequence: vaultSize + index + 1, + item_id: `new-${index}`, + type: 'upsert', + path: `inbox/New ${index}.md`, + previous_path: null, + revision: 1, + content: content(`new ${index}`) + })) + // Timed from the first file written to the last, so the run's one scan + // and one save, which do grow with the vault, are left out. + let first = 0 + let last = 0 + const repository: CloudSyncRepository = { + async scan() { + return [...files.values()] + }, + async apply(change) { + last = performance.now() + first ||= last + if (change.content) files.set(change.path, { path: change.path, kind: 'text', content: change.content }) + } + } + const result = await new CloudSyncCoordinator( + 'vault-1', + remote({ changes }), + repository, + memoryState({ version: 1, vault_id: 'vault-1', cursor: vaultSize, items }), + ids() + ).sync() + expect(result.pulled).toBe(arriving) + return (last - first) / (arriving - 1) + } + + await perChange(200, 2000) + const small = await perChange(200, 2000) + const large = await perChange(20_000, 2000) + expect(large).toBeLessThan(small * 5) + }) + it('queues a real local edit without creating a note beside it', async () => { const fs = memoryFileSystem({ [path]: 'edited here while offline' }) const states = memoryState({ diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 54ba846e4..c53a5290b 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -29,7 +29,7 @@ import { import { emptyCloudSyncState, planCloudSyncMutations, - reduceCloudSyncChange, + reduceCloudSyncChangeInto, resolveCloudSyncMutations, type CloudSyncIdSource, type CloudSyncLocalItem, @@ -892,6 +892,12 @@ export class CloudSyncCoordinator { // revision still waiting for the change that lands it) and save that // before the error escapes, so a retry resumes at the failing change. let landed = initialState + // Tracked items change in place, in one copy this pull owns: copying all + // of them for every change made each pulled file cost more the bigger the + // vault was. What an item held when `landed` was taken is kept until the + // next landing, so `landed` can still be saved exactly as it was. + let items: Record | null = null + const sinceLanded = new Map() let hydratedPage = -1 let pageBodies = new Map() try { @@ -948,12 +954,19 @@ export class CloudSyncCoordinator { } pulled++ } - state = reduceCloudSyncChange(state, change) - if (onDisk.size === 0) landed = state + if (change.sequence > state.cursor) { + items ??= { ...state.items } + if (!sinceLanded.has(change.item_id)) sinceLanded.set(change.item_id, items[change.item_id]) + state = { ...state, cursor: reduceCloudSyncChangeInto(items, state.cursor, change), items } + } + if (onDisk.size === 0) { + landed = state + sinceLanded.clear() + } } throwIfCloudSyncCancelled(this.remote.downloadSignal) } catch (error) { - if (landed !== initialState) await this.states.save(landed) + if (landed !== initialState) await this.states.save(asLanded(landed, sinceLanded)) throw error } if (changes.length > 0) await this.states.save(state) @@ -2010,6 +2023,21 @@ function withConflict(state: CloudSyncState, conflict: CloudSyncStoredConflict): return { ...state, pending_conflicts: { ...state.pending_conflicts, [conflict.id]: conflict } } } +/** A pull's landed state as it was taken, undoing what its items have + * changed in place since (pullChanges). */ +function asLanded( + landed: CloudSyncState, + sinceLanded: ReadonlyMap +): CloudSyncState { + if (sinceLanded.size === 0) return landed + const items = { ...landed.items } + for (const [id, item] of sinceLanded) { + if (item) items[id] = item + else delete items[id] + } + return { ...landed, items } +} + /** * A conflict for a file this device never had (no base) whose Cloud copy no * longer sits on the local file it met: Cloud moved or deleted it since. diff --git a/packages/shared-domain/src/cloud-sync-engine.test.ts b/packages/shared-domain/src/cloud-sync-engine.test.ts index 589dbd1b7..b5b7137f8 100644 --- a/packages/shared-domain/src/cloud-sync-engine.test.ts +++ b/packages/shared-domain/src/cloud-sync-engine.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { CloudSyncContent, CloudSyncMutationResponse } from '@zennotes/bridge-contract/cloud-sync' +import type { CloudSyncChange, CloudSyncContent, CloudSyncMutationResponse } from '@zennotes/bridge-contract/cloud-sync' import { emptyCloudSyncState, planCloudSyncMutations, reduceCloudSyncChange, + reduceCloudSyncChangeInto, resolveCloudSyncMutations, type CloudSyncIdSource, type CloudSyncState @@ -274,3 +275,43 @@ describe('reduceCloudSyncChange', () => { ).toThrow('Expected sync sequence 2') }) }) + +describe('reduceCloudSyncChangeInto', () => { + it('changes the caller’s items in place exactly as reduceCloudSyncChange would', () => { + const changes: CloudSyncChange[] = [ + { sequence: 1, item_id: 'a', type: 'upsert', path: 'a.md', previous_path: null, revision: 1, content: content('a1') }, + { sequence: 2, item_id: 'b', type: 'upsert', path: 'b.md', previous_path: null, revision: 1, content: content('b1') }, + { sequence: 3, item_id: 'a', type: 'move', path: 'archive/a.md', previous_path: 'a.md', revision: 2 }, + { sequence: 4, item_id: 'b', type: 'delete', path: 'b.md', previous_path: null, revision: 2 }, + { sequence: 5, item_id: 'a', type: 'upsert', path: 'archive/a.md', previous_path: null, revision: 3, content: content('a3') } + ] + let reduced = emptyCloudSyncState('vault-1') + const items: CloudSyncState['items'] = {} + let cursor = 0 + for (const change of changes) { + reduced = reduceCloudSyncChange(reduced, change) + cursor = reduceCloudSyncChangeInto(items, cursor, change) + expect({ cursor, items }).toEqual({ cursor: reduced.cursor, items: reduced.items }) + } + // A change the cursor already covers is left out, as before. + expect(reduceCloudSyncChangeInto(items, cursor, changes[1]!)).toBe(5) + expect(items).toEqual(reduced.items) + }) + + it('leaves the items untouched when a change cannot be reduced', () => { + const items = { + a: { item_id: 'a', path: 'a.md', kind: 'text' as const, revision: 1, sha256: 'hash:a1', byte_length: 2, media_type: 'text/markdown' } + } + const before = structuredClone(items) + expect(() => reduceCloudSyncChangeInto(items, 1, { + sequence: 3, item_id: 'a', type: 'delete', path: 'a.md', previous_path: null, revision: 2 + })).toThrow('Expected sync sequence 2') + expect(() => reduceCloudSyncChangeInto(items, 1, { + sequence: 2, item_id: 'missing', type: 'move', path: 'b.md', previous_path: 'a.md', revision: 2 + })).toThrow('Received a move for unknown item missing') + expect(() => reduceCloudSyncChangeInto(items, 1, { + sequence: 2, item_id: 'a', type: 'upsert', path: 'a.md', previous_path: null, revision: 2 + })).toThrow('did not include content') + expect(items).toEqual(before) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-engine.ts b/packages/shared-domain/src/cloud-sync-engine.ts index de88d0075..c3a89f4e9 100644 --- a/packages/shared-domain/src/cloud-sync-engine.ts +++ b/packages/shared-domain/src/cloud-sync-engine.ts @@ -262,11 +262,26 @@ export function reduceCloudSyncChange( change: CloudSyncChange ): CloudSyncState { if (change.sequence <= state.cursor) return state - if (change.sequence !== state.cursor + 1) { - throw cloudProtocolError(`Expected sync sequence ${state.cursor + 1}, received ${change.sequence}`) + const items = { ...state.items } + const cursor = reduceCloudSyncChangeInto(items, state.cursor, change) + return { ...state, cursor, items } +} + +/** + * reduceCloudSyncChange on tracked items the caller owns, changed in place, + * so one change costs the same in a vault of any size. Returns the new + * cursor, and leaves `items` as it was when it throws. + */ +export function reduceCloudSyncChangeInto( + items: Record, + cursor: number, + change: CloudSyncChange +): number { + if (change.sequence <= cursor) return cursor + if (change.sequence !== cursor + 1) { + throw cloudProtocolError(`Expected sync sequence ${cursor + 1}, received ${change.sequence}`) } - const items = { ...state.items } if (change.type === 'delete') { delete items[change.item_id] } else if (change.type === 'move') { @@ -294,7 +309,7 @@ export function reduceCloudSyncChange( } } - return { ...state, cursor: change.sequence, items } + return change.sequence } function inferItemKind(content: CloudSyncContentMetadata): CloudSyncItemKind { From 95f48cde3f084e562a1ff13c910e4a9cf46242e1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 12:28:55 -0500 Subject: [PATCH 08/16] Feat(calendar): a phone opens the calendar from Home, and a second tap opens the day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dave (Discord, 2026-10-26) asked for a calendar on the phone like the desktop sidebar: the month on top, tasks by date below, and a tap on a date that opens or creates that day's note. The phones have had it since their first builds, but only behind a note's ••• sheet and only with daily or weekly notes on, so he never found it. The phone shells could not do better on their own. They reach core through its public exports, the calendar panel is private to core, and EditorPane mounts it only beside an open note. Showing it from Home would have meant opening a note behind it, which for a day without one means creating that note (and rolling unfinished tasks into it). So core now lends the calendar out, and teaches it a finger: - `@zennotes/app-core/calendar` exports DateCalendar: the same CalendarPanel, with no note required. It orients on the open daily or weekly note, else on today. Its onOpen runs once it has opened a day, a week or a task's line, a note that was already open included, so a host that shows it as a sheet can put it away. The pane passes none, so the side panel stays open the way it always has. - On a touch screen a tap on the selected day opens that day's note, or offers to create it with the same "New daily note" confirm. A finger has no double-click, and the only other way was the small "Day +" in the agenda's corner. The first tap still only selects, so the week of tasks below follows it, the way a phone's own calendar works. A mouse keeps click to select and double-click to open: the desktop is unchanged. - Settings can open on Vault, Periodic notes (a `periodic-notes` settings target, public as showSettingsPage), so a phone with daily notes off can take you to the switch instead of hiding the calendar. The new subpath is listed in app-core's package.json and in pack-app-core.mjs, which writes the published exports. New tests: with no note the calendar orients on today; a touch tap selects and a second offers to create; an existing day opens on the second tap and onOpen runs; a declined create runs nothing; a mouse click stays select-only (the two touch tests fail without the change); showSettingsPage opens Settings on that page. app-core's suite passes and the typecheck is clean. The phone side (a Calendar row in the circle menu, the sheet, a note when daily notes are off) is ready in both shells on a rehearsal core, checked in the iPhone preview and on an Android 37 emulator with real taps, and ships when they adopt 2.77.0. --- packages/app-core/package.json | 1 + packages/app-core/src/calendar.tsx | 15 ++ .../src/components/CalendarPanel.test.ts | 140 ++++++++++++++++++ .../app-core/src/components/CalendarPanel.tsx | 66 +++++++-- .../app-core/src/components/SettingsModal.tsx | 15 +- .../app-core/src/lib/settings-navigation.ts | 11 +- packages/app-core/src/public-host-api.test.ts | 7 + packages/app-core/src/settings.ts | 6 + tooling/scripts/pack-app-core.mjs | 1 + 9 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 packages/app-core/src/calendar.tsx create mode 100644 packages/app-core/src/components/CalendarPanel.test.ts diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 2f2aa29b7..71270c269 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -6,6 +6,7 @@ "exports": { "./main": "./src/main.tsx", "./navigation": "./src/navigation.ts", + "./calendar": "./src/calendar.tsx", "./notes": "./src/notes.ts", "./shell": "./src/shell.ts", "./browse": "./src/browse.ts", diff --git a/packages/app-core/src/calendar.tsx b/packages/app-core/src/calendar.tsx new file mode 100644 index 000000000..8b1d90a28 --- /dev/null +++ b/packages/app-core/src/calendar.tsx @@ -0,0 +1,15 @@ +import { CalendarPanel } from './components/CalendarPanel' +import { useStore } from './store' + +/** + * The date calendar as a surface of its own, for hosts that open it from a + * main screen instead of beside a note (the phone shells' Calendar entry), + * so it works with no note open. It orients on the open note when that is a + * daily or weekly note, else on today. `onOpen` runs once it has opened a + * day, a week or a task's line; a host that shows it as a sheet closes it + * there. + */ +export function DateCalendar({ onOpen }: { onOpen?: () => void }): JSX.Element { + const note = useStore((s) => s.activeNote) + return +} diff --git a/packages/app-core/src/components/CalendarPanel.test.ts b/packages/app-core/src/components/CalendarPanel.test.ts new file mode 100644 index 000000000..3e590fed6 --- /dev/null +++ b/packages/app-core/src/components/CalendarPanel.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { dailyNoteLocationForDate } from '../lib/vault-layout' +import { CalendarPanel } from './CalendarPanel' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const confirmApp = vi.hoisted(() => vi.fn(async () => true)) +vi.mock('../lib/confirm-requests', () => ({ confirmApp })) + +function isoDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function dailyNote(date: Date): NoteMeta { + const { title, subpath } = dailyNoteLocationForDate(date, useStore.getState().vaultSettings) + return { + path: `inbox/${subpath ? `${subpath}/` : ''}${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +/** A press then its click, the order a browser fires them in. */ +function tap(el: Element, pointerType: 'touch' | 'mouse'): void { + const down = new Event('pointerdown', { bubbles: true }) + Object.defineProperty(down, 'pointerType', { value: pointerType }) + act(() => { + el.dispatchEvent(down) + el.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('CalendarPanel: a day on a touch screen', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + const openDailyNoteForDate = vi.fn(async (_date: Date) => {}) + const today = new Date() + const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1) + + beforeEach(() => { + originalState = useStore.getState() + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) + const settings = originalState.vaultSettings + useStore.setState({ + vaultSettings: { ...settings, dailyNotes: { ...settings.dailyNotes, enabled: true } }, + notes: [], + vaultTasks: [], + tasksLoading: true, + openDailyNoteForDate + }) + confirmApp.mockClear() + openDailyNoteForDate.mockClear() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + function day(date: Date): Element { + const cell = host.querySelector(`[data-cal-day="${isoDate(date)}"]`) + if (!cell) throw new Error(`no cell for ${isoDate(date)}`) + return cell + } + + it('stands on its own with no note, oriented on today', () => { + act(() => root.render(createElement(CalendarPanel, { note: null }))) + expect(day(today).className).toContain('ring-accent/60') + }) + + it('selects with the first tap and offers to create the day with a second', async () => { + act(() => root.render(createElement(CalendarPanel, { note: null }))) + tap(day(tomorrow), 'touch') + expect(day(tomorrow).className).toContain('ring-accent/60') + expect(confirmApp).not.toHaveBeenCalled() + + tap(day(tomorrow), 'touch') + await act(async () => {}) + expect(confirmApp).toHaveBeenCalledWith(expect.objectContaining({ title: 'New daily note' })) + expect(openDailyNoteForDate).toHaveBeenCalledTimes(1) + expect(isoDate(openDailyNoteForDate.mock.calls[0][0])).toBe(isoDate(tomorrow)) + }) + + it("opens an existing day's note on the tap that lands on the selected day", async () => { + useStore.setState({ notes: [dailyNote(today)] }) + const onOpen = vi.fn() + act(() => root.render(createElement(CalendarPanel, { note: null, onOpen }))) + tap(day(today), 'touch') + await act(async () => {}) + expect(confirmApp).not.toHaveBeenCalled() + expect(openDailyNoteForDate).toHaveBeenCalledTimes(1) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it('stays open when the new day is declined', async () => { + confirmApp.mockResolvedValueOnce(false) + const onOpen = vi.fn() + act(() => root.render(createElement(CalendarPanel, { note: null, onOpen }))) + tap(day(today), 'touch') + await act(async () => {}) + expect(confirmApp).toHaveBeenCalledTimes(1) + expect(openDailyNoteForDate).not.toHaveBeenCalled() + expect(onOpen).not.toHaveBeenCalled() + }) + + it('keeps a mouse click to select only', async () => { + useStore.setState({ notes: [dailyNote(today)] }) + act(() => root.render(createElement(CalendarPanel, { note: null }))) + tap(day(today), 'mouse') + tap(day(tomorrow), 'mouse') + tap(day(tomorrow), 'mouse') + await act(async () => {}) + expect(day(tomorrow).className).toContain('ring-accent/60') + expect(confirmApp).not.toHaveBeenCalled() + expect(openDailyNoteForDate).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 4817952e4..bdc1a7f8d 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -114,25 +114,56 @@ function dotsFor(stats: NoteStats | undefined): { count: number; faint: boolean export function CalendarPanel({ note, - fitWidth + fitWidth, + onOpen }: { - note: NoteContent + /** The note beside the calendar, or null where the calendar stands on its + * own with no note open (the phones' Calendar entry); it then orients on + * today. */ + note: NoteContent | null /** Width to render at when the pane has less room than the width the user * chose; see lib/side-panel-fit. (#805) */ fitWidth?: number + /** Called once the calendar has opened a day, a week or a task's line, even + * a note that was already open, so a host that shows it as a sheet can put + * it away. Beside a note it stays open, so the pane passes none. */ + onOpen?: () => void }): JSX.Element { const notes = useStore((s) => s.notes) const vaultSettings = useStore((s) => s.vaultSettings) const vimMode = useStore((s) => s.vimMode) - const openDailyNoteForDate = useStore((s) => s.openDailyNoteForDate) - const openWeeklyNoteForDate = useStore((s) => s.openWeeklyNoteForDate) + const storeOpenDailyNote = useStore((s) => s.openDailyNoteForDate) + const storeOpenWeeklyNote = useStore((s) => s.openWeeklyNoteForDate) const vaultTasks = useStore((s) => s.vaultTasks) const showArchivedTasks = useStore((s) => s.showArchivedTasks) const tasksLoading = useStore((s) => s.tasksLoading) const refreshTasks = useStore((s) => s.refreshTasks) const addTaskForDate = useStore((s) => s.addTaskForDate) const toggleTaskFromList = useStore((s) => s.toggleTaskFromList) - const openTaskAt = useStore((s) => s.openTaskAt) + const storeOpenTask = useStore((s) => s.openTaskAt) + const onOpenRef = useRef(onOpen) + onOpenRef.current = onOpen + const openDailyNoteForDate = useCallback( + async (date: Date) => { + await storeOpenDailyNote(date) + onOpenRef.current?.() + }, + [storeOpenDailyNote] + ) + const openWeeklyNoteForDate = useCallback( + async (date: Date) => { + await storeOpenWeeklyNote(date) + onOpenRef.current?.() + }, + [storeOpenWeeklyNote] + ) + const openTaskAt = useCallback( + async (task: VaultTask) => { + await storeOpenTask(task) + onOpenRef.current?.() + }, + [storeOpenTask] + ) const applyTaskMutation = useStore((s) => s.applyTaskMutation) const moveTaskToDate = useStore((s) => s.moveTaskToDate) const deleteTaskFromList = useStore((s) => s.deleteTaskFromList) @@ -153,7 +184,10 @@ export function CalendarPanel({ ) // The date the active note represents — what the calendar orients around. - const active = useMemo(() => classifyDateNote(note, vaultSettings), [note, vaultSettings]) + const active = useMemo( + () => (note ? classifyDateNote(note, vaultSettings) : null), + [note, vaultSettings] + ) const refDate = active?.date ?? new Date() const activeDayIso = active?.kind === 'daily' ? isoDateStr(active.date) : null const activeWeekIso = active?.kind === 'weekly' ? isoWeekStr(active.date) : null @@ -172,7 +206,7 @@ export function CalendarPanel({ useEffect(() => { setAnchor(new Date(refDate.getFullYear(), refDate.getMonth(), 1)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [note.path]) + }, [note?.path]) // Calendar key -> NoteMeta for the daily/weekly notes that exist on disk. const { dailyByDate, weeklyByWeek } = useMemo( @@ -187,8 +221,13 @@ export function CalendarPanel({ useEffect(() => { setSelectedIso(activeDayIso ?? todayIso) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [note.path]) + }, [note?.path]) const selectedDate = useMemo(() => parseIsoDate(selectedIso), [selectedIso]) + // A finger has no double-click, so on a touch screen a second tap on the + // selected day opens it, the way a phone's own calendar opens the day it + // shows. The first tap still only selects, which keeps that week's tasks + // below a tap away; a mouse keeps click to select, double-click to open. + const touchPressRef = useRef(false) const selectedWeekIso = useMemo(() => isoWeekStr(selectedDate), [selectedDate]) // Tasks scheduled for the selected day. Tasks written in a daily note inherit @@ -1020,7 +1059,16 @@ export function CalendarPanel({ key={iso} type="button" data-cal-day={iso} - onClick={() => dailyEnabled && setSelectedIso(iso)} + onPointerDown={(e) => { + touchPressRef.current = e.pointerType === 'touch' + }} + onClick={() => { + const touch = touchPressRef.current + touchPressRef.current = false + if (!dailyEnabled) return + if (touch && iso === selectedIso) void openOrCreateDay(day, iso) + else setSelectedIso(iso) + }} onDoubleClick={() => void openOrCreateDay(day, iso)} onContextMenu={(e) => openDayMenu(e, day, iso)} onMouseEnter={(e) => armHover(e.currentTarget, dayMeta)} diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 3814c01a3..284d6c421 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -1409,12 +1409,23 @@ export function SettingsModal(): JSX.Element { ); const settingsSearchHighlightTimerRef = useRef(null); const [activeCategory, setActiveCategory] = useState( - () => initialSettingsTarget === "external-links" ? "editor" : initialSettingsTarget ?? "appearance", + () => + initialSettingsTarget === "external-links" + ? "editor" + : initialSettingsTarget === "periodic-notes" + ? "vault" + : initialSettingsTarget ?? "appearance", ); // Per-category active sub-tab (dense categories split their content into sub-tabs). const [activeSubTabByCategory, setActiveSubTabByCategory] = useState< Partial> - >(() => initialSettingsTarget === "external-links" ? { editor: "links" } : {}); + >(() => + initialSettingsTarget === "external-links" + ? { editor: "links" } + : initialSettingsTarget === "periodic-notes" + ? { vault: "periodic" } + : {}, + ); const [activeSearchResultId, setActiveSearchResultId] = useState< string | null >(null); diff --git a/packages/app-core/src/lib/settings-navigation.ts b/packages/app-core/src/lib/settings-navigation.ts index dd5cf5e53..0012fc67a 100644 --- a/packages/app-core/src/lib/settings-navigation.ts +++ b/packages/app-core/src/lib/settings-navigation.ts @@ -1,8 +1,15 @@ /** Settings pages other surfaces can open directly: the Cloud page from the * status bar, Space r and the palette (it lands on This vault, where what * they reported waits), the Keymaps page from `:unbind` when the action id - * is missing or unknown, the CLI page from Open CLI Settings. */ -export type SettingsNavigationTarget = "cloud" | "keymaps" | "external-links" | "about" | "cli"; + * is missing or unknown, the CLI page from Open CLI Settings, and Periodic + * notes from the phones' Calendar entry while daily notes are off. */ +export type SettingsNavigationTarget = + | "cloud" + | "keymaps" + | "external-links" + | "about" + | "cli" + | "periodic-notes"; let pendingSettingsTarget: SettingsNavigationTarget | null = null; diff --git a/packages/app-core/src/public-host-api.test.ts b/packages/app-core/src/public-host-api.test.ts index 97b09c063..e6d3dc92a 100644 --- a/packages/app-core/src/public-host-api.test.ts +++ b/packages/app-core/src/public-host-api.test.ts @@ -69,6 +69,13 @@ describe('public host APIs', () => { s.settings.setSettingsVisible(true) expect(s.settings.getSettingsSnapshot().open).toBe(true) }) + it('opens Settings on the Periodic notes page for a host', async () => { + const s = await setup() + const navigation = await import('./lib/settings-navigation') + s.settings.showSettingsPage('periodic-notes') + expect(s.settings.getSettingsSnapshot().open).toBe(true) + expect(navigation.consumeSettingsTarget()).toBe('periodic-notes') + }) it('does not let a second host dialog replace an unresolved prompt', async () => { await setup() const dialogs = await import('./dialogs'), requests = await import('./lib/prompt-requests') diff --git a/packages/app-core/src/settings.ts b/packages/app-core/src/settings.ts index 4889e31cb..fc50b3f3f 100644 --- a/packages/app-core/src/settings.ts +++ b/packages/app-core/src/settings.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from 'react' import { useStore } from './store' +import { requestSettingsTarget } from './lib/settings-navigation' export interface SettingsSnapshot { readonly themeId: string @@ -30,6 +31,11 @@ export function subscribeSettings(listener: (next: SettingsSnapshot, previous: S function subscribeReact(notify: () => void): () => void { return subscribeSettings(() => notify()) } export function useSettingsSnapshot(): SettingsSnapshot { return useSyncExternalStore(subscribeReact, getSettingsSnapshot, getSettingsSnapshot) } export function setSettingsVisible(open: boolean): void { useStore.getState().setSettingsOpen(open) } +/** Open Settings on one page: `periodic-notes` is Vault, Periodic notes, where daily notes are turned on. */ +export function showSettingsPage(page: 'periodic-notes'): void { + requestSettingsTarget(page) + useStore.getState().setSettingsOpen(true) +} export function setEditorFontSize(size: number, options?: { persist?: boolean }): void { if (!Number.isFinite(size)) return const clamped = Math.max(12, Math.min(28, Math.round(size))) diff --git a/tooling/scripts/pack-app-core.mjs b/tooling/scripts/pack-app-core.mjs index 4f3fe7048..e8d8340eb 100644 --- a/tooling/scripts/pack-app-core.mjs +++ b/tooling/scripts/pack-app-core.mjs @@ -58,6 +58,7 @@ export async function packAppCore() { const exports = { './main': { types: './dist/main.d.ts', import: './dist/main.js' }, './navigation': { types: './dist/navigation.d.ts', import: './dist/navigation.js' }, + './calendar': { types: './dist/calendar.d.ts', import: './dist/calendar.js' }, './notes': { types: './dist/notes.d.ts', import: './dist/notes.js' }, './shell': { types: './dist/shell.d.ts', import: './dist/shell.js' }, './browse': { types: './dist/browse.d.ts', import: './dist/browse.js' }, From 4050388fe35ac2458f2e34be7e9fded2c9bc6b1a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 14:45:59 -0500 Subject: [PATCH 09/16] Fix(phones): a note moved to Trash leaves you on Home, not in an old note Dave (Discord, 2026-10-26) moved a note to the Trash on Android and the app opened a note he had not looked at in a while, not the first in his vault alphabetically, where he expected the Home screen with his recent notes. A phone keeps every note it opens as a pane tab, with the tab strip hidden, and Home is a pane with no active tab and those tabs kept behind it. When a path leaves the workspace, rewritePathsInTree gives a pane whose active tab was dropped, or that had none, its first tab: the oldest note still open. Desktop wants that, its tab strip shows where it landed. On a phone it is a hidden note nobody asked for. The phone-only Home guard caught that promotion only in an update that also changed the note list, and moving a note to the Trash closes its tab in a separate update, so both cases slipped through: trashing the open note, and trashing a note from a list while on Home (a note that was not even open still pulled the first tab out of Home). Archiving, deleting for good and a delete from outside the app went the same way. rewritePathsInTree now marks the panes whose active tab it chose on its own (activeTabWasPicked). A renamed or kept active tab is not marked, and the mark goes with the next change to the pane. installHomeGuard shows Home when the active pane carries it; its rescan rule stays as it was. Only the phone shells install the guard, so desktop and web keep landing on a remaining tab. Tablets install it too and now land on Home as well. There is no Undo for a trash; Restore in the Trash view is the way back and works as before. New tests: trashing the open note shows Home and Restore brings it back byte for byte; on Home, trashing a note from a list stays on Home; archiving and deleting for good show Home; opening the first open note on purpose still opens it; without the guard the remaining tab still takes over. The three Home tests fail on the old code. app-core's suite passes and the typecheck is clean. Both phone shells pass their checks on a rehearsal core with this change; before and after were checked in the iPhone preview and on an Android 37 emulator with real taps (the shipped 1.1.46 opened the oldest hidden note on both paths, the fix stays on Home). The phones get it when they adopt 2.77.0. --- packages/app-core/src/lib/pane-layout.test.ts | 38 ++++++++ packages/app-core/src/lib/pane-layout.ts | 18 +++- packages/app-core/src/navigation.test.ts | 96 ++++++++++++++++++- packages/app-core/src/navigation.ts | 31 +++--- 4 files changed, 164 insertions(+), 19 deletions(-) diff --git a/packages/app-core/src/lib/pane-layout.test.ts b/packages/app-core/src/lib/pane-layout.test.ts index 696cd7284..267d82f2d 100644 --- a/packages/app-core/src/lib/pane-layout.test.ts +++ b/packages/app-core/src/lib/pane-layout.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + activeTabWasPicked, leafWithAddedTab, leafWithPinnedTab, leafWithPreviewTab, @@ -181,3 +182,40 @@ describe('preserveLayoutIfPruneEmptiesNoteTabs (#384)', () => { expect(preserveLayoutIfPruneEmptiesNoteTabs(prev, pruned, isVirtual)).toBe(pruned) }) }) + +describe('activeTabWasPicked', () => { + const only = (layout: ReturnType): PaneLeaf => { + if (layout.kind !== 'leaf') throw new Error('expected a leaf') + return layout + } + + it('marks a leaf whose active note was dropped', () => { + const base = leaf({ tabs: ['old.md', 'b.md', 'c.md'], activeTab: 'c.md' }) + const next = only(rewritePathsInTree(base, (p) => (p === 'c.md' ? null : p))) + expect(next.activeTab).toBe('old.md') + expect(activeTabWasPicked(next)).toBe(true) + }) + + it('marks a leaf that showed no tab (a phone on Home)', () => { + const base = leaf({ tabs: ['old.md', 'b.md'], activeTab: null }) + const next = only(rewritePathsInTree(base, (p) => p)) + expect(next.activeTab).toBe('old.md') + expect(activeTabWasPicked(next)).toBe(true) + }) + + it('does not mark a leaf whose active note was renamed or kept', () => { + const base = leaf({ tabs: ['old.md', 'b.md'], activeTab: 'b.md' }) + const renamed = only(rewritePathsInTree(base, (p) => (p === 'b.md' ? 'moved/b.md' : p))) + expect(renamed.activeTab).toBe('moved/b.md') + expect(activeTabWasPicked(renamed)).toBe(false) + const kept = only(rewritePathsInTree(base, (p) => (p === 'old.md' ? null : p))) + expect(kept.activeTab).toBe('b.md') + expect(activeTabWasPicked(kept)).toBe(false) + }) + + it('forgets the mark on the next change to the leaf', () => { + const base = leaf({ tabs: ['old.md', 'b.md'], activeTab: 'b.md' }) + const picked = only(rewritePathsInTree(base, (p) => (p === 'b.md' ? null : p))) + expect(activeTabWasPicked(leafWithAddedTab(picked, 'c.md'))).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/pane-layout.ts b/packages/app-core/src/lib/pane-layout.ts index 241120aec..408d78e24 100644 --- a/packages/app-core/src/lib/pane-layout.ts +++ b/packages/app-core/src/lib/pane-layout.ts @@ -257,6 +257,7 @@ export function rewritePathsInTree( nextActive = mappedActive } } + const picked = !nextActive if (!nextActive) nextActive = nextTabs[0] let nextPreview: string | null = null if (leaf.previewTab) { @@ -265,17 +266,32 @@ export function rewritePathsInTree( nextPreview = mappedPreview } } - return enforcePinnedPrefix({ + const next = enforcePinnedPrefix({ ...leaf, tabs: nextTabs, pinnedTabs: nextPinned, activeTab: nextActive, previewTab: nextPreview }) + if (picked) leavesWithPickedTab.add(next) + return next }) return result ?? makeLeaf() } +/** + * Leaves whose active tab `rewritePathsInTree` chose on its own, because the + * note they showed was dropped (trashed, archived, deleted) or because they + * showed none (a phone's Home). Desktop wants that choice: its tab strip shows + * where it landed. A shell that hides tabs reads this to show Home instead of + * a hidden tab the user never asked for (see `installHomeGuard`). + */ +const leavesWithPickedTab = new WeakSet() + +export function activeTabWasPicked(leaf: PaneLeaf): boolean { + return leavesWithPickedTab.has(leaf) +} + /** * Guard against a note-list refresh closing *every* open note tab at once. * diff --git a/packages/app-core/src/navigation.test.ts b/packages/app-core/src/navigation.test.ts index bcd6e64f8..d98cb33f9 100644 --- a/packages/app-core/src/navigation.test.ts +++ b/packages/app-core/src/navigation.test.ts @@ -14,7 +14,7 @@ function meta(path: string, body: string) { return { path, title: path, - folder: 'inbox' as const, + folder: (path.startsWith('trash/') ? 'trash' : path.startsWith('archive/') ? 'archive' : 'inbox') as 'inbox', siblingOrder: 0, createdAt: 0, updatedAt: 1, @@ -27,6 +27,14 @@ function meta(path: string, body: string) { } } +function relocate(from: string, to: string) { + const body = vault.get(from) + if (body === undefined) throw new Error(`Missing note: ${from}`) + vault.delete(from) + vault.set(to, body) + return meta(to, body) +} + beforeEach(() => { vi.resetModules() localStorage.clear() @@ -51,6 +59,12 @@ beforeEach(() => { writeNote: async (path: string, body: string) => { vault.set(path, body) return meta(path, body) + }, + moveToTrash: async (path: string) => relocate(path, `trash/${path}`), + archiveNote: async (path: string) => relocate(path, `archive/${path}`), + restoreFromTrash: async (path: string) => relocate(path, path.slice('trash/'.length)), + deleteNote: async (path: string) => { + vault.delete(path) } } }) @@ -151,6 +165,86 @@ describe('public shell navigation', () => { }) }) +describe('Home after the open note leaves the workspace', () => { + async function phone(options: { guard?: boolean } = {}) { + vault.set('three.md', '# Third note\n') + const s = await setup() + const notes = await import('./notes') + const confirms = await import('./lib/confirm-requests') + if (options.guard !== false) disposers.push(s.installHomeGuard()) + s.useStore.setState({ vault: { root: '/phone', name: 'Phone' } }) + const host = { isCurrent: () => true } + const confirmNext = () => confirms.settleConfirmRequest(confirms.getConfirmRequest()!, true) + const leaf = () => { + const state = s.useStore.getState() + return findLeaf(state.paneLayout, state.activePaneId) + } + for (const path of ['one.md', 'two.md', 'three.md']) await s.openNote(path) + return { ...s, ...notes, host, confirmNext, leaf } + } + + it('shows Home after the open note moves to Trash, and Restore brings it back', async () => { + const s = await phone() + const trashing = s.requestTrashNote(s.host, 'three.md') + s.confirmNext() + expect(await trashing).toBe('completed') + expect(vault.get('trash/three.md')).toBe('# Third note\n') + // Not one.md: that is only the first note still open behind the scenes. + expect(s.useStore.getState().selectedPath).toBeNull() + expect(s.useStore.getState().activeNote).toBeNull() + expect(s.leaf()).toMatchObject({ tabs: ['one.md', 'two.md'], activeTab: null }) + expect(await s.restoreNote(s.host, 'trash/three.md')).toBe('completed') + expect(vault.get('three.md')).toBe('# Third note\n') + await s.openNote('three.md') + expect(s.useStore.getState().activeNote?.body).toBe('# Third note\n') + }) + + it('stays on Home when a note is moved to Trash from a list', async () => { + const s = await phone() + await s.openNote('two.md') + s.goHome() + vault.set('four.md', '# Fourth note\n') + await s.useStore.getState().refreshNotes() + const trashing = s.requestTrashNote(s.host, 'four.md') + s.confirmNext() + expect(await trashing).toBe('completed') + expect(s.useStore.getState().selectedPath).toBeNull() + expect(s.leaf()?.activeTab).toBeNull() + }) + + it('shows Home after the open note is archived or deleted for good', async () => { + const s = await phone() + expect(await s.requestArchiveNote(s.host, 'three.md')).toBe('completed') + expect(s.useStore.getState().selectedPath).toBeNull() + await s.openNote('two.md') + const trashing = s.requestTrashNote(s.host, 'two.md') + s.confirmNext() + await trashing + await s.openNote('trash/two.md') + const deleting = s.requestDeleteNotePermanently(s.host, 'trash/two.md') + s.confirmNext() + expect(await deleting).toBe('completed') + expect(vault.has('trash/two.md')).toBe(false) + expect(s.useStore.getState().selectedPath).toBeNull() + expect(s.leaf()?.tabs).toEqual(['one.md']) + }) + + it('still opens the first open note when the user asks for it', async () => { + const s = await phone() + s.goHome() + await s.openNote('one.md') + expect(s.useStore.getState().selectedPath).toBe('one.md') + }) + + it('leaves desktop on its remaining tab', async () => { + const s = await phone({ guard: false }) + const trashing = s.requestTrashNote(s.host, 'three.md') + s.confirmNext() + await trashing + expect(s.useStore.getState().selectedPath).toBe('one.md') + }) +}) + describe('navigation across workspace changes', () => { function gate() { let resolve!: (value: T) => void diff --git a/packages/app-core/src/navigation.ts b/packages/app-core/src/navigation.ts index d664bdc93..5729fb1f3 100644 --- a/packages/app-core/src/navigation.ts +++ b/packages/app-core/src/navigation.ts @@ -1,6 +1,6 @@ import { captureNavigationContext } from './lib/navigation-context' import { useStore } from './store' -import { findLeaf, updateLeaf } from './lib/pane-layout' +import { activeTabWasPicked, findLeaf, updateLeaf } from './lib/pane-layout' import { paneModesWithPathMode, type PaneMode } from './lib/pane-mode' /** Open a note or app-generated page path through the normal save and history flow. */ @@ -56,27 +56,24 @@ export function goHome(): void { * Register before mounting React or adding other store subscribers. Call the * returned disposer when the host shell is torn down. * - * A rescan or vault mutation can promote the first tab while rewriting paths. - * Restore Home only for that transition. Deliberate note navigation does not - * replace the note index and must remain visible. + * A rescan or vault mutation can promote the first tab while rewriting paths, + * from Home or because the note on screen left the workspace (moved to Trash, + * archived, deleted). That tab is the oldest note still open behind Home, not + * one the user chose, so show Home for those transitions instead. Deliberate + * note navigation does not replace the note index and must remain visible. */ export function installHomeGuard(): () => void { return useStore.subscribe((state, previous) => { - if ( - state.notes === previous.notes || - state.paneLayout === previous.paneLayout - ) - return + if (state.paneLayout === previous.paneLayout) return const leaf = findLeaf(state.paneLayout, state.activePaneId) + if (!leaf || leaf.activeTab === null) return const before = findLeaf(previous.paneLayout, state.activePaneId) - if ( - !leaf || - !before || - before.activeTab !== null || - before.tabs.length === 0 - ) - return - if (leaf.activeTab === null || leaf.activeTab !== leaf.tabs[0]) return + const rescannedFromHome = + state.notes !== previous.notes && + before?.activeTab === null && + before.tabs.length > 0 && + leaf.activeTab === leaf.tabs[0] + if (!rescannedFromHome && !activeTabWasPicked(leaf)) return const next = updateLeaf(state.paneLayout, leaf.id, (pane) => ({ ...pane, activeTab: null From 4f50b8ca72ac661ee8361b0acf2c8dd39ccb0d3e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 14:46:38 -0500 Subject: [PATCH 10/16] Fix(cloud): a phone notices a stalled request in 30 seconds, not 300 yeminn (Discord, 2026-10-26) syncs a 28 MB vault to Android behind a national filter that inspects every connection, and a network like that can hold a connection open and pass nothing. A phone gave every Cloud request 300 seconds before giving up, and a request that gave up failed the whole run: each stall cost five minutes, the next run started over from the manifest, and the vault's queue waited behind it. Phones ask for 1 MiB content pages, so a vault like hers comes down as about 2,700 requests, and every one of them is a chance to stall. A phone's HTTP stack measures silence, not total time: Android's read timeout and an iOS URLRequest's timeout both start over whenever bytes arrive. The new cloud-sync-stall.ts gives the phones one bound for that silence, 30 seconds (the desktop gives a whole request 30 seconds, so a slow connection that keeps moving is never treated worse than there), 15 seconds to open a connection, and what to do next. CloudSyncTransportError marks a request that got no HTTP answer at all, and retryCloudTransfer runs work that is safe to repeat again after 1, 4 and 15 seconds, each wait shortened at random by up to half so devices behind one unsteady link do not come back together. The rate-limit coordinator already replays a GET, or a request marked idempotent, after a 429. It now also replays one after a transport failure when the host opts in with transportRetryDelaysMs. The replay happens below the encryption layer, so an end-to-end encrypted push goes out again with the same sealed bytes under the same operation id and the service applies it once. Upload completion opts out (retryOnTransportFailure: false): the service hashes the whole object before it answers and refuses a second completion while that runs, and the phones already retry completion themselves. The desktop does not opt in, so nothing changes there. Sending is different. Measured with Foundation's URLSession on a Mac: the timeout restarts only on bytes received, so an upload moving at 64 KiB a second failed a 5 second timeout at 5 seconds, and the body is reported sent the moment URLSession holds it, so no progress watchdog can see a stalled upload. cloudSendTimeoutMs gives a request with a body the stall bound plus the body's time at 8 KiB a second, which for a note at the 5 MiB inline cap is never less than the 300 seconds every iOS request used to get. After its retries, a transport failure reads "took too long to answer" when the connection went quiet, or "can't reach ZenNotes Cloud" when it failed outright, instead of Android's bare "timeout". Not done here: the phones get this through a core boundary release; their shells' changes are on their own branches. New tests: the retry schedule, jitter and cancellation; replays only for GETs and idempotent pushes, byte for byte, never for a plain POST or an upload completion, and only for a host that opts in; 429 and transport retries keep separate allowances; an encrypted push that stalls goes out again with the same sealed body and its encryption header; the send timeout; the copy. shared-domain's 2,124 tests pass, the monorepo typechecks, and the desktop's 181 and app-core's 168 Cloud tests pass. On an Android emulator, with the shell's changes on a rehearsal core of this commit, a first sync of a 2,000-note, 29 MB vault with four requests stalled mid-body (a content page, a download instruction, two file downloads) took 3 min 10 s in one run, each stall sent again 30.6 to 30.8 seconds after it went quiet. Shipped 1.1.46 took 21 min 23 s: four runs failed after about 300 seconds each before a fifth finished. Four transfers trickled over 44 to 49 seconds each finished without a resend, a clean sync took the same 57 seconds on both builds, and the vaults matched byte for byte. --- .../src/cloud-e2ee-vault.test.ts | 40 ++++- .../src/cloud-error-copy.test.ts | 12 ++ .../shared-domain/src/cloud-error-copy.ts | 10 +- .../shared-domain/src/cloud-sync-api.test.ts | 1 + packages/shared-domain/src/cloud-sync-api.ts | 10 +- .../src/cloud-sync-rate-limit.test.ts | 125 +++++++++++++++ .../src/cloud-sync-rate-limit.ts | 27 ++++ .../src/cloud-sync-stall.test.ts | 130 +++++++++++++++ .../shared-domain/src/cloud-sync-stall.ts | 151 ++++++++++++++++++ 9 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 packages/shared-domain/src/cloud-sync-stall.test.ts create mode 100644 packages/shared-domain/src/cloud-sync-stall.ts diff --git a/packages/shared-domain/src/cloud-e2ee-vault.test.ts b/packages/shared-domain/src/cloud-e2ee-vault.test.ts index b952c6fe8..f7a6ac893 100644 --- a/packages/shared-domain/src/cloud-e2ee-vault.test.ts +++ b/packages/shared-domain/src/cloud-e2ee-vault.test.ts @@ -22,7 +22,13 @@ import { } from './cloud-e2ee-account' import { CloudE2eeError, fromBase64, sha256Hex, toBase64 } from './cloud-e2ee' import { CLOUD_E2EE_HEADER, CloudE2eeLockedError } from './cloud-e2ee-vault' -import { CloudSyncApiClient, type CloudSyncHttpRequest, type CloudSyncHttpTransport } from './cloud-sync-api' +import { + CloudSyncApiClient, + CloudSyncRateLimitCoordinator, + type CloudSyncHttpRequest, + type CloudSyncHttpTransport +} from './cloud-sync-api' +import { CloudSyncTransportError } from './cloud-sync-stall' import { validateCloudSyncContentReference, validateCloudSyncDownloadInstruction } from './cloud-sync-content' const PASSPHRASE = 'quiet harbor lantern orbit' @@ -295,6 +301,38 @@ describe('encrypted vault sync through the API client', () => { ).rejects.toThrow(/needs the file details/) }, 20_000) + it('replays a stalled encrypted push with the same sealed bytes, never sealing it again', async () => { + const { cloud, keyring, vault } = await encryptedSetup() + const sent: Array<{ body: string; header: string | undefined }> = [] + let stalls = 1 + const flaky: CloudSyncHttpTransport = { + request: async (request: CloudSyncHttpRequest): Promise => { + if (request.path.endsWith('/mutations')) { + sent.push({ body: JSON.stringify(request.body), header: request.headers?.[CLOUD_E2EE_HEADER] }) + if (stalls-- > 0) throw new CloudSyncTransportError('timeout', { stalled: true }) + } + return cloud.request(request) + } + } + const transport = new CloudSyncRateLimitCoordinator().wrap(flaky, { + baseUrl: 'https://cloud.example.test', accountId: 'account', transportRetryDelaysMs: [0] + }) + const client = new CloudSyncApiClient(transport, { endToEndEncryption: true, vaultCiphers: keyring }) + const note = await utf8Content('# Secret\n\nMeet at noon.') + await client.mutate(vault.id, { + mutations: [{ type: 'upsert', operation_id: 'op-1', item_id: 'item-1', base_revision: null, path: 'Secret.md', kind: 'text', content: note }] + }) + + // Sealing uses fresh nonces, so a second seal would be a different + // request under the same operation id, which the service refuses. + expect(sent).toHaveLength(2) + expect(sent[1].body).toBe(sent[0].body) + expect(sent[0].header).toBeDefined() + expect(sent[1].header).toBe(sent[0].header) + expect(sent[0].body).not.toContain('Meet at noon') + expect((await client.manifest(vault.id, { includeContent: true })).data).toMatchObject([{ path: 'Secret.md', content: note }]) + }, 20_000) + it('leaves readable vaults byte for byte as before, minus the client-only hints', async () => { const { cloud, client, readable } = await encryptedSetup() const note = await utf8Content('plain') diff --git a/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts index d6417e116..cb52117dc 100644 --- a/packages/shared-domain/src/cloud-error-copy.test.ts +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -16,6 +16,7 @@ import { } from './cloud-error-copy' import { CLOUD_VAULT_REMOVED_MESSAGE } from './cloud-vault-availability' import { CloudE2eeError } from './cloud-e2ee' +import { CloudSyncTransportError } from './cloud-sync-stall' /** The shape both the desktop client and the phone shells throw. */ function serviceError( @@ -60,6 +61,17 @@ describe('describeCloudError', () => { expect(describeCloudError(overIpc('cloud-vault:sync', timeout)).message).toBe(CLOUD_TIMEOUT_MESSAGE) }) + it('words a phone request that stalled or never connected, keeping the host\'s words as details', () => { + const stalled = new CloudSyncTransportError('timeout', { stalled: true, code: 'SocketTimeoutException' }) + expect(describeCloudError(stalled)).toEqual({ + message: CLOUD_TIMEOUT_MESSAGE, + detail: 'SocketTimeoutException · timeout', + kind: 'offline' + }) + const refused = new CloudSyncTransportError('Unable to resolve host', { code: 'UnknownHostException' }) + expect(describeCloudError(refused)).toMatchObject({ message: CLOUD_OFFLINE_MESSAGE, kind: 'offline' }) + }) + it('treats a refused sign-in as signed out, whatever the server says', () => { for (const error of [ serviceError('Authentication is required.', 401, 'UNAUTHENTICATED'), diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index 5c01ec288..ad4d1eef5 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -224,6 +224,7 @@ interface RawCloudError { causeCode: string | null issue: CloudIssue | null path: string | null + stalled: boolean } /** @@ -249,6 +250,11 @@ export function describeCloudError(error: unknown, fallback = CLOUD_UNEXPECTED_M if (raw.issue) return worded(issueMessage(raw.issue, raw.path)) if (raw.name === 'AbortError') return worded(STOPPED) + // A phone request that got no answer, after its retries: the host already + // knows whether the connection went quiet or failed outright. + if (raw.name === 'CloudSyncTransportError') { + return worded(raw.stalled ? CLOUD_TIMEOUT_MESSAGE : CLOUD_OFFLINE_MESSAGE, 'offline') + } if (isTimeout(raw)) return worded(CLOUD_TIMEOUT_MESSAGE, 'offline') if (isNetworkFailure(raw)) return worded(CLOUD_OFFLINE_MESSAGE, 'offline') @@ -501,6 +507,7 @@ function rawCloudError(error: unknown): RawCloudError { issue?: unknown path?: unknown relPath?: unknown + stalled?: unknown } const message = typeof error === 'string' ? error : typeof value.message === 'string' ? value.message : error == null ? '' : String(error) @@ -519,7 +526,8 @@ function rawCloudError(error: unknown): RawCloudError { : null, causeCode: cause && typeof cause === 'object' && typeof cause.code === 'string' ? cause.code : null, issue: ownName === 'CloudIssueError' && typeof value.issue === 'string' ? (value.issue as CloudIssue) : null, - path: typeof value.path === 'string' ? value.path : typeof value.relPath === 'string' ? value.relPath : null + path: typeof value.path === 'string' ? value.path : typeof value.relPath === 'string' ? value.relPath : null, + stalled: value.stalled === true } } diff --git a/packages/shared-domain/src/cloud-sync-api.test.ts b/packages/shared-domain/src/cloud-sync-api.test.ts index 06e617f67..abccf0c6e 100644 --- a/packages/shared-domain/src/cloud-sync-api.test.ts +++ b/packages/shared-domain/src/cloud-sync-api.test.ts @@ -121,6 +121,7 @@ describe('CloudSyncApiClient', () => { method: 'POST', path: '/api/v1/vaults/vault%2F1/uploads/upload%2F1/complete', timeoutMs: 300_000, + retryOnTransportFailure: false, retryOnRateLimit: true }, { diff --git a/packages/shared-domain/src/cloud-sync-api.ts b/packages/shared-domain/src/cloud-sync-api.ts index 7292b83da..94024cb4f 100644 --- a/packages/shared-domain/src/cloud-sync-api.ts +++ b/packages/shared-domain/src/cloud-sync-api.ts @@ -86,8 +86,13 @@ export interface CloudSyncHttpRequest { timeoutMs?: number signal?: AbortSignal /** Only for explicitly idempotent JSON operations with stable operation/session IDs. - * GETs are replayable by default. Other requests are never retried implicitly. */ + * GETs are replayable by default. Other requests are never retried implicitly. + * A replayable request is also sent again after a transport failure where the + * host opts in (CloudSyncRateLimitOptions.transportRetryDelaysMs). */ retryOnRateLimit?: boolean + /** False where the service may still be working on a request that got no + * answer, so sending it again would race the first attempt. */ + retryOnTransportFailure?: boolean /** Extra request headers; transports forward them. */ headers?: Record } @@ -431,6 +436,9 @@ export class CloudSyncApiClient { method: 'POST', path: `${this.uploadPath(vaultId, uploadId)}/complete`, timeoutMs: 300_000, + // The service hashes the whole object before it answers; a second + // completion sent while that runs is refused. Hosts retry it themselves. + retryOnTransportFailure: false, ...(uploadId ? { retryOnRateLimit: true } : {}) }) if (!cipher) return response diff --git a/packages/shared-domain/src/cloud-sync-rate-limit.test.ts b/packages/shared-domain/src/cloud-sync-rate-limit.test.ts index f40f9bbcc..681e67099 100644 --- a/packages/shared-domain/src/cloud-sync-rate-limit.test.ts +++ b/packages/shared-domain/src/cloud-sync-rate-limit.test.ts @@ -7,6 +7,7 @@ import { type CloudSyncHttpTransport } from './cloud-sync-api' import { CloudAutoSyncController } from './cloud-auto-sync' +import { CloudSyncTransportError } from './cloud-sync-stall' const scope = { baseUrl: 'https://cloud.example.test', accountId: 'account-one' } const read: CloudSyncHttpRequest = { method: 'GET', path: '/api/v1/vaults/one/manifest?page=3' } @@ -411,3 +412,127 @@ describe('account-scoped Cloud request recovery', () => { ) }) }) + +describe('replaying a request that got no answer', () => { + const stalled = () => new CloudSyncTransportError('timeout', { stalled: true, code: 'SocketTimeoutException' }) + const phone = (coordinator = new CloudSyncRateLimitCoordinator()) => { + const send = vi.fn().mockResolvedValue({ ok: true }) + const transport: CloudSyncHttpTransport = { request: send as CloudSyncHttpTransport['request'] } + return { coordinator, send, client: coordinator.wrap(transport, { ...scope, transportRetryDelaysMs: [1_000, 4_000, 15_000] }) } + } + beforeEach(() => { + vi.spyOn(Math, 'random').mockReturnValue(0.999_999) + }) + + it('sends a stalled read again on a fresh attempt instead of failing the sync', async () => { + const f = phone() + f.send.mockRejectedValueOnce(stalled()).mockRejectedValueOnce(stalled()) + const result = f.client.request(read) + await vi.advanceTimersByTimeAsync(999) + expect(f.send).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(f.send).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(4_000) + await expect(result).resolves.toEqual({ ok: true }) + expect(f.send).toHaveBeenCalledTimes(3) + expect(f.send.mock.calls.map(([request]) => request.path)).toEqual([read.path, read.path, read.path]) + }) + + it('gives up after the last wait with the failure the host saw', async () => { + const f = phone() + const failures = [stalled(), stalled(), stalled(), stalled()] + for (const failure of failures) f.send.mockRejectedValueOnce(failure) + const result = f.client.request(read) + const settled = expect(result).rejects.toBe(failures[3]) + await vi.advanceTimersByTimeAsync(20_000) + await settled + expect(f.send).toHaveBeenCalledTimes(4) + }) + + it('stays off for a host that does not opt in (the desktop)', async () => { + const f = fixture() + const failure = stalled() + f.send.mockRejectedValueOnce(failure) + await expect(f.client.request(read)).rejects.toBe(failure) + expect(f.send).toHaveBeenCalledTimes(1) + }) + + it('never sends a request again that the service might apply twice', async () => { + const f = phone() + const failure = stalled() + f.send.mockRejectedValueOnce(failure) + await expect(f.client.request({ method: 'POST', path: '/api/v1/vaults', body: { name: 'New' } })).rejects.toBe(failure) + expect(f.send).toHaveBeenCalledTimes(1) + }) + + it('leaves an upload completion to its caller, since the service may still be hashing the object', async () => { + const f = phone() + const failure = stalled() + f.send.mockRejectedValueOnce(failure) + await expect(new CloudSyncApiClient(f.client).completeUpload('vault', 'upload-1')).rejects.toBe(failure) + expect(f.send).toHaveBeenCalledTimes(1) + expect(f.send.mock.calls[0][0]).toMatchObject({ timeoutMs: 300_000, retryOnTransportFailure: false }) + }) + + it('does not retry an answer the service gave, only a missing one', async () => { + const f = phone() + const refused = Object.assign(new Error('Server error'), { status: 503 }) + f.send.mockRejectedValueOnce(refused) + await expect(f.client.request(read)).rejects.toBe(refused) + expect(f.send).toHaveBeenCalledTimes(1) + }) + + it('replays a mutation with the same operation ids and bytes, so the service applies it once', async () => { + const f = phone() + const body = { + mutations: [{ type: 'delete' as const, item_id: 'item', operation_id: 'op-1', base_revision: 1 }] + } + const seen: string[] = [] + f.send + .mockImplementationOnce(async (request) => { + seen.push(JSON.stringify(request.body)) + ;(request.body as typeof body).mutations[0].operation_id = 'transport-change' + throw stalled() + }) + .mockImplementationOnce(async (request) => { + seen.push(JSON.stringify(request.body)) + return { acknowledged: [], conflicts: [], cursor: 1 } as never + }) + const result = new CloudSyncApiClient(f.client).mutate('vault', body) + await vi.advanceTimersByTimeAsync(1_000) + await result + expect(seen).toHaveLength(2) + expect(seen[1]).toBe(seen[0]) + expect(JSON.parse(seen[0]).mutations[0].operation_id).toBe('op-1') + }) + + it('keeps 429 retries and stall retries on separate allowances', async () => { + const f = phone() + f.send.mockRejectedValueOnce(stalled()).mockRejectedValueOnce(limited('2')).mockRejectedValueOnce(stalled()) + const result = f.client.request(read) + await vi.advanceTimersByTimeAsync(1_000 + 2_000 + 4_000) + await expect(result).resolves.toEqual({ ok: true }) + expect(f.send).toHaveBeenCalledTimes(4) + }) + + it('stops waiting to retry when the client is cancelled', async () => { + const controller = new AbortController() + const coordinator = new CloudSyncRateLimitCoordinator() + const send = vi.fn().mockRejectedValue(stalled()) + const client = coordinator.wrap({ request: send as CloudSyncHttpTransport['request'] }, { + ...scope, signal: controller.signal, transportRetryDelaysMs: [60_000] + }) + const result = client.request(read) + const settled = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + await vi.advanceTimersByTimeAsync(100) + controller.abort() + await settled + expect(send).toHaveBeenCalledTimes(1) + }) + + it('refuses a retry schedule it cannot keep', () => { + const coordinator = new CloudSyncRateLimitCoordinator() + const transport: CloudSyncHttpTransport = { request: vi.fn() as CloudSyncHttpTransport['request'] } + expect(() => coordinator.wrap(transport, { ...scope, transportRetryDelaysMs: [-1] })).toThrow(/retry delays/) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-rate-limit.ts b/packages/shared-domain/src/cloud-sync-rate-limit.ts index c81c37214..f17850d79 100644 --- a/packages/shared-domain/src/cloud-sync-rate-limit.ts +++ b/packages/shared-domain/src/cloud-sync-rate-limit.ts @@ -1,4 +1,9 @@ import type { CloudSyncHttpRequest, CloudSyncHttpTransport } from './cloud-sync-api' +import { + assertCloudTransportRetryDelays, + cloudTransportRetryDelayMs, + isCloudSyncTransportError +} from './cloud-sync-stall' export type CloudSyncResponseHeaders = | Readonly> @@ -14,6 +19,13 @@ export interface CloudSyncRateLimitOptions extends CloudSyncRateLimitScope { signal?: AbortSignal /** Additional attempts for one request, 0 through 10. Defaults to 3. */ maxRetries?: number + /** + * The wait before sending a replayable request again after it got no answer + * at all (a CloudSyncTransportError), one retry per entry. Off by default: + * only a host whose transport gives up on a stalled connection soon should + * opt in, or each retry repeats a long wait (see cloud-sync-stall.ts). + */ + transportRetryDelaysMs?: readonly number[] } interface AccountCooldown { @@ -58,6 +70,8 @@ export class CloudSyncRateLimitCoordinator { if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 10) { throw new Error('Cloud rate-limit retries must be an integer from 0 through 10.') } + const transportRetryDelays = options.transportRetryDelaysMs ?? [] + assertCloudTransportRetryDelays(transportRetryDelays) const key = scopeKey(options) let account = this.accounts.get(key) if (!account) { @@ -75,6 +89,7 @@ export class CloudSyncRateLimitCoordinator { request, cooldown, maxRetries, + transportRetryDelays, combined.signal ).finally(combined.dispose) } @@ -102,16 +117,21 @@ export class CloudSyncRateLimitCoordinator { request: CloudSyncHttpRequest, account: AccountCooldown, maxRetries: number, + transportRetryDelays: readonly number[], signal: AbortSignal ): Promise { throwIfCancelled(signal) const replayable = request.method === 'GET' || request.retryOnRateLimit === true + // A replay is the same request, byte for byte: an encrypted vault's body + // was sealed above this layer, so the service sees the same operation. + const resendable = replayable && request.retryOnTransportFailure !== false const snapshot = { ...request, ...(replayable && request.body !== undefined ? { body: cloneReplayBody(request.body) } : {}), signal } let retries = 0 + let transportFailures = 0 for (;;) { while (account.until > Date.now()) { await wait(Math.min(account.until - Date.now(), MAX_TIMER_DELAY_MS), signal) @@ -130,6 +150,13 @@ export class CloudSyncRateLimitCoordinator { ) } catch (error) { throwIfCancelled(signal) + const pause = resendable && isCloudSyncTransportError(error) + ? cloudTransportRetryDelayMs(transportFailures++, transportRetryDelays) + : null + if (pause !== null) { + await wait(pause, signal) + continue + } if (!error || typeof error !== 'object' || !('status' in error) || error.status !== 429) { throw error } diff --git a/packages/shared-domain/src/cloud-sync-stall.test.ts b/packages/shared-domain/src/cloud-sync-stall.test.ts new file mode 100644 index 000000000..2199c1228 --- /dev/null +++ b/packages/shared-domain/src/cloud-sync-stall.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CLOUD_TRANSPORT_RETRY_DELAYS_MS, + CloudSyncTransportError, + cloudSendTimeoutMs, + cloudTransportRetryDelayMs, + isCloudSyncTransportError, + retryCloudTransfer +} from './cloud-sync-stall' + +const stalled = () => new CloudSyncTransportError('timeout', { stalled: true, code: 'SocketTimeoutException' }) + +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('cloudTransportRetryDelayMs', () => { + it('waits between half and all of each listed delay, then has none left', () => { + expect(cloudTransportRetryDelayMs(0, [1_000, 4_000], () => 0)).toBe(500) + expect(cloudTransportRetryDelayMs(0, [1_000, 4_000], () => 0.999_999)).toBe(1_000) + expect(cloudTransportRetryDelayMs(1, [1_000, 4_000], () => 0.5)).toBe(3_000) + expect(cloudTransportRetryDelayMs(2, [1_000, 4_000], () => 0.5)).toBeNull() + }) + + it('retries three times by default, the longest wait 15 seconds', () => { + expect(CLOUD_TRANSPORT_RETRY_DELAYS_MS).toEqual([1_000, 4_000, 15_000]) + }) +}) + +describe('cloudSendTimeoutMs', () => { + it('gives a body the time it takes at 8 KiB a second on top of the stall bound', () => { + expect(cloudSendTimeoutMs(0)).toBe(30_000) + expect(cloudSendTimeoutMs(4 * 1024)).toBe(30_500) + expect(cloudSendTimeoutMs(1024 * 1024)).toBe(158_000) + // A note at the 5 MiB inline cap, base64 in JSON: never less than the + // 300 seconds every iOS request used to get. + expect(cloudSendTimeoutMs(Math.ceil((5 * 1024 * 1024 * 4) / 3))).toBeGreaterThan(300_000) + expect(cloudSendTimeoutMs(-1)).toBe(30_000) + }) +}) + +describe('isCloudSyncTransportError', () => { + it('knows the error by name too, for a copy from another bundle', () => { + expect(isCloudSyncTransportError(stalled())).toBe(true) + expect(isCloudSyncTransportError(Object.assign(new Error('timeout'), { name: 'CloudSyncTransportError' }))).toBe(true) + expect(isCloudSyncTransportError(Object.assign(new Error('Not found'), { status: 404 }))).toBe(false) + expect(isCloudSyncTransportError(new DOMException('Stopped', 'AbortError'))).toBe(false) + expect(isCloudSyncTransportError(null)).toBe(false) + }) +}) + +describe('retryCloudTransfer', () => { + it('runs a stalled transfer again after each wait until it lands', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.999_999) + const transfer = vi.fn<() => Promise>() + .mockRejectedValueOnce(stalled()) + .mockRejectedValueOnce(stalled()) + .mockResolvedValueOnce('landed') + const result = retryCloudTransfer(transfer) + await vi.advanceTimersByTimeAsync(0) + expect(transfer).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(999) + expect(transfer).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(transfer).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(4_000) + expect(transfer).toHaveBeenCalledTimes(3) + await expect(result).resolves.toBe('landed') + }) + + it('throws the last failure once the waits run out', async () => { + const failures = [stalled(), stalled(), stalled(), stalled()] + const transfer = vi.fn<() => Promise>() + for (const failure of failures) transfer.mockRejectedValueOnce(failure) + const result = retryCloudTransfer(transfer) + const settled = expect(result).rejects.toBe(failures[3]) + await vi.advanceTimersByTimeAsync(20_000) + await settled + expect(transfer).toHaveBeenCalledTimes(4) + }) + + it('never repeats an answer the service gave', async () => { + const refused = Object.assign(new Error('Forbidden'), { status: 403 }) + const transfer = vi.fn<() => Promise>().mockRejectedValueOnce(refused) + await expect(retryCloudTransfer(transfer)).rejects.toBe(refused) + expect(transfer).toHaveBeenCalledTimes(1) + }) + + it('takes the host\'s word for what counts as a transport failure', async () => { + const nativeFailure = Object.assign(new Error('Cloud object download failed.'), { code: 'DIRECT_DOWNLOAD_FAILED' }) + const transfer = vi.fn<() => Promise>().mockRejectedValueOnce(nativeFailure).mockResolvedValueOnce('landed') + const result = retryCloudTransfer(transfer, { + isTransportFailure: (error) => (error as { code?: string }).code === 'DIRECT_DOWNLOAD_FAILED', + delaysMs: [10] + }) + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBe('landed') + }) + + it('stops waiting when the sync is cancelled, and sends nothing more', async () => { + const controller = new AbortController() + const transfer = vi.fn<() => Promise>().mockRejectedValue(stalled()) + const result = retryCloudTransfer(transfer, { signal: controller.signal }) + const settled = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + await vi.advanceTimersByTimeAsync(100) + controller.abort() + await settled + await vi.advanceTimersByTimeAsync(60_000) + expect(transfer).toHaveBeenCalledTimes(1) + }) + + it('does not retry a transfer that failed because the sync was cancelled', async () => { + const controller = new AbortController() + const transfer = vi.fn<() => Promise>().mockImplementation(async () => { + controller.abort() + throw stalled() + }) + await expect(retryCloudTransfer(transfer, { signal: controller.signal })).rejects.toBeInstanceOf(CloudSyncTransportError) + expect(transfer).toHaveBeenCalledTimes(1) + }) + + it('refuses a schedule it cannot keep', async () => { + await expect(retryCloudTransfer(async () => 'x', { delaysMs: [Number.NaN] })).rejects.toThrow(/retry delays/) + await expect(retryCloudTransfer(async () => 'x', { delaysMs: new Array(11).fill(1) })).rejects.toThrow(/retry delays/) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-stall.ts b/packages/shared-domain/src/cloud-sync-stall.ts new file mode 100644 index 000000000..378bcc160 --- /dev/null +++ b/packages/shared-domain/src/cloud-sync-stall.ts @@ -0,0 +1,151 @@ +/** + * How a phone notices a ZenNotes Cloud request that stopped moving, and what + * it does next. + * + * A phone's HTTP stack measures idle time, not total time: Android's socket + * read timeout and an iOS URLRequest's timeout both start over whenever bytes + * arrive. One short bound therefore drops a connection that went quiet (a + * filtering network that holds a connection open and passes nothing is the + * usual cause) while a large download that keeps moving runs as long as it + * needs. Phones used to wait 300 seconds there, and the rest of the sync + * waited behind it. A request dropped that way never got an answer, so one + * that is safe to send twice goes out again on a fresh connection instead of + * failing the whole sync. + * + * Sending is the exception. Bytes going out do not restart the iOS timeout, + * and URLSession reports a body as sent once it holds it, long before the + * bytes leave, so on iOS a request with a body gets cloudSendTimeoutMs. + * Android's read timeout does not run while it sends; its native uploader + * watches the bytes it writes instead. + */ + +/** + * No bytes for this long and a request is stalled. The desktop gives a whole + * request 30 seconds, so 30 seconds without a single byte never treats a slow + * but moving connection worse than the desktop does. + */ +export const CLOUD_STALL_TIMEOUT_MS = 30_000 + +/** A connection that has not opened in this long is not going to. */ +export const CLOUD_CONNECT_TIMEOUT_MS = 15_000 + +/** + * The slowest upload a phone still waits out. URLSession on iOS restarts a + * request's timeout only when data arrives, so while a body goes out the + * timeout keeps running from the start (Android's runs only while it reads). + * 8 KiB a second is a weak mobile signal; slower than that, retrying later + * serves better than waiting. + */ +export const CLOUD_SLOWEST_UPLOAD_BYTES_PER_SECOND = 8 * 1024 + +/** + * The timeout for a request that sends `bodyBytes`, on a host whose timeout + * keeps running while it sends (iOS): the stall bound, which also covers the + * service's answer, plus the time the body takes at the slowest upload rate. + */ +export function cloudSendTimeoutMs(bodyBytes: number): number { + return CLOUD_STALL_TIMEOUT_MS + Math.ceil((Math.max(0, bodyBytes) / CLOUD_SLOWEST_UPLOAD_BYTES_PER_SECOND) * 1000) +} + +/** + * The wait before each new attempt at a request that got no answer, one entry + * per retry. Each is shortened by up to half at random, so devices behind the + * same unsteady link do not all come back at once. + */ +export const CLOUD_TRANSPORT_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000, 15_000] + +/** + * A request that got no HTTP answer at all: it stalled, its connection was + * reset, or the connection never opened. Hosts wrap their native failures in + * it so a retry can tell them from an answer the service gave. The message is + * the host's own, so the words a person sees do not change. + */ +export class CloudSyncTransportError extends Error { + /** The connection went quiet, as opposed to failing outright. */ + readonly stalled: boolean + /** The host's own name for the failure, for the details line. */ + readonly code: string | null + + constructor(message: string, options: { stalled?: boolean; code?: string | null; cause?: unknown } = {}) { + super(message, { cause: options.cause }) + this.name = 'CloudSyncTransportError' + this.stalled = options.stalled === true + this.code = options.code ?? null + } +} + +export function isCloudSyncTransportError(error: unknown): error is CloudSyncTransportError { + return error instanceof CloudSyncTransportError || + (typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'CloudSyncTransportError') +} + +/** How long to wait before retry number `attempt` (0 first), or null when none is left. */ +export function cloudTransportRetryDelayMs( + attempt: number, + delays: readonly number[] = CLOUD_TRANSPORT_RETRY_DELAYS_MS, + random: () => number = Math.random +): number | null { + const base = delays[attempt] + if (base === undefined) return null + return Math.round(base * (0.5 + random() * 0.5)) +} + +/** Throws unless `delays` is a usable retry schedule. */ +export function assertCloudTransportRetryDelays(delays: readonly number[]): void { + if (delays.length > 10 || delays.some((delay) => !Number.isFinite(delay) || delay < 0)) { + throw new Error('Cloud transport retry delays must be at most 10 non-negative numbers.') + } +} + +/** + * Runs `transfer`, and runs it again after each failure `isTransportFailure` + * recognizes, waiting out the delays in between; anything else is thrown at + * once, and so is the last failure. Only for work that is safe to repeat: a + * GET, or a PUT of the same bytes to the same object. + */ +export async function retryCloudTransfer( + transfer: () => Promise, + options: { + signal?: AbortSignal + isTransportFailure?: (error: unknown) => boolean + delaysMs?: readonly number[] + } = {} +): Promise { + const isTransportFailure = options.isTransportFailure ?? isCloudSyncTransportError + const delays = options.delaysMs ?? CLOUD_TRANSPORT_RETRY_DELAYS_MS + assertCloudTransportRetryDelays(delays) + for (let attempt = 0; ; attempt++) { + throwIfAborted(options.signal) + try { + return await transfer() + } catch (error) { + if (options.signal?.aborted) throw error + const delay = isTransportFailure(error) ? cloudTransportRetryDelayMs(attempt, delays) : null + if (delay === null) throw error + await pause(delay, options.signal) + } + } +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The Cloud request was aborted.', 'AbortError') +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw abortReason(signal) +} + +function pause(milliseconds: number, signal: AbortSignal | undefined): Promise { + throwIfAborted(signal) + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer) + reject(abortReason(signal!)) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', abort) + resolve() + }, milliseconds) + signal?.addEventListener('abort', abort, { once: true }) + }) +} From 7afe7a3be8a218d5eb6e4a69facf56618264be78 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 09:49:13 -0500 Subject: [PATCH 11/16] Fix(tags): code in a note saved with Windows line endings stops showing up as tags A note saved with CRLF line endings leaked its code into the tag index: `#include ` inside a ```c fence became a tag `include` in the sidebar, the Tags view and tag completion, and a [[link]] in a fence counted as a real link. The scanners split a body at "\n", so every line still ends in "\r", and the fence pattern ends in `(.*)$` with no m flag. JavaScript's `.` never matches "\r", so `$` never matched and a CRLF fence never opened. The close check would have tolerated the CR; the open never got that far. The four TypeScript copies of stripCodeContent (shared-domain tag-scan, desktop main, MCP, app-core wikilinks) now allow `\r?` before `$`. The info string still excludes the CR, so an LF body scans byte for byte as before and each copy stays line-preserving. The shared FENCE_OPEN_RE and FENCE_CLOSE_RE had the same blind spot, so in a CRLF note the outline listed `# comment` lines from a shell fence as headings and a `^Z2` inside a bash fence read as a block anchor; both gain the same `\r?`. The Go copy in ZenNotes/znserver was already right (RE2's `.` matches "\r"), and the workflows fence map splits on \r?\n, so neither changes here. Tests: a CRLF case for every copy (extractTags, wikilink targets, the outline, block anchors, and listNotes tags and links in desktop main and MCP), each failing before. Typecheck clean; shared-domain 2,092, app-core 3,458 and desktop 1,194 tests pass. --- apps/desktop/src/main/vault.test.ts | 16 ++++++++++++++++ apps/desktop/src/main/vault.ts | 3 ++- apps/desktop/src/mcp/vault-ops.test.ts | 14 ++++++++++++++ apps/desktop/src/mcp/vault-ops.ts | 3 ++- packages/app-core/src/lib/outline.test.ts | 5 +++++ packages/app-core/src/lib/tags.test.ts | 5 +++++ packages/app-core/src/lib/wikilinks.test.ts | 5 +++++ packages/app-core/src/lib/wikilinks.ts | 3 ++- packages/shared-domain/src/block-anchors.test.ts | 6 ++++++ packages/shared-domain/src/markdown-lines.ts | 9 ++++++--- packages/shared-domain/src/tag-scan.ts | 3 ++- 11 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index cb6364433..6aa98e2ed 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -1010,6 +1010,22 @@ describe('listNotes metadata parsing', () => { expect(note?.tags).toEqual(['realtag']) }) + it('does not index #tags or [[links]] inside a fence in a CRLF note', async () => { + const root = await makeTempDir('zennotes-meta-fence-crlf-') + await ensureVaultLayout(root) + const rel = 'inbox/windows.md' + await writeFile( + path.join(root, rel), + '# Notes\r\n\r\n```c\r\n#include \r\n// [[Secret Note]]\r\n```\r\n\r\n#realtag [[Real Link]]\r\n', + 'utf8' + ) + + const notes = await listNotes(root) + const note = notes.find((n) => n.path === rel) + expect(note?.tags).toEqual(['realtag']) + expect(note?.wikilinks).toEqual(['Real Link']) + }) + it('indexes frontmatter tags as first-class note tags', async () => { const root = await makeTempDir('zennotes-meta-frontmatter-tags-') await ensureVaultLayout(root) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 4b638f269..e89a34c71 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -1991,7 +1991,8 @@ function stripCodeContent(body: string): string { let fenceLen = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] as string - const m = /^[ \t]*(`{3,}|~{3,})(.*)$/.exec(line) + // `.` never matches `\r`, so the `\r?` is what lets a CRLF-saved fence line match. + const m = /^[ \t]*(`{3,}|~{3,})(.*)\r?$/.exec(line) if (m) { const marker = m[1] as string const char = marker[0] as string diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index f08efdb26..be47a40b9 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -151,6 +151,20 @@ describe('mcp task states', () => { }) }) +describe('list_notes metadata skips fenced code', () => { + it('does not index #tags or [[links]] inside a fence in a CRLF note', async () => { + await writeFile( + path.join(root, 'inbox', 'Windows.md'), + '# Notes\r\n\r\n```c\r\n#include \r\n// [[Secret Note]]\r\n```\r\n\r\n#realtag [[Real Link]]\r\n' + ) + + const notes = await listNotes(root) + const note = notes.find((n) => n.path === 'inbox/Windows.md') + expect(note?.tags).toEqual(['realtag']) + expect(note?.wikilinks).toEqual(['Real Link']) + }) +}) + describe('remapped system folders (#398)', () => { it('classifies a remapped trash directory as trash and keeps its tasks out', async () => { await mkdir(path.join(root, '.zennotes'), { recursive: true }) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index de6dc5e20..07473f4dd 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -669,7 +669,8 @@ function stripCodeContent(body: string): string { let fenceLen = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] as string - const m = /^[ \t]*(`{3,}|~{3,})(.*)$/.exec(line) + // `.` never matches `\r`, so the `\r?` is what lets a CRLF-saved fence line match. + const m = /^[ \t]*(`{3,}|~{3,})(.*)\r?$/.exec(line) if (m) { const marker = m[1] as string const char = marker[0] as string diff --git a/packages/app-core/src/lib/outline.test.ts b/packages/app-core/src/lib/outline.test.ts index a1adb3924..e28178baf 100644 --- a/packages/app-core/src/lib/outline.test.ts +++ b/packages/app-core/src/lib/outline.test.ts @@ -67,6 +67,11 @@ describe('parseOutline — #249 code block with wikilinks', () => { expect(parseOutline(body).map((i) => i.text)).toEqual(['Real', 'Also real']) }) + it('skips headings inside a fence in a body with CRLF line endings', () => { + const body = ['# Real', '```sh', '# fake heading in code', '```', '# Also real', ''].join('\r\n') + expect(parseOutline(body).map((i) => i.text)).toEqual(['Real', 'Also real']) + }) + it('is unaffected by a single-backtick inline code span', () => { const body = ['# Section 1', '`[[Linux-Backup]]`', '# Section 2'].join('\n') expect(parseOutline(body).map((i) => i.text)).toEqual(['Section 1', 'Section 2']) diff --git a/packages/app-core/src/lib/tags.test.ts b/packages/app-core/src/lib/tags.test.ts index 523cac15c..5296bd00f 100644 --- a/packages/app-core/src/lib/tags.test.ts +++ b/packages/app-core/src/lib/tags.test.ts @@ -24,6 +24,11 @@ describe('extractTags — code fences are never scanned for tags (#293)', () => expect(extractTags('- item\n ```\n #include\n ```\n #after')).toEqual(['after']) }) + it('ignores #tags inside a fence in a note saved with CRLF line endings', () => { + expect(extractTags('```c\r\n#include \r\n```\r\nreal #ok\r\n')).toEqual(['ok']) + expect(extractTags('- item\r\n ~~~\r\n #nope\r\n ~~~\r\n #after\r\n')).toEqual(['after']) + }) + it('includes first-class frontmatter tags', () => { expect(extractTags('---\ntags: [frontmatter, "#quoted", project/nested]\ntitle: #ignored\n---\n\n#inline')).toEqual([ 'frontmatter', diff --git a/packages/app-core/src/lib/wikilinks.test.ts b/packages/app-core/src/lib/wikilinks.test.ts index b1f7cc9a4..0ff9381a4 100644 --- a/packages/app-core/src/lib/wikilinks.test.ts +++ b/packages/app-core/src/lib/wikilinks.test.ts @@ -21,6 +21,11 @@ describe('extractWikilinkTargets — code fences are not scanned (#293)', () => const body = '- item:\n\n ```\n see [[Secret Note]] here\n ```\n\n[[Real Link]]' expect(extractWikilinkTargets(body)).toEqual(['Real Link']) }) + + it('ignores [[links]] inside a fence in a note saved with CRLF line endings', () => { + const body = '```\r\nsee [[Secret Note]] here\r\n```\r\n\r\n[[Real Link]]\r\n' + expect(extractWikilinkTargets(body)).toEqual(['Real Link']) + }) }) const notes = [ diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index 76fc1f6cd..757abd81b 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -19,7 +19,8 @@ function stripCodeContent(body: string): string { let fenceLen = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] as string - const m = /^[ \t]*(`{3,}|~{3,})(.*)$/.exec(line) + // `.` never matches `\r`, so the `\r?` is what lets a CRLF-saved fence line match. + const m = /^[ \t]*(`{3,}|~{3,})(.*)\r?$/.exec(line) if (m) { const marker = m[1] as string const char = marker[0] as string diff --git a/packages/shared-domain/src/block-anchors.test.ts b/packages/shared-domain/src/block-anchors.test.ts index c47b10fa8..22f349909 100644 --- a/packages/shared-domain/src/block-anchors.test.ts +++ b/packages/shared-domain/src/block-anchors.test.ts @@ -27,6 +27,12 @@ describe('stripBlockAnchorMarkers (#601 review)', () => { expect(stripBlockAnchorMarkers(fm)).toBe(fm) }) + it('never touches a code fence in a body with CRLF line endings', () => { + const code = '```bash\r\nkill %1 ^Z2\r\n```\r\nafter\r\n' + expect(stripBlockAnchorMarkers(code)).toBe(code) + expect(parseBlockAnchors(code)).toEqual([]) + }) + it('blanks a standalone marker line without changing the line count', () => { const src = 'A paragraph.\n\n^tag\n\nNext.' const out = stripBlockAnchorMarkers(src) diff --git a/packages/shared-domain/src/markdown-lines.ts b/packages/shared-domain/src/markdown-lines.ts index 32e1f159a..559fddb9c 100644 --- a/packages/shared-domain/src/markdown-lines.ts +++ b/packages/shared-domain/src/markdown-lines.ts @@ -7,11 +7,14 @@ // export) can consume the same rules as the renderer. // A fenced code block opens with a run of >=3 backticks or tildes; the second -// group is the rest of the line (the info string). -export const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/ +// group is the rest of the line (the info string). Callers split at `\n` +// only, so a CRLF body hands both patterns lines that still end in `\r`, which +// neither `.` nor `[ \t]` matches: the `\r?` before `$` lets those fences open +// and close. +export const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)\r?$/ // A closing fence is a run of fence characters alone on its line, save for // trailing whitespace (no info string). -export const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})[ \t]*$/ +export const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})[ \t]*\r?$/ /** * A YAML frontmatter fence: `---` alone on its line, surrounding whitespace diff --git a/packages/shared-domain/src/tag-scan.ts b/packages/shared-domain/src/tag-scan.ts index 9f8b17751..2852e67f7 100644 --- a/packages/shared-domain/src/tag-scan.ts +++ b/packages/shared-domain/src/tag-scan.ts @@ -54,7 +54,8 @@ export function stripCodeContent(body: string): string { let fenceLen = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] as string - const m = /^[ \t]*(`{3,}|~{3,})(.*)$/.exec(line) + // `.` never matches `\r`, so the `\r?` is what lets a CRLF-saved fence line match. + const m = /^[ \t]*(`{3,}|~{3,})(.*)\r?$/.exec(line) if (m) { const marker = m[1] as string const char = marker[0] as string From 37a68a33d195788056bce2bbab93ded86a1cd43b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 09:11:22 -0500 Subject: [PATCH 12/16] Fix(sidebar): the Vim cursor stays on its row when the rows renumber The sidebar's Vim cursor is a row number: sidebarCursorIndex names the row whose data-sidebar-idx matches it, and Sidebar hands those numbers out again on every render, in render order. Render order is not display order: a folder numbers its notes before the subfolders it shows above them. So when the listing changed under the cursor (a note written from outside the app, a sync), every row numbered after the change moved by one, and the cursor stayed on the number and landed on another row. Measured in the built app: inbox with Areas, Projects and Reading and two notes, cursor on Areas (12); writing inbox/Churn.md from outside renumbered Areas to 13, and the cursor showed on Open note, which had inherited 12. The number stays the cursor, since VimNav, panel-rows and the windowed rows all speak it. Sidebar now also remembers which row the number named: a store subscription takes an anchor the moment the index changes, while the DOM is still the one the number was read from, and a layout effect puts the number back on that row after each render, before paint. A row is identified by its data-sidebar-* identity attributes (lib/sidebar-cursor.ts), so a note pinned under Favorites is not the tree note, a windowed placeholder is the full row it stands in for, and Atlas and Workflows, which share a type, are told apart by their order. When the row itself is gone, the cursor takes the row now in its place, so a note renamed in place keeps it, except at the end of a folder, where it takes the row before it instead of jumping to the next folder or the System section; a folder's only note hands it to the folder. It only runs while the sidebar has focus: unfocused the cursor is not drawn, and coming back lands on the current item. The reveal effect reads the live index now, because the layout effect can move it in the same commit the reveal runs for. On the built macOS app over CDP, the repro above keeps the cursor on Areas (13) and j goes on to Projects; a row removed above the cursor, the cursor's own row deleted (stays in the folder), `l` on a folder (#993), `h` back onto it, and a write above a cursor inside an expanded folder all hold. With the new effect switched off, the same run lands on Open note at 12. On 5,000 notes, taking the anchor costs 0.5 ms per cursor move and the check after a render 0.13 ms, next to the 6 ms row scan VimNav already does per key. Tests: 18 for the row identity, twins and the gone-row fallback, in the measured layout; the sidebar Vim smoke test gains an outside-write check that fails without the fix. app-core 3,463 tests and the smoke test pass; typecheck clean. --- packages/app-core/src/components/Sidebar.tsx | 60 +++- .../app-core/src/lib/sidebar-cursor.test.ts | 288 ++++++++++++++++++ packages/app-core/src/lib/sidebar-cursor.ts | 177 +++++++++++ tooling/scripts/sidebar-vim-smoke.mjs | 21 ++ 4 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 packages/app-core/src/lib/sidebar-cursor.test.ts create mode 100644 packages/app-core/src/lib/sidebar-cursor.ts diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 21d230d11..fc062ac3d 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -135,6 +135,12 @@ import { isRecentSidebarPointerInteraction, sidebarSelectionChanged, } from "../lib/sidebar-scroll"; +import { + anchorSidebarCursor, + findSidebarCursorRow, + sidebarCursorFallbackInDom, + type SidebarCursorAnchor, +} from "../lib/sidebar-cursor"; import { buildVaultSwitcherEntries } from "../lib/vault-switcher"; import { appUpdateBadgeLabel, useAppUpdateState } from "../lib/app-update-state"; import { getISOWeekYear } from "../lib/template-render"; @@ -707,6 +713,9 @@ export function Sidebar(): JSX.Element { // note that was selected; see that effect. const revealedSelectionRef = useRef(null); const lastSelectedNoteRef = useRef(null); + // Which row the Vim cursor's number named; see the layout effect that + // keeps the number on it. + const sidebarCursorAnchorRef = useRef(null); const pendingSidebarScrollAnchorRef = useRef<{ selector: string; top: number; @@ -2801,6 +2810,50 @@ export function Sidebar(): JSX.Element { } }; + // The Vim cursor is a row number, and every render hands the numbers out + // again in render order, which puts a folder's notes before the subfolders + // it shows above them. A listing change above the cursor (a note written + // from outside the app, a sync) renumbers its row, and the cursor used to + // stay on the number and so land on another row. Note which row the number + // named whenever it changes, while the rows it was read from are still in + // the DOM, and put the number back on that row after each render. + useEffect( + () => + useStore.subscribe((state, previous) => { + if (state.sidebarCursorIndex === previous.sidebarCursorIndex) return; + sidebarCursorAnchorRef.current = anchorSidebarCursor( + document, + state.sidebarCursorIndex, + ); + }), + [], + ); + useLayoutEffect(() => { + // Unfocused, the cursor is not drawn, and focusing the sidebar lands on + // the current item anyway (the reveal effect below). + if (!isSidebarFocused) return; + const { sidebarCursorIndex: index, setSidebarCursorIndex } = + useStore.getState(); + const anchor = sidebarCursorAnchorRef.current; + if (anchor?.index !== index) { + sidebarCursorAnchorRef.current = anchorSidebarCursor(document, index); + return; + } + // Moving the number re-anchors through the subscription above. + const found = findSidebarCursorRow(document, anchor); + if (found !== null) { + if (found !== index) setSidebarCursorIndex(found); + return; + } + // The row is gone (deleted, moved, renamed elsewhere): the cursor goes to + // the row that took its place, or the one before it at a folder's end. + const neighbor = sidebarCursorFallbackInDom(document, anchor); + if (neighbor === null) return; + if (neighbor !== index) setSidebarCursorIndex(neighbor); + // Already on it: anchor it, or every render would look for the gone row. + else sidebarCursorAnchorRef.current = anchorSidebarCursor(document, index); + }); + useEffect(() => { if (!isSidebarFocused) { // Coming back into the sidebar always lands on the current item. @@ -2829,6 +2882,9 @@ export function Sidebar(): JSX.Element { return; } + // Read live: the layout effect above may have moved the cursor in this + // very commit, after this render captured sidebarCursorIndex. + const cursorIndex = useStore.getState().sidebarCursorIndex; const findTarget = (): HTMLElement | null => { if ( isRecentSidebarPointerInteraction( @@ -2837,7 +2893,7 @@ export function Sidebar(): JSX.Element { ) ) { const cursorEl = document.querySelector( - `[data-sidebar-idx="${sidebarCursorIndex}"]`, + `[data-sidebar-idx="${cursorIndex}"]`, ); if (cursorEl) return cursorEl; } @@ -2936,7 +2992,7 @@ export function Sidebar(): JSX.Element { revealedSelectionRef.current = selection; const idx = Number(target.dataset.sidebarIdx); - if (Number.isFinite(idx) && idx !== sidebarCursorIndex) { + if (Number.isFinite(idx) && idx !== cursorIndex) { useStore.getState().setSidebarCursorIndex(idx); } diff --git a/packages/app-core/src/lib/sidebar-cursor.test.ts b/packages/app-core/src/lib/sidebar-cursor.test.ts new file mode 100644 index 000000000..29679ad95 --- /dev/null +++ b/packages/app-core/src/lib/sidebar-cursor.test.ts @@ -0,0 +1,288 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from 'vitest' +import { + anchorSidebarCursor, + findSidebarCursorRow, + sidebarCursorFallbackInDom, + sidebarCursorNeighbors, + sidebarRowIdentity +} from './sidebar-cursor' + +type Attrs = Record + +const noteKey = (path: string): string => `note:${encodeURIComponent(path)}` +const folderKey = (subpath: string): string => `folder:inbox:${encodeURIComponent(subpath)}` + +/** A tree note row, with the attributes NoteLeaf (and its placeholder) carry. */ +const note = (idx: number, path: string): [number, Attrs] => [ + idx, + { 'data-sidebar-type': 'note', 'data-sidebar-path': path, 'data-sidebar-select-key': noteKey(path) } +] +/** A tree folder row, with the attributes the folder row carries. */ +const folder = (idx: number, subpath: string): [number, Attrs] => [ + idx, + { + 'data-sidebar-type': 'folder', + 'data-sidebar-folder': 'inbox', + 'data-sidebar-subpath': subpath, + 'data-sidebar-key': `inbox:${subpath}`, + 'data-sidebar-expandable': 'false', + 'data-sidebar-collapsed': 'true', + 'data-sidebar-select-key': folderKey(subpath) + } +] +const plain = (idx: number, type: string): [number, Attrs] => [idx, { 'data-sidebar-type': type }] + +const escapeHtml = (value: string): string => + value.replace(/&/g, '&').replace(/"/g, '"').replace(/, hidden: number[] = []): void { + document.body.innerHTML = rows + .map(([idx, attrs]) => { + const rendered = Object.entries(attrs) + .map(([name, value]) => `${name}="${escapeHtml(value)}"`) + .join(' ') + return `` + }) + .join('') + document.querySelectorAll('[data-sidebar-idx]').forEach((el) => { + const idx = Number(el.dataset.sidebarIdx) + el.getClientRects = (() => + (hidden.includes(idx) ? [] : [{ width: 200, height: 24 }]) as unknown as DOMRectList) as never + }) +} + +const pathAt = (idx: number): string | null => + document.querySelector(`[data-sidebar-idx="${idx}"]`)?.getAttribute('data-sidebar-path') ?? + document.querySelector(`[data-sidebar-idx="${idx}"]`)?.getAttribute('data-sidebar-subpath') ?? + null + +beforeEach(() => { + document.body.innerHTML = '' +}) + +// The measured repro: a folder numbers its notes before the subfolders it +// shows above them, so a note written from outside the app renumbers rows +// the cursor sits on, and the cursor used to stay on the number. +describe('keeping the sidebar cursor on its row', () => { + const before: Array<[number, Attrs]> = [ + plain(0, 'vault'), + folder(9, ''), + folder(12, 'Areas'), + folder(13, 'Projects'), + folder(14, 'Reading'), + note(10, 'inbox/Another.md'), + note(11, 'inbox/Open note.md'), + plain(15, 'archive') + ] + const afterOutsideWrite: Array<[number, Attrs]> = [ + plain(0, 'vault'), + folder(9, ''), + folder(13, 'Areas'), + folder(14, 'Projects'), + folder(15, 'Reading'), + note(10, 'inbox/Another.md'), + note(11, 'inbox/Churn.md'), + note(12, 'inbox/Open note.md'), + plain(16, 'archive') + ] + + it('follows its row to the new number after an outside write renumbers it', () => { + renderRows(before) + const anchor = anchorSidebarCursor(document, 12) + expect(anchor).not.toBeNull() + renderRows(afterOutsideWrite) + // The old number now names another row; the anchor still finds Areas. + expect(pathAt(12)).toBe('inbox/Open note.md') + expect(findSidebarCursorRow(document, anchor!)).toBe(13) + expect(pathAt(13)).toBe('Areas') + }) + + it('stays on a row whose number did not change', () => { + renderRows(before) + const anchor = anchorSidebarCursor(document, 10)! + renderRows(afterOutsideWrite) + expect(findSidebarCursorRow(document, anchor)).toBe(10) + }) + + it('has no anchor for a number no row carries', () => { + renderRows(before) + expect(anchorSidebarCursor(document, 99)).toBeNull() + }) + + it('reads a folder by its identity, not its open or closed state', () => { + renderRows(before) + const anchor = anchorSidebarCursor(document, 12)! + renderRows( + before.map(([idx, attrs]) => + idx === 12 ? [idx, { ...attrs, 'data-sidebar-collapsed': 'false' }] : [idx, attrs] + ) + ) + expect(findSidebarCursorRow(document, anchor)).toBe(12) + }) + + it('tells a tree note from the same note pinned under Favorites', () => { + const favorite: [number, Attrs] = [ + 1, + { 'data-sidebar-type': 'note', 'data-sidebar-path': 'inbox/Open note.md' } + ] + renderRows([plain(0, 'vault'), favorite, note(11, 'inbox/Open note.md')]) + const tree = anchorSidebarCursor(document, 11)! + const pinned = anchorSidebarCursor(document, 1)! + expect(tree.identity).not.toBe(pinned.identity) + // A second favorite above both renumbers them. + renderRows([ + plain(0, 'vault'), + [1, { 'data-sidebar-type': 'note', 'data-sidebar-path': 'inbox/Another.md' }], + [2, favorite[1]], + note(12, 'inbox/Open note.md') + ]) + expect(findSidebarCursorRow(document, tree)).toBe(12) + expect(findSidebarCursorRow(document, pinned)).toBe(2) + }) + + it('tells twins apart by their order (Atlas shares Workflows type)', () => { + renderRows([plain(0, 'vault'), plain(1, 'tasks'), plain(2, 'workflows'), plain(3, 'workflows')]) + const atlas = anchorSidebarCursor(document, 3)! + const workflows = anchorSidebarCursor(document, 2)! + expect(atlas.occurrence).toBe(1) + expect(workflows.occurrence).toBe(0) + // A favorite above them moves both down one. + renderRows([ + plain(0, 'vault'), + note(1, 'inbox/Pinned.md'), + plain(2, 'tasks'), + plain(3, 'workflows'), + plain(4, 'workflows') + ]) + expect(findSidebarCursorRow(document, atlas)).toBe(4) + expect(findSidebarCursorRow(document, workflows)).toBe(3) + // With one twin left, either anchor lands on it: it is also the row next door. + renderRows([plain(0, 'vault'), plain(1, 'tasks'), plain(2, 'workflows')]) + expect(findSidebarCursorRow(document, atlas)).toBe(2) + }) + + it('treats a windowed placeholder and its full row as one row', () => { + document.body.innerHTML = `
` + const anchor = anchorSidebarCursor(document, 20)! + document.body.innerHTML = `` + expect(findSidebarCursorRow(document, anchor)).toBe(21) + }) + + it('copes with quotes and backslashes in a path', () => { + const odd = 'inbox/He said "hi" \\ there.md' + renderRows([note(4, 'inbox/a.md'), note(5, odd)]) + const anchor = anchorSidebarCursor(document, 5)! + renderRows([note(4, 'inbox/a.md'), note(5, 'inbox/b.md'), note(6, odd)]) + expect(findSidebarCursorRow(document, anchor)).toBe(6) + expect(sidebarRowIdentity(document.querySelector('[data-sidebar-idx="6"]')!)).toBe(anchor.identity) + }) +}) + +describe('when the cursor row is gone', () => { + it('reports it gone', () => { + renderRows([note(10, 'inbox/a.md'), note(11, 'inbox/b.md')]) + const anchor = anchorSidebarCursor(document, 11)! + renderRows([note(10, 'inbox/a.md')]) + expect(findSidebarCursorRow(document, anchor)).toBeNull() + }) + + it('takes the row that now sits where it sat', () => { + // Display order: Areas, Reading, a, b, c. Deleting `b` (ordinal 3) leaves `c` there. + renderRows([folder(12, 'Areas'), folder(13, 'Reading'), note(9, 'inbox/a.md'), note(10, 'inbox/b.md'), note(11, 'inbox/c.md')]) + const anchor = anchorSidebarCursor(document, 10)! + expect(anchor.ordinal).toBe(3) + renderRows([folder(11, 'Areas'), folder(12, 'Reading'), note(9, 'inbox/a.md'), note(10, 'inbox/c.md')]) + expect(findSidebarCursorRow(document, anchor)).toBeNull() + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(10) + expect(pathAt(10)).toBe('inbox/c.md') + }) + + it('takes the renamed row when a rename keeps its place', () => { + renderRows([note(9, 'inbox/a.md'), note(10, 'inbox/b.md'), note(11, 'inbox/c.md')]) + const anchor = anchorSidebarCursor(document, 10)! + renderRows([note(9, 'inbox/a.md'), note(10, 'inbox/bb.md'), note(11, 'inbox/c.md')]) + expect(findSidebarCursorRow(document, anchor)).toBeNull() + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(10) + }) + + it('takes the row before it when it was the last one', () => { + renderRows([note(9, 'inbox/a.md'), note(10, 'inbox/b.md')]) + const anchor = anchorSidebarCursor(document, 10)! + renderRows([note(9, 'inbox/a.md')]) + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(9) + }) + + it('skips rows that are not laid out', () => { + renderRows([note(9, 'inbox/a.md'), note(10, 'inbox/b.md'), note(11, 'inbox/c.md'), note(12, 'inbox/d.md')]) + const anchor = anchorSidebarCursor(document, 10)! + renderRows([note(9, 'inbox/a.md'), note(11, 'inbox/c.md'), note(12, 'inbox/d.md')], [11]) + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(12) + }) + + // The measured layout: the Inbox tree, then the System section. + function renderTree(): void { + document.body.innerHTML = ` +
+ +
+
+ +
+
+ + +
+
+
` + document.querySelectorAll('[data-sidebar-idx]').forEach((el) => { + el.getClientRects = (() => [{ width: 200, height: 24 }] as unknown as DOMRectList) as never + }) + } + const removeRow = (idx: number): void => document.querySelector(`[data-sidebar-idx="${idx}"]`)!.remove() + + it('stays in the folder when the gone row was its last one', () => { + // Deleting Inbox's last note used to hand the cursor to Archive, a section away. + renderTree() + const anchor = anchorSidebarCursor(document, 11)! + removeRow(11) + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(10) + }) + + it('takes the row in its place from the middle of a folder', () => { + renderTree() + const anchor = anchorSidebarCursor(document, 10)! + removeRow(10) + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(11) + }) + + it('goes up to the folder when its only note goes', () => { + renderTree() + const anchor = anchorSidebarCursor(document, 13)! + removeRow(13) + expect(sidebarCursorFallbackInDom(document, anchor)).toBe(12) + }) + + it('finds no neighbor in an empty or hidden sidebar', () => { + expect(sidebarCursorNeighbors([], 3)).toEqual({ after: -1, before: -1 }) + expect(sidebarCursorNeighbors([{ index: 4, rendered: false }], 0)).toEqual({ after: -1, before: -1 }) + renderRows([note(9, 'inbox/a.md')]) + const anchor = anchorSidebarCursor(document, 9)! + document.body.innerHTML = '' + expect(sidebarCursorFallbackInDom(document, anchor)).toBeNull() + }) + + it('looks back from past the end, and never lands on a row without a number', () => { + const rows = [ + { index: 2, rendered: true }, + { index: -1, rendered: true }, + { index: 7, rendered: false } + ] + expect(sidebarCursorNeighbors(rows, 9)).toEqual({ after: -1, before: 0 }) + expect(sidebarCursorNeighbors(rows, 1)).toEqual({ after: -1, before: 0 }) + expect(sidebarCursorNeighbors(rows, 0)).toEqual({ after: 0, before: -1 }) + }) +}) diff --git a/packages/app-core/src/lib/sidebar-cursor.ts b/packages/app-core/src/lib/sidebar-cursor.ts new file mode 100644 index 000000000..f0290011c --- /dev/null +++ b/packages/app-core/src/lib/sidebar-cursor.ts @@ -0,0 +1,177 @@ +/** + * The sidebar's Vim cursor is a row NUMBER: `sidebarCursorIndex` names the row + * whose `data-sidebar-idx` matches it. Sidebar hands those numbers out in + * render order on every render, and render order is not even display order (a + * folder numbers its notes before the subfolders it shows above them). So when + * the listing changes under the cursor (a note written from outside the app, a + * sync), every row numbered after the change moves up or down one, and the + * cursor silently lands on whichever row inherits its number. + * + * The number stays the cursor (VimNav, panel-rows and the windowed rows all + * speak it). What this adds is a note of which row the number named, taken + * from the DOM when the cursor moves, so that Sidebar can put the number back + * on that row after a render renumbers it. + */ + +export interface SidebarCursorAnchor { + /** The cursor's `data-sidebar-idx` when the anchor was taken. */ + index: number + /** What the row is, from its identifying attributes (see sidebarRowIdentity). */ + identity: string + /** Matches the row and any twin that shares its identity. */ + selector: string + /** Which of those twins it is, in document order. Atlas and Workflows are + * both `data-sidebar-type="workflows"` with nothing else to tell them apart. */ + occurrence: number + /** Its position among all sidebar rows in document order, to find the row + * that took its place when it goes away. */ + ordinal: number + /** Its ancestor elements, nearest first, to tell which neighbor shared its + * folder when it goes away. */ + ancestors: Element[] +} + +const ROW_SELECTOR = '[data-sidebar-idx]' + +/** The attributes that say which row a row is. Display state (collapsed, + * expandable, the number itself) is left out on purpose. */ +const IDENTITY_ATTRIBUTES = [ + 'data-sidebar-type', + 'data-sidebar-select-key', + 'data-sidebar-path', + 'data-sidebar-key', + 'data-sidebar-folder', + 'data-sidebar-subpath', + 'data-sidebar-tag', + 'data-sidebar-datenav-key', + 'data-sidebar-database' +] as const + +function escapeForAttr(value: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value) + return value.replace(/["\\]/g, '\\$&') +} + +function rowIndex(row: Element): number { + const value = Number(row.getAttribute('data-sidebar-idx')) + return Number.isInteger(value) && value >= 0 ? value : -1 +} + +/** + * A row's identity. A tree note and the same note pinned under Favorites are + * different rows (only the tree row carries a select key), while a windowed + * placeholder carries the attributes of the full row it stands in for, so the + * two read as one row. + */ +export function sidebarRowIdentity(row: Element): string { + return JSON.stringify(IDENTITY_ATTRIBUTES.map((name) => row.getAttribute(name))) +} + +function sidebarRowSelector(row: Element): string { + let selector = ROW_SELECTOR + for (const name of IDENTITY_ATTRIBUTES) { + const value = row.getAttribute(name) + if (value !== null) selector += `[${name}="${escapeForAttr(value)}"]` + } + return selector +} + +function rowTwins(root: ParentNode, selector: string, identity: string): Element[] { + return [...root.querySelectorAll(selector)].filter((row) => sidebarRowIdentity(row) === identity) +} + +/** The anchor for the row numbered `index`, or null when no row has it. */ +export function anchorSidebarCursor(root: ParentNode, index: number): SidebarCursorAnchor | null { + const rows = [...root.querySelectorAll(ROW_SELECTOR)] + const ordinal = rows.findIndex((row) => rowIndex(row) === index) + if (ordinal < 0) return null + const row = rows[ordinal] + const identity = sidebarRowIdentity(row) + const selector = sidebarRowSelector(row) + const ancestors: Element[] = [] + for (let node = row.parentElement; node; node = node.parentElement) ancestors.push(node) + return { + index, + identity, + selector, + occurrence: Math.max(0, rowTwins(root, selector, identity).indexOf(row)), + ordinal, + ancestors + } +} + +/** + * The number the anchored row has now, or null when it is gone. A twin that + * went away takes its neighbor twin's place, which is also the row next to it. + */ +export function findSidebarCursorRow(root: ParentNode, anchor: SidebarCursorAnchor): number | null { + const twins = rowTwins(root, anchor.selector, anchor.identity) + if (twins.length === 0) return null + const index = rowIndex(twins[Math.min(anchor.occurrence, twins.length - 1)]) + return index >= 0 ? index : null +} + +/** + * The rows on either side of where a gone row sat, as positions in `rows` + * (-1 for none): the one that now sits in its place, and the one before it. + * Rows the browser does not lay out are skipped, the way the j/k row scan + * skips them, and so are rows without a number. + */ +export function sidebarCursorNeighbors( + rows: readonly { index: number; rendered: boolean }[], + ordinal: number +): { after: number; before: number } { + const usable = (i: number): boolean => rows[i].index >= 0 && rows[i].rendered + const start = Math.min(Math.max(ordinal, 0), rows.length) + let after = start + while (after < rows.length && !usable(after)) after++ + let before = start - 1 + while (before >= 0 && !usable(before)) before-- + return { after: after < rows.length ? after : -1, before } +} + +/** How far up from `row` the nearest ancestor it shares with the anchored + * row is; Infinity when it shares none. */ +function treeDistance(row: Element, ancestors: readonly Element[]): number { + for (let node = row.parentElement; node; node = node.parentElement) { + const at = ancestors.indexOf(node) + if (at >= 0) return at + } + return Infinity +} + +/** + * Where the cursor goes when its row is gone (deleted, moved away, renamed to + * another sort position): the row that now sits where it sat, so a note + * renamed without changing its place keeps the cursor. When the row before + * it is closer in the tree (the gone row was the last one in its folder, and + * the row in its place belongs to another folder or another sidebar section), + * the cursor stays in the folder and takes that one instead. + */ +export function sidebarCursorFallbackInDom( + root: ParentNode, + anchor: Pick +): number | null { + // Only reached when the cursor's row is gone, so the layout reads stay off + // the path every render takes. + const rows = [...root.querySelectorAll(ROW_SELECTOR)] + const { after, before } = sidebarCursorNeighbors( + rows.map((row) => ({ + index: rowIndex(row), + get rendered() { + return row.getClientRects().length > 0 + } + })), + anchor.ordinal + ) + if (after < 0 && before < 0) return null + const pick = + after < 0 + ? before + : before < 0 + ? after + : treeDistance(rows[before], anchor.ancestors) < treeDistance(rows[after], anchor.ancestors) + ? before + : after + return rowIndex(rows[pick]) +} diff --git a/tooling/scripts/sidebar-vim-smoke.mjs b/tooling/scripts/sidebar-vim-smoke.mjs index 1cbc0f009..69ff752c4 100644 --- a/tooling/scripts/sidebar-vim-smoke.mjs +++ b/tooling/scripts/sidebar-vim-smoke.mjs @@ -12,6 +12,7 @@ * and drives it over the Chrome DevTools Protocol: * - windowing is actually active (most rows are placeholders), * - j / k move the cursor one real row at a time, + * - a note written from outside above the cursor leaves it on its row, * - G / gg scroll to the ends and the end rows render as full rows, * - every row exposes a selection key (range-select sees the whole list), * - clicking an off-screen placeholder opens its note, @@ -284,6 +285,26 @@ async function main() { } check('k moves up one real row at a time', kOk, kDetail) + // 5b. A note written from outside the app lands above the cursor, and every + // row after it is renumbered. The cursor follows its row, not its number. + const cursorRow = `(() => { + const el = [...document.querySelectorAll('[data-sidebar-idx]')].find(e => /(^|\\s)vim-cursor/.test(e.className)); + return el ? { idx: Number(el.getAttribute('data-sidebar-idx')), path: el.getAttribute('data-sidebar-path') } : null; + })()` + const beforeWrite = await evaluate(client, cursorRow) + const outsideFile = '00000a - outside.md' + await writeFile(join(vaultRoot, 'inbox', outsideFile), '# Outside\n\nWritten while the sidebar had focus.\n') + const outsideIdx = await until(client, `(() => { + const el = document.querySelector('[data-sidebar-path$=${JSON.stringify(outsideFile)}]'); + return el ? Number(el.getAttribute('data-sidebar-idx')) : null; + })()`, 8000, 100) + await sleep(150) + const afterWrite = await evaluate(client, cursorRow) + check('an outside write above the cursor leaves it on its row', + !!beforeWrite && outsideIdx != null && outsideIdx < beforeWrite.idx && + afterWrite?.path === beforeWrite.path && afterWrite?.idx === beforeWrite.idx + 1, + JSON.stringify({ beforeWrite, outsideIdx, afterWrite })) + // 6. G scrolls to the bottom and the last note (a placeholder until now) renders full. await pressKey(client, 'G', true) const gOk = await until(client, `(() => { From f69b5fa5558226d8e0df05a24aaeedaedbc4cb5a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 09:50:02 -0500 Subject: [PATCH 13/16] Feat(links): every unresolved link in one list, and a popover to write the note in place uNyanda links notes they mean to write later and then forgets to write them, and wanted to edit or create a linked note without leaving the one they are in, the way Logseq's hover editor works. Both asks are about wikilinks to notes that do not exist yet. The Unresolved Links view (a tab, like Assets) lists every [[wikilink]] in the vault that no note answers, grouped by the note it names, most linked first, with each linking note and the line the link sits on. Space u, :unresolved, Open Unresolved Links in the palette, or a new row under the Connections panel's missing links opens it. Enter on a name creates the note where gD would; Enter on a line opens the linking note with the cursor on the link. j/k, gg/G and / are Vim mode's; with it off the arrows, Enter and Escape do the work. The view holds the keyboard the way the Tasks and Tags views do: VimNav stands down for it except for the leader. What counts as unresolved is one decision. The editor already drew a link as broken when it reached no note, spot in this note, database or file; that check moved out of cm-wikilink-render.ts into wikilinkReaches (lib/unresolved-links.ts), and the view asks it about every link in the vault, so a link the editor draws broken is exactly a link the list shows. Headings, blocks and aliases of existing notes resolve; a [[link]] inside code is text. Grouping runs on the index's per-note link lists, with no file reads; the lines come from reading only the linking notes, and only while the view is open. The line scan is new pure logic in shared-domain (unresolved-links.ts) and masks code through scanMarkdownLines, which gains an includeFrontmatter option, instead of a sixth copy of stripCodeContent. The link popover opens when the pointer rests on a wikilink in the editor, or from K (Vim) and Shift+Mod+K (editor.linkPopover, Vim on or off) on the link under the cursor. It is a real editor on the store's noteContents, the buffer the panes and the pinned reference pane share, so typing there saves through the usual debounce and shows in any tab of the same note. A link to a missing note offers Create note, which writes the note without opening it in the pane (createNoteInPlace; a daily note through ensureDailyNoteForDate) and puts the cursor at its end in insert mode. Escape closes it from Vim normal mode and gives focus and cursor back to the link; Mod+Enter opens the note in a tab; K inside it follows the next link; a [[Note#Heading]] link opens on the heading. Weekly and monthly note links still create through the pane, where their templates run. Every cache prune drops content no pane references, so the store now holds the popover's note (linkPopoverHolds) and, on close or when another link replaces it, saves the last edit before letting go: dropping it first would leave the debounced save nothing to write. Renames rewrite its path, and trashing its note closes it. Moving from a link onto the popover fires the popover's pointerenter before the editor's mouseleave, which re-armed the close; the hover timer now checks where the pointer is. Hover is a new portable preference, link_popover_on_hover under [editor] (Settings, Writing, Link popover on hover), on by default; the keys work either way. Both features are in the in-app manual. Verified in the built app over CDP with isolated userData and config dir. With Vim on (24 checks): Space u lists three missing notes in order with their lines and skips links in a fence and in inline code; Enter on a line lands on line 3; Enter on a name creates the note and drops it from the list; hovering opens a note in the popover without taking focus; the popover survives the pointer crossing onto it and closes after it leaves; an edit made in it lands on disk and Escape returns to the original cursor; K on a missing link then Enter creates the note in place and typing lands in it; the link stops drawing as broken; a heading link opens on its heading. With Vim off (12 checks): Shift+Cmd+K, Create, the palette entry, the Connections row and the Settings toggle work, j does nothing in the list and the arrows do. No console errors. Tests: the occurrence scan (fences, tilde and list fences, inline code, frontmatter, CRLF offsets), grouping, the vault scan against the real resolver (anchors, aliases, assets, databases, trash, remapped system folders), the popover's target rules and anchor offsets, and the in-place create path. shared-domain 2,085, app-core 3,470 and desktop 1,192 tests pass; typecheck clean. --- apps/desktop/src/main/app-config.ts | 5 + packages/app-core/src/App.tsx | 10 + .../src/components/ConnectionsPanel.tsx | 36 ++ packages/app-core/src/components/Editor.tsx | 18 + .../app-core/src/components/EditorPane.tsx | 67 ++- packages/app-core/src/components/HelpView.tsx | 3 + .../app-core/src/components/LinkPopover.tsx | 551 ++++++++++++++++++ .../src/components/PinnedReferencePane.tsx | 4 +- .../app-core/src/components/SettingsModal.tsx | 16 + .../src/components/UnresolvedLinksView.tsx | 473 +++++++++++++++ packages/app-core/src/components/VimNav.tsx | 33 +- .../app-core/src/lib/cm-wikilink-render.ts | 26 +- packages/app-core/src/lib/commands.ts | 38 +- .../src/lib/create-note-from-link.test.ts | 74 ++- .../app-core/src/lib/create-note-from-link.ts | 34 ++ packages/app-core/src/lib/help.ts | 27 +- packages/app-core/src/lib/keymaps.ts | 40 ++ .../app-core/src/lib/link-popover-open.ts | 198 +++++++ .../app-core/src/lib/link-popover.test.ts | 72 +++ packages/app-core/src/lib/link-popover.ts | 81 +++ .../app-core/src/lib/self-keyed-surfaces.ts | 5 +- .../app-core/src/lib/unresolved-links.test.ts | 147 +++++ packages/app-core/src/lib/unresolved-links.ts | 67 +++ packages/app-core/src/lib/workspace-tabs.ts | 2 + packages/app-core/src/store.ts | 213 +++++++ packages/app-core/src/styles/index.css | 31 + packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + packages/shared-domain/src/keymaps-catalog.ts | 3 + packages/shared-domain/src/markdown-lines.ts | 21 +- .../src/unresolved-links.test.ts | 197 +++++++ .../shared-domain/src/unresolved-links.ts | 181 ++++++ 32 files changed, 2639 insertions(+), 36 deletions(-) create mode 100644 packages/app-core/src/components/LinkPopover.tsx create mode 100644 packages/app-core/src/components/UnresolvedLinksView.tsx create mode 100644 packages/app-core/src/lib/link-popover-open.ts create mode 100644 packages/app-core/src/lib/link-popover.test.ts create mode 100644 packages/app-core/src/lib/link-popover.ts create mode 100644 packages/app-core/src/lib/unresolved-links.test.ts create mode 100644 packages/app-core/src/lib/unresolved-links.ts create mode 100644 packages/shared-domain/src/unresolved-links.test.ts create mode 100644 packages/shared-domain/src/unresolved-links.ts diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 3d29f7c20..c20c78e42 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -184,6 +184,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'list_indent_guides', comment: 'vertical guide lines at each nested list level' }, + linkPopoverOnHover: { + section: 'editor', + tomlKey: 'link_popover_on_hover', + comment: 'resting the pointer on a [[wikilink]] opens the link popover' + }, editorScrollOff: { section: 'editor', tomlKey: 'scroll_off', diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 899a8d489..eb309a443 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -174,6 +174,10 @@ const Editor = lazy(async () => { return { default: module.Editor } }) +const LinkPopover = lazy(async () => { + const module = await import('./components/LinkPopover') + return { default: module.LinkPopover } +}) const PinnedReferencePane = lazy(async () => { const module = await import('./components/PinnedReferencePane') return { default: module.PinnedReferencePane } @@ -370,6 +374,7 @@ function App(): JSX.Element { const init = useStore((s) => s.init) const workspaceRestored = useStore((s) => s.workspaceRestored && !s.workspaceTransitioning) const searchOpen = useStore((s) => s.searchOpen) + const linkPopoverOpen = useStore((s) => s.linkPopover !== null) const setSearchOpen = useStore((s) => s.setSearchOpen) const vaultTextSearchOpen = useStore((s) => s.vaultTextSearchOpen) const setVaultTextSearchOpen = useStore((s) => s.setVaultTextSearchOpen) @@ -1340,6 +1345,11 @@ function App(): JSX.Element { )} + {linkPopoverOpen && ( + + + + )} diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index 6e51c9318..ca4381fe0 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -127,6 +127,8 @@ export function ConnectionsPanel({ outgoing.resolvedItems.length + outgoing.attachmentItems.length + outgoing.missingItems.length + + // The way to the vault-wide list, under this note's missing links. + (outgoing.missingItems.length > 0 ? 1 : 0) + backlinks.length + mentions.length @@ -339,6 +341,12 @@ export function ConnectionsPanel({ rowIndex={rowIndex++} /> ))} + {outgoing.missingItems.length > 0 && ( + + )}
@@ -680,3 +688,31 @@ function ConnectionKeyHint({ ) } + +/** + * Under a note's missing links: the way to every missing link in the vault, + * the Unresolved Links view. A row like the others so `j`/`k` reach it. + */ +function AllUnresolvedLinksRow({ + active, + rowIndex +}: { + active: boolean + rowIndex: number +}): JSX.Element { + const openUnresolvedLinksView = useStore((s) => s.openUnresolvedLinksView) + return ( + + ) +} diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index b30888e98..a2d1d35a4 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -95,6 +95,7 @@ import { registerNoteMoveExCommands } from "../lib/vim-ex-commands"; import { promptImageWidth, setImageWidthFromInput } from "../lib/image-resize"; import { copyLinkAtCursor } from "../lib/link-copy"; import { followLinkTarget } from "../lib/follow-link"; +import { openLinkPopoverAtCursor } from "../lib/link-popover-open"; let vimCommandsRegistered = false; let syncedVimBindings: Partial> = {}; @@ -214,6 +215,13 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { toVimSequence(getKeymapBinding(overrides, "vim.createNoteFromLink")), ].filter((binding): binding is string => !!binding), }, + { + id: "vim.linkPopover", + action: "zenLinkPopover", + bindings: [ + toVimSequence(getKeymapBinding(overrides, "vim.linkPopover")), + ].filter((binding): binding is string => !!binding), + }, { id: "vim.paneFocusLeft", action: "focusPaneLeft", @@ -662,6 +670,9 @@ function registerVimCommands(): void { Vim.defineEx("tasks", "tasks", () => { void useStore.getState().openTasksView(); }); + Vim.defineEx("unresolved", "unres", () => { + void useStore.getState().openUnresolvedLinksView(); + }); // Quick-add a whole-note task file. `:newtask` (or `:task`) prompts for a // title and creates it at the configured tasks location; `:newtask ` @@ -823,6 +834,13 @@ function registerVimCommands(): void { }); }); + // `K` on a [[wikilink]]: its note in the link popover, keyboard inside. + // Inside the popover's own editor it chains to the next link's note. + Vim.defineAction("zenLinkPopover", (cm: ReturnType) => { + const view = (cm as unknown as { cm6?: EditorView }).cm6; + if (view) openLinkPopoverAtCursor(view); + }); + Vim.defineAction("goToDefinition", (cm: ReturnType) => { const view = (cm as unknown as { cm6?: EditorView }).cm6; if (!view) return; diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 263c75934..92de089a8 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -74,6 +74,15 @@ import { keyBindingsFor, vimHalfPageKeymap } from '../lib/vim-half-page-keymap' import { scrollOff } from '../lib/cm-scrolloff' import { followLinkTarget } from '../lib/follow-link' import { pointerOverRange } from '../lib/cm-pointer-range' +import { + openLinkPopoverAtCursor, + pointerLeftWikilink, + pointerOverWikilink, + rangeRect +} from '../lib/link-popover-open' +import { isInsideCode } from '../lib/cm-wikilink-render' +import { isUnresolvedLinksTabPath } from '@shared/unresolved-links' +import { UnresolvedLinksView } from './UnresolvedLinksView' import { setHoveredLink } from '../lib/hovered-link' import { setYankToClipboardEnabled, @@ -251,6 +260,7 @@ import { CheckSquareIcon, CloseIcon, PaperclipIcon, + LinkIcon, DocumentIcon, FileDownIcon, FeedbackIcon, @@ -410,6 +420,9 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens // Join a hard-wrapped paragraph back into one line so the pane wraps it // (#676). Mode-agnostic like the line moves; Vim mode also has `gq`. ...keyBindingsFor(getKeymapBinding(overrides, 'editor.reflowParagraph'), reflowParagraph), + // The note a [[wikilink]] names, in the link popover, keyboard inside. + // Mode-agnostic; Vim mode also has it as `K`. + ...keyBindingsFor(getKeymapBinding(overrides, 'editor.linkPopover'), openLinkPopoverAtCursor), // Step across inline markers, so a formatted word can be finished without // reaching for the arrow keys. Mode-agnostic like the line moves. (#490) ...keyBindingsFor(getKeymapBinding(overrides, 'editor.hopMarkerForward'), markerHop.forward), @@ -2215,22 +2228,36 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } const line = view.state.doc.lineAt(pos) const link = linkRangeAtCursor(line.text, pos - line.from) - setHoveredLink( - link && - pointerOverRange( - view, - line.from + link.from, - line.from + link.to, - event.clientX, - event.clientY - ) - ? link.target - : null - ) + const over = + !!link && + pointerOverRange( + view, + line.from + link.from, + line.from + link.to, + event.clientX, + event.clientY + ) + setHoveredLink(over && link ? link.target : null) + // Resting on a [[wikilink]] opens its note in the link popover. + // Not while a button is down (a selection drag crossing a link), + // and not for `[[...]]` inside code, which is text. + const from = line.from + (link?.from ?? 0) + if ( + over && + link?.kind === 'wikilink' && + event.buttons === 0 && + !isInsideCode(view.state, from) + ) { + const to = line.from + link.to + pointerOverWikilink(link.target, viewPathRef.current, () => rangeRect(view, from, to)) + } else { + pointerLeftWikilink() + } return false }, mouseleave: () => { setHoveredLink(null) + pointerLeftWikilink() return false }, click: (event) => { @@ -3276,6 +3303,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isArchive: false, isTrash: false, isAssetsView: false, + isUnresolvedLinks: false, isAsset: false, isDiagram: false, isDatabase: false @@ -3343,6 +3371,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isAssetsView: true } } + if (isUnresolvedLinksTabPath(path)) { + return { + ...base, + title: 'Unresolved Links', + isUnresolvedLinks: true + } + } if (isAssetTabPath(path)) { const assetPath = assetPathFromTab(path) return { @@ -3441,6 +3476,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isHelpTabPath(path) || isArchiveTabPath(path) || isTrashTabPath(path) || + isUnresolvedLinksTabPath(path) || isAssetTabPath(path) || isDiagramTabPath(path) || isDatabaseTabPath(path) @@ -3549,6 +3585,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isArchive: boolean isTrash: boolean isAssetsView: boolean + isUnresolvedLinks: boolean isAsset: boolean isDiagram: boolean }) => { @@ -3562,6 +3599,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { tab.isArchive || tab.isTrash || tab.isAssetsView || + tab.isUnresolvedLinks || tab.isAsset || tab.isDiagram return ( @@ -3736,6 +3774,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {tab.isAssetsView && ( )} + {tab.isUnresolvedLinks && ( + + )} {tab.isAsset && ( )} @@ -4448,6 +4489,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) : isAssetsViewTabPath(activeTab) ? ( + ) : isUnresolvedLinksTabPath(activeTab) ? ( + ) : activeTab && isAssetTabPath(activeTab) ? ( isDatabaseCsvPath(assetPathFromTab(activeTab) ?? '') ? ( l f') { return chord( diff --git a/packages/app-core/src/components/LinkPopover.tsx b/packages/app-core/src/components/LinkPopover.tsx new file mode 100644 index 000000000..bbba3c378 --- /dev/null +++ b/packages/app-core/src/components/LinkPopover.tsx @@ -0,0 +1,551 @@ +/** + * The link popover: rest the pointer on a `[[wikilink]]` (or press `K` on + * it) and the note it names opens in a small floating editor over the note + * you are in, Logseq-style. A link no note answers yet offers to create the + * note, and the new note opens right there for typing. + * + * The editor edits the store's path-keyed `noteContents`, the same buffer + * every pane and the pinned reference pane use, so an edit here shows up in + * an open tab of the same note and saves through the same debounced path. + * The store keeps that buffer alive while the popover shows it (see + * `linkPopoverHolds`) and saves it on close. + * + * Keys: VimNav stands down inside the popover (it is a self-keyed surface), + * and the popover owns Escape: it closes from Vim normal mode (insert and + * visual mode get their own Escape first) and hands focus back to whatever + * had it before, usually the editor the link was in. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Annotation, EditorSelection, EditorState, type Transaction } from '@codemirror/state' +import { EditorView, highlightActiveLine, keymap, tooltips } from '@codemirror/view' +import { Vim, getCM, vim } from '@replit/codemirror-vim' +import { history } from '@codemirror/commands' +import { autocompletion, completionStatus } from '@codemirror/autocomplete' +import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { wikilinkNoteName } from '@shared/unresolved-links' +import { useStore } from '../store' +import { noteEditingLockExtension, noteEditingSync, refreshNoteEditingLock } from '../lib/note-lifecycle-lock' +import { scrollerClickFocus } from '../lib/cm-scroller-click-focus' +import { vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' +import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' +import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' +import { noteMarkdown } from '../lib/cm-markdown-language' +import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languages' +import { markdownLinkExtension } from '../lib/cm-markdown-links' +import { listIndentWidth, markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' +import { livePreviewPlugin } from '../lib/cm-live-preview' +import { frontmatterStyle, frontmatterTagExtension } from '../lib/cm-frontmatter' +import { slashCommandRender, slashCommandSource } from '../lib/cm-slash-commands' +import { calloutTypeSource } from '../lib/cm-callouts' +import { dateShortcutSource } from '../lib/cm-date-shortcuts' +import { wikilinkBlockSource, wikilinkHeadingSource, wikilinkSource } from '../lib/cm-wikilinks' +import { hashtagSource } from '../lib/cm-hashtag-complete' +import { frontmatterTagSource } from '../lib/cm-frontmatter-tag-complete' +import { completionKeymapExtension, completionNavKeymap } from '../lib/cm-completion-nav' +import { editorTabSize } from '../lib/editor-tab-size' +import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' +import { cursorDrawSelection } from '../lib/cm-cursor-blink' +import { formatKeymapBinding, getKeymapBinding, matchesShortcutBinding } from '../lib/keymaps' +import { keyBindingsFor } from '../lib/vim-half-page-keymap' +import { isEditorInsertMode, isEditorVisualMode } from '../lib/vim-nav' +import { linkAnchorOffset, linkCreateDestination } from '../lib/link-popover' +import { getSystemFolderLabel } from '../lib/system-folder-labels' +import { + openLinkPopoverAtCursor, + pointerEnteredLinkPopover, + pointerLeftLinkPopover, + resetLinkPopoverHover +} from '../lib/link-popover-open' +import { createNoteForLinkInPlace, createNoteFromLinkNow } from '../lib/create-note-from-link' +import { openWikilinkTarget } from '../lib/wikilink-navigation' +import { focusEditorNormalMode } from '../lib/editor-focus' +import { Button, IconButton } from './ui' +import { ArrowUpRightIcon, CloseIcon, LinkIcon } from './icons' +import { buildReferenceKeymap, paperHighlight } from './PinnedReferencePane' + +const programmatic = Annotation.define() + +const OPEN_IN_TAB_BINDING = 'Mod+Enter' + +/** Viewport room kept clear around the popover. */ +const EDGE = 12 +const GAP = 6 +const MAX_WIDTH = 520 +const MAX_HEIGHT = 440 +const MIN_ROOM = 220 + +interface Placement { + left: number + width: number + top?: number + bottom?: number + maxHeight: number +} + +/** Under the link when there is room, above it otherwise, always on screen. */ +function placementFor(rect: { left: number; top: number; bottom: number }): Placement { + const vw = window.innerWidth + const vh = window.innerHeight + const width = Math.min(MAX_WIDTH, vw - EDGE * 2) + const left = Math.min(Math.max(EDGE, rect.left - 12), vw - width - EDGE) + const below = vh - rect.bottom - GAP - EDGE + const above = rect.top - GAP - EDGE + if (below >= MIN_ROOM || below >= above) { + return { left, width, top: rect.bottom + GAP, maxHeight: Math.min(MAX_HEIGHT, below) } + } + return { left, width, bottom: vh - rect.top + GAP, maxHeight: Math.min(MAX_HEIGHT, above) } +} + +export function LinkPopover(): JSX.Element | null { + const popover = useStore((s) => s.linkPopover) + const note = useStore((s) => + s.linkPopover?.path ? s.notes.find((n) => n.path === s.linkPopover?.path) ?? null : null + ) + const content = useStore((s) => + s.linkPopover?.path ? s.noteContents[s.linkPopover.path] ?? null : null + ) + const isDirty = useStore((s) => + s.linkPopover?.path ? s.noteDirty[s.linkPopover.path] ?? false : false + ) + const keymapOverrides = useStore((s) => s.keymapOverrides) + const closeLinkPopover = useStore((s) => s.closeLinkPopover) + const pinLinkPopover = useStore((s) => s.pinLinkPopover) + const showNoteInLinkPopover = useStore((s) => s.showNoteInLinkPopover) + const selectedPath = useStore((s) => s.selectedPath) + const vaultRoot = useStore((s) => s.vault?.root ?? null) + const systemFolderLabels = useStore((s) => s.systemFolderLabels) + + const rootRef = useRef(null) + const viewRef = useRef(null) + const createRef = useRef(null) + // Where the keyboard was before the popover took it; Escape gives it back. + const returnFocusRef = useRef(null) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + + const placement = useMemo( + () => (popover ? placementFor(popover.anchorRect) : null), + [popover?.anchorRect] + ) + + const rememberReturnFocus = useCallback(() => { + const active = document.activeElement + if (!(active instanceof HTMLElement)) return + if (rootRef.current?.contains(active)) return + if (active === document.body) return + returnFocusRef.current = active + }, []) + + const close = useCallback( + (restoreFocus: boolean) => { + const hadFocus = + !!rootRef.current && rootRef.current.contains(document.activeElement) + resetLinkPopoverHover() + closeLinkPopover() + const back = returnFocusRef.current + returnFocusRef.current = null + if (!restoreFocus || !hadFocus) return + // The note editor goes back to Vim normal mode on the cursor it had + // (CodeMirror keeps the selection while blurred); anything else, such + // as the Unresolved Links list, just takes focus back. + const mainView = useStore.getState().editorViewRef + if (back?.isConnected && !mainView?.dom.contains(back)) { + back.focus({ preventScroll: true }) + return + } + focusEditorNormalMode() + }, + [closeLinkPopover] + ) + + // A popover is about a link in the note on screen; another note, or + // another vault, leaves it behind. + const firstSelected = useRef(selectedPath) + useEffect(() => { + if (selectedPath === firstSelected.current) return + close(false) + }, [selectedPath, close]) + const firstVault = useRef(vaultRoot) + useEffect(() => { + if (vaultRoot === firstVault.current) return + close(false) + }, [vaultRoot, close]) + + // Keyboard-opened popovers take focus; remember where it came from first. + useLayoutEffect(() => { + if (!popover?.focus) return + rememberReturnFocus() + if (!popover.path) { + requestAnimationFrame(() => createRef.current?.focus({ preventScroll: true })) + } + }, [popover?.serial, popover?.focus, popover?.path, rememberReturnFocus]) + + useEffect(() => { + setCreateError(null) + setCreating(false) + }, [popover?.serial]) + + // Outside clicks close a pinned popover; keys typed elsewhere close a + // hover popover, which is only ever a glance; scrolling the page under a + // hover popover moves its link away from it. + useEffect(() => { + if (!popover) return + const onPointerDown = (e: PointerEvent): void => { + const target = e.target as Node | null + if (target && rootRef.current?.contains(target)) return + // Completion menus of the popover editor render into document.body. + if ((target as HTMLElement | null)?.closest?.('.cm-tooltip')) return + close(false) + } + const onKeyDown = (e: KeyboardEvent): void => { + const target = e.target as Node | null + if (target && rootRef.current?.contains(target)) return + if (useStore.getState().linkPopover?.mode === 'hover') close(false) + } + const onScroll = (e: Event): void => { + const target = e.target as Node | null + if (target && rootRef.current?.contains(target)) return + if (useStore.getState().linkPopover?.mode === 'hover') close(false) + } + window.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('scroll', onScroll, true) + return () => { + window.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('scroll', onScroll, true) + } + }, [popover !== null, close]) + + const openInTab = useCallback(() => { + const current = useStore.getState().linkPopover + if (!current) return + close(false) + if (current.path) { + void openWikilinkTarget(current.path, current.target).then(() => focusEditorNormalMode()) + } else { + void createNoteFromLinkNow(current.target) + } + }, [close]) + + const create = useCallback(async () => { + const current = useStore.getState().linkPopover + if (!current || current.path || creating) return + setCreating(true) + setCreateError(null) + const path = await createNoteForLinkInPlace(current.target) + // The popover may have closed while the note was being written. + if (useStore.getState().linkPopover?.serial !== current.serial) return + setCreating(false) + if (path) { + await showNoteInLinkPopover(path) + return + } + // A weekly or monthly note is made by opening it (its template runs + // there), so that one link leaves the popover for the main editor. + const name = wikilinkNoteName(current.target) + if (name && !/[\\:*?"<>|]/.test(name)) { + close(false) + void createNoteFromLinkNow(current.target) + return + } + setCreateError('This link cannot name a note file.') + }, [close, creating, showNoteInLinkPopover]) + + const onKeyDownCapture = useCallback( + (e: React.KeyboardEvent) => { + const native = e.nativeEvent + const state = useStore.getState() + if (matchesShortcutBinding(native, OPEN_IN_TAB_BINDING)) { + e.preventDefault() + e.stopPropagation() + openInTab() + return + } + if (e.key !== 'Escape' || native.isComposing) return + const view = viewRef.current + if (view && view.hasFocus) { + // An open completion menu, Vim insert or visual mode: their Escape first. + if (completionStatus(view.state) === 'active') return + if (isEditorInsertMode(view, state.vimMode) || isEditorVisualMode(view, state.vimMode)) return + } + e.preventDefault() + e.stopPropagation() + close(true) + }, + [close, openInTab] + ) + + if (!popover || !placement) return null + + const exists = !!popover.path + const title = exists + ? note?.title ?? popover.path!.split('/').pop()!.replace(/\.md$/i, '') + : wikilinkNoteName(popover.target) || popover.target + const destination = exists ? null : linkCreateDestination(popover.target) + const subtitle = exists + ? popover.path! + : destination + ? `Not written yet · new note in ${getSystemFolderLabel(destination.folder, systemFolderLabels)}${destination.subpath ? ` / ${destination.subpath}` : ''}` + : 'Not written yet' + const openHint = formatKeymapBinding(OPEN_IN_TAB_BINDING, 'shortcut') + + return createPortal( +
{ + rememberReturnFocus() + pinLinkPopover() + }} + onFocusCapture={() => pinLinkPopover()} + onKeyDownCapture={onKeyDownCapture} + > +
+ +
+
+ {title} + {exists && isDirty && ( + + )} +
+
{subtitle}
+
+ + + + close(true)} + > + + +
+ {exists ? ( + content ? ( + + ) : ( +
Loading…
+ ) + ) : ( +
+

+ No note answers this link yet. Create it and write it here, without leaving the note you are in. +

+
+ + Enter to create · Esc to close +
+ {createError &&

{createError}

} +
+ )} +
, + document.body + ) +} + +/** + * The popover's editor, mounted per note. Same building blocks as the pinned + * reference pane's editor, without its gutters: a popover is a glance and a + * few lines of writing, not a second workspace. + */ +function PopoverNoteEditor({ + path, + target, + focus, + created, + serial, + viewRef, + keymapOverrides +}: { + path: string + target: string + focus: boolean + created: boolean + serial: number + viewRef: React.MutableRefObject + keymapOverrides: ReturnType['keymapOverrides'] +}): JSX.Element { + const content = useStore((s) => s.noteContents[path] ?? null) + const updateNoteBody = useStore((s) => s.updateNoteBody) + const pathRef = useRef(path) + pathRef.current = path + + const setContainerRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el) { + viewRef.current?.destroy() + viewRef.current = null + return + } + if (viewRef.current) return + const s0 = useStore.getState() + const body = s0.noteContents[path]?.body ?? '' + const state = EditorState.create({ + doc: body, + extensions: [ + noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: pathRef.current })), + appMarkdownSnippetExtension(), + s0.vimMode ? vim() : [], + vimImeGuard(() => useStore.getState().vimBlockImeInNormalMode && !isTouchPrimaryDevice()), + vimVisualHighlightExtension, + history(), + scrollerClickFocus, + cursorDrawSelection(s0.cursorBlink), + editorTabSize(s0.editorTabSize), + listIndentWidth(s0.editorTabSize), + highlightActiveLine(), + EditorView.lineWrapping, + noteMarkdown(), + customCodeFenceHighlightExtension, + markdownLinkExtension, + vimAwareMarkdownKeymap, + markdownListIndentPlugin, + frontmatterStyle, + frontmatterTagExtension, + syntaxHighlighting(paperHighlight), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + s0.livePreview ? livePreviewPlugin : [], + tooltips({ parent: document.body }), + autocompletion({ + defaultKeymap: false, + override: [ + slashCommandSource, + calloutTypeSource, + dateShortcutSource, + frontmatterTagSource, + hashtagSource, + wikilinkSource, + wikilinkHeadingSource, + wikilinkBlockSource + ], + addToOptions: [{ render: slashCommandRender.render, position: 0 }], + icons: false, + optionClass: (completion) => { + const kind = (completion as { _kind?: string })._kind + if (kind === 'wikilink') return 'wikilink-cmd-option' + if (kind === 'callout') return 'callout-cmd-option' + return 'slash-cmd-option' + } + }), + completionNavKeymap, + completionKeymapExtension, + keymap.of([ + // The popover for a link inside the popover replaces this one, + // so a chain of notes can be followed without leaving the note + // underneath. + ...keyBindingsFor(getKeymapBinding(keymapOverrides, 'editor.linkPopover'), (view) => + openLinkPopoverAtCursor(view) + ) + ]), + buildReferenceKeymap(s0.vimMode, keymapOverrides), + EditorView.updateListener.of((upd) => { + if (!upd.docChanged) return + if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return + updateNoteBody(pathRef.current, upd.state.doc.toString()) + }) + ] + }) + const view = new EditorView({ state, parent: el }) + viewRef.current = view + }, + // The view is built once per note; the component is keyed by path. + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + // Edits made elsewhere (an open tab of the same note, a sync) flow in. + useEffect(() => { + const view = viewRef.current + if (!view || !content) return + const next = content.body + if (view.state.doc.toString() === next) return + const sel = view.state.selection.main + refreshNoteEditingLock(view) + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + annotations: [programmatic.of(true), noteEditingSync.of(true)], + selection: { + anchor: Math.min(sel.anchor, next.length), + head: Math.min(sel.head, next.length) + } + }) + }, [content?.body, viewRef]) + + // Place the cursor and, when the popover was opened from a key, take the + // keyboard. A link's anchor wins; a note just created starts at its end, + // in insert mode, ready for the first sentence. + useLayoutEffect(() => { + const view = viewRef.current + if (!view) return + const doc = view.state.doc + const anchor = linkAnchorOffset(doc.toString(), target) + const at = anchor ?? (created ? doc.length : 0) + view.dispatch({ + selection: EditorSelection.cursor(Math.min(at, doc.length)), + effects: EditorView.scrollIntoView(Math.min(at, doc.length), { y: 'start', yMargin: 8 }) + }) + if (!focus) return + const raf = requestAnimationFrame(() => { + view.focus() + if (created && useStore.getState().vimMode) { + const cm = getCM(view) + if (cm && !cm.state.vim?.insertMode) Vim.handleKey(cm, 'a', 'user') + } + }) + return () => cancelAnimationFrame(raf) + }, [serial, focus, created, path, target, viewRef]) + + // Unsaved edits are saved by the store as the popover closes; a note + // switched away from inside the popover saves on its own debounce. + return ( +
+ ) +} diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 63add99cc..eee88797b 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -73,7 +73,7 @@ export const pinnedRefPaneId = PINNED_REF_PANE_ID const programmatic = Annotation.define() -const paperHighlight = HighlightStyle.define([ +export const paperHighlight = HighlightStyle.define([ { tag: t.heading1, class: 'tok-heading1' }, { tag: t.heading2, class: 'tok-heading2' }, { tag: t.heading3, class: 'tok-heading3' }, @@ -133,7 +133,7 @@ function lineNumberExtension(mode: LineNumberMode): Extension { ] } -function buildReferenceKeymap(vimMode: boolean, overrides: KeymapOverrides): Extension { +export function buildReferenceKeymap(vimMode: boolean, overrides: KeymapOverrides): Extension { return keymap.of([ // Note search must precede the find bar only while its binding is set. ...keyBindingsFor(getKeymapBinding(overrides, 'global.searchNotesNonVim'), () => { diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 284d6c421..3a068d332 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -754,6 +754,8 @@ export function SettingsModal(): JSX.Element { const editorTabSize = useStore((s) => s.editorTabSize); const setEditorTabSize = useStore((s) => s.setEditorTabSize); const setListIndentGuides = useStore((s) => s.setListIndentGuides); + const linkPopoverOnHover = useStore((s) => s.linkPopoverOnHover); + const setLinkPopoverOnHover = useStore((s) => s.setLinkPopoverOnHover); const textReplacementsEnabled = useStore((s) => s.textReplacementsEnabled); const setTextReplacementsEnabled = useStore( (s) => s.setTextReplacementsEnabled, @@ -2483,6 +2485,12 @@ export function SettingsModal(): JSX.Element { description: "Vertical guide lines at each nested list level.", keywords: ["indent", "guides", "list", "nested", "outline", "lines"], }, + { + id: "link-popover-on-hover", + title: "Link popover on hover", + description: "Rest the pointer on a [[wikilink]] to edit or create its note in place.", + keywords: ["link", "popover", "hover", "preview", "wikilink", "peek", "floating", "create"], + }, { id: "text-replacements-enabled", title: "Text replacements", @@ -2944,6 +2952,7 @@ export function SettingsModal(): JSX.Element { "heading-level-labels", "editor-tab-size", "list-indent-guides", + "link-popover-on-hover", "auto-pairs", "auto-pair-quotes-in-prose", "note-tabs", @@ -3128,6 +3137,13 @@ export function SettingsModal(): JSX.Element { settingId="list-indent-guides" onChange={setListIndentGuides} /> + } + | { + kind: 'mention' + id: string + group: UnresolvedLinkGroup + note: NoteMeta + occurrence: WikilinkOccurrence | null + /** The note was read; a null occurrence then means its saved text no + * longer has the link (an unsaved edit, or the index running ahead). */ + loaded: boolean + } + +interface LoadedBody { + updatedAt: number + body: string +} + +function cssEscape(value: string): string { + return value.replace(/["\\]/g, '\\$&') +} + +/** The linking line, trimmed to the neighborhood of the link. */ +function excerpt(occurrence: WikilinkOccurrence): { before: string; link: string; after: string } { + const text = occurrence.text + const start = occurrence.column + const end = start + (occurrence.to - occurrence.from) + const before = text.slice(0, start) + const clippedBefore = before.length > 48 ? `…${before.slice(-46).trimStart()}` : before.trimStart() + return { before: clippedBefore, link: text.slice(start, end), after: text.slice(end) } +} + +export function UnresolvedLinksView(): JSX.Element { + const notes = useStore((s) => s.notes) + const folders = useStore((s) => s.folders) + const vaultSettings = useStore((s) => s.vaultSettings) + const assetFiles = useStore((s) => s.assetFiles) + const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) + const amActive = useStore(isUnresolvedLinksViewActive) + const openNoteAtOffset = useStore((s) => s.openNoteAtOffset) + const selectNote = useStore((s) => s.selectNote) + const systemFolderLabels = useStore((s) => s.systemFolderLabels) + + const [filter, setFilter] = useState('') + const [cursor, setCursor] = useState(0) + const [bodies, setBodies] = useState>(() => new Map()) + const rootRef = useRef(null) + const filterRef = useRef(null) + const gPending = useRef(0) + const gTimer = useRef>() + + const groups = useMemo( + () => + vaultUnresolvedLinks(notes, { + databases: databaseLinkTargetsFor(folders, vaultSettings), + assets: assetFiles + }), + [notes, folders, vaultSettings, assetFiles] + ) + + // Read the linking notes for their lines. Keyed on each note's mtime so an + // edit elsewhere refreshes its lines, and nothing is read twice. + useEffect(() => { + if (!amActive) return + let cancelled = false + const wanted = new Map() + for (const group of groups) for (const note of group.sources) wanted.set(note.path, note) + const stale = [...wanted.values()].filter( + (note) => bodies.get(note.path)?.updatedAt !== note.updatedAt + ) + if (stale.length === 0) return + void (async () => { + const loaded: Array<[string, LoadedBody]> = [] + for (const note of stale) { + const cached = useStore.getState().noteContents[note.path]?.body + try { + const body = cached ?? (await window.zen.readNote(note.path)).body + loaded.push([note.path, { updatedAt: note.updatedAt, body }]) + } catch { + loaded.push([note.path, { updatedAt: note.updatedAt, body: '' }]) + } + if (cancelled) return + } + setBodies((prev) => { + const next = new Map(prev) + for (const [path, entry] of loaded) next.set(path, entry) + return next + }) + })() + return () => { + cancelled = true + } + // `bodies` is read, not tracked: tracking it would rerun the effect for + // the result it just stored. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [amActive, groups]) + + const rows = useMemo(() => { + const q = filter.trim().toLowerCase() + const out: Row[] = [] + for (const group of groups) { + const matches = + !q || + group.target.toLowerCase().includes(q) || + group.sources.some((note) => note.title.toLowerCase().includes(q)) + if (!matches) continue + out.push({ kind: 'target', id: `t:${group.key}`, group }) + for (const note of group.sources) { + const loaded = bodies.get(note.path) + const hits = loaded ? unresolvedOccurrencesIn(loaded.body, new Set([group.key])) : [] + if (hits.length === 0) { + out.push({ + kind: 'mention', + id: `m:${group.key}:${note.path}`, + group, + note, + occurrence: null, + loaded: !!loaded + }) + continue + } + for (const hit of hits) { + out.push({ + kind: 'mention', + id: `m:${group.key}:${note.path}:${hit.from}`, + group, + note, + occurrence: hit, + loaded: true + }) + } + } + } + return out + }, [groups, bodies, filter]) + + const safeCursor = Math.min(cursor, Math.max(0, rows.length - 1)) + const current = rows[safeCursor] ?? null + + useEffect(() => { + if (safeCursor !== cursor) setCursor(safeCursor) + }, [cursor, safeCursor]) + + useEffect(() => { + if (!current) return + rootRef.current + ?.querySelector(`[data-unresolved-row="${cssEscape(current.id)}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [current]) + + // Take the keyboard when the view comes up with nothing else holding it. + useEffect(() => { + if (!amActive) return + const raf = requestAnimationFrame(() => { + const active = document.activeElement + if (!active || active === document.body) rootRef.current?.focus({ preventScroll: true }) + }) + return () => cancelAnimationFrame(raf) + }, [amActive]) + + const createNote = useCallback((group: UnresolvedLinkGroup) => { + void createNoteFromLinkNow(group.target) + }, []) + + const openMention = useCallback( + async (row: Extract) => { + if (row.occurrence) await openNoteAtOffset(row.note.path, row.occurrence.from) + else await selectNote(row.note.path) + focusEditorNormalMode() + }, + [openNoteAtOffset, selectNote] + ) + + const activate = useCallback( + (row: Row) => { + if (row.kind === 'target') createNote(row.group) + else void openMention(row) + }, + [createNote, openMention] + ) + + /** Write the missing note in the link popover, hanging off its row. */ + const writeInPopover = useCallback((row: Row) => { + const el = rootRef.current?.querySelector( + `[data-unresolved-row="${cssEscape(row.id)}"]` + ) + const rect = el?.getBoundingClientRect() + if (!rect) return + requestLinkPopover({ + target: row.group.target, + sourcePath: row.kind === 'mention' ? row.note.path : row.group.sources[0]?.path ?? null, + anchorRect: { + left: rect.left + 24, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height + }, + mode: 'pinned', + focus: true + }) + }, []) + + useEffect(() => { + if (!amActive) return + const handler = (e: KeyboardEvent): void => { + if (isAppOverlayOpen()) return + const target = e.target instanceof HTMLElement ? e.target : null + if (target?.closest('[data-link-popover]')) return + const focusedPanel = useStore.getState().focusedPanel + if (focusedPanel !== null && focusedPanel !== 'editor') return + if (target) { + const tag = target.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return + } + const overrides = keymapOverrides + const consume = (): void => { + e.preventDefault() + e.stopImmediatePropagation() + } + // The popover key works with Vim on or off; its Vim twin is `K`. + if ( + current && + (matchesShortcut(e, overrides, 'editor.linkPopover') || + (vimMode && matchesSequenceToken(e, overrides, 'vim.linkPopover'))) + ) { + consume() + writeInPopover(current) + return + } + if (e.metaKey || e.ctrlKey || e.altKey) return + // With Vim off, single-key shortcuts stay off: arrows, Enter, Escape. + const seq = (id: Parameters[2]): boolean => + vimMode && matchesSequenceToken(e, overrides, id) + const key = e.key + if (key === 'Escape') { + if (!filter) return + consume() + setFilter('') + return + } + if (seq('nav.filter')) { + consume() + filterRef.current?.focus() + filterRef.current?.select() + return + } + if (seq('nav.moveDown') || key === 'ArrowDown') { + consume() + setCursor((i) => Math.min(rows.length - 1, i + 1)) + return + } + if (seq('nav.moveUp') || key === 'ArrowUp') { + consume() + setCursor((i) => Math.max(0, i - 1)) + return + } + if (seq('nav.jumpBottom')) { + consume() + setCursor(Math.max(0, rows.length - 1)) + return + } + if ( + vimMode && + advanceSequence( + e, + getKeymapBinding(overrides, 'nav.jumpTop'), + gPending, + gTimer, + () => setCursor(0), + consume, + 500 + ) + ) { + return + } + if ((key === 'Enter' || seq('nav.openResult')) && current) { + consume() + activate(current) + } + } + window.addEventListener('keydown', handler, true) + return () => { + if (gTimer.current) clearTimeout(gTimer.current) + window.removeEventListener('keydown', handler, true) + } + }, [activate, amActive, current, filter, keymapOverrides, rows.length, vimMode, writeInPopover]) + + const popoverKey = vimMode + ? getKeymapDisplay(keymapOverrides, 'vim.linkPopover') + : getKeymapDisplay(keymapOverrides, 'editor.linkPopover') + const mentionCount = groups.reduce((sum, group) => sum + group.sources.length, 0) + + return ( +
+
+ +

Unresolved Links

+ + {groups.length} + +
+ + { + setFilter(e.target.value) + setCursor(0) + }} + onKeyDown={(e) => { + if (e.key === 'Escape' || e.key === 'Enter') { + e.preventDefault() + if (e.key === 'Escape') setFilter('') + rootRef.current?.focus({ preventScroll: true }) + } + }} + placeholder="Filter links or notes" + className="w-44 bg-transparent text-xs text-ink-900 outline-none placeholder:text-ink-400" + /> +
+
+

+ {groups.length === 0 + ? 'Links to notes that do not exist yet show up here.' + : `${groups.length} ${groups.length === 1 ? 'note' : 'notes'} linked but not written yet, from ${mentionCount} ${mentionCount === 1 ? 'link' : 'links'}. Enter on a name creates the note; Enter on a line opens it there${popoverKey ? `; ${popoverKey} writes the note in a popover` : ''}.`} +

+
+ {rows.length === 0 ? ( +
+ {groups.length === 0 + ? 'No unresolved links. Every [[wikilink]] in this vault points at a note that exists.' + : 'Nothing matches your filter.'} +
+ ) : ( +
    + {rows.map((row, index) => { + const selected = index === safeCursor + const base = [ + 'group flex cursor-pointer items-center gap-3 px-4 outline-none', + selected ? 'bg-paper-200/80' : 'hover:bg-paper-200/40' + ].join(' ') + if (row.kind === 'target') { + const count = row.group.sources.length + const destination = linkCreateDestination(row.group.target) + return ( +
  • setCursor(index)} + onDoubleClick={() => activate(row)} + > + +
    +
    {row.group.target}
    + {destination && ( +
    + {`New note in ${getSystemFolderLabel(destination.folder, systemFolderLabels)}${destination.subpath ? ` / ${destination.subpath}` : ''}`} +
    + )} +
    + + {count === 1 ? 'linked from 1 note' : `linked from ${count} notes`} + + +
  • + ) + } + const parts = row.occurrence ? excerpt(row.occurrence) : null + return ( +
  • { + setCursor(index) + void openMention(row) + }} + title={row.note.path} + > + + {row.note.title} + + + {row.occurrence ? `line ${row.occurrence.line}` : ''} + + + {parts ? ( + <> + {parts.before} + {parts.link} + {parts.after} + + ) : row.loaded ? ( + '' + ) : ( + 'Reading…' + )} + +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 5ebbe3ae5..20d647952 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -1,6 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { completionStatus } from '@codemirror/autocomplete' -import { isAtlasViewActive, isTagsViewActive, isTasksViewActive, useStore } from '../store' +import { + isAtlasViewActive, + isTagsViewActive, + isTasksViewActive, + isUnresolvedLinksViewActive, + useStore +} from '../store' import { HintOverlay } from './HintOverlay' import { WhichKeyOverlay, type WhichKeyItem } from './WhichKeyOverlay' import { @@ -339,6 +345,11 @@ export function VimNav(): JSX.Element | null { label: 'Note outline', detail: 'Jump to any heading in the active note.' }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderUnresolvedLinks'), + label: 'Unresolved links', + detail: 'Every link to a note that does not exist yet, with the lines that link to it.' + }, ...(canSwitchVaults ? [ { @@ -925,6 +936,17 @@ export function VimNav(): JSX.Element | null { ) { return } + // The Unresolved Links view stands where the editor would, so it holds + // the keyboard whenever the editor panel does, through its own + // capture handler. The leader still reaches the logic below. + if ( + isUnresolvedLinksViewActive(state) && + (state.focusedPanel == null || state.focusedPanel === 'editor') && + !leaderPending.current && + sequenceTokenFromEvent(e) !== leaderToken + ) { + return + } // ------- Global leader handling ----------------------------------- // Runs before per-panel routing so -prefixed shortcuts work @@ -1015,6 +1037,13 @@ export function VimNav(): JSX.Element | null { state.setOutlinePaletteOpen(true) return } + if (matchesSequenceToken(e, overrides, 'vim.leaderUnresolvedLinks')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + void state.openUnresolvedLinksView() + return + } if (canSwitchVaults && matchesSequenceToken(e, overrides, 'vim.leaderSwitchVault')) { e.preventDefault() e.stopImmediatePropagation() @@ -2362,7 +2391,7 @@ export function VimNav(): JSX.Element | null { }) return } - if (type === 'missing' || type === 'attachment') { + if (type === 'missing' || type === 'attachment' || type === 'unresolved-all') { el.click() } } diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index 6489d7892..f1b75fe39 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -24,8 +24,8 @@ import { isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget } fro import { openDatabaseFromWikilink, openWikilinkTarget } from './wikilink-navigation' import { createNoteFromLinkNow, offerCreateNoteFromLink } from './create-note-from-link' import { openWikilinkAttachment } from './open-wikilink-attachment' -import { resolveAssetPathAmong } from './asset-path-resolution' -import { listDatabaseLinkTargets, resolveDatabaseWikilink } from './database-links' +import { listDatabaseLinkTargets } from './database-links' +import { wikilinkReaches } from './unresolved-links' import { setHoveredLink } from './hovered-link' // Same shape as the Preview pipeline (remarkWikilinks). @@ -41,10 +41,11 @@ const bracketMark = Decoration.mark({ class: 'cm-wikilink-bracket' }) const refreshWikilinksEffect = StateEffect.define() /** - * Whether a wikilink target reaches something: a note, a spot in this note, a - * `.base` database, or a file in the vault. Anything else would create a note - * when followed, and is drawn as unresolved (#768). Mirrors the reading view's - * `broken` decision in Preview.tsx. + * Whether a wikilink target reaches something (`wikilinkReaches`): a note, a + * spot in this note, a `.base` database, or a file in the vault. Anything else + * would create a note when followed, and is drawn as unresolved (#768). + * Mirrors the reading view's `broken` decision in Preview.tsx, and is the same + * call the Unresolved Links view makes for every link in the vault. * * Decorations rebuild on every selection change, and note resolution scans * the whole notes list, so answers are memoized until any input changes. @@ -98,12 +99,11 @@ export function wikilinkResolves(target: string): boolean { if (cached != null) return cached let resolves = true try { - resolves = - resolveWikilinkTarget(notes, target) != null || - isSameFileHeadingLink(target) || - isSameFileBlockLink(target) || - resolveDatabaseWikilink(resolverCache.databases, target) != null || - resolveAssetPathAmong(assetFiles, selectedPath ?? '', target) != null + resolves = wikilinkReaches(target, selectedPath ?? '', { + notes, + databases: resolverCache.databases, + assets: assetFiles + }) } catch { resolves = true } @@ -116,7 +116,7 @@ export function wikilinkResolves(target: string): boolean { * literal text, not a link, so it should render as code (matching the Preview * pipeline, whose remark transform never visits code nodes). (#248) */ -function isInsideCode(state: EditorView['state'], pos: number): boolean { +export function isInsideCode(state: EditorView['state'], pos: number): boolean { let node = syntaxTree(state).resolveInner(pos, 1) while (node) { const n = node.name diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index a00ff1619..cac7d7e30 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -7,7 +7,14 @@ * sensitive commands like "Unarchive" only show up when applicable). */ import { requestAgentEditReview } from './agent-review-runtime' -import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' +import { + isTagsViewActive, + isTasksViewActive, + isTrashViewActive, + isUnresolvedLinksViewActive, + useStore +} from '../store' +import { openLinkPopoverAtCursor } from './link-popover-open' import { confirmApp } from './confirm-requests' import { requestSettingsTarget } from './settings-navigation' import { hideWindowToTray, useTrayStore } from './tray-runtime' @@ -1006,6 +1013,25 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma await getState().cancelTaskFromList(task) } }, + { + id: 'editor.link-popover', + title: 'Open Link Popover', + category: 'Editor', + shortcut: getState().vimMode + ? [shortcut('vim.linkPopover'), shortcut('editor.linkPopover')].filter(Boolean).join(' / ') + : shortcut('editor.linkPopover'), + keywords: 'link popover hover preview edit create note wikilink peek floating in place', + when: () => !!getState().editorViewRef && !!getState().activeNote, + run: () => { + const view = getState().editorViewRef + if (!view) return + // The palette took focus; the cursor the popover reads is the + // editor's, which it kept. + if (!openLinkPopoverAtCursor(view)) { + window.alert('Put the cursor on a [[wikilink]] to open its note in the link popover.') + } + } + }, { id: 'task.toggle-checkbox', title: 'Toggle Checkbox', @@ -1307,6 +1333,16 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => getState().atlasEnabled, run: () => getState().openAtlasView() }, + { + id: 'view.unresolved-links', + title: 'Open Unresolved Links', + category: 'View', + shortcut: getState().vimMode ? leaderShortcut('vim.leaderUnresolvedLinks') : ':unresolved', + keywords: + 'unresolved links missing broken dangling wikilinks notes to write later create orphan dead', + when: () => !isUnresolvedLinksViewActive(getState()), + run: () => getState().openUnresolvedLinksView() + }, { id: 'view.tags', title: 'Open Tags', diff --git a/packages/app-core/src/lib/create-note-from-link.test.ts b/packages/app-core/src/lib/create-note-from-link.test.ts index c8c86d92d..b47ddd832 100644 --- a/packages/app-core/src/lib/create-note-from-link.test.ts +++ b/packages/app-core/src/lib/create-note-from-link.test.ts @@ -13,6 +13,14 @@ const state = vi.hoisted(() => ({ openDailyNoteForDate: vi.fn((_date: Date) => Promise.resolve()), openWeeklyNoteForDate: vi.fn((_date: Date) => Promise.resolve()), openMonthlyNoteForDate: vi.fn((_date: Date) => Promise.resolve()), + createNoteInPlace: vi.fn((folder: string, subpath: string, title: string) => + Promise.resolve({ path: `${folder}/${subpath ? `${subpath}/` : ''}${title}.md` } as { + path: string + } | null) + ), + ensureDailyNoteForDate: vi.fn((_date: Date) => + Promise.resolve({ path: 'Daily Notes/2026-11-03.md' } as { path: string } | null) + ), setFocusedPanel: vi.fn(), editorViewRef: null })) @@ -23,7 +31,9 @@ vi.mock('../store', () => ({ useStore: { getState: () => state } })) vi.mock('./prompt-requests', () => ({ promptApp })) vi.mock('./confirm-requests', () => ({ confirmApp })) -const { createNoteFromLinkNow, offerCreateNoteFromLink } = await import('./create-note-from-link') +const { createNoteForLinkInPlace, createNoteFromLinkNow, offerCreateNoteFromLink } = await import( + './create-note-from-link' +) beforeEach(() => { promptApp.mockClear() @@ -34,6 +44,8 @@ beforeEach(() => { state.openDailyNoteForDate.mockClear() state.openWeeklyNoteForDate.mockClear() state.openMonthlyNoteForDate.mockClear() + state.createNoteInPlace.mockClear() + state.ensureDailyNoteForDate.mockClear() }) describe('createNoteFromLinkNow (#768)', () => { @@ -127,3 +139,63 @@ describe('a dead link that names a periodic note', () => { }) }) + +// uNyanda (Discord): hover a link and create or edit its note in a small +// floating window, without leaving the note. The link popover creates the +// note where gD would, but never opens it: the popover edits it in place. +describe('createNoteForLinkInPlace (link popover)', () => { + it('creates the note named after the link without opening or prompting', async () => { + const path = await createNoteForLinkInPlace('Garden Plan') + + expect(path).toBe('inbox/Garden Plan.md') + expect(state.createNoteInPlace).toHaveBeenCalledWith('inbox', '', 'Garden Plan') + expect(state.createAndOpen).not.toHaveBeenCalled() + expect(state.selectNote).not.toHaveBeenCalled() + expect(promptApp).not.toHaveBeenCalled() + }) + + it('drops a heading anchor and an alias-free path keeps its folders', async () => { + await createNoteForLinkInPlace('projects/Plan#Goals') + expect(state.createNoteInPlace).toHaveBeenLastCalledWith('inbox', 'projects', 'Plan') + + await createNoteForLinkInPlace('archive/Old idea') + expect(state.createNoteInPlace).toHaveBeenLastCalledWith('archive', '', 'Old idea') + }) + + it('answers with an existing note at that path instead of creating a second one', async () => { + const path = await createNoteForLinkInPlace('existing') + + expect(path).toBe('inbox/Existing.md') + expect(state.createNoteInPlace).not.toHaveBeenCalled() + expect(state.selectNote).not.toHaveBeenCalled() + }) + + it("makes a daily-note link that day's daily note, still without opening it", async () => { + state.vaultSettings = { + dailyNotes: { enabled: true, directory: 'Daily Notes' }, + weeklyNotes: { enabled: true, directory: 'Weekly Notes' } + } + const path = await createNoteForLinkInPlace('2026-11-03') + + expect(path).toBe('Daily Notes/2026-11-03.md') + expect(state.ensureDailyNoteForDate).toHaveBeenCalledTimes(1) + expect(state.openDailyNoteForDate).not.toHaveBeenCalled() + expect(state.createNoteInPlace).not.toHaveBeenCalled() + }) + + it('leaves a weekly note to the opening path, which runs its template', async () => { + state.vaultSettings = { + dailyNotes: { enabled: true, directory: 'Daily Notes' }, + weeklyNotes: { enabled: true, directory: 'Weekly Notes' } + } + expect(await createNoteForLinkInPlace('2026-W45')).toBeNull() + expect(state.createNoteInPlace).not.toHaveBeenCalled() + expect(state.openWeeklyNoteForDate).not.toHaveBeenCalled() + }) + + it('reports a failed create as null, and a name no file can carry the same way', async () => { + state.createNoteInPlace.mockResolvedValueOnce(null) + expect(await createNoteForLinkInPlace('Will fail')).toBeNull() + expect(await createNoteForLinkInPlace('bad|name?')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/create-note-from-link.ts b/packages/app-core/src/lib/create-note-from-link.ts index 817521268..9e4ec2f88 100644 --- a/packages/app-core/src/lib/create-note-from-link.ts +++ b/packages/app-core/src/lib/create-note-from-link.ts @@ -92,6 +92,40 @@ export async function createNoteFromLinkNow(target: string): Promise { await createNoteAtPath(suggestCreateNotePath(target)) } +/** + * Create the note a dead link names without opening it, so the link popover + * can be the place it is written, over the note that links to it. Same + * destination as `createNoteFromLinkNow`: named after the link, in Inbox + * unless the link names a top folder, and a link named like a daily note + * makes that day's daily note (template included). + * + * Resolves to the note's path: the new note, or one already at that path. + * Null when only opening can make the note (a weekly or monthly note, which + * the calendar creates as it opens it) or when creating failed; the caller + * falls back to `createNoteFromLinkNow`. + */ +export async function createNoteForLinkInPlace(target: string): Promise { + const dateNote = dateNoteForLink(target) + if (dateNote) { + if (dateNote.info.kind !== 'daily') return null + const daily = await useStore.getState().ensureDailyNoteForDate(dateNote.info.date) + return daily?.path ?? null + } + let parsed: ReturnType + try { + parsed = parseCreateNotePath(suggestCreateNotePath(target)) + } catch { + return null + } + const state = useStore.getState() + const existing = state.notes.find( + (note) => note.folder !== 'trash' && note.path.toLowerCase() === parsed.relPath.toLowerCase() + ) + if (existing) return existing.path + const created = await state.createNoteInPlace(parsed.folder, parsed.subpath, parsed.title) + return created?.path ?? null +} + async function createNoteAtPath(value: string): Promise { const focusEditorSoon = (): void => { useStore.getState().setFocusedPanel('editor') diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 1dde8e6cc..f141855d7 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -156,6 +156,16 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Switch the active pane to Split mode when you want markdown on one side and rendered output on the other. Use Preview when you only want the rendered view, or keep Edit when you want the least visual noise while writing.' }, + { + title: 'Find links to notes you have not written yet', + body: + 'Link a note before it exists, like `[[Garden Plan]]`, and the Unresolved Links view keeps it for you: every `[[wikilink]]` in the vault that points at a note that does not exist yet, grouped by the missing note with the most-linked first, and under each one the notes that link to it and the line each link sits on. Open it with `Space u` in Vim mode, `:unresolved`, Open Unresolved Links in the command palette, or the All unresolved links row under a note\'s missing links in the Connections panel. Enter on a name creates the note where `gD` would (in Inbox, named after the link) and opens it, and the name leaves the list; Enter on a line opens the linking note with the cursor on the link; `K` (Shift+Cmd+K, or Shift+Ctrl+K on Windows and Linux) writes the missing note in the link popover without leaving the list. In Vim mode j / k move, gg / G jump to the ends and / filters; with Vim mode off the arrows, Enter and Escape do the work. A link is listed exactly when the editor draws it as unresolved: a heading (`[[Note#Heading]]`), block (`[[Note^id]]`) or display-text (`[[Note|text]]`) link to an existing note resolves, so does a link to a file in the vault or to a `.base` database, and a `[[link]]` inside a code block or inline code is text, not a link.' + }, + { + title: 'Edit or create a linked note without leaving yours', + body: + 'Rest the pointer on a `[[wikilink]]` in the editor and the link popover opens: the note the link names, in a small floating editor over the note you are in. It is the note itself, not a preview: what you type there is saved to it like any other edit, and shows up in any tab that has it open. Move the pointer away and it closes; click or type in it and it stays until you press Escape or click outside it. A link to a note that does not exist yet offers Create note instead: press Enter (or click it) and the note is created where `gD` would put it, then opens in the popover, ready for its first line. From the keyboard, put the cursor on a link and press `K` in Vim mode, or Shift+Cmd+K (Shift+Ctrl+K on Windows and Linux) with Vim mode on or off. The popover takes the keyboard: `K` on a link inside it moves on to that note, Cmd+Enter (Ctrl+Enter) opens the note in a tab, and Escape closes it and puts you back on the link you came from, cursor where it was. In Vim mode the first Escape leaves insert mode as usual and the next one closes the popover. A `[[Note#Heading]]` link opens it on that heading. To keep the popover to the keyboard only, turn off Settings → Writing → Link popover on hover.' + }, { title: 'Search the right thing', body: @@ -422,7 +432,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Reference and connections support research-heavy work', body: - 'Pin a companion note or PDF in the reference pane, then toggle the connections panel to inspect backlinks and unresolved links while you draft. Connections count both `[[wikilinks]]` and standard Markdown links (`[text](Note.md)`), so incoming and outgoing associations show up even if you never use wikilinks. A wikilink at a file in the vault, such as an embedded image or a PDF, is listed as a file and opens in its own tab; the panel only offers to create a note for a wikilink that reaches nothing.' + 'Pin a companion note or PDF in the reference pane, then toggle the connections panel to inspect backlinks and unresolved links while you draft. Connections count both `[[wikilinks]]` and standard Markdown links (`[text](Note.md)`), so incoming and outgoing associations show up even if you never use wikilinks. A wikilink at a file in the vault, such as an embedded image or a PDF, is listed as a file and opens in its own tab; the panel only offers to create a note for a wikilink that reaches nothing, and under those missing links the All unresolved links in the vault row opens the Unresolved Links view, the same list for the whole vault.' }, { title: 'Zen mode removes chrome', @@ -432,7 +442,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Links are actionable', body: - 'Use [[wikilinks]] or markdown links. Following a link (click it, Cmd/Ctrl-click it, or use the follow-link motion `gd` in normal mode) opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link; hold Cmd (macOS) or Ctrl (Windows/Linux) while clicking the link, or use `gD` in normal mode, to create it at once at the suggested path: Inbox, named after the link text. A link named like one of your daily, weekly or monthly notes, such as `[[2026-11-03]]` while daily notes are on, creates that note instead, in its folder and from its template, the way the calendar does, so it shows up on that day; a link that names a folder (`[[journal/2026-11-03]]`) still makes a plain note there. A link at a note that does not exist yet is drawn muted with a dashed underline, in the editor and in the reading view, so you can tell live links from ones that would create a note. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export (recursively, with cycle protection), so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image and `![[drawing.excalidraw]]` embeds an Excalidraw drawing as a PNG preview; both take optional `|width` or `|WxH` size hints (`![[image.png|300]]`, `![[image.png|600x400]]`), and the markdown form carries the same hint after the alt text (`![caption|300](image.png)`). In the editor, drag the handle on a picture\'s right edge to resize it; the width is written back as that hint, so the reading view, exports and Obsidian all show the same size. Resize Image… in the command palette (or `:imgwidth 480` in Vim mode, `:imgw auto` to reset) sets it by number for the image under the cursor. Right-click a web link or an email address, in the editor or the reading view, to open it or copy it (the address itself, without `mailto:`); `gy` in normal mode, or Copy Link Under Cursor in the palette, copies the one under the caret.' + 'Use [[wikilinks]] or markdown links. Following a link (click it, Cmd/Ctrl-click it, or use the follow-link motion `gd` in normal mode) opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link; hold Cmd (macOS) or Ctrl (Windows/Linux) while clicking the link, or use `gD` in normal mode, to create it at once at the suggested path: Inbox, named after the link text. A link named like one of your daily, weekly or monthly notes, such as `[[2026-11-03]]` while daily notes are on, creates that note instead, in its folder and from its template, the way the calendar does, so it shows up on that day; a link that names a folder (`[[journal/2026-11-03]]`) still makes a plain note there. A link at a note that does not exist yet is drawn muted with a dashed underline, in the editor and in the reading view, so you can tell live links from ones that would create a note; the Unresolved Links view (`Space u`) lists all of them in the vault. Rest the pointer on a wikilink, or press `K` on it in Vim mode, to edit or create its note in the link popover without leaving the note you are in. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export (recursively, with cycle protection), so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image and `![[drawing.excalidraw]]` embeds an Excalidraw drawing as a PNG preview; both take optional `|width` or `|WxH` size hints (`![[image.png|300]]`, `![[image.png|600x400]]`), and the markdown form carries the same hint after the alt text (`![caption|300](image.png)`). In the editor, drag the handle on a picture\'s right edge to resize it; the width is written back as that hint, so the reading view, exports and Obsidian all show the same size. Resize Image… in the command palette (or `:imgwidth 480` in Vim mode, `:imgw auto` to reset) sets it by number for the image under the cursor. Right-click a web link or an email address, in the editor or the reading view, to open it or copy it (the address itself, without `mailto:`); `gy` in normal mode, or Copy Link Under Cursor in the palette, copies the one under the caret.' }, { title: 'Open links in other applications', @@ -552,6 +562,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+O', action: 'Open file', detail: 'Desktop only: pick a Markdown file with the native dialog. A file inside a known vault opens against that vault; anything else opens in a standalone external-file window. Links in that window follow from the file\'s own folder: a relative link such as `../README.md` opens the file it names, and a `[[wikilink]]` finds a page of that name in the folder or below it, each in its own window (or in its vault, when the target lives in one).' }, { keys: 'Mod+4 / Mod+5 / Mod+6', action: 'Edit / Split / Preview mode', detail: 'Switch the active note between the raw editor, side-by-side split, and rendered preview. Preview opens at the line you were editing; Edit and Split open on the section you were reading when you scrolled away from the cursor in Preview, and keep the cursor where it was when you only peeked.' }, { keys: 'Mod+L', action: 'Toggle checkbox', detail: 'Turn the current line into a checkbox and toggle it on repeat. See the “Check off a task with ⌘L” card in Core concepts for the full state rules.' }, + { keys: 'Shift+Mod+K', action: 'Open link popover', detail: 'Open the link popover for the [[wikilink]] under the cursor, keyboard inside: edit the note it names in place, or create it when it does not exist yet. Escape closes it and returns to the link; Mod+Enter opens the note in a tab. Works with Vim mode on or off (Vim mode also has `K`). Remappable as editor.linkPopover.' }, { keys: 'Alt+Q (macOS: Ctrl+Q)', action: 'Reflow paragraph', detail: 'Join the hard-wrapped lines of the paragraph under the cursor (or every paragraph in the selection) into one line, so the editor wraps it to the pane. Headings, lists, tables, code, and explicit line breaks are untouched. See the “Reflow a hard-wrapped paragraph” card. Remappable as editor.reflowParagraph.' }, { keys: 'Shift+Mod+E', action: 'Export note as PDF', detail: 'Export the active note as a PDF file.' }, { keys: 'Mod+=', action: 'Zoom in', detail: 'Scale the whole app up, including chrome, editor, and preview.' }, @@ -592,6 +603,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'gq{motion} / gw{motion}', action: 'Reflow paragraph', detail: 'Vim’s format operator, tuned for an editor that wraps to the pane: `gqip` joins the hard-wrapped lines of the paragraph into one line, `gqj` joins two lines, `Vgq` a visual selection. `gq` lands on the first formatted line like Vim; `gw` keeps the cursor where it was. Headings, list markers, tables, code, math, and explicit line breaks are left alone.' }, { keys: 'Ctrl+V', action: 'Visual block', detail: 'Select a rectangle across rows; `I`, `A` and `c` apply the edit to every row of the block, short rows included.' }, { keys: 'Space p', action: 'Note outline', detail: 'Jump to any heading in the active note via a searchable overlay.' }, + { keys: 'Space u', action: 'Unresolved links', detail: 'Open the Unresolved Links view: every link in the vault to a note that does not exist yet, with the notes and lines that link to it. Enter on a name creates the note, Enter on a line opens the note at the link, and K writes the missing note in the link popover. Also `:unresolved`.' }, { keys: 'Space v', action: 'Switch vault', detail: 'Open the command palette directly to the local vault switcher.' }, { keys: 'Space a', action: 'Open workflows', detail: 'Open the Workflows view, where saved pipelines over your notes are built and run. Workflows are off by default; turn them on under Settings → Workflows first.' }, { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. When the open note is one of the waiting files, the queue starts on it. A waiting note also says so across its top, in Edit and Preview alike: sync is paused for it until you choose what to keep, and its Review button opens the queue on it. Choosing the other device’s version puts the file where that device left it; when this device already has a different file there, that file moves beside it under the next free name, such as Untitled 2.md. A notification with Review appears once when a new conflict arrives. When the Cloud vault this vault syncs with is deleted, on this device, another device or the website, the status bar says Cloud vault deleted and the binding opens Settings → Cloud instead, where This vault says what happened until you choose a cloud vault, start a new one, or dismiss the notice. If another device moved it into an end-to-end encrypted vault, the status bar says Cloud vault now encrypted instead, and This vault asks you to unlock this device if needed, then choose the encrypted vault. The binding and the command palette entry appear only while something is waiting, and open the same place as Review in the status bar.' }, @@ -976,6 +988,16 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Create the note for the link under the cursor', detail: 'Like `gd`, but a link at a note that does not exist yet creates it at once at the suggested path (Inbox, named after the link) instead of asking first. A link named like a daily, weekly or monthly note, such as `[[2026-11-03]]`, creates that note where the calendar would. The keyboard twin of Cmd/Ctrl-clicking an unresolved wikilink.' }, + { + command: 'K', + summary: 'Open the link popover for the link under the cursor', + detail: 'Opens the note the `[[wikilink]]` under the cursor names in a floating editor over this note, with the keyboard in it; a link to a note that does not exist yet offers Create note, and the new note opens there ready for typing. `K` on a link inside the popover moves on to that note. Escape in normal mode closes it and puts the cursor back on the link. Vim\'s own `K` looks up the word under the cursor; this one looks up the note. Rebind it as `vim.linkPopover`.' + }, + { + command: ':unresolved', + summary: 'Open the Unresolved Links view', + detail: 'Lists every `[[wikilink]]` in the vault that points at a note that does not exist yet, with the notes and lines that link to it. `:unres` for short; `Space u` does the same.' + }, { command: 'gy', summary: 'Copy the link under the cursor', @@ -1161,6 +1183,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Heading level labels', detail: 'Show H1 through H6 badges before headings. Heading fold arrows stay available whether labels are on or off.' }, { label: 'Tab size', detail: 'Choose how many spaces a tab occupies when rendered and when indenting in every Markdown editor surface. Nested list levels also render this many columns deep, whatever the note’s source spacing, so levels stay tellable apart on any monitor.' }, { label: 'Indent guides', detail: 'Draw a vertical guide line at each nested list level in the editor, at the Tab size columns. On by default; the raw markup is never changed either way.' }, + { label: 'Link popover on hover', detail: 'On by default. Resting the pointer on a `[[wikilink]]` in the editor opens the link popover, where the linked note can be edited, or created when it does not exist yet, without leaving the note you are in. Off, the popover opens only from the keyboard: `K` in Vim mode, or Shift+Cmd+K (Shift+Ctrl+K on Windows and Linux). `link_popover_on_hover` under `[editor]` in config.toml.' }, { label: 'Text replacements', detail: 'Enable or disable typed expansions and manage the trigger-to-text rules under the dedicated Text replacements tab.' }, { label: 'Note tabs and wrap tabs', detail: 'Enable or disable tab-based editing and split-friendly note workflows, and wrap the tab strip onto extra rows when it overflows.' }, { label: 'Word wrap', detail: 'Wrap long lines to the editor width or let them scroll horizontally.' }, diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 5148c2ee7..f8e77892e 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -76,6 +76,7 @@ export type KeymapId = | "vim.leaderWeeklyNote" | "vim.leaderMonthlyNote" | "vim.leaderCalendar" + | "vim.leaderUnresolvedLinks" | "vim.panePrefix" | "vim.paneFocusLeft" | "vim.paneFocusDown" @@ -91,6 +92,7 @@ export type KeymapId = | "vim.tabNext" | "vim.hintMode" | "vim.goToDefinition" + | "vim.linkPopover" | "vim.createNoteFromLink" | "vim.foldCurrent" | "vim.unfoldCurrent" @@ -139,6 +141,7 @@ export type KeymapId = | "editor.unfoldHeading" | "editor.toggleCheckbox" | "editor.reflowParagraph" + | "editor.linkPopover" | "editor.reviewNextChange" | "editor.reviewPreviousChange" | "editor.reviewAcceptChange" @@ -811,6 +814,19 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderUnresolvedLinks", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader: unresolved links", + // `u` for unresolved: every link in the vault that names a note no one wrote yet. + description: + "Open the Unresolved Links view: every [[wikilink]] in the vault that points at a note that does not exist yet.", + defaultBinding: "u", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.panePrefix", kind: "sequence", @@ -989,6 +1005,20 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 2, }, + { + id: "vim.linkPopover", + kind: "sequence", + scope: "vim-editor", + group: "vim", + title: "Link popover for link at cursor", + // Vim's own `K` looks up the word under the cursor; here the word is a + // note, and the lookup shows it. + description: + "Open the link popover for the [[wikilink]] under the cursor: edit the note it names in place, or create it when it does not exist yet. Escape in normal mode closes it and returns here.", + defaultBinding: "K", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.foldCurrent", kind: "sequence", @@ -1490,6 +1520,16 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ defaultBinding: "Alt+Q", defaultBindingMac: "Ctrl+Q", }, + { + id: "editor.linkPopover", + kind: "shortcut", + scope: "vim-editor", + group: "view-actions", + title: "Open link popover", + description: + "Open the link popover for the [[wikilink]] under the cursor and move into it: edit the note it names in place, or create it when it does not exist yet. Escape closes it and returns to the note you were in. Works with Vim mode on or off; Vim mode also has it as `K`.", + defaultBinding: "Shift+Mod+K", + }, { id: "nav.localEx", kind: "sequence", diff --git a/packages/app-core/src/lib/link-popover-open.ts b/packages/app-core/src/lib/link-popover-open.ts new file mode 100644 index 000000000..cb54d71f4 --- /dev/null +++ b/packages/app-core/src/lib/link-popover-open.ts @@ -0,0 +1,198 @@ +import type { EditorView } from '@codemirror/view' +import { useStore, type LinkPopoverState, type PreviewAnchorRect } from '../store' +import { linkRangeAtCursor } from './internal-links' +import { + LINK_POPOVER_HOVER_DELAY_MS, + LINK_POPOVER_LEAVE_GRACE_MS, + linkPopoverTarget +} from './link-popover' +import { databaseLinkTargetsFor } from './unresolved-links' + +/** + * Opening the link popover: from the pointer resting on a `[[wikilink]]` + * (hover intent below), or from a key on the link under the cursor (`K`, the + * editor shortcut). Kept out of the component so the editor's handlers and + * Vim actions reach it without mounting anything. + */ + +/** Screen rect of the document range `from..to`, for placing the popover. */ +export function rangeRect(view: EditorView, from: number, to: number): PreviewAnchorRect | null { + const start = view.coordsAtPos(from, 1) + const end = view.coordsAtPos(to, -1) + if (!start || !end) return null + // A link wrapped onto a second line ends left of where it starts; its + // first line is where the eye is, so hang the popover off that. + const right = end.top > start.top + 2 ? start.left + 1 : Math.max(end.right, start.left + 1) + return { + left: start.left, + top: start.top, + right, + bottom: end.bottom, + width: right - start.left, + height: end.bottom - start.top + } +} + +/** + * Show the popover for `target`, written in the note at `sourcePath`, when + * the popover has something for it (a note, or a note to create). False for + * a link it leaves to other surfaces: a database, a file, an address, or a + * spot in the note being read. + */ +export function requestLinkPopover(args: { + target: string + sourcePath: string | null + anchorRect: PreviewAnchorRect + mode: LinkPopoverState['mode'] + focus: boolean +}): boolean { + const s = useStore.getState() + const shows = linkPopoverTarget(args.target, args.sourcePath ?? '', { + notes: s.notes, + databases: databaseLinkTargetsFor(s.folders, s.vaultSettings), + assets: s.assetFiles + }) + if (!shows) return false + void s.openLinkPopover({ + target: args.target.trim(), + path: shows.kind === 'note' ? shows.path : null, + anchorRect: args.anchorRect, + mode: args.mode, + focus: args.focus, + created: false + }) + return true +} + +/** The note an editor view shows: the popover's own editor shows the + * popover's note, so `K` inside it chains to the next link. */ +function notePathOfView(view: EditorView): string | null { + const s = useStore.getState() + if (view.dom.closest('[data-link-popover]')) return s.linkPopover?.path ?? null + return s.selectedPath ?? null +} + +/** + * `K` (Vim) and the editor shortcut: the popover for the wikilink under the + * cursor, with the keyboard moved into it. False when the cursor is not on a + * wikilink the popover can show, so the key falls through. + */ +export function openLinkPopoverAtCursor(view: EditorView): boolean { + const pos = view.state.selection.main.head + const line = view.state.doc.lineAt(pos) + const link = linkRangeAtCursor(line.text, pos - line.from) + if (!link || link.kind !== 'wikilink') return false + const anchorRect = rangeRect(view, line.from + link.from, line.from + link.to) + if (!anchorRect) return false + return requestLinkPopover({ + target: link.target, + sourcePath: notePathOfView(view), + anchorRect, + mode: 'pinned', + focus: true + }) +} + +// ---- Hover intent --------------------------------------------------------- +// +// The pointer has to rest on a link before its popover opens, and may cross +// the gap between link and popover without closing it. One link is "under +// the pointer" at a time; `shownKey` is the link whose popover the pointer +// opened, so coming back to it cancels a pending close instead of reopening. + +let pointerKey: string | null = null +let shownKey: string | null = null +// Moving from a link onto the popover fires the popover's pointerenter +// before the editor's mouseleave, so a close armed by the second must check +// where the pointer actually is. +let pointerOnPopover = false +let openTimer: ReturnType | null = null +let closeTimer: ReturnType | null = null + +function clearOpenTimer(): void { + if (openTimer) clearTimeout(openTimer) + openTimer = null +} + +function clearCloseTimer(): void { + if (closeTimer) clearTimeout(closeTimer) + closeTimer = null +} + +function scheduleHoverClose(): void { + clearCloseTimer() + closeTimer = setTimeout(() => { + closeTimer = null + const s = useStore.getState() + // A popover the user has worked in waits for Escape or a click outside. + if (s.linkPopover?.mode !== 'hover') return + if (pointerOnPopover) return + if (pointerKey !== null && pointerKey === shownKey) return + shownKey = null + s.closeLinkPopover() + }, LINK_POPOVER_LEAVE_GRACE_MS) +} + +/** + * The pointer is over the wikilink `target` in the note at `sourcePath`. + * Called on every pointer move over a link, so it does nothing new until the + * link changes. `rect` is read when the popover opens, after the rest. + */ +export function pointerOverWikilink( + target: string, + sourcePath: string | null, + rect: () => PreviewAnchorRect | null +): void { + const key = `${sourcePath ?? ''}\n${target.trim().toLowerCase()}` + if (key === pointerKey) return + pointerKey = key + clearOpenTimer() + const s = useStore.getState() + if (key === shownKey && s.linkPopover) { + clearCloseTimer() + return + } + // Moving onto another link starts the old popover's goodbye. + if (s.linkPopover?.mode === 'hover') scheduleHoverClose() + if (!s.linkPopoverOnHover) return + openTimer = setTimeout(() => { + openTimer = null + if (pointerKey !== key) return + const now = useStore.getState() + // Never swap out a popover the user is working in. + if (now.linkPopover?.mode === 'pinned' || !now.linkPopoverOnHover) return + const anchorRect = rect() + if (!anchorRect) return + if (requestLinkPopover({ target, sourcePath, anchorRect, mode: 'hover', focus: false })) { + shownKey = key + clearCloseTimer() + } + }, LINK_POPOVER_HOVER_DELAY_MS) +} + +/** The pointer left whatever link it was on. */ +export function pointerLeftWikilink(): void { + if (pointerKey === null) return + pointerKey = null + clearOpenTimer() + if (useStore.getState().linkPopover?.mode === 'hover') scheduleHoverClose() +} + +export function pointerEnteredLinkPopover(): void { + pointerOnPopover = true + clearCloseTimer() +} + +export function pointerLeftLinkPopover(): void { + pointerOnPopover = false + if (useStore.getState().linkPopover?.mode === 'hover') scheduleHoverClose() +} + +/** Forget any pending hover: the popover was closed some other way. */ +export function resetLinkPopoverHover(): void { + clearOpenTimer() + clearCloseTimer() + pointerKey = null + shownKey = null + pointerOnPopover = false +} diff --git a/packages/app-core/src/lib/link-popover.test.ts b/packages/app-core/src/lib/link-popover.test.ts new file mode 100644 index 000000000..dbd304e48 --- /dev/null +++ b/packages/app-core/src/lib/link-popover.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { linkAnchorOffset, linkCreateDestination, linkPopoverTarget } from './link-popover' +import type { WikilinkReachContext } from './unresolved-links' + +// The link popover edits the note a wikilink opens, or offers to create the +// note it names. Everything else a wikilink can reach keeps its own surface. + +const context: WikilinkReachContext = { + notes: [ + { path: 'inbox/Current.md', title: 'Current', folder: 'inbox' }, + { path: 'inbox/Doc.md', title: 'Doc', folder: 'inbox' }, + { path: 'trash/Gone.md', title: 'Gone', folder: 'trash' } + ], + databases: [{ csvPath: 'Budget.base/data.csv', title: 'Budget' }], + assets: [{ path: 'assets/diagram.png' }] +} + +const at = (target: string) => linkPopoverTarget(target, 'inbox/Current.md', context) + +describe('linkPopoverTarget', () => { + it('shows the note a link opens, whatever the anchor or alias spelling', () => { + expect(at('Doc')).toEqual({ kind: 'note', path: 'inbox/Doc.md' }) + expect(at('doc#Heading')).toEqual({ kind: 'note', path: 'inbox/Doc.md' }) + expect(at('Doc^block')).toEqual({ kind: 'note', path: 'inbox/Doc.md' }) + }) + + it('offers to create a note no one has written yet, a trashed one included', () => { + expect(at('Garden Plan')).toEqual({ kind: 'missing' }) + expect(at('Gone')).toEqual({ kind: 'missing' }) + }) + + it('stays out of links into the note being read', () => { + expect(at('#Heading')).toBeNull() + expect(at('^block')).toBeNull() + expect(at('Current')).toBeNull() + }) + + it('stays out of databases, files and addresses outside the vault', () => { + expect(at('Budget')).toBeNull() + expect(at('assets/diagram.png')).toBeNull() + expect(at('https://example.com')).toBeNull() + expect(at('mailto:me@example.com')).toBeNull() + expect(at(' ')).toBeNull() + }) +}) + +describe('linkAnchorOffset', () => { + const body = '# Plan\n\nIntro.\n\n## Goals\n\nShip it. ^ship\n' + + it('lands a heading link on its heading and a block link on its block', () => { + expect(linkAnchorOffset(body, 'Plan#Goals')).toBe(body.indexOf('## Goals')) + expect(linkAnchorOffset(body, 'Plan#goals')).toBe(body.indexOf('## Goals')) + expect(linkAnchorOffset(body, 'Plan^ship')).toBe(body.indexOf('Ship it.')) + }) + + it('opens at the top for a plain link or an anchor the note lost', () => { + expect(linkAnchorOffset(body, 'Plan')).toBeNull() + expect(linkAnchorOffset(body, 'Plan#Gone')).toBeNull() + }) +}) + +describe('linkCreateDestination', () => { + it('names the folder creating the note would put it in', () => { + expect(linkCreateDestination('Garden Plan')).toEqual({ folder: 'inbox', subpath: '' }) + expect(linkCreateDestination('projects/Plan#Goals')).toEqual({ folder: 'inbox', subpath: 'projects' }) + expect(linkCreateDestination('archive/Old idea')).toEqual({ folder: 'archive', subpath: '' }) + }) + + it('has no destination for a name no file can carry', () => { + expect(linkCreateDestination('what?')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/link-popover.ts b/packages/app-core/src/lib/link-popover.ts new file mode 100644 index 000000000..442811314 --- /dev/null +++ b/packages/app-core/src/lib/link-popover.ts @@ -0,0 +1,81 @@ +import { classifyApplicationLink } from '@shared/application-links' +import { externalUrlForLink } from './internal-links' +import { wikilinkReaches, type WikilinkReachContext } from './unresolved-links' +import { findBlockAnchor } from './block-anchors' +import { findHeadingForAnchor } from './heading-anchor' +import { parseOutline } from './outline' +import type { NoteFolder } from '@shared/ipc' +import { + isSameFileBlockLink, + isSameFileHeadingLink, + parseCreateNotePath, + resolveWikilinkTarget, + suggestCreateNotePath, + wikilinkBlockAnchor, + wikilinkHeadingAnchor +} from './wikilinks' + +/** How long the pointer rests on a link before its popover opens. Long + * enough that a pointer crossing the text on its way somewhere opens + * nothing. */ +export const LINK_POPOVER_HOVER_DELAY_MS = 500 + +/** How long a hover popover survives the pointer leaving the link or the + * popover, so the trip between the two does not close it. */ +export const LINK_POPOVER_LEAVE_GRACE_MS = 280 + +export type LinkPopoverTarget = { kind: 'note'; path: string } | { kind: 'missing' } + +/** + * What the link popover shows for a wikilink written in the note at + * `fromPath`: the note it opens, or an offer to create the note it names. + * Null for a link the popover has nothing to add to: one into the note + * being read (`[[#heading]]`, or the note's own name), a `.base` database, + * a file in the vault, or an address that opens outside the vault. + */ +export function linkPopoverTarget( + target: string, + fromPath: string, + context: WikilinkReachContext +): LinkPopoverTarget | null { + const name = target.trim() + if (!name) return null + if (externalUrlForLink(name, 'wikilink') || classifyApplicationLink(name)) return null + if (isSameFileHeadingLink(name) || isSameFileBlockLink(name)) return null + const note = resolveWikilinkTarget(context.notes, name) + if (note) return note.path === fromPath ? null : { kind: 'note', path: note.path } + // A database or a file: it has its own tab, and nothing to create. + if (wikilinkReaches(name, fromPath, context)) return null + return { kind: 'missing' } +} + +/** + * Where in `body` a link's anchor points: the heading of `[[Doc#Heading]]`, + * the block of `[[Doc^id]]`. Null for a plain link, or an anchor the note no + * longer carries; the popover then opens at the top, as following the link + * would. + */ +export function linkAnchorOffset(body: string, target: string): number | null { + const heading = wikilinkHeadingAnchor(target) + if (heading) return findHeadingForAnchor(parseOutline(body), heading)?.from ?? null + const block = wikilinkBlockAnchor(target) + if (block) return findBlockAnchor(body, block)?.from ?? null + return null +} + +/** + * Where creating the note a dead link names puts it: the top folder and the + * folders under it, as `gD` and the popover's Create would. Null for a link + * no file can be named after. Surfaces show the folder by its label, since a + * system folder can live under another directory name. + */ +export function linkCreateDestination( + target: string +): { folder: NoteFolder; subpath: string } | null { + try { + const parsed = parseCreateNotePath(suggestCreateNotePath(target)) + return { folder: parsed.folder, subpath: parsed.subpath } + } catch { + return null + } +} diff --git a/packages/app-core/src/lib/self-keyed-surfaces.ts b/packages/app-core/src/lib/self-keyed-surfaces.ts index eded9841a..9f9b49535 100644 --- a/packages/app-core/src/lib/self-keyed-surfaces.ts +++ b/packages/app-core/src/lib/self-keyed-surfaces.ts @@ -29,7 +29,10 @@ export const SELF_KEYED_SURFACES = [ '[data-workflow-list-pane]', '[data-workflow-canvas]', // Owns bracket region navigation before global Vim buffer prefixes. - '[data-atlas-view]' + '[data-atlas-view]', + // The link popover: its editor and its Create button own every key, and + // its Escape closes it rather than walking the sidebar. + '[data-link-popover]' ].join(', ') /** diff --git a/packages/app-core/src/lib/unresolved-links.test.ts b/packages/app-core/src/lib/unresolved-links.test.ts new file mode 100644 index 000000000..598282e7b --- /dev/null +++ b/packages/app-core/src/lib/unresolved-links.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import type { NoteFolder } from '@shared/ipc' +import { unresolvedOccurrencesIn } from '@shared/unresolved-links' +import { vaultUnresolvedLinks, wikilinkReaches, type WikilinkReachContext } from './unresolved-links' +import { extractWikilinkTargets } from './wikilinks' + +// uNyanda (Discord): "I sometimes create wiki links to notes I want to write +// later, but I forget to create the actual notes." The Unresolved Links view +// lists every such link, and must agree with the editor about which links are +// broken: same resolver, applied to every note in the vault. + +interface FixtureNote { + path: string + title: string + folder: NoteFolder + wikilinks: string[] + body: string +} + +function note(path: string, body: string, folder: NoteFolder = 'inbox'): FixtureNote { + const title = path.split('/').pop()!.replace(/\.md$/i, '') + // The vault index stores each note's wikilink targets, code masked. The + // renderer's scanner applies the same rules as the main process's. + return { path, title, folder, body, wikilinks: extractWikilinkTargets(body) } +} + +const context: Omit = { + databases: [{ csvPath: 'Budget.base/data.csv', title: 'Budget' }], + assets: [{ path: 'assets/diagram.png' }, { path: 'Papers/spec.pdf' }] +} + +function groupsOf(notes: FixtureNote[]) { + return vaultUnresolvedLinks(notes, context).map((g) => ({ + target: g.target, + sources: g.sources.map((n) => n.path) + })) +} + +describe('vaultUnresolvedLinks', () => { + it('lists a missing note with every note that links to it', () => { + const notes = [ + note('inbox/Ideas.md', 'Write up [[Garden Plan]] and see [[Reading]]'), + note('inbox/Reading.md', 'Back to [[Ideas]]. Also [[Garden Plan#Beds|the beds]].'), + note('inbox/Journal.md', 'Nothing linked here') + ] + expect(groupsOf(notes)).toEqual([ + { target: 'Garden Plan', sources: ['inbox/Ideas.md', 'inbox/Reading.md'] } + ]) + }) + + it('treats headings, blocks and aliases of an existing note as resolved', () => { + const notes = [ + note('inbox/Doc.md', '# Intro\ntext ^b1'), + note('inbox/A.md', '[[Doc#Intro]] [[Doc^b1]] [[Doc#^b1]] [[Doc|alias]] [[doc]] [[Doc.md]]') + ] + expect(groupsOf(notes)).toEqual([]) + }) + + it('treats same-note heading and block links as resolved', () => { + expect(groupsOf([note('inbox/A.md', '# H\n[[#H]] [[#Missing heading]] [[^blk]]')])).toEqual([]) + }) + + it('ignores links that only appear inside code', () => { + const body = [ + '`[[Inline Code]]`', + '```', + '[[Fenced]]', + '```', + '- list', + ' ~~~', + ' [[Nested Fence]]', + ' ~~~', + 'Real [[Later]]' + ].join('\n') + expect(groupsOf([note('inbox/A.md', body)])).toEqual([ + { target: 'Later', sources: ['inbox/A.md'] } + ]) + }) + + it('counts files in the vault and databases as resolved, wherever the note sits', () => { + const notes = [ + note('Projects/Deep/A.md', '[[assets/diagram.png]] [[spec.pdf]] [[Budget]] [[Budget#x]]') + ] + expect(groupsOf(notes)).toEqual([]) + }) + + it('lists a link to a trashed note, and never a trashed note as a source', () => { + const notes = [ + note('trash/Old.md', '[[Ghost]]', 'trash'), + note('inbox/A.md', 'See [[Old]]') + ] + expect(groupsOf(notes)).toEqual([{ target: 'Old', sources: ['inbox/A.md'] }]) + }) + + it('resolves through remapped system folders by title and by path', () => { + // Inbox mapped to `Notes/`, Archive to `Old Stuff/` (vault.json + // systemFolderPaths). Notes keep their logical folder; paths are on-disk. + const notes = [ + note('Notes/Plan.md', 'body'), + note('Old Stuff/Retro.md', 'body', 'archive'), + note('Notes/A.md', '[[Plan]] [[Notes/Plan]] [[Retro]] [[Old Stuff/Retro]] [[Notes/Nope]]') + ] + expect(groupsOf(notes)).toEqual([{ target: 'Notes/Nope', sources: ['Notes/A.md'] }]) + }) + + it('groups spellings of one missing note, case and anchors aside', () => { + const notes = [ + note('inbox/A.md', '[[Someday]] [[someday#When]]'), + note('inbox/B.md', '[[SOMEDAY|later]] [[Someday.md]]') + ] + expect(groupsOf(notes)).toEqual([ + { target: 'Someday', sources: ['inbox/A.md', 'inbox/B.md'] } + ]) + }) + + it('finds the line each source note mentions its missing targets on', () => { + const notes = [ + note('inbox/A.md', 'top\n```\n[[Later]]\n```\nwrite [[Later]] soon\n[[later#part]]') + ] + const [group] = vaultUnresolvedLinks(notes, context) + const hits = unresolvedOccurrencesIn(notes[0]!.body, new Set([group!.key])) + expect(hits.map((h) => [h.line, h.text])).toEqual([ + [5, 'write [[Later]] soon'], + [6, '[[later#part]]'] + ]) + }) +}) + +describe('wikilinkReaches', () => { + const reach: WikilinkReachContext = { + notes: [{ path: 'inbox/Doc.md', title: 'Doc', folder: 'inbox' }], + ...context + } + + it('resolves an embed relative to the note it sits in', () => { + // Two files share the basename, so only the note-relative reading can + // pick one. + const local = { ...reach, assets: [{ path: 'Projects/img/a.png' }, { path: 'Elsewhere/a.png' }] } + expect(wikilinkReaches('img/a.png', 'Projects/Note.md', local)).toBe(true) + expect(wikilinkReaches('img/a.png', 'Other/Note.md', local)).toBe(false) + }) + + it('says no for a name nothing answers', () => { + expect(wikilinkReaches('Nowhere', 'inbox/A.md', reach)).toBe(false) + expect(wikilinkReaches('Doc', 'inbox/A.md', reach)).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/unresolved-links.ts b/packages/app-core/src/lib/unresolved-links.ts new file mode 100644 index 000000000..bef4c971a --- /dev/null +++ b/packages/app-core/src/lib/unresolved-links.ts @@ -0,0 +1,67 @@ +import type { FolderEntry, NoteMeta, VaultSettings } from '@shared/ipc' +import { collectUnresolvedLinks, type UnresolvedLinkGroup } from '@shared/unresolved-links' +import { resolveAssetPathAmong, type AssetPathRef } from './asset-path-resolution' +import { + listDatabaseLinkTargets, + resolveDatabaseWikilink, + type DatabaseLinkTarget +} from './database-links' +import { isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget } from './wikilinks' + +type NoteRef = Pick + +/** The vault lists a wikilink can reach into. */ +export interface WikilinkReachContext { + notes: NoteRef[] + databases: DatabaseLinkTarget[] + assets: ReadonlyArray +} + +/** + * Whether a wikilink target written in the note at `fromPath` reaches + * something: a note, a spot in this note, a `.base` database, or a file in + * the vault. Anything else would create a note when followed. + * + * The one decision behind both the editor's unresolved look (#768) and the + * Unresolved Links view, so a link the editor draws as broken is exactly a + * link the view lists. `fromPath` matters for files only: an embed resolves + * relative to the note it sits in. + */ +export function wikilinkReaches( + target: string, + fromPath: string, + context: WikilinkReachContext +): boolean { + return ( + resolveWikilinkTarget(context.notes, target) != null || + isSameFileHeadingLink(target) || + isSameFileBlockLink(target) || + resolveDatabaseWikilink(context.databases, target) != null || + resolveAssetPathAmong(context.assets, fromPath, target) != null + ) +} + +/** Database link targets for a vault, or none when its folders cannot be read. */ +export function databaseLinkTargetsFor( + folders: FolderEntry[] | null | undefined, + vaultSettings: VaultSettings | null | undefined +): DatabaseLinkTarget[] { + if (!Array.isArray(folders) || !vaultSettings) return [] + try { + return listDatabaseLinkTargets(folders, vaultSettings) + } catch { + return [] + } +} + +/** + * Every missing note the vault links to, grouped by the note each link names, + * from the index's per-note link lists. See `collectUnresolvedLinks`. + */ +export function vaultUnresolvedLinks>( + notes: N[], + context: Omit +): UnresolvedLinkGroup[] { + const reach: WikilinkReachContext = { ...context, notes } + return collectUnresolvedLinks(notes, (target, note) => wikilinkReaches(target, note.path, reach)) +} diff --git a/packages/app-core/src/lib/workspace-tabs.ts b/packages/app-core/src/lib/workspace-tabs.ts index 10d4736eb..ea76b7ffb 100644 --- a/packages/app-core/src/lib/workspace-tabs.ts +++ b/packages/app-core/src/lib/workspace-tabs.ts @@ -8,6 +8,7 @@ import { isWorkflowsTabPath } from '@shared/workflows-view' import { isAtlasTabPath } from '@shared/atlas-view' import { isDatabaseTabPath } from '@shared/databases' import { isAssetsViewTabPath } from '@shared/assets-view' +import { isUnresolvedLinksTabPath } from '@shared/unresolved-links' import { isAssetTabPath } from './asset-tabs' import { isDiagramTabPath } from './diagram-tabs' import { allLeaves, type PaneLayout } from './pane-layout' @@ -23,6 +24,7 @@ export function isWorkspaceVirtualTabPath(path: string): boolean { isArchiveTabPath(path) || isTrashTabPath(path) || isAssetsViewTabPath(path) || + isUnresolvedLinksTabPath(path) || isAssetTabPath(path) || isDiagramTabPath(path) || isDatabaseTabPath(path) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 4e52f2997..be2ce6a77 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -96,6 +96,7 @@ import { HELP_TAB_PATH, isHelpTabPath } from '@shared/help' import { ARCHIVE_TAB_PATH, isArchiveTabPath } from '@shared/archive' import { TRASH_TAB_PATH, isTrashTabPath } from '@shared/trash' import { ASSETS_VIEW_TAB_PATH } from '@shared/assets-view' +import { UNRESOLVED_LINKS_TAB_PATH } from '@shared/unresolved-links' import { QUICK_NOTES_TAB_PATH, isQuickNotesTabPath } from '@shared/quick-notes' import { isAssetTabPath, assetPathFromTab, assetTabPath } from './lib/asset-tabs' import { @@ -576,6 +577,9 @@ interface Prefs { showHeadingLevelLabels: boolean /** Vertical guide lines at each nested-list level in the editor (#491). */ listIndentGuides: boolean + /** Resting the pointer on a `[[wikilink]]` in the editor opens the link + * popover. Off leaves the popover to its keys (`K`, the editor shortcut). */ + linkPopoverOnHover: boolean /** Render Markdown tables as interactive WYSIWYG widgets in live preview. * Off keeps tables as plain editable markdown — full keyboard/Vim editing. */ renderTablesInLivePreview: boolean @@ -1111,6 +1115,7 @@ export const DEFAULT_PREFS: Prefs = { livePreview: true, showHeadingLevelLabels: false, listIndentGuides: true, + linkPopoverOnHover: true, renderTablesInLivePreview: true, completedTaskStyle: 'none', mathRenderer: 'katex', @@ -1287,6 +1292,10 @@ function normalizePrefs(p: Partial): Prefs { typeof p.listIndentGuides === 'boolean' ? p.listIndentGuides : DEFAULT_PREFS.listIndentGuides, + linkPopoverOnHover: + typeof p.linkPopoverOnHover === 'boolean' + ? p.linkPopoverOnHover + : DEFAULT_PREFS.linkPopoverOnHover, renderTablesInLivePreview: typeof p.renderTablesInLivePreview === 'boolean' ? p.renderTablesInLivePreview @@ -1822,6 +1831,29 @@ export interface ConnectionPreviewState { anchorRect: PreviewAnchorRect } +/** + * The link popover: the note a `[[wikilink]]` names, editable in place over + * the note that links to it (Logseq-style), or an offer to create it when no + * note answers the link yet. + */ +export interface LinkPopoverState { + /** The target as written, `Doc#Heading` included. */ + target: string + /** The note the link opens, or null while no note answers it. */ + path: string | null + /** Screen rect of the link the popover hangs off. */ + anchorRect: PreviewAnchorRect + /** 'hover' closes when the pointer leaves; 'pinned' waits for Escape, a + * click outside or the close button. Working in a hover popover pins it. */ + mode: 'hover' | 'pinned' + /** Take keyboard focus as it shows: it was opened from a key. */ + focus: boolean + /** The note was just created from the popover: start at its end, typing. */ + created: boolean + /** Bumped by every open, so reopening one link reruns the popover's setup. */ + serial: number +} + export interface CreateDatabaseOptions { /** Initial columns and rows (a converted Markdown table, #832). */ seed?: DatabaseSeed @@ -2409,6 +2441,7 @@ function collectPrefs(s: { livePreview: boolean showHeadingLevelLabels: boolean listIndentGuides: boolean + linkPopoverOnHover: boolean renderTablesInLivePreview: boolean completedTaskStyle: CompletedTaskStyle mathRenderer: MathRenderer @@ -2525,6 +2558,7 @@ function collectPrefs(s: { livePreview: s.livePreview, showHeadingLevelLabels: s.showHeadingLevelLabels, listIndentGuides: s.listIndentGuides, + linkPopoverOnHover: s.linkPopoverOnHover, renderTablesInLivePreview: s.renderTablesInLivePreview, completedTaskStyle: s.completedTaskStyle, mathRenderer: s.mathRenderer, @@ -2946,6 +2980,15 @@ export function isArchiveViewActive(state: { return leaf?.activeTab === ARCHIVE_TAB_PATH } +/** True when the active pane's active tab is the Unresolved Links view. */ +export function isUnresolvedLinksViewActive(state: { + paneLayout: PaneLayout + activePaneId: string +}): boolean { + const leaf = findLeaf(state.paneLayout, state.activePaneId) + return leaf?.activeTab === UNRESOLVED_LINKS_TAB_PATH +} + /** True when the active pane's active tab is the built-in Assets view. */ export function isAssetsViewActive(state: { paneLayout: PaneLayout @@ -3059,6 +3102,7 @@ interface Store { livePreview: boolean showHeadingLevelLabels: boolean listIndentGuides: boolean + linkPopoverOnHover: boolean renderTablesInLivePreview: boolean completedTaskStyle: CompletedTaskStyle mathRenderer: MathRenderer @@ -3330,6 +3374,7 @@ interface Store { * pane navigation can restore where you were. (#477) */ outlineCursorIndex: number connectionPreview: ConnectionPreviewState | null + linkPopover: LinkPopoverState | null editorViewRef: EditorView | null editorCursorPosition: EditorCursorPosition | null pendingTitleFocusPath: string | null @@ -3427,6 +3472,8 @@ interface Store { /** Open the built-in Archive tab in the active pane. */ openArchiveView: () => Promise openAssetsView: () => Promise + /** Open the built-in Unresolved Links view in the active pane. */ + openUnresolvedLinksView: () => Promise /** Open the built-in Trash tab in the active pane. */ openTrashView: () => Promise /** Read a CSV database (CSV + sidecar) into `databases` if not already loaded. */ @@ -3697,6 +3744,7 @@ interface Store { setLivePreview: (on: boolean) => void setShowHeadingLevelLabels: (on: boolean) => void setListIndentGuides: (on: boolean) => void + setLinkPopoverOnHover: (on: boolean) => void setRenderTablesInLivePreview: (on: boolean) => void setCompletedTaskStyle: (style: CompletedTaskStyle) => void setMathRenderer: (renderer: MathRenderer) => void @@ -3898,6 +3946,18 @@ interface Store { setConnectionsCursorIndex: (idx: number) => void setOutlineCursorIndex: (idx: number) => void setConnectionPreview: (preview: ConnectionPreviewState | null) => void + /** Show the link popover. An existing note is read into the shared content + * cache first, so the popover edits the same buffer as every pane. */ + openLinkPopover: (popover: Omit) => Promise + /** Keep a hover popover open once the user works in it. */ + pinLinkPopover: () => void + /** Point the open popover at a note it just created, and focus it. */ + showNoteInLinkPopover: (path: string) => Promise + /** Close the popover; its note is saved first when it has unsaved edits. */ + closeLinkPopover: () => void + /** Create a note without opening it, for the link popover to edit in place. + * Resolves to the new note, or null when it could not be made. */ + createNoteInPlace: (folder: NoteFolder, subpath: string, title: string) => Promise setEditorViewRef: (view: EditorView | null) => void setEditorCursorPosition: (position: EditorCursorPosition | null) => void @@ -4661,6 +4721,7 @@ function rewriteFolderWorkspace( pendingJumpLocation: pendingPath ? { ...s.pendingJumpLocation!, path: pendingPath } : null, pendingTitleFocusPath: s.pendingTitleFocusPath ? rewrite(s.pendingTitleFocusPath) : null, pinnedRefPath: s.pinnedRefPath ? rewrite(s.pinnedRefPath) : null, + linkPopover: rewriteLinkPopover(s.linkPopover, rewrite), ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } } @@ -4881,6 +4942,69 @@ function rememberPrefetchedPath(path: string): void { prefetchedNotePaths.push(path) } +/** + * Notes whose cached content the link popover still needs. The popover edits + * a note through `noteContents`, like any pane, but no pane may have that + * note open, and every cache prune drops unreferenced paths. A closed + * popover keeps its note held until the last edit is saved: dropping the + * cache first would leave the debounced save nothing to write. + */ +const closingLinkPopoverPaths = new Set() + +function linkPopoverHeldPaths(s: Pick): string[] { + const held = [...closingLinkPopoverPaths] + if (s.linkPopover?.path) held.push(s.linkPopover.path) + return held +} + +function linkPopoverHolds(s: Pick, path: string): boolean { + return s.linkPopover?.path === path || closingLinkPopoverPaths.has(path) +} + +function rewriteLinkPopover( + popover: LinkPopoverState | null, + rewrite: (path: string) => string | null +): LinkPopoverState | null { + if (!popover?.path) return popover + const path = rewrite(popover.path) + if (path === null) return null + return path === popover.path ? popover : { ...popover, path } +} + +/** + * Let go of a note the link popover stopped showing (closed, or replaced by + * another link's note): save its last edit, then drop the cached copy unless + * something else shows it, so a later open reads it fresh from disk, as + * closing a tab does. The caller adds `path` to `closingLinkPopoverPaths` + * before clearing the popover, which keeps the cache alive until the save. + */ +async function releaseLinkPopoverNote(path: string): Promise { + const store = useStore + try { + if (store.getState().noteDirty[path]) await store.getState().persistNote(path) + } finally { + closingLinkPopoverPaths.delete(path) + store.setState((s) => { + const referenced = + s.selectedPath === path || + s.pinnedRefPath === path || + linkPopoverHolds(s, path) || + allLeaves(s.paneLayout).some((leaf) => leaf.tabs.includes(path)) + if (referenced || s.noteDirty[path] || !s.noteContents[path]) return {} + const { [path]: _drop, ...contents } = s.noteContents + const { [path]: _d, ...dirty } = s.noteDirty + void _drop + void _d + return { noteContents: contents, noteDirty: dirty } + }) + } +} + +/** Bumped by every popover open and close, so a slow note read that loses + * the race to a close (the pointer left first) does not reopen it. */ +let linkPopoverRequest = 0 +let linkPopoverSerial = 0 + function prunePrefetchedContents(s: Store): Partial { if (prefetchedNotePaths.length <= MAX_PREFETCHED_NOTE_CONTENTS) return {} @@ -4892,6 +5016,7 @@ function prunePrefetchedContents(s: Store): Partial { const referenced = s.selectedPath === path || s.pinnedRefPath === path || + linkPopoverHolds(s, path) || allLeaves(s.paneLayout).some((leaf) => leaf.tabs.includes(path)) if (referenced || dirty[path]) continue delete contents[path] @@ -4966,6 +5091,7 @@ function withoutNoteInWorkspace(s: Store, path: string): Partial { noteDirty: dirty, pendingJumpLocation: null, pinnedRefPath: s.pinnedRefPath === path ? null : s.pinnedRefPath, + linkPopover: s.linkPopover?.path === path ? null : s.linkPopover, ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } } @@ -5918,6 +6044,7 @@ export const useStore = create((set, get) => { livePreview: loadPrefs().livePreview, showHeadingLevelLabels: loadPrefs().showHeadingLevelLabels, listIndentGuides: loadPrefs().listIndentGuides, + linkPopoverOnHover: loadPrefs().linkPopoverOnHover, renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, completedTaskStyle: loadPrefs().completedTaskStyle, mathRenderer: loadPrefs().mathRenderer, @@ -6041,6 +6168,7 @@ export const useStore = create((set, get) => { connectionsCursorIndex: 0, outlineCursorIndex: 0, connectionPreview: null, + linkPopover: null, editorViewRef: null, editorCursorPosition: null, pendingTitleFocusPath: null, @@ -6322,6 +6450,15 @@ export const useStore = create((set, get) => { set({ focusedPanel: 'editor' }) }, + openUnresolvedLinksView: async () => { + // A link at a file in the vault is not missing, so the scan needs the + // asset list; it is read lazily and may not be there yet. + if (!get().assetFilesListed) await get().refreshAssets() + await get().openNoteInPane(get().activePaneId, UNRESOLVED_LINKS_TAB_PATH) + ;(document.activeElement as HTMLElement | null)?.blur?.() + set({ focusedPanel: 'editor' }) + }, + openAssetsView: async () => { // Refresh both: assets for the list, notes for fresh assetEmbeds (usage). await Promise.all([get().refreshAssets(), get().refreshNotes()]) @@ -7767,6 +7904,8 @@ export const useStore = create((set, get) => { for (const tab of leaf.tabs) referenced.add(tab) } if (pinnedRefPath) referenced.add(pinnedRefPath) + // The link popover edits a cached note no pane may have open. + for (const held of linkPopoverHeldPaths(s)) referenced.add(held) const contents: Record = {} const dirty: Record = {} for (const [path, content] of Object.entries(s.noteContents)) { @@ -7944,6 +8083,7 @@ export const useStore = create((set, get) => { noteContents: contents, noteDirty: dirty, pinnedRefPath: s.pinnedRefPath === notePath ? null : s.pinnedRefPath, + linkPopover: s.linkPopover?.path === notePath ? null : s.linkPopover, ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } }) @@ -9190,6 +9330,10 @@ export const useStore = create((set, get) => { set({ listIndentGuides: on }) savePrefs(collectPrefs(get())) }, + setLinkPopoverOnHover: (on) => { + set({ linkPopoverOnHover: on }) + savePrefs(collectPrefs(get())) + }, setRenderTablesInLivePreview: (on) => { set({ renderTablesInLivePreview: on }) savePrefs(collectPrefs(get())) @@ -9383,6 +9527,11 @@ export const useStore = create((set, get) => { contents[s.pinnedRefPath] = s.noteContents[s.pinnedRefPath] dirty[s.pinnedRefPath] = s.noteDirty[s.pinnedRefPath] ?? false } + for (const held of linkPopoverHeldPaths(s)) { + if (!s.noteContents[held]) continue + contents[held] = s.noteContents[held] + dirty[held] = s.noteDirty[held] ?? false + } return { tabsEnabled: false, paneLayout: onlyLeaf, @@ -10323,6 +10472,67 @@ export const useStore = create((set, get) => { setConnectionsCursorIndex: (idx) => set({ connectionsCursorIndex: idx }), setOutlineCursorIndex: (idx) => set({ outlineCursorIndex: idx }), setConnectionPreview: (preview) => set({ connectionPreview: preview }), + openLinkPopover: async (popover) => { + const request = ++linkPopoverRequest + const path = popover.path + if (path && !get().noteContents[path]) { + let content: NoteContent + try { + content = await readNoteContent(path, get()) + } catch (err) { + console.error('openLinkPopover readNote failed', err) + return + } + if (request !== linkPopoverRequest) return + set((s) => + s.noteContents[path] + ? {} + : { + noteContents: { ...s.noteContents, [path]: content }, + noteDirty: { ...s.noteDirty, [path]: false } + } + ) + } + if (request !== linkPopoverRequest) return + const replaced = get().linkPopover?.path + if (replaced && replaced !== path) closingLinkPopoverPaths.add(replaced) + set({ linkPopover: { ...popover, serial: ++linkPopoverSerial } }) + if (replaced && replaced !== path) void releaseLinkPopoverNote(replaced) + }, + pinLinkPopover: () => { + const popover = get().linkPopover + if (!popover || popover.mode === 'pinned') return + set({ linkPopover: { ...popover, mode: 'pinned' } }) + }, + showNoteInLinkPopover: async (path) => { + const popover = get().linkPopover + if (!popover) return + await get().openLinkPopover({ ...popover, path, mode: 'pinned', focus: true, created: true }) + }, + closeLinkPopover: () => { + linkPopoverRequest++ + const popover = get().linkPopover + if (!popover) return + const path = popover.path + if (path) closingLinkPopoverPaths.add(path) + set({ linkPopover: null }) + if (path) void releaseLinkPopoverNote(path) + }, + createNoteInPlace: trackNoteWrite(null, async (folder, subpath, title) => { + try { + const meta = await window.zen.createNote(folder, title, subpath) + rememberEditModeForCreatedNote(meta.path) + emitNoteEvent('note-created', meta.path) + set((s) => ({ + notes: s.notes.some((n) => n.path === meta.path) ? s.notes : [...s.notes, meta] + })) + await get().refreshNotes() + return get().notes.find((n) => n.path === meta.path) ?? meta + } catch (err) { + console.error('createNoteInPlace failed', err) + return null + } + }), setEditorViewRef: (view) => set({ editorViewRef: view, @@ -10618,6 +10828,7 @@ export const useStore = create((set, get) => { const ensured = ensureActivePane(nextLayout, s.activePaneId) const stillOpen = s.pinnedRefPath === path || + linkPopoverHolds(s, path) || allLeaves(nextLayout).some((l) => l.tabs.includes(path)) const contents = { ...s.noteContents } const dirty = { ...s.noteDirty } @@ -10690,6 +10901,7 @@ export const useStore = create((set, get) => { // including the pinned-reference pane. const stillOpen = cur.pinnedRefPath === path || + linkPopoverHolds(cur, path) || allLeaves(layout).some((l) => l.tabs.includes(path)) const nextContents = { ...contents } const nextDirty = { ...dirty } @@ -10747,6 +10959,7 @@ export const useStore = create((set, get) => { layout = splitLeaf(layout, targetPaneId, edge, newLeaf) const stillOpen = cur.pinnedRefPath === path || + linkPopoverHolds(cur, path) || allLeaves(layout).some((l) => l.tabs.includes(path)) const nextContents = { ...contents } const nextDirty = { ...dirty } diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 8ea505304..f876a3049 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -3837,6 +3837,37 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite text-decoration-color: rgb(var(--z-accent)); } +/* The link popover's editor. The main editor's 20vh bottom runway, centered + measure and 32px gutter are sized for a whole pane; in a floating card they + only push the note out of sight. */ +.zn-link-popover .cm-editor { + height: auto; + max-height: 100%; + font-size: calc(var(--z-editor-font-size, 16px) * 0.875); +} +/* A card a few lines tall cannot spend most of them on a 2em title. */ +.zn-link-popover .cm-editor .tok-heading1 { + font-size: 1.45em; +} +.zn-link-popover .cm-editor .tok-heading2 { + font-size: 1.3em; +} +.zn-link-popover .cm-editor .tok-heading3 { + font-size: 1.15em; +} +.zn-link-popover .cm-editor .cm-scroller { + padding: 8px 0 12px 0; +} +.zn-link-popover .cm-editor .cm-content { + max-width: none; + margin: 0; + padding: 0 14px; +} +.zn-link-popover [data-link-popover-editor] { + display: flex; + flex-direction: column; +} + .prose-hover-preview { font-size: 13px; line-height: var(--z-editor-line-height, 1.7); diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 3442a068d..e936c9d53 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -27,6 +27,7 @@ export const PORTABLE_PREF_KEYS = [ 'livePreview', 'showHeadingLevelLabels', 'listIndentGuides', + 'linkPopoverOnHover', 'renderTablesInLivePreview', 'completedTaskStyle', 'mathRenderer', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 5be936ded..8dfbde57c 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -91,6 +91,7 @@ export const PORTABLE_DEFAULTS: Record = { livePreview: true, showHeadingLevelLabels: false, listIndentGuides: true, + linkPopoverOnHover: true, renderTablesInLivePreview: true, completedTaskStyle: 'none', mathRenderer: 'katex', diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 2d81fd58c..cd067205e 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -108,6 +108,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderWeeklyNote", group: "vim", defaultBinding: "w", title: "Leader: this week's note" }, { id: "vim.leaderMonthlyNote", group: "vim", defaultBinding: "m", title: "Leader: this month's note" }, { id: "vim.leaderCalendar", group: "vim", defaultBinding: "c", title: "Leader: toggle calendar" }, + { id: "vim.leaderUnresolvedLinks", group: "vim", defaultBinding: "u", title: "Leader: unresolved links" }, { id: "vim.panePrefix", group: "vim", defaultBinding: "Ctrl+W", title: "Pane command prefix" }, { id: "vim.paneFocusLeft", group: "vim", defaultBinding: "h", title: "Pane: focus left" }, { id: "vim.paneFocusDown", group: "vim", defaultBinding: "j", title: "Pane: focus down" }, @@ -124,6 +125,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.hintMode", group: "vim", defaultBinding: "h", title: "Leader: hint mode" }, { id: "vim.goToDefinition", group: "vim", defaultBinding: "g d", title: "Follow link at cursor" }, { id: "vim.createNoteFromLink", group: "vim", defaultBinding: "g D", title: "Create note for link at cursor" }, + { id: "vim.linkPopover", group: "vim", defaultBinding: "K", title: "Link popover for link at cursor" }, { id: "vim.foldCurrent", group: "vim", defaultBinding: "z c", title: "Fold heading or list item at cursor" }, { id: "vim.unfoldCurrent", group: "vim", defaultBinding: "z o", title: "Unfold heading or list item at cursor" }, { id: "vim.foldAll", group: "vim", defaultBinding: "z M", title: "Fold all headings and lists" }, @@ -168,6 +170,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "editor.moveLineDown", group: "view-actions", defaultBinding: "Alt+ArrowDown", title: "Move line down" }, { id: "editor.toggleCheckbox", group: "view-actions", defaultBinding: "Mod+L", title: "Toggle checkbox" }, { id: "editor.reflowParagraph", group: "view-actions", defaultBinding: "Alt+Q", defaultBindingMac: "Ctrl+Q", title: "Reflow paragraph" }, + { id: "editor.linkPopover", group: "view-actions", defaultBinding: "Shift+Mod+K", title: "Open link popover" }, { id: "editor.reviewNextChange", group: "view-actions", defaultBinding: "Alt+Mod+ArrowDown", title: "Next change to review" }, { id: "editor.reviewPreviousChange", group: "view-actions", defaultBinding: "Alt+Mod+ArrowUp", title: "Previous change to review" }, { id: "editor.reviewAcceptChange", group: "view-actions", defaultBinding: "Shift+Mod+Enter", title: "Accept change" }, diff --git a/packages/shared-domain/src/markdown-lines.ts b/packages/shared-domain/src/markdown-lines.ts index 559fddb9c..bf9e0fb35 100644 --- a/packages/shared-domain/src/markdown-lines.ts +++ b/packages/shared-domain/src/markdown-lines.ts @@ -54,11 +54,23 @@ export interface MarkdownLine { from: number } +export interface ScanMarkdownLinesOptions { + /** + * Also yield the lines of a leading YAML frontmatter block (its fences + * included). Outline and anchor grammars never want them; a link scanner + * does, because `related: "[[Doc]]"` is a link the vault index counts. + */ + includeFrontmatter?: boolean +} + /** * Walk the lines of a note body that markdown owns, skipping leading YAML * frontmatter and the inside of fenced code blocks. */ -export function scanMarkdownLines(body: string): MarkdownLine[] { +export function scanMarkdownLines( + body: string, + options: ScanMarkdownLinesOptions = {} +): MarkdownLine[] { const scanned: MarkdownLine[] = [] if (!body) return scanned @@ -80,7 +92,12 @@ export function scanMarkdownLines(body: string): MarkdownLine[] { // Skip the leading frontmatter block wholesale: its fences and `key: value` // lines are YAML, not markdown, and must never surface as outline entries. - if (i <= frontmatterEnd) continue + if (i <= frontmatterEnd) { + if (options.includeFrontmatter) { + scanned.push({ text: raw, next: lines[i + 1], line: i + 1, from: lineStart }) + } + continue + } if (fence) { const close = raw.match(FENCE_CLOSE_RE) diff --git a/packages/shared-domain/src/unresolved-links.test.ts b/packages/shared-domain/src/unresolved-links.test.ts new file mode 100644 index 000000000..1b2a44521 --- /dev/null +++ b/packages/shared-domain/src/unresolved-links.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { + UNRESOLVED_LINKS_TAB_PATH, + collectUnresolvedLinks, + findWikilinkOccurrences, + isUnresolvedLinksTabPath, + unresolvedLinkKey, + unresolvedOccurrencesIn, + wikilinkNoteName, + type UnresolvedLinkSource +} from './unresolved-links' +import { scanMarkdownLines } from './markdown-lines' + +describe('findWikilinkOccurrences', () => { + it('reports each link with its 1-based line and body offsets', () => { + const body = 'intro\nsee [[Alpha]] and [[Beta|the beta]]\n![[Gamma]]' + const found = findWikilinkOccurrences(body) + expect(found.map((o) => [o.target, o.line])).toEqual([ + ['Alpha', 2], + ['Beta', 2], + ['Gamma', 3] + ]) + expect(body.slice(found[0]!.from, found[0]!.to)).toBe('[[Alpha]]') + expect(body.slice(found[1]!.from, found[1]!.to)).toBe('[[Beta|the beta]]') + expect(body.slice(found[2]!.from, found[2]!.to)).toBe('![[Gamma]]') + expect(found[1]!.text).toBe('see [[Alpha]] and [[Beta|the beta]]') + expect(found[1]!.column).toBe('see [[Alpha]] and '.length) + }) + + it('ignores links inside fenced code blocks, tilde fences and nested list fences', () => { + const body = [ + '[[Real]]', + '```md', + '[[In Fence]]', + '```', + '~~~', + '[[In Tilde]]', + '~~~', + '- item', + ' ```', + ' [[In List Fence]]', + ' ```', + 'after [[After]]' + ].join('\n') + expect(findWikilinkOccurrences(body).map((o) => o.target)).toEqual(['Real', 'After']) + }) + + it('ignores links inside inline code spans and keeps later columns exact', () => { + const body = 'a `[[Code]]` b [[Prose]]' + const found = findWikilinkOccurrences(body) + expect(found.map((o) => o.target)).toEqual(['Prose']) + expect(body.slice(found[0]!.from, found[0]!.to)).toBe('[[Prose]]') + }) + + it('treats a backtick run with a backtick in its info string as inline code, not a fence', () => { + const body = '```[[Inline]]```\n[[Next]]' + expect(findWikilinkOccurrences(body).map((o) => o.target)).toEqual(['Next']) + }) + + it('does not let a longer closer requirement leak a fence open', () => { + const body = '````\n```\n[[Still Code]]\n````\n[[Out]]' + expect(findWikilinkOccurrences(body).map((o) => o.target)).toEqual(['Out']) + }) + + it('scans frontmatter lines, as the vault index does', () => { + const body = '---\nrelated: "[[Front]]"\n---\nbody [[Body]]' + expect(findWikilinkOccurrences(body).map((o) => [o.target, o.line])).toEqual([ + ['Front', 2], + ['Body', 4] + ]) + }) + + it('counts a CRLF break as one character, like the editor', () => { + const body = 'one\r\ntwo [[Link]]' + const [hit] = findWikilinkOccurrences(body) + expect(hit!.line).toBe(2) + expect(hit!.from).toBe('one\ntwo '.length) + }) + + it('skips empty targets', () => { + expect(findWikilinkOccurrences('[[ ]] [[|alias]]')).toEqual([]) + }) +}) + +describe('scanMarkdownLines includeFrontmatter', () => { + it('still skips frontmatter by default', () => { + const body = '---\na: b\n---\ntext' + expect(scanMarkdownLines(body).map((l) => l.text)).toEqual(['text']) + expect(scanMarkdownLines(body, { includeFrontmatter: true }).map((l) => l.text)).toEqual([ + '---', + 'a: b', + '---', + 'text' + ]) + }) +}) + +describe('unresolvedLinkKey', () => { + it('folds case, anchors, the .md extension and a leading slash into one note', () => { + for (const spelling of ['Doc', 'doc', 'Doc#Intro', 'Doc^block', 'Doc.md', '/Doc', ' Doc ']) { + expect(unresolvedLinkKey(spelling)).toBe('doc') + } + }) + + it('keeps folders: a path spelling names a different file', () => { + expect(unresolvedLinkKey('ideas/Doc')).toBe('ideas/doc') + expect(unresolvedLinkKey('ideas\\Doc#h')).toBe('ideas/doc') + }) + + it('names the note part of an anchored target', () => { + expect(wikilinkNoteName('Doc#Heading')).toBe('Doc') + expect(wikilinkNoteName('#Heading')).toBe('') + }) +}) + +describe('collectUnresolvedLinks', () => { + const note = (path: string, wikilinks: string[], folder = 'inbox'): UnresolvedLinkSource => ({ + path, + folder, + wikilinks + }) + const existing = new Set(['alpha']) + const resolves = (target: string): boolean => existing.has(unresolvedLinkKey(target)) + + it('groups spellings of one missing note and lists each source note once', () => { + const a = note('inbox/a.md', ['Missing', 'missing#Part', 'Alpha']) + const b = note('inbox/b.md', ['Missing|alias'.split('|')[0]!]) + const groups = collectUnresolvedLinks([a, b], resolves) + expect(groups).toHaveLength(1) + expect(groups[0]!.target).toBe('Missing') + expect(groups[0]!.sources.map((n) => n.path)).toEqual(['inbox/a.md', 'inbox/b.md']) + }) + + it('drops the anchor from the name it would create', () => { + const groups = collectUnresolvedLinks([note('inbox/a.md', ['Later#Plan'])], resolves) + expect(groups[0]!.target).toBe('Later') + }) + + it('ignores trashed source notes and targets the resolver accepts', () => { + const groups = collectUnresolvedLinks( + [note('trash/t.md', ['Gone'], 'trash'), note('inbox/a.md', ['Alpha'])], + resolves + ) + expect(groups).toEqual([]) + }) + + it('asks the resolver with the note the link was written in', () => { + const seen: Array<[string, string]> = [] + collectUnresolvedLinks([note('inbox/a.md', ['Sibling#Local'])], (target, from) => { + seen.push([target, from.path]) + return true + }) + expect(seen).toEqual([['Sibling#Local', 'inbox/a.md']]) + }) + + it('skips a target that names no note at all', () => { + let asked = 0 + const groups = collectUnresolvedLinks([note('inbox/a.md', ['#heading', '^block'])], () => { + asked++ + return false + }) + expect(groups).toEqual([]) + expect(asked).toBe(0) + }) + + it('puts the most-linked missing note first, then sorts by name', () => { + const groups = collectUnresolvedLinks( + [ + note('inbox/a.md', ['beta', 'Zeta']), + note('inbox/b.md', ['Zeta', 'alpha 2']), + note('inbox/c.md', ['alpha 10']) + ], + () => false + ) + expect(groups.map((g) => g.target)).toEqual(['Zeta', 'alpha 2', 'alpha 10', 'beta']) + }) +}) + +describe('unresolvedOccurrencesIn', () => { + it('returns only the lines that link to the requested missing notes', () => { + const body = '[[Alpha]]\n[[Later#Plan]] and `[[Later]]`\n```\n[[Later]]\n```\n[[later]]' + const hits = unresolvedOccurrencesIn(body, new Set(['later'])) + expect(hits.map((h) => [h.target, h.line, h.key])).toEqual([ + ['Later#Plan', 2, 'later'], + ['later', 6, 'later'] + ]) + }) +}) + +describe('Unresolved Links tab path', () => { + it('is a zen:// virtual path', () => { + expect(UNRESOLVED_LINKS_TAB_PATH.startsWith('zen://')).toBe(true) + expect(isUnresolvedLinksTabPath(UNRESOLVED_LINKS_TAB_PATH)).toBe(true) + expect(isUnresolvedLinksTabPath('inbox/unresolved-links.md')).toBe(false) + expect(isUnresolvedLinksTabPath(null)).toBe(false) + }) +}) diff --git a/packages/shared-domain/src/unresolved-links.ts b/packages/shared-domain/src/unresolved-links.ts new file mode 100644 index 000000000..108a26111 --- /dev/null +++ b/packages/shared-domain/src/unresolved-links.ts @@ -0,0 +1,181 @@ +/** + * Unresolved wikilinks: `[[targets]]` no note in the vault answers, the notes + * someone meant to write later. The Unresolved Links view lists them + * vault-wide with the lines that mention them. + * + * What counts as "resolved" is the editor's decision, not this module's: the + * renderer resolves titles, paths, headings, assets and databases with rules + * that live in app-core, so `collectUnresolvedLinks` takes that answer as a + * callback and owns only the grouping. The line scan masks code the way the + * vault index does (a `[[link]]` inside a fence or an inline code span is + * text, not a link) through `scanMarkdownLines`, the shared fence walker, + * instead of another copy of `stripCodeContent`. + */ +import { scanMarkdownLines } from './markdown-lines' + +/** Virtual tab path of the Unresolved Links view. */ +export const UNRESOLVED_LINKS_TAB_PATH = 'zen://unresolved-links' + +/** True when `path` is the Unresolved Links view tab. */ +export function isUnresolvedLinksTabPath(path: string | null | undefined): boolean { + return path === UNRESOLVED_LINKS_TAB_PATH +} + +/** One `[[wikilink]]` as it sits in a note body. */ +export interface WikilinkOccurrence { + /** The target as written, before any `|alias`, trimmed. */ + target: string + /** 1-based line number. */ + line: number + /** Offset of the link within its line. */ + column: number + /** Body offset of the opening `[[` (or the `!` of an embed). */ + from: number + /** Body offset just past the closing `]]`. */ + to: number + /** The line the link sits on, as written. */ + text: string +} + +// Same shape as the vault index's scanner (`extractWikilinks` in the desktop +// main process): an optional embed bang, the target up to `|` or `]`, and an +// optional alias. +const WIKILINK_RE = /(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g + +// Inline code spans as the vault index masks them (`[^`\n]*` between single +// backticks), blanked to spaces of the same length so the columns of every +// link after the span stay where the editor has them. +const INLINE_CODE_RE = /`[^`\n]*`/g + +function maskInlineCode(line: string): string { + if (!line.includes('`')) return line + return line.replace(INLINE_CODE_RE, (span) => ' '.repeat(span.length)) +} + +/** + * Every wikilink in a note body, in reading order, with its line and offsets. + * Links inside fenced code blocks and inline code spans are skipped. Lines of + * a leading frontmatter block are scanned, as the vault index scans them. + * + * Offsets count a CRLF line break as one character, the way CodeMirror counts + * them, so `from` places the editor cursor on the link in either encoding. + */ +export function findWikilinkOccurrences(body: string): WikilinkOccurrence[] { + if (!body.includes('[[')) return [] + const normalized = body.includes('\r') ? body.replace(/\r\n?/g, '\n') : body + const found: WikilinkOccurrence[] = [] + for (const line of scanMarkdownLines(normalized, { includeFrontmatter: true })) { + if (!line.text.includes('[[')) continue + const masked = maskInlineCode(line.text) + for (const m of masked.matchAll(WIKILINK_RE)) { + const target = (m[2] ?? '').trim() + if (!target) continue + const start = m.index ?? 0 + found.push({ + target, + line: line.line, + column: start, + from: line.from + start, + to: line.from + start + m[0].length, + text: line.text + }) + } + } + return found +} + +/** + * The part of a wikilink target that names a note: `Doc#Heading` and + * `Doc^block` both point into `Doc`. Note names cannot contain `#` or `^`, + * so the first one always starts the anchor. + */ +export function wikilinkNoteName(target: string): string { + const anchor = target.search(/[#^]/) + return (anchor === -1 ? target : target.slice(0, anchor)).trim() +} + +/** + * Identity of a missing note across its spellings. `[[Doc]]`, `[[doc]]`, + * `[[Doc#Intro]]` and `[[Doc.md]]` all name one note that does not exist, so + * the view lists it once. A path spelling keeps its folders: `[[ideas/Doc]]` + * names a different file than `[[Doc]]`. + */ +export function unresolvedLinkKey(target: string): string { + return wikilinkNoteName(target) + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .replace(/\.md$/i, '') + .trim() + .toLowerCase() +} + +/** The note fields the scan reads: the vault index's per-note link list. */ +export interface UnresolvedLinkSource { + path: string + folder: string + wikilinks?: readonly string[] +} + +export interface UnresolvedLinkGroup { + /** See {@link unresolvedLinkKey}. */ + key: string + /** The note name as first written, anchor dropped: what creating it names the file. */ + target: string + /** The notes that link to it, in vault order, each once. */ + sources: N[] +} + +/** + * Group every unresolved wikilink in the vault by the note it names, from the + * index's per-note link lists (no file reads). `isResolved(target, note)` + * answers for one target as written in `note`, so relative paths and + * same-note `[[#heading]]` links resolve against the right note. + * + * Trashed notes are not sources: their links are not part of the vault. Most + * linked first, then by name, so the note the vault asks for most is on top. + */ +export function collectUnresolvedLinks( + notes: readonly N[], + isResolved: (target: string, note: N) => boolean +): UnresolvedLinkGroup[] { + const groups = new Map>() + for (const note of notes) { + if (note.folder === 'trash' || !note.wikilinks?.length) continue + for (const raw of note.wikilinks) { + const target = raw.trim() + if (!target) continue + const key = unresolvedLinkKey(target) + if (!key) continue + if (isResolved(target, note)) continue + const group = groups.get(key) + if (!group) { + groups.set(key, { key, target: wikilinkNoteName(target), sources: [note] }) + } else if (group.sources[group.sources.length - 1] !== note) { + group.sources.push(note) + } + } + } + return [...groups.values()].sort( + (a, b) => + b.sources.length - a.sources.length || + a.target.localeCompare(b.target, undefined, { sensitivity: 'base', numeric: true }) + ) +} + +/** + * The lines of `body` that link to any of the missing notes in `keys`, in + * reading order. Used to show where each source note mentions a missing + * target once its body has been read. + */ +export function unresolvedOccurrencesIn( + body: string, + keys: ReadonlySet +): Array { + const out: Array = [] + for (const occurrence of findWikilinkOccurrences(body)) { + const key = unresolvedLinkKey(occurrence.target) + if (keys.has(key)) out.push({ ...occurrence, key }) + } + return out +} From 9ab0f1c7bcc992fa674e36917fd9417d44457587 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 15:26:55 -0500 Subject: [PATCH 14/16] Docs(whats-new): the 2.77.0 highlights What's New opens on the first launch after the update with four highlights. First, the Unresolved Links view lists every [[link]] to a note that does not exist yet, opened from the palette or Space u. Second, the link popover edits a linked note in place and creates a missing one, on hover, K or Shift+Cmd+K. Third, tags find the idea: the Tags view and Search notes list the tagged blocks inside each note (#837). Fourth, ZenNotes Cloud retries a stalled request after 30 seconds and opens a conflict review at once, even mid-sync. --- packages/app-core/src/lib/whats-new.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 7ca057ac7..40ff4c267 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,27 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.77.0', + highlights: [ + { + title: 'Links to notes you haven’t written yet', + body: 'Unresolved Links lists every [[link]] in your vault that points at no note, most linked first, with the lines that link to it. Open it from the command palette, or with Space u in Vim mode.' + }, + { + title: 'Edit a linked note in place', + body: 'Rest the pointer on a link and its note opens in a popover over yours: type, then Escape. A link to a missing note offers to create it right there. K in Vim mode, Shift+Cmd+K without.' + }, + { + title: 'Tags find the idea, not just the note', + body: 'The Tags view lists the tagged blocks inside each note: a list item, a paragraph, or a whole section under a tagged heading. In Search notes, #idea does the same, and Enter jumps to the line.' + }, + { + title: 'Steadier on slow networks', + body: 'ZenNotes Cloud retries a stalled request after 30 seconds instead of waiting five minutes, and a conflict review opens at once, even while a sync is running.' + } + ] + }, { version: '2.76.0', highlights: [ From 69b6b5ff7ed876b07c6cc86c9506d6f066f0ff3f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 15:27:09 -0500 Subject: [PATCH 15/16] Release: align desktop and shared packages at 2.77.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 560dc0ff4..120038a1f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.76.0", + "version": "2.77.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 24ae2d8ec..182f2dd7a 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.76.0", + "version": "2.77.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 942973056..ab493ac0c 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.76.0", + "version": "2.77.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 9ee0f6b87..c07894892 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.76.0", + "version": "2.77.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 84d3910f6..1dc724e53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.76.0", + "version": "2.77.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.76.0", + "version": "2.77.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.76.0", + "version": "2.77.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.76.0", + "version": "2.77.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.76.0", + "version": "2.77.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.76.0", + "version": "2.77.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.76.0", + "version": "2.77.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.76.0", + "version": "2.77.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.76.0", + "version": "2.77.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.76.0" + "version": "2.77.0" } } } diff --git a/package.json b/package.json index 2fd6ffa75..532043046 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.76.0", + "version": "2.77.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 71270c269..c55eb663e 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.76.0", + "version": "2.77.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index a08bf33cb..7b4b07581 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.76.0", + "version": "2.77.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index e523638a3..0dffe2c1b 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.76.0", + "version": "2.77.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index e907f5294..604c2d259 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.76.0", + "version": "2.77.0", "type": "module", "exports": { ".": "./src/index.ts" From 3f0356de0d9351d9414ffd103cfd5d919c122cc3 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 26 Oct 2026 15:55:14 -0500 Subject: [PATCH 16/16] Fix(sidebar): the first outside write after a folder click leaves the Vim cursor alone (#993) 61eac765 made the reveal effect move the sidebar's Vim cursor only when the selection really changes, with the last open note standing in for the gap a folder selection leaves: selecting a folder clears selectedPath, and the next store update usually puts it back. The effect remembered that note only while the sidebar had focus; it returned before the line that remembers it otherwise. So one order still slipped through. Open a note, click a folder with the mouse (selectedPath goes null while the sidebar is unfocused, so the note was never remembered), then Ctrl+W h into the sidebar: the effect's first selection there was "no note". The first refresh after that, a file written from outside the app or a sync, rebuilt the active fields from the open tab, selectedPath came back, and the effect read the note as a new pick and moved the cursor onto it. Found while recording the 2.77.0 demo clip; a store log on a debug build showed selectedPath going from null to the open note on that refresh and nothing else in the selection changing. The note is now remembered on every run, focused or not. The early return for an unfocused sidebar, which resets the revealed selection so coming back lands on the current item, comes after it and is unchanged. On the built macOS app over CDP, the outside-write probe (a folder clicked before Ctrl+W h, then four outside writes with the cursor on Tasks, the vault header twice and Areas) keeps the cursor on its row for all four; before, the first write moved it to the open note. The #993 repro (`l` on a 30-note folder, then j), the sidebar Vim smoke test and the Vim-off tag and link checks pass. app-core 3,541 tests pass; typecheck clean. --- packages/app-core/src/components/Sidebar.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index fc062ac3d..15bc37c37 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -2855,12 +2855,6 @@ export function Sidebar(): JSX.Element { }); useEffect(() => { - if (!isSidebarFocused) { - // Coming back into the sidebar always lands on the current item. - revealedSelectionRef.current = null; - return; - } - // Follow a new selection, never a re-run for the same one. Selecting a // folder clears selectedPath, and almost any store update after it (the // first expand's note prefetch landing, a listing refresh) sets it back @@ -2868,7 +2862,16 @@ export function Sidebar(): JSX.Element { // the last note counts through the gap; and a refresh that leaves the // selection alone is no reason to move. Both used to drag the cursor off // the row Vim keys had moved it to and onto the open note. (#993) + // The note is remembered while the sidebar is unfocused too: a folder + // clicked before the keys moved in opens the gap before this effect + // would otherwise see the note, and the first refresh then read as a pick. if (selectedPath) lastSelectedNoteRef.current = selectedPath; + if (!isSidebarFocused) { + // Coming back into the sidebar always lands on the current item. + revealedSelectionRef.current = null; + return; + } + const selection = [ lastSelectedNoteRef.current, view,