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/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/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, 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 c2f7c516..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", @@ -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", @@ -16840,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", @@ -16898,15 +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 23a7dcdf..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": { @@ -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/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/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index b39aabd3..4f7a1c63 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -102,7 +102,13 @@ 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 { HomeView } from './HomeView' +import { + isExcalidrawPath, + isObsidianExcalidrawMarkdown, + isObsidianExcalidrawPath +} from '@shared/excalidraw' import { TagView } from './TagView' import { HelpView } from './HelpView' import { ArchiveView } from './ArchiveView' @@ -3298,6 +3304,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) : activeTab && isExcalidrawPath(activeTab) ? ( + ) : activeTab && + content && + (isObsidianExcalidrawPath(activeTab) || + isObsidianExcalidrawMarkdown(content.body)) ? ( + ) : content ? (
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/ObsidianExcalidrawPrompt.tsx b/packages/app-core/src/components/ObsidianExcalidrawPrompt.tsx new file mode 100644 index 00000000..2f14e64f --- /dev/null +++ b/packages/app-core/src/components/ObsidianExcalidrawPrompt.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { useStore } from '../store' +import { Button } from './ui/Button' + +/** + * Shown in place of the raw markdown when the open file is an Obsidian Excalidraw + * drawing. Offers to convert it into a native ZenNotes `.excalidraw` drawing + * (which then opens in the drawing editor). The original file is left in place. (#266) + */ +export function ObsidianExcalidrawPrompt({ path }: { path: string }): JSX.Element { + const [converting, setConverting] = useState(false) + const [error, setError] = useState(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/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index a069296b..8176b8c9 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', @@ -2557,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 ( <>
-
)} - + + )}
@@ -2676,7 +2943,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 +3312,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}
} 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/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/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: 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/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) ---------------------- */ 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/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..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" @@ -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) +} 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" 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) })