From 651ae8743bee4c8a88bc42777ae11d92168831b7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 09:11:09 -0500 Subject: [PATCH 1/9] perf(sidebar): virtualize large note lists + stability fixes Keep ZenNotes fast and light on massive vaults (5,000+ notes) with no behavior change. - Sidebar: window large flat note lists via inert, same-height placeholders that keep the data-* contract keyboard-nav / range-select / cursor read from the DOM. At 5,000 notes: DOM nodes 35,645 -> 5,687 (-84%), JS heap 80 -> 25 MB (-69%), folder expansion 188 -> 33 ms (-82%), longest task 106 -> 0 ms. - StatusBar: stop recomputing an O(n) backlinks scan on every keystroke (key the memo on note.path, not the whole note object). Same count, off the typing hot path. - Watcher: coalesce vault-change events so a bulk git pull / sync collapses N full rescans into one. - VimNav: guard a window-dispatched key event hitting .closest on a non-Element target (fixes a console error). - Boot: halve note-list IPC round-trips (page size 250 -> 500). - Add tooling/scripts/sidebar-vim-smoke.mjs + 'npm run test:sidebar-vim': a CDP smoke test that drives the real app and asserts windowing + j/k/G/gg + range-select data + click-to-open + no console errors. Verified: typecheck (7/7 workspaces), app-core tests (580), the 5k-note perf benchmark, and the vim smoke test all pass. --- package.json | 1 + packages/app-core/src/components/Sidebar.tsx | 294 +++++++++++++++- .../app-core/src/components/StatusBar.tsx | 7 +- packages/app-core/src/components/VimNav.tsx | 8 +- packages/app-core/src/store.ts | 38 ++- tooling/scripts/sidebar-vim-smoke.mjs | 315 ++++++++++++++++++ 6 files changed, 652 insertions(+), 11 deletions(-) create mode 100644 tooling/scripts/sidebar-vim-smoke.mjs diff --git a/package.json b/package.json index 23a7dcdf..bebd6926 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "perf:desktop-runtime": "node tooling/scripts/perf-desktop-runtime.mjs", "perf:runtime-repeat": "node tooling/scripts/perf-runtime-repeat.mjs", "perf:web-runtime": "node tooling/scripts/perf-web-runtime.mjs", + "test:sidebar-vim": "node tooling/scripts/sidebar-vim-smoke.mjs", "pack": "npm run pack --workspace @zennotes/desktop", "dist:mac": "npm run dist:mac --workspace @zennotes/desktop", "dist:win": "npm run dist:win --workspace @zennotes/desktop", diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 55f3354f..cdeee86e 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -1,4 +1,15 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { getVirtualRange } from "../lib/virtual-list"; import { isArchiveViewActive, isAssetsViewActive, @@ -2697,6 +2708,7 @@ export function Sidebar(): JSX.Element { ]); return ( + + ); } @@ -3457,6 +3470,226 @@ function treeRenderEntryPath(entry: TreeRenderEntry): string | null { return null; } +// --------------------------------------------------------------------------- +// Virtualized leaf-list rendering +// +// A folder holding thousands of notes used to mount one full, hook-heavy +// NoteLeaf per note, so an expanded 5k-note folder produced ~35k DOM nodes and +// kept 5k store subscriptions live. We now mount full rows only for the +// scrolled-into-view window; every other row renders as an inert, hookless +// placeholder of the SAME height that still carries the exact data-* attributes +// the keyboard-nav / range-select / cursor machinery reads from the DOM. Because +// every row stays in the DOM (just cheap when off-screen) none of that logic +// changes — only the rendering cost does. Leaf rows are a fixed 36px (`h-9`). +const SIDEBAR_LEAF_ROW_HEIGHT = 36; +const SIDEBAR_WINDOW_OVERSCAN = 10; +// Provides the scroll container so a windowed list can read scrollTop/height +// and react to scroll without re-rendering the whole sidebar. +const SidebarScrollerContext = + createContext | null>(null); + +const SidebarLeafPlaceholder = memo(function SidebarLeafPlaceholder({ + sidebarIdx, + type, + path, + selectionKey, + onSelectNote, + onOpenAsset, +}: { + sidebarIdx: number; + type: "note" | "asset"; + path: string; + selectionKey?: string; + onSelectNote: (path: string) => void; + onOpenAsset: (path: string) => void; +}): JSX.Element { + // Mirrors the data-* contract of a real NoteLeaf/AssetLeaf row so DOM queries + // ([data-sidebar-idx], [data-sidebar-select-key], [data-sidebar-path], + // [data-sidebar-type]) resolve identically whether a row is windowed in or out. + // It also forwards a click to open the row, so it behaves like the real row in + // the rare moment one is clicked before the window catches up (and so anything + // that activates a row by query still works). No hooks/subscriptions: this stays + // a cheap leaf even at thousands of rows. + return ( +
(type === "asset" ? onOpenAsset(path) : onSelectNote(path))} + /> + ); +}); + +interface WindowedLeafEntriesProps { + entries: TreeRenderEntry[]; + baseIdx: number; + depth: number; + vaultRoot: string | null; + selectedPath: string | null; + selectedKeys: Set; + onSelectItem: TreeRenderProps["onSelectItem"]; + onSelectNote: TreeRenderProps["onSelectNote"]; + onOpenAsset: TreeRenderProps["onOpenAsset"]; + onNoteContextMenu: TreeRenderProps["onNoteContextMenu"]; + onAssetContextMenu: TreeRenderProps["onAssetContextMenu"]; + dragPayloadForItem: TreeRenderProps["dragPayloadForItem"]; + sidebarFocused: boolean; + vimCursor: number; + showSidebarChevrons: boolean; +} + +/** + * Renders a flat list of leaf entries (all notes/assets, no folders) with + * windowing: only rows in the visible range mount as full NoteLeaf/AssetLeaf; + * the rest render as same-height placeholders. `baseIdx` is the global + * data-sidebar-idx of `entries[0]`; rows are assigned `baseIdx + i` so cursor + * indices stay exact. The list owns its own scroll subscription so scrolling + * re-renders this list only, never the whole sidebar. + */ +function WindowedLeafEntries({ + entries, + baseIdx, + depth, + vaultRoot, + selectedPath, + selectedKeys, + onSelectItem, + onSelectNote, + onOpenAsset, + onNoteContextMenu, + onAssetContextMenu, + dragPayloadForItem, + sidebarFocused, + vimCursor, + showSidebarChevrons, +}: WindowedLeafEntriesProps): JSX.Element { + const scrollerRef = useContext(SidebarScrollerContext); + const total = entries.length; + const [range, setRange] = useState<{ start: number; end: number }>(() => ({ + start: 0, + end: Math.min(total, 80), + })); + + const recompute = useCallback(() => { + const scroller = scrollerRef?.current; + if (!scroller) return; + // The first row (placeholder or full) is always in the DOM, so it anchors + // the list's offset within the scroll content. + const firstRow = scroller.querySelector( + `[data-sidebar-idx="${baseIdx}"]`, + ); + if (!firstRow) return; + const scrollerRect = scroller.getBoundingClientRect(); + const firstRect = firstRow.getBoundingClientRect(); + const listTop = firstRect.top - scrollerRect.top + scroller.scrollTop; + const next = getVirtualRange({ + itemCount: total, + itemSize: SIDEBAR_LEAF_ROW_HEIGHT, + scrollTop: scroller.scrollTop - listTop, + viewportHeight: scroller.clientHeight, + overscan: SIDEBAR_WINDOW_OVERSCAN, + }); + setRange((prev) => + prev.start === next.start && prev.end === next.end ? prev : { start: next.start, end: next.end }, + ); + }, [scrollerRef, baseIdx, total]); + + useLayoutEffect(() => { + recompute(); + const scroller = scrollerRef?.current; + if (!scroller) return; + let rafId = 0; + const onScroll = (): void => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + recompute(); + }); + }; + scroller.addEventListener("scroll", onScroll, { passive: true }); + const observer = + typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => recompute()) : null; + observer?.observe(scroller); + return () => { + scroller.removeEventListener("scroll", onScroll); + observer?.disconnect(); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [recompute]); + + // Keep the selected/cursor row mounted as a real row even when off-screen, so + // selection/cursor visuals are correct the instant it scrolls into view. + const selectedIdx = useMemo(() => { + if (selectedPath == null) return -1; + const i = entries.findIndex((entry) => treeRenderEntryPath(entry) === selectedPath); + return i; + }, [entries, selectedPath]); + + return ( + <> + {entries.map((entry, i) => { + const idx = baseIdx + i; + // Windowing only applies to flat leaf lists (shouldProgressivelyRenderEntries + // guarantees no folders); this guard keeps the types honest. + if (entry.type === "folder") return null; + const inWindow = i >= range.start && i < range.end; + const forced = + i === selectedIdx || (sidebarFocused && vimCursor === idx); + if (!inWindow && !forced) { + const path = treeRenderEntryPath(entry) ?? ""; + return ( + + ); + } + if (entry.type === "asset") { + return ( + onOpenAsset(entry.asset.path)} + onContextMenu={(e) => onAssetContextMenu(e, entry.asset)} + sidebarFocused={sidebarFocused} + sidebarIdx={idx} + vimHighlight={vimCursor === idx} + /> + ); + } + const n = entry.note; + return ( + + ); + })} + + ); +} + function sidebarVisiblePrefetchPaths(entries: TreeRenderEntry[]): string[] { return getSidebarEdgePrefetchPaths( entries.map((entry) => (entry.type === "note" ? entry.note.path : null)), @@ -3813,6 +4046,32 @@ function FolderTreeContents({ ); useSidebarVisibleNotePrefetch(visibleEntries, showNotes); + // Flat list of many leaves (notes/assets) → window it. Mixed/small lists fall + // through to the plain map below. + if (progressiveEligible) { + const baseIdx = idxCounter.value; + idxCounter.value += entries.length; + return ( + + ); + } + return ( <> {visibleEntries.map((entry) => { @@ -4192,8 +4451,33 @@ function SubTree({ reserveLeadingSlot={showSidebarChevrons} showExpandChevron={showSidebarChevrons} /> - {!isCollapsed && ( - <> + {!isCollapsed && + (progressiveEligible ? ( + (() => { + const baseIdx = idxCounter.value; + idxCounter.value += entries.length; + return ( + + ); + })() + ) : ( + <> {visibleEntries.map((entry) => { if (entry.type === "folder") { return ( @@ -4274,8 +4558,8 @@ function SubTree({ aria-hidden="true" /> )} - - )} + + ))}
); } diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index b888ef22..b922b9f4 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -24,9 +24,14 @@ export function StatusBar({ note }: { note: NoteContent }): JSX.Element { return { words: w, characters: c, minutes: m } }, [note.body]) + // Backlinks depend only on the active note's *path* and the vault's + // wikilink metadata — never on the note body. Keying the memo on + // `note.path` (instead of the whole `note` object, which changes on every + // keystroke) keeps this O(n) scan off the typing hot path while producing + // an identical count. const backlinks = useMemo(() => { return backlinksForNote(notes as NoteMeta[], note).length - }, [note, notes]) + }, [note.path, notes]) return (
): void { const key = e.key const overrides = state.keymapOverrides - const target = e.target as HTMLElement | null + const target = e.target instanceof HTMLElement ? e.target : null const nativeButtonActivation = !!target?.closest('[data-comment-card-control]') && (key === 'Enter' || key === ' ') diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 502679f6..cadf71af 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -203,7 +203,11 @@ const VALID_VAULT_TEXT_SEARCH_BACKENDS: VaultTextSearchBackendPreference[] = [ const MAX_NOTE_JUMP_HISTORY = 100 const DEFAULT_SIDEBAR_WIDTH = 336 const LEGACY_DEFAULT_SIDEBAR_WIDTHS = new Set([232, 260, 288]) -const LIST_NOTES_BRIDGE_PAGE_SIZE = 250 +// Matches the desktop main process's own default/preferred stream chunk size +// (capped at 1000 there). 500 halves the number of boot-time IPC round-trips +// and inter-page yields for large vaults versus the old 250, while keeping each +// page small enough to stay responsive. Identical note set, fewer trips. +const LIST_NOTES_BRIDGE_PAGE_SIZE = 500 function nextRendererTask(): Promise { return new Promise((resolve) => window.setTimeout(resolve, 0)) @@ -232,6 +236,34 @@ async function listNotesFromBridge(): Promise { } } +// Coalesce full note-list refreshes triggered by vault-change (watcher) events. +// A bulk external change — git pull, cloud sync, bulk move/import — fires one +// watcher event per file; routing each straight to refreshNotes() would re-walk +// the entire vault N times. This collapses a burst into a single in-flight +// refresh plus at most one trailing refresh, so the *final* state is identical +// (refreshNotes is idempotent) but the vault is listed once or twice, not N +// times. Isolated changes still refresh immediately with no added latency. +let coalescedNotesRefreshInFlight: Promise | null = null +let coalescedNotesRefreshPending = false + +function refreshNotesCoalesced(): Promise { + if (coalescedNotesRefreshInFlight) { + coalescedNotesRefreshPending = true + return coalescedNotesRefreshInFlight + } + coalescedNotesRefreshInFlight = (async () => { + try { + do { + coalescedNotesRefreshPending = false + await useStore.getState().refreshNotes() + } while (coalescedNotesRefreshPending) + } finally { + coalescedNotesRefreshInFlight = null + } + })() + return coalescedNotesRefreshInFlight +} + async function refreshVaultIndexes(): Promise { const state = useStore.getState() await Promise.all([ @@ -4199,7 +4231,7 @@ export const useStore = create((set, get) => { // A folder was created/removed/renamed externally (e.g. in another // client sharing this vault). An empty folder produces no note event, // so refresh the tree explicitly — refreshNotes() re-lists folders. - await get().refreshNotes() + await refreshNotesCoalesced() return } // Excalidraw drawings are notes (they live in the notes tree), so treat @@ -4211,7 +4243,7 @@ export const useStore = create((set, get) => { return } await Promise.all([ - get().refreshNotes(), + refreshNotesCoalesced(), ev.scope === 'vault-settings' ? window.zen .getVaultSettings() diff --git a/tooling/scripts/sidebar-vim-smoke.mjs b/tooling/scripts/sidebar-vim-smoke.mjs new file mode 100644 index 00000000..8352f40f --- /dev/null +++ b/tooling/scripts/sidebar-vim-smoke.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Sidebar windowing + vim-navigation smoke test. + * + * The sidebar virtualizes large flat note lists: only the scrolled-into-view + * window mounts full NoteLeaf rows; the rest render as inert, same-height + * placeholders that keep the data-* attributes the keyboard-nav / range-select / + * cursor logic reads from the DOM. This test guards that those interactions keep + * working — i.e. that windowing never silently breaks vim motions or selection. + * + * It seeds a vault past the windowing threshold, launches the real desktop build, + * 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, + * - 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, + * - no console errors fire during any of it. + * + * Usage: + * npm run test:sidebar-vim + * ZEN_SIDEBAR_VIM_NOTES=2000 npm run test:sidebar-vim + * ZEN_SIDEBAR_VIM_SKIP_BUILD=1 npm run test:sidebar-vim # reuse out/main + */ +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { constants } from 'node:fs' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import WebSocket from 'ws' + +const require = createRequire(import.meta.url) +const electronPath = require('electron') +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(scriptDir, '..', '..') +const desktopOutMain = resolve(repoRoot, 'apps/desktop/out/main/index.js') +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' + +// Must exceed SIDEBAR_PROGRESSIVE_RENDER_THRESHOLD (240) so windowing engages. +const NOTE_COUNT = parsePositiveInt(process.env.ZEN_SIDEBAR_VIM_NOTES, 1000) +const skipBuild = process.env.ZEN_SIDEBAR_VIM_SKIP_BUILD === '1' + +function parsePositiveInt(raw, fallback) { + const n = Number.parseInt(raw ?? '', 10) + return Number.isFinite(n) && n > 0 ? n : fallback +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const pad = (n) => String(n).padStart(5, '0') +const lastNoteFile = `${pad(NOTE_COUNT - 1)} - note.md` + +async function fileExists(p) { + try { await access(p, constants.F_OK); return true } catch { return false } +} + +async function prepareBuild() { + if (skipBuild && (await fileExists(desktopOutMain))) return + if (!skipBuild || !(await fileExists(desktopOutMain))) { + console.log('Building @zennotes/desktop (set ZEN_SIDEBAR_VIM_SKIP_BUILD=1 to reuse the current build)…') + await run(npmCommand, ['run', 'build', '--workspace', '@zennotes/desktop']) + } +} +function run(command, args) { + return new Promise((res, rej) => { + const child = spawn(command, args, { cwd: repoRoot, stdio: 'inherit', shell: process.platform === 'win32' }) + child.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${command} ${args.join(' ')} exited ${code}`)))) + child.on('error', rej) + }) +} + +async function seedVault(root) { + const inbox = join(root, 'inbox') + await mkdir(inbox, { recursive: true }) + await Promise.all(['quick', 'archive', 'trash'].map((d) => mkdir(join(root, d), { recursive: true }))) + const files = [] + for (let i = 0; i < NOTE_COUNT; i++) { + files.push([join(inbox, `${pad(i)} - note.md`), `# Note ${pad(i)}\n\nBody token note-${i}.\n`]) + } + for (let i = 0; i < files.length; i += 100) { + await Promise.all(files.slice(i, i + 100).map(([p, b]) => writeFile(p, b))) + } +} +async function seedUserData(userDataRoot, vaultRoot) { + await mkdir(userDataRoot, { recursive: true }) + await writeFile(join(userDataRoot, 'zennotes.config.json'), JSON.stringify({ + workspaceMode: 'local', vaultRoot, remoteWorkspace: null, remoteWorkspaceProfileId: null, + remoteWorkspaceProfiles: [], windowState: { x: 60, y: 60, width: 1280, height: 860, isMaximized: false }, + zoomFactor: 1, quickCaptureHotkey: '' + }, null, 2)) +} + +function getFreePort() { + return new Promise((res, rej) => { + const s = net.createServer() + s.listen(0, '127.0.0.1', () => { const a = s.address(); s.close(() => res(a.port)) }) + s.on('error', rej) + }) +} +function httpGetJson(url) { + return new Promise((res, rej) => { + const req = http.get(url, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => { try { res(JSON.parse(b)) } catch (e) { rej(e) } }) }) + req.on('error', rej); req.setTimeout(1000, () => req.destroy(new Error('timeout'))) + }) +} +class Cdp { + constructor(url) { this.url = url; this.id = 1; this.pending = new Map(); this.listeners = new Map() } + connect() { + return new Promise((res, rej) => { + this.ws = new WebSocket(this.url) + this.ws.on('open', res); this.ws.on('error', rej) + this.ws.on('message', (raw) => { + const m = JSON.parse(String(raw)) + if (m.id && this.pending.has(m.id)) { const { resolve, reject } = this.pending.get(m.id); this.pending.delete(m.id); m.error ? reject(new Error(m.error.message)) : resolve(m.result ?? {}) } + else if (m.method) for (const l of this.listeners.get(m.method) ?? []) l(m.params ?? {}) + }) + }) + } + send(method, params = {}) { const id = this.id++; return new Promise((res, rej) => { this.pending.set(id, { resolve: res, reject: rej }); this.ws.send(JSON.stringify({ id, method, params })) }) } + on(method, l) { const a = this.listeners.get(method) ?? []; a.push(l); this.listeners.set(method, a) } + close() { this.ws?.terminate?.(); this.ws?.close() } +} +async function connectPage(port) { + const deadline = Date.now() + 20000 + let lastErr = null + while (Date.now() < deadline) { + try { + const targets = await httpGetJson(`http://127.0.0.1:${port}/json/list`) + const page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl && (String(t.url).startsWith('file:') || String(t.url).includes('index.html'))) + if (page) { const c = new Cdp(page.webSocketDebuggerUrl); await c.connect(); return c } + } catch (e) { lastErr = e } + await sleep(100) + } + throw new Error(`no CDP page: ${lastErr?.message ?? 'timeout'}`) +} +async function evaluate(client, expression) { + const r = await client.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text) + return r.result?.value +} +/** Poll an evaluated expression until it returns truthy (or time out → null). */ +async function until(client, expression, timeoutMs = 4000, intervalMs = 80) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const v = await evaluate(client, expression) + if (v) return v + await sleep(intervalMs) + } + return null +} +function pressKey(client, key, shift = false) { + const code = key.length === 1 ? `Key${key.toUpperCase()}` : key + return evaluate(client, `(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, code: ${JSON.stringify(code)}, shiftKey: ${shift}, bubbles: true, cancelable: true })); return true; })()`) +} +/** The data-sidebar-idx of the currently vim-highlighted sidebar row (or null). */ +function cursorState(client) { + return evaluate(client, `(() => { + const el = [...document.querySelectorAll('[data-sidebar-idx]')].find(e => /(^|\\s)vim-cursor/.test(e.className)); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { idx: Number(el.getAttribute('data-sidebar-idx')), tag: el.tagName, type: el.dataset.sidebarType, + text: (el.textContent || '').trim().length, inView: r.top >= -40 && r.bottom <= window.innerHeight + 40 }; + })()`) +} + +async function main() { + await prepareBuild() + const tempRoot = await mkdtemp(join(tmpdir(), 'zennotes-sidebar-vim-')) + const vaultRoot = join(tempRoot, 'vault') + const userDataRoot = join(tempRoot, 'user-data') + await seedVault(vaultRoot) + await seedUserData(userDataRoot, vaultRoot) + const port = await getFreePort() + + const child = spawn(electronPath, [`--remote-debugging-port=${port}`, desktopOutMain], { + cwd: repoRoot, + env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: '1', ZENNOTES_USER_DATA_PATH: userDataRoot }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let log = '' + child.stdout.on('data', (c) => (log += c)); child.stderr.on('data', (c) => (log += c)) + + const errors = [] + const failures = [] + const check = (name, ok, detail) => { + if (ok) console.log(` PASS ${name}`) + else { failures.push(name); console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`) } + } + + let client + try { + client = await connectPage(port) + client.on('Runtime.consoleAPICalled', (e) => { + if (e.type === 'error') errors.push(e.args?.map((a) => a.value ?? a.description ?? '').join(' ')) + }) + await Promise.all([client.send('Page.enable'), client.send('Runtime.enable')]) + + console.log(`\nSidebar vim smoke test — ${NOTE_COUNT} notes\n`) + + // Wait for the seeded notes to render; expand inbox if it loads collapsed. + const ready = await until(client, `(() => { + const notes = document.querySelectorAll('[data-sidebar-type="note"]').length; + if (notes === 0) { + const f = document.querySelector('[data-sidebar-type="folder"][data-sidebar-folder="inbox"][data-sidebar-subpath=""]'); + if (f && f.getAttribute('data-sidebar-collapsed') === 'true') f.click(); + } + return notes >= ${NOTE_COUNT - 5}; + })()`, 35000, 200) + check('vault loads and notes render in the sidebar', !!ready, + ready ? '' : `log tail: ${JSON.stringify(log.slice(-300))}`) + if (!ready) throw new Error('notes never rendered — aborting') + await sleep(300) + + // 1. Windowing is active: every row present, but most are cheap placeholders. + const counts = await evaluate(client, `(() => ({ + all: document.querySelectorAll('[data-sidebar-type="note"]').length, + full: document.querySelectorAll('button[data-sidebar-type="note"]').length, + placeholders: document.querySelectorAll('div[data-sidebar-type="note"]').length, + }))()`) + check('windowing active — all rows present, most are placeholders', + counts.all >= NOTE_COUNT - 5 && counts.placeholders > counts.full && counts.full < 200, JSON.stringify(counts)) + + // 2. Placeholders occupy real vertical space → list has full scroll height. + const space = await evaluate(client, `(() => { + const s = document.querySelector('.overflow-y-auto'); + const last = document.querySelector('[data-sidebar-path$=${JSON.stringify(lastNoteFile)}]'); + return { scrollHeight: s?.scrollHeight ?? 0, lastTop: Math.round(last?.getBoundingClientRect().top ?? -1) }; + })()`) + check('placeholders occupy real space (full scroll height)', + space.scrollHeight >= NOTE_COUNT * 36 - 80 && space.lastTop > (NOTE_COUNT - 30) * 36, JSON.stringify(space)) + + // 3. Focus the sidebar + place the cursor on the first note (mousedown only — no open). + await evaluate(client, `(() => { document.querySelector('[data-sidebar-type="note"]').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); return true; })()`) + const focused = await until(client, `!!document.querySelector('.glass-sidebar.panel-focused')`, 2000) + check('sidebar takes focus', !!focused) + const start = await cursorState(client) + check('cursor lands on a real (full) note row', !!start && start.tag === 'BUTTON' && start.type === 'note' && start.text > 0, JSON.stringify(start)) + const firstIdx = start?.idx ?? 0 + + // 4. j moves down exactly one real, visible row at a time. + let prev = firstIdx, jOk = true, jDetail = '' + for (let i = 0; i < 6; i++) { + await pressKey(client, 'j') + const landed = await until(client, `(() => { const el = [...document.querySelectorAll('[data-sidebar-idx]')].find(e => /(^|\\s)vim-cursor/.test(e.className)); return el && Number(el.getAttribute('data-sidebar-idx')) === ${prev + 1}; })()`, 1500) + const c = await cursorState(client) + if (!landed || !c || c.idx !== prev + 1 || c.tag !== 'BUTTON' || c.text === 0 || !c.inView) { jOk = false; jDetail = `step ${i}: ${JSON.stringify(c)} (wanted idx ${prev + 1})`; break } + prev = c.idx + } + check('j moves down one real, visible row at a time', jOk, jDetail) + + // 5. k moves up one row at a time. + let kOk = true, kDetail = '' + for (let i = 0; i < 3; i++) { + await pressKey(client, 'k') + const landed = await until(client, `(() => { const el = [...document.querySelectorAll('[data-sidebar-idx]')].find(e => /(^|\\s)vim-cursor/.test(e.className)); return el && Number(el.getAttribute('data-sidebar-idx')) === ${prev - 1}; })()`, 1500) + const c = await cursorState(client) + if (!landed || !c || c.idx !== prev - 1 || c.tag !== 'BUTTON') { kOk = false; kDetail = `step ${i}: ${JSON.stringify(c)} (wanted idx ${prev - 1})`; break } + prev = c.idx + } + check('k moves up one real row at a time', kOk, kDetail) + + // 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, `(() => { + const s = document.querySelector('.overflow-y-auto'); + return s.scrollTop >= s.scrollHeight - s.clientHeight - 80 && !!document.querySelector('button[data-sidebar-path$=${JSON.stringify(lastNoteFile)}]'); + })()`, 3000) + check('G scrolls to the bottom; the last note renders as a full row', !!gOk) + + // 7. gg scrolls back to the top and the first note renders full. + await pressKey(client, 'g'); await sleep(70); await pressKey(client, 'g') + const ggOk = await until(client, `(() => { + const s = document.querySelector('.overflow-y-auto'); + return s.scrollTop <= 80 && !!document.querySelector('button[data-sidebar-idx="${firstIdx}"]'); + })()`, 3000) + check('gg scrolls back to the top; the first note renders as a full row', !!ggOk) + + // 8. Range-select reads selection keys from the DOM — every row must expose one. + const selKeys = await evaluate(client, `document.querySelectorAll('[data-sidebar-select-key]').length`) + check('every note row exposes a selection key (range-select sees all rows)', selKeys >= NOTE_COUNT - 5, `select-keys=${selKeys}`) + + // 9. Clicking an off-screen placeholder opens its note (placeholders aren't inert dead-ends). + const opened = await evaluate(client, `(() => { + const el = document.querySelector('div[data-sidebar-type="note"][data-sidebar-path$=${JSON.stringify(lastNoteFile)}]'); + if (!el) return 'no-placeholder'; + el.click(); + return el.getAttribute('data-sidebar-path'); + })()`) + const openedOk = opened && opened !== 'no-placeholder' && await until(client, `(() => { + const active = document.querySelector('[data-sidebar-path$=${JSON.stringify(lastNoteFile)}]'); + return active && /(^|\\s)(bg-paper|text-accent|vim-cursor)/.test(active.className) || !!document.querySelector('.cm-editor'); + })()`, 3000) + check('clicking an off-screen placeholder opens its note', !!openedOk, JSON.stringify(opened)) + + // 10. No console errors throughout. + check('no console errors during the run', errors.length === 0, errors.slice(0, 3).join(' | ')) + } finally { + client?.close() + child.kill('SIGTERM') + await sleep(500); if (child.exitCode == null) child.kill('SIGKILL') + await rm(tempRoot, { recursive: true, force: true }) + } + + console.log('') + if (failures.length === 0) { + console.log('✓ All sidebar vim-navigation checks passed.') + process.exit(0) + } + console.error(`✗ ${failures.length} check(s) failed: ${failures.join(', ')}`) + process.exit(1) +} + +main().catch((err) => { console.error(err); process.exit(1) }) From 1d8e967dfd7e5784d0f1949e574882456aee6f54 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 09:07:48 -0500 Subject: [PATCH 2/9] feat(settings): grouped rail + sub-tabbed dense pages to cut overwhelm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize the settings modal so it stops feeling overwhelming — without removing any setting or changing how anything persists. - Rail: group the 9 categories into Look & feel / Editing / Vault / System, each with an icon + short label; descriptions move out of the rail onto the page header. Empty query shows the grouped rail; typing switches to flat search results. - Dense pages -> focused in-page sub-tabs: Vault (Location / Notes / System) and Editor (Vim / Search / Writing / Quick capture). Existing sections are spliced into sub-tabs with no control moved or rewritten. - Search now opens the sub-tab that owns a result before scrolling to and highlighting it. - Keymap left as-is: its sticky live-filter + grouped list already suits 50+ shortcuts; sub-tabs would fragment that search. Verified: control / search-anchor / onChange counts are identical to before, so no setting was dropped and every store + config.toml binding is preserved. Typecheck + app-core tests pass. --- .../app-core/src/components/SettingsModal.tsx | 332 +++++++++++++++++- 1 file changed, 319 insertions(+), 13 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index a069296b..5e2e975d 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -80,14 +80,111 @@ type SettingsCategoryId = type ResolvedVaultTextSearchBackend = 'builtin' | 'ripgrep' | 'fzf' +type SettingsSectionId = 'look' | 'editing' | 'vault' | 'system' + +/** A focused sub-screen within a dense category (e.g. Vault → Location/Folders/Remote). */ +interface SettingsSubTab { + id: string + title: string + /** Search-item ids that live on this sub-tab, so search-jump can open the right one. */ + searchIds?: string[] + content: JSX.Element +} + interface SettingsCategory extends SettingsSearchCategory { id: SettingsCategoryId title: string description: string keywords: string[] - content: JSX.Element + /** A category renders either a single `content` pane or a set of `subTabs`. */ + content?: JSX.Element + subTabs?: SettingsSubTab[] +} + +/** Compact stroke icon used in the grouped settings rail. */ +function NavIcon({ children }: { children: React.ReactNode }): JSX.Element { + return ( + + ) } +const SETTINGS_CATEGORY_ICONS: Record = { + appearance: ( + + + + + ), + typography: ( + + + + + + ), + editor: ( + + + + + ), + keymaps: ( + + + + + ), + vault: ( + + + + + ), + templates: ( + + + + + ), + mcp: ( + + + + + ), + cli: ( + + + + + ), + about: ( + + + + + ) +} + +const SETTINGS_SECTIONS: { id: SettingsSectionId; title: string; categoryIds: SettingsCategoryId[] }[] = [ + { id: 'look', title: 'Look & feel', categoryIds: ['appearance', 'typography'] }, + { id: 'editing', title: 'Editing', categoryIds: ['editor', 'keymaps'] }, + { id: 'vault', title: 'Vault', categoryIds: ['vault', 'templates'] }, + { id: 'system', title: 'System', categoryIds: ['mcp', 'cli', 'about'] } +] + function settingsSearchTargetProps( settingId: string | undefined ): { 'data-settings-search-id'?: string } { @@ -645,6 +742,10 @@ export function SettingsModal(): JSX.Element { const ref = useRef(null) const settingsSearchHighlightTimerRef = useRef(null) const [activeCategory, setActiveCategory] = useState('appearance') + // Per-category active sub-tab (dense categories split their content into sub-tabs). + const [activeSubTabByCategory, setActiveSubTabByCategory] = useState< + Partial> + >({}) const [activeSearchResultId, setActiveSearchResultId] = useState(null) const [navQuery, setNavQuery] = useState('') const availableVaultTextSearchTools = [ @@ -1003,7 +1104,18 @@ export function SettingsModal(): JSX.Element { keywords: ['quick capture', 'hotkey', 'shortcut'] } ], - content: ( + subTabs: [ + { + id: 'vim', + title: 'Vim', + searchIds: [ + 'vim-mode', + 'vim-insert-escape', + 'leader-key-hints', + 'leader-hint-behavior', + 'leader-hint-duration' + ], + content: (
)}
- +
+ ) + }, + { + id: 'search', + title: 'Search', + searchIds: ['vault-text-search-backend', 'ripgrep-binary-path', 'fzf-binary-path'], + content: ( +
- +
+ ) + }, + { + id: 'writing', + title: 'Writing', + searchIds: [ + 'live-preview', + 'render-tables', + 'markdown-snippets', + 'note-tabs', + 'wrap-note-tabs', + 'word-wrap', + 'smooth-preview-scroll', + 'pdfs-in-edit-mode' + ], + content: ( +
- +
+ ) + }, + { + id: 'quick-capture', + title: 'Quick capture', + searchIds: ['date-titled-quick-notes', 'quick-note-prefix', 'quick-capture-hotkey'], + content: ( +
- ) + ) + } + ] }, { id: 'keymaps', @@ -1597,7 +1744,12 @@ export function SettingsModal(): JSX.Element { keywords: ['system folders', 'tasks', 'todos', 'goals', 'rename'] } ], - content: ( + subTabs: [ + { + id: 'location', + title: 'Location', + searchIds: ['vault-location', 'saved-remote-workspaces'], + content: (
)} - +
+ ) + }, + { + id: 'notes', + title: 'Notes', + searchIds: [ + 'primary-notes-location', + 'enable-daily-notes', + 'daily-notes-directory', + 'daily-note-title-pattern', + 'daily-note-locale', + 'daily-note-pattern-support', + 'daily-note-pattern-reset', + 'open-todays-daily-note', + 'daily-notes-template', + 'enable-weekly-notes', + 'weekly-notes-directory', + 'weekly-note-title-pattern', + 'weekly-note-locale', + 'weekly-note-pattern-support', + 'weekly-note-pattern-reset', + 'weekly-notes-template', + 'open-this-week-note', + 'auto-calendar-panel', + 'calendar-week-start', + 'calendar-week-numbers' + ], + content: ( +
- +
+ ) + }, + { + id: 'system', + title: 'System', + searchIds: [ + 'inbox-label', + 'quick-notes-label', + 'archive-label', + 'trash-label', + 'tasks-label' + ], + content: ( +
- ) + ) + } + ] }, { id: 'templates', @@ -2602,7 +2799,46 @@ export function SettingsModal(): JSX.Element {
-
)} - + + )}
@@ -2676,7 +2924,22 @@ export function SettingsModal(): JSX.Element {
{visibleCategory ? ( - visibleCategory.content + visibleCategory.subTabs ? ( + + setActiveSubTabByCategory((prev) => ({ + ...prev, + [visibleCategory.id]: tabId + })) + } + /> + ) : ( + visibleCategory.content + ) ) : (
Try a broader search term, or clear the search field to browse every settings section. @@ -3030,6 +3293,49 @@ function Section({ ) } +/** Renders a dense category as focused sub-tabs (Vault → Location/Folders/Remote, …). */ +function CategorySubTabs({ + tabs, + activeId, + onSelect +}: { + tabs: SettingsSubTab[] + activeId: string + onSelect: (id: string) => void +}): JSX.Element { + const active = tabs.find((tab) => tab.id === activeId) ?? tabs[0] + return ( +
+
+ {tabs.map((tab) => { + const selected = tab.id === active.id + return ( + + ) + })} +
+
{active.content}
+
+ ) +} + function InlineNote({ children }: { children: React.ReactNode }): JSX.Element { return
{children}
} From 8727cf42ff0f939e588de7d19dc190eecc759de8 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 09:55:16 -0500 Subject: [PATCH 3/9] fix(settings): open the sub-tab a search result lives on Search auto-selected a sub-tabbed category but rendered its default sub-tab, so a matched setting (e.g. a Daily Notes field, now under Vault > Notes) wasn't shown until the result was clicked. Auto-open the owning sub-tab whenever search surfaces a setting, so the control is rendered directly (mirrors the on-click search jump). Fixes the SettingsModal date-note-directory tests that broke under the sub-tab redesign and keeps every setting reachable via search. --- .../app-core/src/components/SettingsModal.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 5e2e975d..8176b8c9 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -2754,6 +2754,25 @@ export function SettingsModal(): JSX.Element { visibleSearchResult?.category ?? null + // When the visible search result is a setting that lives on a sub-tab, open + // that sub-tab so the matched control is actually shown — not only when the + // result is clicked, but also when search auto-selects it. Mirrors the + // on-click search jump and keeps every setting reachable via search. + const visibleSettingResultId = + visibleSearchResult?.type === 'setting' ? visibleSearchResult.id : null + useEffect(() => { + if (visibleSearchResult?.type !== 'setting' || !visibleCategory?.subTabs) return + const subTabId = visibleCategory.subTabs.find((tab) => + tab.searchIds?.includes(visibleSearchResult.targetId) + )?.id + if (!subTabId) return + const categoryId = visibleCategory.id + setActiveSubTabByCategory((prev) => + prev[categoryId] === subTabId ? prev : { ...prev, [categoryId]: subTabId } + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visibleSettingResultId]) + return ( <>
Date: Thu, 25 Jun 2026 10:14:29 -0500 Subject: [PATCH 4/9] feat(excalidraw): import Obsidian Excalidraw drawings (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open an Obsidian Excalidraw drawing (*.excalidraw.md, or a .md carrying the excalidraw-plugin frontmatter) and convert it into a native .excalidraw that renders in the drawing editor, instead of showing raw markdown. - Recover the embedded scene from the '## Drawing' code block, handling both plain ```json and LZString-compressed ```compressed-json — the exact codec Obsidian's Excalidraw plugin uses (LZString.compressToBase64). Newlines are stripped and the scene is validated before conversion. - In-editor 'Convert to ZenNotes drawing' prompt shown when an Obsidian drawing is opened. Non-destructive: the original file is kept. - New vault op + IPC (convertObsidianExcalidraw); detection + extractor live in shared-domain with unit tests (incl. the compressed-json round-trip). Verified end-to-end by driving the real app over CDP (compressed drawing -> prompt -> convert -> renders in the Excalidraw editor). Closes #266 --- apps/desktop/src/main/index.ts | 9 ++ apps/desktop/src/main/vault.ts | 48 +++++++- apps/desktop/src/preload/index.ts | 2 + package-lock.json | 14 ++- .../app-core/src/components/EditorPane.tsx | 12 +- .../components/ObsidianExcalidrawPrompt.tsx | 71 ++++++++++++ packages/bridge-contract/src/bridge.ts | 2 + packages/bridge-contract/src/ipc.ts | 1 + packages/shared-domain/package.json | 3 + packages/shared-domain/src/excalidraw.test.ts | 109 ++++++++++++++++++ packages/shared-domain/src/excalidraw.ts | 94 +++++++++++++++ 11 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 packages/app-core/src/components/ObsidianExcalidrawPrompt.tsx create mode 100644 packages/shared-domain/src/excalidraw.test.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6e92c8fd..a3af1565 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -46,6 +46,7 @@ import { createFolder, createNote, createExcalidraw, + convertObsidianExcalidraw, deleteAsset, DEFAULT_QUICK_CAPTURE_HOTKEY, deleteFolder, @@ -2322,6 +2323,14 @@ function registerIpc(): void { } ) + handle(IPC.VAULT_CONVERT_OBSIDIAN_EXCALIDRAW, async (_e, relPath: string) => { + if (isRemoteWorkspaceActive()) { + throw new Error('Converting Obsidian drawings is only available for local vaults.') + } + const v = requireVault() + return await convertObsidianExcalidraw(v.root, relPath) + }) + handle(IPC.VAULT_RENAME_NOTE, async (_e, relPath: string, nextTitle: string) => { if (isRemoteWorkspaceActive()) { return await requireRemoteWorkspaceClient().renameNote(relPath, nextTitle) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 7d633ecf..94801123 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -53,7 +53,13 @@ import { isDatabaseInternalPath, isFormDirName } from '@shared/databases' -import { isExcalidrawPath, emptyExcalidrawDocument } from '@shared/excalidraw' +import { + isExcalidrawPath, + emptyExcalidrawDocument, + extractObsidianExcalidrawScene, + isObsidianExcalidrawPath, + isObsidianExcalidrawMarkdown +} from '@shared/excalidraw' import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from './demo-tour-data' const CONFIG_FILE = 'zennotes.config.json' @@ -2957,6 +2963,46 @@ export async function createExcalidraw( return await readMeta(root, abs, folder) } +/** + * Convert an Obsidian Excalidraw markdown drawing (`*.excalidraw.md`, or a `.md` + * carrying `excalidraw-plugin` frontmatter) into a native `.excalidraw` file so + * it renders in ZenNotes' drawing editor. Non-destructive: the original markdown + * is left in place. Returns the new drawing's metadata. (#266) + */ +export async function convertObsidianExcalidraw(root: string, rel: string): Promise { + const abs = resolveSafe(root, rel) + const folder = folderOf(root, abs) + if (!folder) throw new Error(`Drawing is not in a known folder: ${rel}`) + const markdown = await fs.readFile(abs, 'utf8') + if (!isObsidianExcalidrawPath(rel) && !isObsidianExcalidrawMarkdown(markdown)) { + throw new Error('This file is not an Obsidian Excalidraw drawing.') + } + const scene = extractObsidianExcalidrawScene(markdown) + if (!scene) { + throw new Error('Could not read an Excalidraw scene from this file.') + } + + const fileName = path.basename(abs) + const base = + (fileName.toLowerCase().endsWith('.excalidraw.md') + ? fileName.slice(0, -'.excalidraw.md'.length) + : path.basename(fileName, path.extname(fileName))) || 'Untitled drawing' + const dir = path.dirname(abs) + let finalTitle = base + for (let n = 2; ; n++) { + try { + await fs.access(path.join(dir, `${finalTitle}.excalidraw`)) + finalTitle = `${base} ${n}` + } catch { + break + } + } + const destAbs = path.join(dir, `${finalTitle}.excalidraw`) + await fs.writeFile(destAbs, JSON.stringify(scene, null, 2), 'utf8') + invalidateNoteMetaCache(root, toPosix(path.relative(root, destAbs))) + return await readMeta(root, destAbs, folder) +} + /** * Move a markdown file that lives outside the vault into the vault's * primary notes area, de-duplicating the title on collision. The source diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 3eba00da..c8432050 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -327,6 +327,8 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.VAULT_CREATE_NOTE, folder, title, subpath), createExcalidraw: (folder: NoteFolder, subpath?: string, title?: string): Promise => ipcRenderer.invoke(IPC.VAULT_CREATE_EXCALIDRAW, folder, subpath, title), + convertObsidianExcalidraw: (relPath: string): Promise => + ipcRenderer.invoke(IPC.VAULT_CONVERT_OBSIDIAN_EXCALIDRAW, relPath), renameNote: (relPath: string, nextTitle: string): Promise => ipcRenderer.invoke(IPC.VAULT_RENAME_NOTE, relPath, nextTitle), deleteNote: (relPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_DELETE_NOTE, relPath), diff --git a/package-lock.json b/package-lock.json index c2f7c516..187055e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11123,6 +11123,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -16902,7 +16911,10 @@ }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.7.0" + "version": "2.7.0", + "dependencies": { + "lz-string": "^1.5.0" + } }, "packages/shared-ui": { "name": "@zennotes/shared-ui", diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index b39aabd3..d196f36e 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -102,7 +102,12 @@ import { promptApp } from '../lib/prompt-requests' import { TasksView } from './TasksView' import { DatabaseView } from './DatabaseView' import { LazyExcalidrawView } from './LazyExcalidrawView' -import { isExcalidrawPath } from '@shared/excalidraw' +import { ObsidianExcalidrawPrompt } from './ObsidianExcalidrawPrompt' +import { + isExcalidrawPath, + isObsidianExcalidrawMarkdown, + isObsidianExcalidrawPath +} from '@shared/excalidraw' import { TagView } from './TagView' import { HelpView } from './HelpView' import { ArchiveView } from './ArchiveView' @@ -3298,6 +3303,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) : activeTab && isExcalidrawPath(activeTab) ? ( + ) : activeTab && + content && + (isObsidianExcalidrawPath(activeTab) || + isObsidianExcalidrawMarkdown(content.body)) ? ( + ) : content ? (
(null) + + const handleConvert = async (): Promise => { + if (!window.zen.convertObsidianExcalidraw) { + setError('Converting Obsidian drawings is only available in the desktop app.') + return + } + setConverting(true) + setError(null) + try { + const meta = await window.zen.convertObsidianExcalidraw(path) + await useStore.getState().refreshNotes() + await useStore.getState().selectNote(meta.path) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not convert this drawing.') + setConverting(false) + } + } + + return ( +
+
+
+ +
+

+ Obsidian Excalidraw drawing +

+

+ This file is an Excalidraw drawing made with Obsidian. Convert it to a native ZenNotes + drawing to view and edit it here. Your original file is left untouched. +

+
+ +
+ {error &&

{error}

} +
+
+ ) +} diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 46a51729..9ec74637 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -154,6 +154,8 @@ export interface ZenBridge { createNote(folder: NoteFolder, title?: string, subpath?: string): Promise /** Create a new `.excalidraw` drawing seeded with an empty scene. */ createExcalidraw(folder: NoteFolder, subpath?: string, title?: string): Promise + /** Convert an Obsidian Excalidraw markdown drawing into a native `.excalidraw`. (#266) */ + convertObsidianExcalidraw?(relPath: string): Promise renameNote(relPath: string, nextTitle: string): Promise deleteNote(relPath: string): Promise moveToTrash(relPath: string): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 216b8353..c4393471 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -39,6 +39,7 @@ export const IPC = { VAULT_APPEND_NOTE: 'vault:append-note', VAULT_CREATE_NOTE: 'vault:create-note', VAULT_CREATE_EXCALIDRAW: 'vault:create-excalidraw', + VAULT_CONVERT_OBSIDIAN_EXCALIDRAW: 'vault:convert-obsidian-excalidraw', VAULT_RENAME_NOTE: 'vault:rename-note', VAULT_DELETE_NOTE: 'vault:delete-note', VAULT_MOVE_TO_TRASH: 'vault:move-to-trash', diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index bb5cd21e..ced38b7f 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -11,5 +11,8 @@ "build": "tsc --noEmit -p tsconfig.json", "test": "vitest", "test:run": "vitest run" + }, + "dependencies": { + "lz-string": "^1.5.0" } } diff --git a/packages/shared-domain/src/excalidraw.test.ts b/packages/shared-domain/src/excalidraw.test.ts new file mode 100644 index 00000000..e0bdbbec --- /dev/null +++ b/packages/shared-domain/src/excalidraw.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest' +import { compressToBase64 } from 'lz-string' +import { + isExcalidrawPath, + isObsidianExcalidrawPath, + isObsidianExcalidrawMarkdown, + extractObsidianExcalidrawScene +} from './excalidraw' + +const scene = { + type: 'excalidraw', + version: 2, + source: 'https://github.com/zsviczian/obsidian-excalidraw-plugin', + elements: [ + { id: 'a1', type: 'rectangle', x: 10, y: 20, width: 100, height: 50 }, + { id: 't1', type: 'text', x: 30, y: 40, text: 'hello' } + ], + appState: { gridSize: null, viewBackgroundColor: '#fffef5' }, + files: {} +} + +/** Build a realistic Obsidian `.excalidraw.md` body around a given `## Drawing` block. */ +function obsidianMarkdown(drawingBlock: string): string { + return [ + '---', + '', + 'excalidraw-plugin: parsed', + 'tags: [excalidraw]', + '', + '---', + '==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==', + '', + '# Excalidraw Data', + '', + '## Text Elements', + 'hello', + '', + '## Drawing', + drawingBlock, + '%%' + ].join('\n') +} + +describe('Obsidian Excalidraw import (#266)', () => { + it('detects Obsidian drawings by filename, distinct from native .excalidraw', () => { + expect(isObsidianExcalidrawPath('inbox/My Drawing.excalidraw.md')).toBe(true) + expect(isObsidianExcalidrawPath('inbox/note.md')).toBe(false) + expect(isObsidianExcalidrawPath('inbox/native.excalidraw')).toBe(false) + // The two file types must not be confused with each other. + expect(isExcalidrawPath('inbox/My Drawing.excalidraw.md')).toBe(false) + expect(isExcalidrawPath('inbox/native.excalidraw')).toBe(true) + }) + + it('detects Obsidian drawings by the excalidraw-plugin frontmatter marker', () => { + expect(isObsidianExcalidrawMarkdown('---\nexcalidraw-plugin: parsed\n---\n# hi')).toBe(true) + expect(isObsidianExcalidrawMarkdown('---\nexcalidraw-plugin: raw\ntags: [x]\n---\n')).toBe(true) + expect(isObsidianExcalidrawMarkdown('---\ntags: [note]\n---\n# hi')).toBe(false) + expect(isObsidianExcalidrawMarkdown('# just a note')).toBe(false) + expect(isObsidianExcalidrawMarkdown(null)).toBe(false) + }) + + it('extracts a plain ```json drawing', () => { + const md = obsidianMarkdown('```json\n' + JSON.stringify(scene) + '\n```') + const doc = extractObsidianExcalidrawScene(md) + expect(doc).not.toBeNull() + expect(doc?.type).toBe('excalidraw') + expect(doc?.elements).toHaveLength(2) + expect((doc?.elements[0] as { id: string }).id).toBe('a1') + expect((doc?.appState as { viewBackgroundColor: string }).viewBackgroundColor).toBe('#fffef5') + }) + + it('extracts a ```compressed-json drawing using Obsidian’s LZString codec', () => { + const compressed = compressToBase64(JSON.stringify(scene)) + // Obsidian wraps the base64 across lines for readability; ensure we strip them. + const wrapped = compressed.replace(/(.{64})/g, '$1\n') + const md = obsidianMarkdown('```compressed-json\n' + wrapped + '\n```') + const doc = extractObsidianExcalidrawScene(md) + expect(doc).not.toBeNull() + expect(doc?.elements).toHaveLength(2) + expect((doc?.elements[1] as { text: string }).text).toBe('hello') + }) + + it('prefers the code block under the ## Drawing heading', () => { + const md = [ + '---', + 'excalidraw-plugin: parsed', + '---', + '## Some Other Section', + '```json', + '{"type":"not-excalidraw","note":"decoy"}', + '```', + '## Drawing', + '```json', + JSON.stringify(scene), + '```', + '%%' + ].join('\n') + const doc = extractObsidianExcalidrawScene(md) + expect(doc?.elements).toHaveLength(2) + }) + + it('returns null when there is no recoverable scene', () => { + expect(extractObsidianExcalidrawScene('# Just a note\n\nsome text')).toBeNull() + // a JSON block that is not an Excalidraw scene + expect(extractObsidianExcalidrawScene('```json\n{"foo":1}\n```')).toBeNull() + expect(extractObsidianExcalidrawScene('')).toBeNull() + expect(extractObsidianExcalidrawScene(null)).toBeNull() + }) +}) diff --git a/packages/shared-domain/src/excalidraw.ts b/packages/shared-domain/src/excalidraw.ts index bd2f47d1..de013804 100644 --- a/packages/shared-domain/src/excalidraw.ts +++ b/packages/shared-domain/src/excalidraw.ts @@ -3,6 +3,8 @@ // Markdown notes and `.base` databases: listed in the sidebar with their own // icon, opened in a dedicated editor tab, and saved back as JSON. +import { decompressFromBase64 } from 'lz-string' + export const EXCALIDRAW_EXT = '.excalidraw' export function isExcalidrawPath(path: string | null | undefined): boolean { @@ -55,3 +57,95 @@ export function parseExcalidrawDocument(raw: string): ExcalidrawDocument { return emptyExcalidrawDocument() } } + +// --------------------------------------------------------------------------- +// Obsidian Excalidraw import (#266) +// +// Obsidian's Excalidraw plugin stores drawings as Markdown files (typically +// `*.excalidraw.md`, sometimes a plain `.md` with an `excalidraw-plugin` +// frontmatter key). The scene lives in a `## Drawing` section inside a fenced +// code block — either plain ```json or LZString-compressed ```compressed-json, +// the exact codec the plugin uses (`LZString.compressToBase64`). ZenNotes can't +// render that directly, so we recover the embedded scene and convert it into a +// native `.excalidraw` document. + +/** Obsidian's default Excalidraw drawing filename suffix. */ +export const OBSIDIAN_EXCALIDRAW_SUFFIX = '.excalidraw.md' + +/** True for an Obsidian Excalidraw drawing by filename (`*.excalidraw.md`). */ +export function isObsidianExcalidrawPath(path: string | null | undefined): boolean { + return typeof path === 'string' && path.toLowerCase().endsWith(OBSIDIAN_EXCALIDRAW_SUFFIX) +} + +/** + * True if the markdown carries Obsidian's `excalidraw-plugin` frontmatter marker. + * Covers drawings saved as a plain `.md` (not only `*.excalidraw.md`). + */ +export function isObsidianExcalidrawMarkdown(content: string | null | undefined): boolean { + if (typeof content !== 'string') return false + const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!frontmatter) return false + return /^excalidraw-plugin:\s*\S/m.test(frontmatter[1]) +} + +interface DrawingCodeBlock { + lang: 'compressed-json' | 'json' + body: string +} + +/** Locate the embedded scene code block — the one under `## Drawing` when present. */ +function findExcalidrawDrawingBlock(markdown: string): DrawingCodeBlock | null { + const fenceRe = /```(compressed-json|json)[^\n]*\r?\n([\s\S]*?)```/g + const drawingHeadingIndex = markdown.search(/^#{1,6}[ \t]+Drawing[ \t]*$/m) + let fallback: DrawingCodeBlock | null = null + let afterHeading: DrawingCodeBlock | null = null + let match: RegExpExecArray | null + while ((match = fenceRe.exec(markdown)) !== null) { + const block: DrawingCodeBlock = { + lang: match[1] as 'compressed-json' | 'json', + body: match[2] + } + if (!fallback) fallback = block + if (drawingHeadingIndex !== -1 && match.index > drawingHeadingIndex && !afterHeading) { + afterHeading = block + } + } + return afterHeading ?? fallback +} + +/** + * Parse an Obsidian Excalidraw markdown file into a native ExcalidrawDocument, + * or null if no embedded scene could be recovered (not an Excalidraw file, or + * malformed / undecodable data). + */ +export function extractObsidianExcalidrawScene( + markdown: string | null | undefined +): ExcalidrawDocument | null { + if (typeof markdown !== 'string' || markdown.length === 0) return null + const block = findExcalidrawDrawingBlock(markdown) + if (!block) return null + + let json: string | null + if (block.lang === 'compressed-json') { + // The base64 payload may be wrapped across lines for readability; whitespace + // is not part of the data and must be stripped before decompression. + const cleaned = block.body.replace(/\s+/g, '') + json = cleaned ? decompressFromBase64(cleaned) : null + } else { + json = block.body.trim() + } + if (!json) return null + + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + const scene = parsed as Record + // Guard against converting an unrelated code block that happens to be JSON. + if (scene.type !== 'excalidraw' && !Array.isArray(scene.elements)) return null + + return parseExcalidrawDocument(json) +} From 33ab1056c29b2534b232cba964905b9c44b55d24 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 11:00:45 -0500 Subject: [PATCH 5/9] Fix excessive vertical spacing around Live Preview images (#261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rendered image is an inline (side:1) widget at end-of-line with the raw source hidden, so its host .cm-line still reserved a full text line-box (~one line-height) above and below the block figure — a phantom blank row, most visible beneath the image. Stamp a cm-image-embed-line line decoration only while the source is hidden and collapse that line's strut to line-height:0 (caption line-height restored); trim the figure margins. All scoped to .cm-editor, so Preview (.prose-zen) is unaffected. When the cursor reveals the source the class is absent, so the editable markdown keeps its normal line-height. Adds a live-preview test asserting the class toggles with source visibility. --- .../app-core/src/lib/cm-live-preview.test.ts | 47 +++++++++++++++++++ packages/app-core/src/lib/cm-live-preview.ts | 9 ++++ packages/app-core/src/styles/index.css | 17 ++++++- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/lib/cm-live-preview.test.ts b/packages/app-core/src/lib/cm-live-preview.test.ts index 416523f1..ac1400c9 100644 --- a/packages/app-core/src/lib/cm-live-preview.test.ts +++ b/packages/app-core/src/lib/cm-live-preview.test.ts @@ -5,6 +5,7 @@ import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { describe, expect, it, vi } from 'vitest' import { livePreviewPlugin } from './cm-live-preview' +import { useStore } from '../store' vi.mock('../store', () => { const state = { @@ -150,6 +151,52 @@ describe('livePreviewPlugin', () => { view.destroy() }) + it('collapses the host-line strut on a hidden-source image, restores it when editing (#261)', () => { + // The image widget is an inline (side:1) decoration, so its host line would + // otherwise reserve a full text line-box above/below the block figure. The + // plugin stamps `cm-image-embed-line` only while the source is hidden. + const store = useStore.getState() as unknown as { + vault: unknown + activeNote: unknown + assetFiles: Array<{ path: string }> + } + const original = { vault: store.vault, activeNote: store.activeNote, assetFiles: store.assetFiles } + ;(window as unknown as { zen: unknown }).zen = { + resolveVaultAssetUrl: () => 'asset://pic.png', + resolveLocalAssetUrl: () => 'asset://pic.png' + } + store.vault = { root: '/vault' } + store.activeNote = { path: 'inbox/Image Note.md' } + store.assetFiles = [{ path: 'inbox/pic.png' }] + try { + const doc = 'Above\n\n![sample](pic.png)\n\nBelow' + const view = mountEditor(doc, 0) // cursor on "Above" → image line inactive + + const figure = view.dom.querySelector('.cm-local-image-embed') + expect(figure).toBeTruthy() + const hostLine = figure!.closest('.cm-line') + expect(hostLine?.classList.contains('cm-image-embed-line')).toBe(true) + // Raw markdown stays hidden while the line is inactive. + expect(view.dom.textContent).not.toContain('![sample](pic.png)') + + // Move the caret onto the image line: source revealed, strut class gone. + view.dispatch({ selection: { anchor: doc.indexOf('![sample]') + 2 } }) + expect(view.dom.textContent).toContain('![sample](pic.png)') + const revealed = [...view.dom.querySelectorAll('.cm-line')].find((l) => + (l.textContent || '').includes('![sample](pic.png)') + ) + expect(revealed).toBeTruthy() + expect(revealed!.classList.contains('cm-image-embed-line')).toBe(false) + + view.destroy() + } finally { + store.vault = original.vault + store.activeNote = original.activeNote + store.assetFiles = original.assetFiles + delete (window as unknown as { zen?: unknown }).zen + } + }) + it('renders checkboxes for ordered, nested, and quoted tasks', () => { // Task variants the TASK_LINE_RE in shared/tasklists supports. Cursor on // the intro line so every task line is inactive (and thus rendered). diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index d256bfbc..af253cd2 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -50,6 +50,9 @@ const PREFIX_HIDE_WITH_SPACE = new Set(['HeaderMark', 'QuoteMark']) const hide = Decoration.replace({}) const imageSourceHide = Decoration.replace({}) +// Stamped on an image line only while its raw source is hidden, so the host +// line stops reserving a blank text row above/below the block figure (#261). +const imageEmbedLine = Decoration.line({ class: 'cm-image-embed-line' }) const STANDALONE_IMAGE_RE = /^\s*!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)\s*$/ const STANDALONE_OBSIDIAN_EMBED_RE = /^\s*!\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]\s*$/ // Anchor-style standalone PDF link: `[Label](file.pdf)` or `[Label]()`. @@ -659,6 +662,12 @@ function computeDecorations(view: EditorView): DecorationSet { to: line.to, deco: imageSourceHide }) + // Collapse the now text-less line's strut (see imageEmbedLine). + pending.push({ + from: line.from, + to: line.from, + deco: imageEmbedLine + }) } continue } diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 269b100b..5a9f7fe3 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -2711,7 +2711,22 @@ textarea, display: block; width: 100%; max-width: min(100%, 980px); - margin: 0.75rem 0 1rem; + margin: 0.5rem 0 0.6rem; +} + +/* The image widget is an inline (side:1) decoration, so its host line still + * reserves a full text line-box above and below the block figure — roughly one + * line-height of phantom vertical space on each side (issue #261). When the raw + * source is hidden (cursor off the line) the live-preview plugin stamps the line + * with `cm-image-embed-line`; collapse that line's strut to 0. The class is + * absent whenever the source markdown is revealed, so the editable text keeps + * its normal line-height. The caption restores its own line-height just below. */ +.cm-editor .cm-image-embed-line { + line-height: 0; +} + +.cm-editor .local-image-embed-caption { + line-height: 1.4; } /* --- PDF embed widget (CodeMirror live preview) ---------------------- */ From f71d4b5116eb493c52a6b25d9cb7f249bca67a12 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 11:24:06 -0500 Subject: [PATCH 6/9] Fix small images upscaling to full page in PDF export (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone local image is rendered as a .local-image-embed figure whose carries width:100% (correct for on-screen preview, where it fills the frame). In the export window that stretches even a tiny image to the full content width, and the max-height:9.3in cap added in #231 then blows it up to a full page — a regression surfaced by that fix. Override the embed in export so images size to their intrinsic dimensions, only ever scaling DOWN to fit the content width or page height, and shrink the frame/caption to hug the image. The .prose-zen-prefixed selectors beat the shared width:100% rule on specificity regardless of stylesheet order. Tall images still scale to fit the page (#231 preserved); wide images still cap to the content width. Verified via real printToPDF for small/wide/tall images. --- apps/desktop/src/renderer/export-window.tsx | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index f4b3109d..ed077de1 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -228,6 +228,30 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { max-height: 9.3in; object-fit: contain; } + /* A standalone local image is rendered as a .local-image-embed figure + whose carries width:100% (great on screen, fills the frame). + In export that stretches even a tiny image to the full content width, + and the max-height above then blows it up to a full page (#256, a + regression surfaced by #231). Here, size embeds to the image's + intrinsic dimensions instead — only ever scaling DOWN to fit the + content width or the page height — and shrink the frame/caption to + hug it. The .prose-zen prefix beats the shared (.prose-zen img-embed) + rule on specificity regardless of stylesheet order. */ + .export-note-shell .prose-zen .local-image-embed { + width: fit-content; + max-width: 100%; + margin-inline: auto; + } + .export-note-shell .prose-zen .local-image-embed-frame { + width: fit-content; + max-width: 100%; + } + .export-note-shell .prose-zen .local-image-embed-image { + width: auto; + height: auto; + max-width: 100%; + max-height: 9.3in; + } @media print { html, body, From bbacd2e3c0a67cfc136c11f76c80f79aea0e93e7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 12:07:21 -0500 Subject: [PATCH 7/9] Add a minimal home view when no note is open (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the blank 'No note selected' screen (outside Zen mode) with a light landing page: a greeting, quick-create actions (new note / database / drawing, plus daily / weekly when those are enabled), the few most recently edited notes, and today's open tasks with an overdue badge. Rows open on click; ↑/↓ (and j/k in vim mode) rove between them, Enter opens. Tasks are scanned lazily on first show. VimNav yields to the view via a data-home-nav guard. --- .../app-core/src/components/EditorPane.tsx | 11 +- packages/app-core/src/components/HomeView.tsx | 339 ++++++++++++++++++ packages/app-core/src/components/VimNav.tsx | 2 + 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 packages/app-core/src/components/HomeView.tsx diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index d196f36e..4f7a1c63 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -103,6 +103,7 @@ import { TasksView } from './TasksView' import { DatabaseView } from './DatabaseView' import { LazyExcalidrawView } from './LazyExcalidrawView' import { ObsidianExcalidrawPrompt } from './ObsidianExcalidrawPrompt' +import { HomeView } from './HomeView' import { isExcalidrawPath, isObsidianExcalidrawMarkdown, @@ -3394,7 +3395,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
Loading…
- ) : ( + ) : zenMode ? ( + ) : ( + { + toggleSidebar() + setFocusedPanel('sidebar') + }} + /> )}
{content && connectionsOpen && isActive && !zenMode && } diff --git a/packages/app-core/src/components/HomeView.tsx b/packages/app-core/src/components/HomeView.tsx new file mode 100644 index 00000000..0fcd81fb --- /dev/null +++ b/packages/app-core/src/components/HomeView.tsx @@ -0,0 +1,339 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { NoteMeta } from '@shared/ipc' +import type { VaultTask } from '@shared/tasks' +import { useStore } from '../store' +import { computeTasksRender } from '../lib/tasks-filter' +import { + ArrowUpRightIcon, + CalendarIcon, + CheckSquareIcon, + DatabaseIcon, + DocumentTextIcon, + ExcalidrawIcon, + NotePlusIcon, + PanelLeftIcon, + ZapIcon +} from './icons' + +const MAX_RECENT = 5 +const MAX_TASKS = 6 + +const NO_COLLAPSE = { today: false, upcoming: false, waiting: false, done: false } + +function greetingFor(date: Date): string { + const h = date.getHours() + if (h < 12) return 'Good morning' + if (h < 18) return 'Good afternoon' + return 'Good evening' +} + +/** Compact "edited 3h ago" style stamp; falls back to a short date past a week. */ +function timeAgo(ts: number, now: number): string { + const mins = Math.round((now - ts) / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.round(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.round(hrs / 24) + if (days === 1) return 'yesterday' + if (days < 7) return `${days}d ago` + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +/** A light landing view shown when no note is open: the few most recently + * edited notes plus the open tasks for today. Keyboard: ↑/↓ (and j/k in vim + * mode) move between rows, Enter opens. */ +export function HomeView({ + sidebarOpen, + onShowSidebar +}: { + sidebarOpen: boolean + onShowSidebar: () => void +}): JSX.Element { + const notes = useStore((s) => s.notes) + const vaultTasks = useStore((s) => s.vaultTasks) + const tasksLoading = useStore((s) => s.tasksLoading) + const vimMode = useStore((s) => s.vimMode) + const selectNote = useStore((s) => s.selectNote) + const openTaskAt = useStore((s) => s.openTaskAt) + const toggleTaskFromList = useStore((s) => s.toggleTaskFromList) + const refreshTasks = useStore((s) => s.refreshTasks) + const openTasksView = useStore((s) => s.openTasksView) + const vaultSettings = useStore((s) => s.vaultSettings) + const createAndOpen = useStore((s) => s.createAndOpen) + const createDatabase = useStore((s) => s.createDatabase) + const createDrawingAndOpen = useStore((s) => s.createDrawingAndOpen) + const openTodayDailyNote = useStore((s) => s.openTodayDailyNote) + const openWeeklyNoteForDate = useStore((s) => s.openWeeklyNoteForDate) + + const containerRef = useRef(null) + + // Quick-create actions. Daily/weekly only appear when enabled in settings + // (they default off), matching the command palette's gating. + const actions = useMemo void }>>(() => { + const list: Array<{ label: string; icon: JSX.Element; run: () => void }> = [ + { + label: 'New note', + icon: , + run: () => void createAndOpen('inbox', '') + }, + { + label: 'Database', + icon: , + run: () => void createDatabase('inbox', '') + }, + { + label: 'Drawing', + icon: , + run: () => void createDrawingAndOpen('inbox', '') + } + ] + if (vaultSettings?.dailyNotes?.enabled) { + list.push({ + label: 'Daily note', + icon: , + run: () => void openTodayDailyNote() + }) + } + if (vaultSettings?.weeklyNotes?.enabled) { + list.push({ + label: 'Weekly note', + icon: , + run: () => void openWeeklyNoteForDate(new Date()) + }) + } + return list + }, [ + vaultSettings?.dailyNotes?.enabled, + vaultSettings?.weeklyNotes?.enabled, + createAndOpen, + createDatabase, + createDrawingAndOpen, + openTodayDailyNote, + openWeeklyNoteForDate + ]) + + // Tasks are scanned lazily (normally on first Tasks-view open), so the home + // view kicks off its own scan when it has nothing yet. The view re-renders + // from the store once `vaultTasks` lands. + useEffect(() => { + if (vaultTasks.length === 0 && !tasksLoading) void refreshTasks() + // Intentionally mount-only: re-running on every task change would loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // One clock read per mount drives both the greeting and the relative stamps. + const now = useMemo(() => Date.now(), []) + + const recent = useMemo( + () => + notes + .filter((n) => n.folder !== 'trash' && n.folder !== 'archive') + .slice() + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, MAX_RECENT), + [notes] + ) + + const { today, overdueCount } = useMemo(() => { + const render = computeTasksRender(vaultTasks, '', new Date(now), NO_COLLAPSE) + return { today: render.groups.today, overdueCount: render.groups.overdueCount } + }, [vaultTasks, now]) + + const visibleTasks = today.slice(0, MAX_TASKS) + const hiddenTaskCount = today.length - visibleTasks.length + + // Focus the view on mount so keyboard navigation works without a click. + useEffect(() => { + containerRef.current?.focus() + }, []) + + // Roving focus across the recent-note + task rows (`[data-home-item]`). + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const down = e.key === 'ArrowDown' || (vimMode && e.key === 'j') + const up = e.key === 'ArrowUp' || (vimMode && e.key === 'k') + if (!down && !up) return + const items = Array.from( + containerRef.current?.querySelectorAll('[data-home-item]') ?? [] + ) + if (items.length === 0) return + e.preventDefault() + e.stopPropagation() + const current = items.indexOf(document.activeElement as HTMLElement) + let next: number + if (current < 0) next = 0 + else if (down) next = Math.min(current + 1, items.length - 1) + else next = Math.max(current - 1, 0) + items[next]?.focus() + }, + [vimMode] + ) + + const dateLine = new Date(now).toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric' + }) + + return ( +
+
+
+
+

+ {greetingFor(new Date(now))} +

+

{dateLine}

+
+ {!sidebarOpen && ( + + )} +
+ +
+ {actions.map((action) => ( + + ))} +
+ +
+ } text="Recent" /> + {recent.length > 0 ? ( +
    + {recent.map((note) => ( +
  • + +
  • + ))} +
+ ) : ( + + )} +
+ +
+ } + text="Today" + trailing={ + overdueCount > 0 ? ( + + {overdueCount} overdue + + ) : null + } + /> + {visibleTasks.length > 0 ? ( +
    + {visibleTasks.map((task) => ( +
  • + + +
  • + ))} +
+ ) : tasksLoading ? ( + + ) : ( + + )} + {hiddenTaskCount > 0 && ( + + )} +
+
+
+ ) +} + +function SectionLabel({ + icon, + text, + trailing +}: { + icon: React.ReactNode + text: string + trailing?: React.ReactNode +}): JSX.Element { + return ( +
+ {icon} + {text} + {trailing && {trailing}} +
+ ) +} + +function EmptyHint({ text }: { text: string }): JSX.Element { + return

{text}

+} diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 6bc8e92c..2fd9be20 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -319,6 +319,8 @@ export function VimNav(): JSX.Element | null { // The selection format toolbar handles its own keyboard navigation // (arrows / Enter / Esc) once focused — yield to it entirely. if (target?.closest('[data-selection-toolbar]')) return + // The home view owns its own roving-focus navigation (↑/↓/j/k/Enter). + if (target?.closest('[data-home-nav]')) return // The database/table view runs its own vim-style motion grid; yield to it // so sidebar/note-list navigation doesn't steal j/k/h/l etc. — EXCEPT the // pane prefix (Ctrl+W) and its pending direction key, so the grid can hand From 5677632fff1ce9d68ab950757badb14345aefdd6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 12:11:42 -0500 Subject: [PATCH 8/9] docs(help): document the home view (#258) --- packages/app-core/src/lib/help.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 305c8d99..a4e90445 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -189,6 +189,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'Select text in the editor and press `Mod+Alt+M` — or open the text menu with `m` — to start a comment, and toggle the Comments panel itself with `Mod+Shift+C`. ZenNotes stores note comments beside the note in vault metadata, then highlights the anchored text and line when the comment is active. In the panel, move with `j` / `k` and use `e` to edit, `r` to resolve, and `d` to delete.' }, + { + title: 'The home view is where you land', + body: + 'When no note is open (outside Zen mode), ZenNotes shows a light home view instead of a blank pane: a greeting, quick-create actions (new note, database, drawing — plus daily and weekly notes when those are enabled in Settings), your most recently edited notes, and today’s open tasks with an overdue count. Click a note or task to open it, tick a checkbox to complete a task in place, and use ↑/↓ — or j/k in Vim mode — then Enter to move and open from the keyboard.' + }, { title: 'Sessions restore on relaunch', body: From 1c900130ef15b80a353dc403ba4e9f118f136cab Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 25 Jun 2026 15:07:09 -0500 Subject: [PATCH 9/9] chore(release): bump version to 2.8.0 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- 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 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0400e646..ed941ede 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.7.0", + "version": "2.8.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index 3f6123bc..d7233267 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.7.0", + "version": "2.8.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index f567c9df..ef94802f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.7.0", + "version": "2.8.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 187055e8..08cd2c6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.7.0", + "version": "2.8.0", "workspaces": [ "apps/*", "packages/*" @@ -20,7 +20,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.7.0", + "version": "2.8.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -92,11 +92,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.7.0" + "version": "2.8.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.7.0", + "version": "2.8.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16849,7 +16849,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.7.0", + "version": "2.8.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16907,18 +16907,18 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.7.0" + "version": "2.8.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.7.0", + "version": "2.8.0", "dependencies": { "lz-string": "^1.5.0" } }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.7.0" + "version": "2.8.0" } } } diff --git a/package.json b/package.json index bebd6926..9a0b41a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.7.0", + "version": "2.8.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 1605f10a..9549a549 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.7.0", + "version": "2.8.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index e6773edd..ae83056e 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.7.0", + "version": "2.8.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index ced38b7f..4c61b903 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.7.0", + "version": "2.8.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index db45eeee..04b6b625 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.7.0", + "version": "2.8.0", "type": "module", "exports": { ".": "./src/index.ts"