diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 65d0f578..3e824992 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -217,7 +217,7 @@ flowchart TD ``` - **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. -- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. +- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). diff --git a/src/App.tsx b/src/App.tsx index 5481d07e..5bfd2b65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -124,6 +124,7 @@ import { activeGitRepositories } from './utils/gitRepositories' import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useWorkspaceGraphState' import { useGitSetupState } from './hooks/useGitSetupState' import { useAppPreferences } from './hooks/useAppPreferences' +import { useInboxOrganizeAdvance } from './hooks/useInboxOrganizeAdvance' import './App.css' const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror' @@ -145,12 +146,6 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION -function getNextVisibleInboxEntry(entries: VaultEntry[], currentPath: string): VaultEntry | null { - const currentIndex = entries.findIndex((entry) => entry.path === currentPath) - if (currentIndex < 0) return null - return entries[currentIndex + 1] ?? null -} - function shouldPreferOnboardingVaultPath( onboardingState: { status: string; vaultPath?: string }, vaults: Array<{ path: string }>, @@ -1638,30 +1633,17 @@ function App() { const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath) return hasNoteIconValue(entry?.icon) }, [notes.activeTabPath, vault.entries]) - const handleToggleOrganizedWithInboxAdvance = useCallback(async (path: string) => { - const entry = visibleEntries.find((candidate) => candidate.path === path) - if (!entry) return - - const shouldAutoAdvance = settings.auto_advance_inbox_after_organize === true - && !entry.organized - && notes.activeTabPath === path - && effectiveSelection.kind === 'filter' - && effectiveSelection.filter === 'inbox' - const nextVisibleInboxEntry = shouldAutoAdvance - ? getNextVisibleInboxEntry(visibleNotesRef.current, path) - : null - - const organized = await entryActions.handleToggleOrganized(path) - - if ( - organized - && nextVisibleInboxEntry - && notes.activeTabPathRef.current === path - && notes.requestedActiveTabPathRef.current === path - ) { - void notes.handleSelectNote(nextVisibleInboxEntry) - } - }, [effectiveSelection, entryActions, notes, settings.auto_advance_inbox_after_organize, visibleEntries]) + const handleToggleOrganizedWithInboxAdvance = useInboxOrganizeAdvance({ + activeTabPath: notes.activeTabPath, + activeTabPathRef: notes.activeTabPathRef, + autoAdvanceEnabled: settings.auto_advance_inbox_after_organize === true, + entries: visibleEntries, + onSelectNote: notes.handleSelectNote, + onToggleOrganized: entryActions.handleToggleOrganized, + requestedActiveTabPathRef: notes.requestedActiveTabPathRef, + selection: effectiveSelection, + visibleNotesRef, + }) const toggleOrganizedCommand = explicitOrganizationEnabled ? handleToggleOrganizedWithInboxAdvance : undefined const canCustomizeNoteListColumns = useMemo(() => ( canCustomizeColumnsForSelection(effectiveSelection, explicitOrganizationEnabled) diff --git a/src/hooks/useInboxOrganizeAdvance.test.ts b/src/hooks/useInboxOrganizeAdvance.test.ts new file mode 100644 index 00000000..7f8372e1 --- /dev/null +++ b/src/hooks/useInboxOrganizeAdvance.test.ts @@ -0,0 +1,111 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useRef } from 'react' +import { useInboxOrganizeAdvance } from './useInboxOrganizeAdvance' +import type { SidebarSelection, VaultEntry } from '../types' + +function makeEntry(path: string, organized = false): VaultEntry { + const filename = path.split('/').pop() ?? path + return { + aliases: [], + archived: false, + belongsTo: [], + color: null, + createdAt: null, + favorite: false, + favoriteIndex: null, + fileSize: 0, + filename, + hasH1: true, + icon: null, + isA: 'Note', + listPropertiesDisplay: [], + modifiedAt: null, + order: null, + organized, + outgoingLinks: [], + path, + properties: {}, + relatedTo: [], + relationships: {}, + sidebarLabel: null, + snippet: '', + sort: null, + status: null, + template: null, + title: filename.replace(/\.md$/, ''), + view: null, + visible: null, + wordCount: 0, + } +} + +function renderInboxAdvance(options: { + activePath?: string | null + autoAdvanceEnabled?: boolean + entries?: VaultEntry[] + onSelectNote?: (entry: VaultEntry) => void | Promise + onToggleOrganized?: (path: string) => Promise + selection?: SidebarSelection + visibleEntries?: VaultEntry[] +}) { + const entries = options.entries ?? [ + makeEntry('/vault/first.md'), + makeEntry('/vault/second.md'), + ] + const activePath = options.activePath ?? entries[0]?.path ?? null + + return renderHook(() => { + const activeTabPathRef = useRef(activePath) + const requestedActiveTabPathRef = useRef(activePath) + const visibleNotesRef = useRef(options.visibleEntries ?? entries) + + return { + organize: useInboxOrganizeAdvance({ + activeTabPath: activePath, + activeTabPathRef, + autoAdvanceEnabled: options.autoAdvanceEnabled ?? true, + entries, + onSelectNote: options.onSelectNote ?? vi.fn(), + onToggleOrganized: options.onToggleOrganized ?? vi.fn().mockResolvedValue(true), + requestedActiveTabPathRef, + selection: options.selection ?? { kind: 'filter', filter: 'inbox' }, + visibleNotesRef, + }), + requestedActiveTabPathRef, + } + }) +} + +describe('useInboxOrganizeAdvance', () => { + it('opens the next visible inbox note after organizing the active note', async () => { + const entries = [makeEntry('/vault/first.md'), makeEntry('/vault/second.md')] + const onSelectNote = vi.fn() + const { result } = renderInboxAdvance({ entries, onSelectNote }) + + await act(async () => { + await result.current.organize('/vault/first.md') + }) + + expect(onSelectNote).toHaveBeenCalledWith(entries[1]) + }) + + it('does not advance when focus changed during organization', async () => { + const entries = [makeEntry('/vault/first.md'), makeEntry('/vault/second.md')] + const onSelectNote = vi.fn() + const hook = renderInboxAdvance({ + entries, + onSelectNote, + onToggleOrganized: async () => { + hook.result.current.requestedActiveTabPathRef.current = '/vault/other.md' + return true + }, + }) + + await act(async () => { + await hook.result.current.organize('/vault/first.md') + }) + + expect(onSelectNote).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useInboxOrganizeAdvance.ts b/src/hooks/useInboxOrganizeAdvance.ts new file mode 100644 index 00000000..3a74c014 --- /dev/null +++ b/src/hooks/useInboxOrganizeAdvance.ts @@ -0,0 +1,84 @@ +import { useCallback, type RefObject } from 'react' +import type { SidebarSelection, VaultEntry } from '../types' + +interface UseInboxOrganizeAdvanceOptions { + activeTabPath: string | null + activeTabPathRef: RefObject + autoAdvanceEnabled: boolean + entries: VaultEntry[] + onSelectNote: (entry: VaultEntry) => void | Promise + onToggleOrganized: (path: string) => Promise + requestedActiveTabPathRef: RefObject + selection: SidebarSelection + visibleNotesRef: RefObject +} + +function nextVisibleEntryAfter(entries: VaultEntry[], currentPath: string): VaultEntry | null { + const currentIndex = entries.findIndex((entry) => entry.path === currentPath) + const nextEntry = entries[currentIndex + 1] + return currentIndex >= 0 && nextEntry ? nextEntry : null +} + +function shouldAdvanceAfterOrganize( + entry: VaultEntry, + path: string, + options: Pick, +): boolean { + return options.autoAdvanceEnabled + && !entry.organized + && options.activeTabPath === path + && options.selection.kind === 'filter' + && options.selection.filter === 'inbox' +} + +function isStillFocusedOnPath( + path: string, + activeTabPathRef: RefObject, + requestedActiveTabPathRef: RefObject, +): boolean { + return activeTabPathRef.current === path + && requestedActiveTabPathRef.current === path +} + +export function useInboxOrganizeAdvance(options: UseInboxOrganizeAdvanceOptions): (path: string) => Promise { + const { + activeTabPath, + activeTabPathRef, + autoAdvanceEnabled, + entries, + onSelectNote, + onToggleOrganized, + requestedActiveTabPathRef, + selection, + visibleNotesRef, + } = options + + return useCallback(async (path: string) => { + const entry = entries.find((candidate) => candidate.path === path) + if (!entry) return + + const nextEntry = shouldAdvanceAfterOrganize(entry, path, { + activeTabPath, + autoAdvanceEnabled, + selection, + }) + ? nextVisibleEntryAfter(visibleNotesRef.current, path) + : null + + const organized = await onToggleOrganized(path) + if (!organized || !nextEntry) return + if (!isStillFocusedOnPath(path, activeTabPathRef, requestedActiveTabPathRef)) return + + void onSelectNote(nextEntry) + }, [ + activeTabPath, + activeTabPathRef, + autoAdvanceEnabled, + entries, + onSelectNote, + onToggleOrganized, + requestedActiveTabPathRef, + selection, + visibleNotesRef, + ]) +}