refactor: extract inbox organize advance

This commit is contained in:
lucaronin
2026-05-12 02:27:53 +02:00
parent e84bd1a8cd
commit a3fdaee7ad
4 changed files with 208 additions and 31 deletions

View File

@@ -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).

View File

@@ -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)

View File

@@ -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<void>
onToggleOrganized?: (path: string) => Promise<boolean>
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<string | null>(activePath)
const requestedActiveTabPathRef = useRef<string | null>(activePath)
const visibleNotesRef = useRef<VaultEntry[]>(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()
})
})

View File

@@ -0,0 +1,84 @@
import { useCallback, type RefObject } from 'react'
import type { SidebarSelection, VaultEntry } from '../types'
interface UseInboxOrganizeAdvanceOptions {
activeTabPath: string | null
activeTabPathRef: RefObject<string | null>
autoAdvanceEnabled: boolean
entries: VaultEntry[]
onSelectNote: (entry: VaultEntry) => void | Promise<void>
onToggleOrganized: (path: string) => Promise<boolean>
requestedActiveTabPathRef: RefObject<string | null>
selection: SidebarSelection
visibleNotesRef: RefObject<VaultEntry[]>
}
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<UseInboxOrganizeAdvanceOptions, 'activeTabPath' | 'autoAdvanceEnabled' | 'selection'>,
): boolean {
return options.autoAdvanceEnabled
&& !entry.organized
&& options.activeTabPath === path
&& options.selection.kind === 'filter'
&& options.selection.filter === 'inbox'
}
function isStillFocusedOnPath(
path: string,
activeTabPathRef: RefObject<string | null>,
requestedActiveTabPathRef: RefObject<string | null>,
): boolean {
return activeTabPathRef.current === path
&& requestedActiveTabPathRef.current === path
}
export function useInboxOrganizeAdvance(options: UseInboxOrganizeAdvanceOptions): (path: string) => Promise<void> {
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,
])
}