Files
tolaria/src/App.tsx
Test 98cad76aa0 feat: fast note open — use allContent cache to skip IPC disk reads
handleSelectNote and handleReplaceActiveTab now check the in-memory
allContent cache before issuing a Tauri IPC call. Cache hits open the
tab synchronously (zero latency). Cache misses fall back to the disk
read and populate allContent via onContentLoaded so subsequent opens
of the same note are instant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:33:57 +01:00

683 lines
33 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
import { ResizeHandle } from './components/ResizeHandle'
import { CreateTypeDialog } from './components/CreateTypeDialog'
import { QuickOpenPalette } from './components/QuickOpenPalette'
import { CommandPalette } from './components/CommandPalette'
import { SearchPanel } from './components/SearchPanel'
import { Toast } from './components/Toast'
import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAiActivity } from './hooks/useAiActivity'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import { flushEditorContent } from './utils/autoSave'
import './App.css'
// Type declarations for mock content storage and test overrides
declare global {
interface Window {
__mockContent?: Record<string, string>
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
__mockHandlers?: Record<string, (args: any) => any>
}
}
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function useBulkActions(
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleArchiveNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash }
}
function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const layout = useLayoutPanels()
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
// onSwitch closure captures `notes` declared below — safe because it's only
// called on user interaction, never during render (refs inside the hook
// guarantee the latest closure is always used).
const vaultSwitcher = useVaultSwitcher({
onSwitch: () => { setSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const indexing = useIndexing(resolvedPath)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onSyncUpdated: indexing.triggerIncrementalIndex,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
// Ref bridges for conflict resolution callbacks (notes declared below)
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
onResolved: () => {
dialogs.closeConflictResolver()
autoSync.resumePull()
vault.reloadVault()
autoSync.triggerSync()
},
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
})
const handleOpenConflictResolver = useCallback(async () => {
let files = autoSync.conflictFiles
// If no cached conflicts, check directly — there may be pre-existing
// conflicts from a prior session that the pull flow didn't detect.
if (files.length === 0) {
try {
files = isTauri()
? await invoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
: await mockInvoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
} catch {
return
}
if (files.length === 0) {
setToastMessage('No merge conflicts to resolve')
return
}
}
autoSync.pausePull()
conflictResolver.initFiles(files)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs, resolvedPath])
const handleCloseConflictResolver = useCallback(() => {
autoSync.resumePull()
dialogs.closeConflictResolver()
}, [autoSync, dialogs])
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
// Stable ref for allContent so getCachedContent callback never changes identity
const allContentForCacheRef = useRef(vault.allContent)
allContentForCacheRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern
const getCachedContent = useCallback((path: string) => allContentForCacheRef.current[path], [])
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, getCachedContent })
// Keep tab entries in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
useEffect(() => {
notes.setTabs(prev => {
let changed = false
const next = prev.map(tab => {
const fresh = vault.entries.find(e => e.path === tab.entry.path)
if (fresh && fresh !== tab.entry) {
changed = true
return { ...tab, entry: fresh }
}
return tab
})
return changed ? next : prev
})
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
const navHistory = useNavigationHistory()
// Push to navigation history whenever the active tab changes (user-initiated)
const navFromHistoryRef = useRef(false)
useEffect(() => {
if (notes.activeTabPath && !navFromHistoryRef.current) {
navHistory.push(notes.activeTabPath)
}
navFromHistoryRef.current = false
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
const handleGoBack = useCallback(() => {
const target = navHistory.goBack(isEntryExists)
if (target) {
navFromHistoryRef.current = true
if (notes.tabs.some(t => t.entry.path === target)) {
notes.handleSwitchTab(target)
} else {
const entry = vault.entries.find(e => e.path === target)
if (entry) notes.handleSelectNote(entry)
}
}
}, [navHistory, isEntryExists, vault.entries, notes])
const handleGoForward = useCallback(() => {
const target = navHistory.goForward(isEntryExists)
if (target) {
navFromHistoryRef.current = true
if (notes.tabs.some(t => t.entry.path === target)) {
notes.handleSwitchTab(target)
} else {
const entry = vault.entries.find(e => e.path === target)
if (entry) notes.handleSelectNote(entry)
}
}
}, [navHistory, isEntryExists, vault.entries, notes])
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// O(1) path lookup map — rebuilt only when vault.entries changes
const entriesByPath = useMemo(() => {
const map = new Map<string, VaultEntry>()
for (const e of vault.entries) map.set(e.path, e)
return map
}, [vault.entries])
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
const openNoteByPath = useCallback((path: string) => {
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
if (entry) {
notes.handleSelectNote(entry)
} else {
// Entry not yet in vault (just created) — reload then open
vault.reloadVault().then(freshEntries => {
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (fresh) notes.handleSelectNote(fresh)
})
}
}, [entriesByPath, vault, notes, resolvedPath])
const aiActivity = useAiActivity({
onOpenNote: openNoteByPath,
onOpenTab: openNoteByPath,
onSetFilter: (filterType) => {
setSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
})
// Stable callback for Pulse "open note" — never triggers reloadVault.
// Pulse files always exist in the vault; if somehow not found, silently skip.
const handlePulseOpenNote = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
if (entry) notes.handleSelectNote(entry)
}, [entriesByPath, resolvedPath, notes])
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
const handleAgentFileCreated = useCallback((relativePath: string) => {
vault.reloadVault().then(freshEntries => {
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
if (entry) notes.handleSelectNote(entry)
})
}, [vault, notes, resolvedPath])
const handleAgentFileModified = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath
if (notes.tabs.some(t => t.entry.path === matchPath)) {
vault.reloadVault()
}
}, [vault, notes, resolvedPath])
const handleAgentVaultChanged = useCallback(() => {
vault.reloadVault()
}, [vault])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
triggerIncrementalIndex()
}, [vault, triggerIncrementalIndex])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave,
onNotePersisted: vault.clearUnsaved,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change)
const tabsRef = useRef(notes.tabs)
tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
const allContentRef = useRef(vault.allContent)
allContentRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern
const unsavedPathsRef = useRef(vault.unsavedPaths)
unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
/** Auto-save unsaved editor content before a destructive action (trash/archive). */
const { updateContent: vaultUpdateContent, clearUnsaved: vaultClearUnsaved } = vault
const flushBeforeAction = useCallback(async (path: string) => {
try {
await flushEditorContent(path, {
savePendingForPath,
getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content,
isUnsaved: (p) => unsavedPathsRef.current.has(p),
getSavedContent: (p) => allContentRef.current[p],
onSaved: (p, c) => { vaultUpdateContent(p, c); vaultClearUnsaved(p) },
})
} catch (err) {
setToastMessage(`Auto-save failed: ${err}`)
throw err
}
}, [savePendingForPath, vaultUpdateContent, vaultClearUnsaved, setToastMessage])
// Wire conflict file opener now that notes is available
useEffect(() => {
openConflictFileRef.current = (relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
// Markdown note — open inside Laputa editor
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
} else {
// Non-note file (e.g. .laputa-cache.json, settings.json) —
// open with system default app so the user can inspect/edit it
openLocalFile(fullPath)
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
? { path: activeTab.entry.path, content: activeTab.content }
: undefined
await handleSaveRaw(fallback)
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
const entryActions = useEntryActions({
entries: vault.entries, updateEntry: vault.updateEntry,
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onFrontmatterPersisted: vault.loadModifiedFiles,
onBeforeAction: flushBeforeAction,
})
const handleDeleteNote = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
notes.handleCloseTab(path)
vault.removeEntry(path)
setToastMessage('Note permanently deleted')
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
}
}, [notes, vault, setToastMessage])
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)
setToastMessage(`Type "${name}" created`)
}, [notes])
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, resolvedPath, vault, savePendingForPath])
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
const handleTitleSync = useCallback((path: string, newTitle: string) => {
vault.updateEntry(path, { title: newTitle })
notes.setTabs(prev => prev.map(t =>
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
))
}, [vault, notes])
const bulkActions = useBulkActions(entryActions, setToastMessage)
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
const rawToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const zoom = useZoom()
const buildNumber = useBuildNumber()
const { status: updateStatus, actions: updateActions } = useUpdater()
const handleCheckForUpdates = useCallback(async () => {
if (updateStatus.state === 'downloading') {
setToastMessage('Update is downloading…')
return
}
if (updateStatus.state === 'ready') {
await restartApp()
return
}
const result = await updateActions.checkForUpdates()
if (result === 'up-to-date') {
setToastMessage("You're on the latest version")
} else if (result === 'error') {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, updateStatus.state, setToastMessage])
const handleRestoreDefaultThemes = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('restore_default_themes', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to restore themes: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
entries: vault.entries, allContent: vault.allContent,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
selection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate,
onOpenDailyNote: notes.handleOpenDailyNote,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onResolveConflicts: handleOpenConflictResolver,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: () => rawToggleRef.current(),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => {
const path = await themeManager.createTheme()
const freshEntries = await vault.reloadVault()
setSelection({ kind: 'sectionGroup', type: 'Theme' })
if (path) {
const entry = freshEntries.find(e => e.path === path)
if (entry) notes.handleSelectNote(entry)
}
},
onOpenTheme: (themeId: string) => {
const entry = vault.entries.find(e => e.path === themeId)
if (entry) notes.handleSelectNote(entry)
},
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
onRestoreDefaultThemes: handleRestoreDefaultThemes,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onReindexVault: indexing.triggerFullReindex,
onRepairVault: handleRepairVault,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const aiNoteList = useMemo<NoteListItem[]>(() => {
return filterEntries(vault.entries, selection).map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
return { type: null, query: '' }
}, [selection])
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
const defaultPath = onboarding.state.defaultPath
return (
<div className="app-shell">
<WelcomeScreen
mode={onboarding.state.status === 'welcome' ? 'welcome' : 'vault-missing'}
missingPath={onboarding.state.status === 'vault-missing' ? onboarding.state.vaultPath : undefined}
defaultVaultPath={defaultPath}
onCreateVault={onboarding.handleCreateVault}
onOpenFolder={onboarding.handleOpenFolder}
creating={onboarding.creating}
error={onboarding.error}
/>
</div>
)
}
// Show loading spinner while checking vault
if (onboarding.state.status === 'loading') {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
return (
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onSwitchTab={notes.handleSwitchTab}
onCloseTab={notes.handleCloseTab}
onReorderTabs={notes.handleReorderTabs}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)}
inspectorWidth={layout.inspectorWidth}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
allContent={vault.allContent}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
onContentChange={handleContentChange}
onSave={handleSave}
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
<GitHubVaultModal
open={dialogs.showGitHubVault}
githubToken={settings.github_token}
onClose={dialogs.closeGitHubVault}
onVaultCloned={vaultSwitcher.handleVaultCloned}
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
/>
</div>
)
}
export default App