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 { 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 { 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 } from './types' import type { NoteListItem } from './utils/ai-context' import { filterEntries } from './utils/noteListHelpers' import './App.css' // Type declaration for mock content storage declare global { interface Window { __mockContent?: Record } } const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } function useBulkActions( entryActions: { handleArchiveNote: (path: string) => Promise; handleTrashNote: (path: string) => Promise }, setToastMessage: (msg: string | null) => void, ) { const handleBulkArchive = useCallback(async (paths: string[]) => { for (const path of paths) await entryActions.handleArchiveNote(path) setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} archived`) }, [entryActions, setToastMessage]) const handleBulkTrash = useCallback(async (paths: string[]) => { for (const path of paths) await entryActions.handleTrashNote(path) setToastMessage(`${paths.length} note${paths.length > 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(DEFAULT_SELECTION) const layout = useLayoutPanels() const [toastMessage, setToastMessage] = useState(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) const { settings, saveSettings } = useSettings() const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) const autoSync = useAutoSync({ vaultPath: resolvedPath, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: vault.reloadVault, 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 indexing = useIndexing(resolvedPath) 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(() => { if (autoSync.conflictFiles.length === 0) return autoSync.pausePull() conflictResolver.initFiles(autoSync.conflictFiles) dialogs.openConflictResolver() }, [autoSync, conflictResolver, dialogs]) 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>(() => {}) const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, 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 }) // 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 }) // MCP UI bridge: react to AI-driven open/highlight/vault-change events const aiActivity = useAiActivity({ onOpenNote: (path) => { const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`) if (entry) notes.handleSelectNote(entry) }, onOpenTab: (path) => { const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`) if (entry) notes.handleSelectNote(entry) }, onSetFilter: (filterType) => { setSelection({ kind: 'sectionGroup', type: filterType }) }, onVaultChanged: () => { vault.reloadVault() }, }) 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]) // 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) { notes.handleSelectNote(entry) dialogs.closeConflictResolver() } } }, [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, }) 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 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, conflictCount: autoSync.conflictFiles.length, 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, isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, vaultCount: vaultSwitcher.allVaults.length, mcpStatus, onInstallMcp: installMcp, }) const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null const aiNoteList = useMemo(() => { 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 (
) } // Show loading spinner while checking vault if (onboarding.state.status === 'loading') { return (
Loading…
) } return (
{sidebarVisible && ( <>
)} {noteListVisible && ( <>
)}
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} />
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} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> setToastMessage(null)} /> { dialogs.closeGitHubVault(); dialogs.openSettings() }} onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })} />
) } export default App