refactor: extract inbox organize advance
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
42
src/App.tsx
42
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)
|
||||
|
||||
111
src/hooks/useInboxOrganizeAdvance.test.ts
Normal file
111
src/hooks/useInboxOrganizeAdvance.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
84
src/hooks/useInboxOrganizeAdvance.ts
Normal file
84
src/hooks/useInboxOrganizeAdvance.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user