import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import type { DeletedNoteEntry } from './components/note-list/noteListUtils' import { Editor } from './components/Editor' import { ResizeHandle } from './components/ResizeHandle' import { CreateTypeDialog } from './components/CreateTypeDialog' import { CreateViewDialog } from './components/CreateViewDialog' 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 { CloneVaultModal } from './components/CloneVaultModal' import { WelcomeScreen } from './components/WelcomeScreen' import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt' import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' import { FeedbackDialog } from './components/FeedbackDialog' import { useTelemetry } from './hooks/useTelemetry' import { useMcpStatus } from './hooks/useMcpStatus' import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus' import { useAutoGit } from './hooks/useAutoGit' import { useVaultLoader } from './hooks/useVaultLoader' import { useAiAgentPreferences } from './hooks/useAiAgentPreferences' import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' import { slugify } from './hooks/useNoteCreation' import { useCommitFlow } from './hooks/useCommitFlow' import { useGitRemoteStatus } from './hooks/useGitRemoteStatus' import { useViewMode, type ViewMode } from './hooks/useViewMode' import { useEntryActions } from './hooks/useEntryActions' import { useAppCommands } from './hooks/useAppCommands' import { triggerCommitEntryAction } from './utils/commitEntryAction' import { generateCommitMessage } from './utils/commitMessage' import { useDialogs } from './hooks/useDialogs' import { useVaultSwitcher } from './hooks/useVaultSwitcher' import { useGitHistory } from './hooks/useGitHistory' import { useUpdater, restartApp } from './hooks/useUpdater' import { useAutoSync } from './hooks/useAutoSync' import { useConflictResolver } from './hooks/useConflictResolver' import { useZoom } from './hooks/useZoom' import { useVaultConfig } from './hooks/useVaultConfig' import { useBuildNumber } from './hooks/useBuildNumber' import { useOnboarding } from './hooks/useOnboarding' import { useGettingStartedClone } from './hooks/useGettingStartedClone' import { useNetworkStatus } from './hooks/useNetworkStatus' import { useAppNavigation } from './hooks/useAppNavigation' import { applyMainWindowSizeConstraints, getMainWindowMinWidth, useMainWindowSizeConstraints, } from './hooks/useMainWindowSizeConstraints' import { useAiActivity } from './hooks/useAiActivity' import { useBulkActions } from './hooks/useBulkActions' import { useDeleteActions } from './hooks/useDeleteActions' import { useLayoutPanels } from './hooks/useLayoutPanels' import { useConflictFlow } from './hooks/useConflictFlow' import { useAppSave } from './hooks/useAppSave' import { useVaultBridge } from './hooks/useVaultBridge' import type { CommitDiffRequest } from './hooks/useDiffMode' import { ConflictResolverModal } from './components/ConflictResolverModal' import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' import { DeleteProgressNotice } from './components/DeleteProgressNotice' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types' import type { NoteListItem } from './utils/ai-context' import { initializeNoteProperties } from './utils/initializeNoteProperties' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' import { openNoteInNewWindow } from './utils/openNoteWindow' import { refreshPulledVaultState } from './utils/pulledVaultRefresh' import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands' import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents' import { trackEvent } from './lib/telemetry' import { buildVaultAiGuidanceRefreshKey, } from './lib/vaultAiGuidance' import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils' import { hasNoteIconValue } from './utils/noteIcon' import { filenameStemToTitle } from './utils/noteTitle' import { focusNoteListContainer, isEditableElement, isEditorEscapeTarget, popNeighborhoodHistory, pushNeighborhoodHistory, shouldProcessNeighborhoodEscape, } from './utils/neighborhoodHistory' import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge' import { INBOX_SELECTION, isExplicitOrganizationEnabled, sanitizeSelectionForOrganization, } from './utils/organizationWorkflow' import './App.css' // Type declarations for mock content storage and test overrides declare global { interface Window { __mockContent?: Record // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides __mockHandlers?: Record any> } } const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION function shouldPreferOnboardingVaultPath( onboardingState: { status: string; vaultPath?: string }, vaults: Array<{ path: string }>, ): onboardingState is { status: 'ready'; vaultPath: string } { return onboardingState.status === 'ready' && typeof onboardingState.vaultPath === 'string' && onboardingState.vaultPath.length > 0 && !vaults.some((vault) => vault.path === onboardingState.vaultPath) } async function resolveNoteWindowEntry( noteWindowParams: NoteWindowParams, entries: VaultEntry[], ): Promise { const fallbackEntry = () => findNoteWindowEntry(entries, noteWindowParams) if (!isTauri()) { return fallbackEntry() } for (const path of getNoteWindowPathCandidates(noteWindowParams)) { try { return await invoke('reload_vault_entry', { path }) } catch { // Try the next normalized candidate before falling back to the scanned entries. } } return fallbackEntry() } function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry { const filename = relativePath.split('/').pop() ?? relativePath return { path: fullPath, filename, title: filenameStemToTitle(filename), isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, archived: false, modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], outgoingLinks: [], properties: {}, hasH1: true, fileKind: 'markdown', __deletedNotePreview: true, __deletedRelativePath: relativePath, __changeAddedLines: null, __changeDeletedLines: null, __changeBinary: false, } } /** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */ function App() { const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, []) const [selection, setSelection] = useState(DEFAULT_SELECTION) const [noteListFilter, setNoteListFilter] = useState('open') const selectionRef = useRef(DEFAULT_SELECTION) const neighborhoodHistoryRef = useRef([]) const inboxPeriod: InboxPeriod = 'all' const handleSetSelection = useCallback((sel: SidebarSelection, options?: { preserveNeighborhoodHistory?: boolean }) => { if (!options?.preserveNeighborhoodHistory && sel.kind !== 'entity') { neighborhoodHistoryRef.current = [] } setSelection(sel) setNoteListFilter('open') }, []) const handleEnterNeighborhood = useCallback((entry: VaultEntry) => { const nextSelection: SidebarSelection = { kind: 'entity', entry } neighborhoodHistoryRef.current = pushNeighborhoodHistory( neighborhoodHistoryRef.current, selectionRef.current, nextSelection, ) handleSetSelection(nextSelection, { preserveNeighborhoodHistory: true }) }, [handleSetSelection]) const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined) const { setInspectorCollapsed } = layout const visibleNotesRef = useRef([]) const multiSelectionCommandRef = useRef(null) const [toastMessage, setToastMessage] = useState(null) const dialogs = useDialogs() const { showAIChat, toggleAIChat } = dialogs const [showFeedback, setShowFeedback] = useState(false) const openFeedback = useCallback(() => setShowFeedback(true), []) const closeFeedback = useCallback(() => setShowFeedback(false), []) const networkStatus = useNetworkStatus() useEffect(() => { const handleOpenAiChat = () => { if (!showAIChat) toggleAIChat() } window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat) return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat) }, [showAIChat, toggleAIChat]) // 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: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() }, onToast: (msg) => setToastMessage(msg), }) const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => { if (!vaultPath) return if (allVaults.some((vault) => vault.path === vaultPath)) { switchVault(vaultPath) return } const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault' handleVaultCloned(vaultPath, label) }, [allVaults, handleVaultCloned, switchVault]) const handleGettingStartedVaultReady = useCallback((vaultPath: string) => { rememberOnboardingVaultChoice(vaultPath) setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`) }, [rememberOnboardingVaultChoice]) const cloneGettingStartedVault = useGettingStartedClone({ onError: (message) => setToastMessage(message), onSuccess: handleGettingStartedVaultReady, }) const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => { handleGettingStartedVaultReady(vaultPath) }, vaultSwitcher.loaded) const aiAgentsStatus = useAiAgentsStatus() const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams) const lastHandledOnboardingUserVaultPathRef = useRef(null) useEffect(() => { const onboardingVaultPath = onboarding.userReadyVaultPath if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath if (onboardingVaultPath !== vaultSwitcher.vaultPath) { rememberOnboardingVaultChoice(onboardingVaultPath) } }, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath]) // Onboarding can briefly own the vault path for a newly created/opened vault // before the persisted switcher catches up, but once the path is already in // the switcher list we should trust the explicit switcher state. const resolvedPath = noteWindowParams?.vaultPath ?? ( shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults) ? onboarding.state.vaultPath : vaultSwitcher.vaultPath ) // Git repo check: 'checking' | 'required' | 'ready' const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking') useEffect(() => { if (!resolvedPath) return setGitRepoState('checking') const check = isTauri() ? invoke('is_git_repo', { vaultPath: resolvedPath }) : Promise.resolve(true) // browser mock: assume git check .then(isGit => setGitRepoState(isGit ? 'ready' : 'required')) .catch(() => setGitRepoState('ready')) // fail open }, [resolvedPath]) const handleInitGitRepo = useCallback(async () => { if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath }) setGitRepoState('ready') }, [resolvedPath]) const vault = useVaultLoader(resolvedPath) const { status: vaultAiGuidanceStatus, refresh: refreshVaultAiGuidance, } = useVaultAiGuidanceStatus( resolvedPath, buildVaultAiGuidanceRefreshKey(vault.entries), ) const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) useEffect(() => { selectionRef.current = effectiveSelection }, [effectiveSelection]) useEffect(() => { if (effectiveSelection !== selection) { if (effectiveSelection.kind !== 'entity') { neighborhoodHistoryRef.current = [] } setSelection(effectiveSelection) setNoteListFilter('open') } }, [effectiveSelection, selection]) const handleNeighborhoodHistoryBack = useCallback(() => { const { previousSelection, nextHistory } = popNeighborhoodHistory(neighborhoodHistoryRef.current) if (!previousSelection) return false neighborhoodHistoryRef.current = nextHistory handleSetSelection(previousSelection, { preserveNeighborhoodHistory: true }) requestAnimationFrame(() => { focusNoteListContainer(document) }) return true }, [handleSetSelection]) const shouldBlockNeighborhoodEscape = ( dialogs.showCreateTypeDialog || dialogs.showQuickOpen || dialogs.showCommandPalette || dialogs.showAIChat || dialogs.showSettings || dialogs.showCloneVault || dialogs.showSearch || dialogs.showConflictResolver || dialogs.showCreateViewDialog || showFeedback ) useEffect(() => { const handleWindowKeyDown = (event: KeyboardEvent) => { if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return const activeElement = document.activeElement if (isEditorEscapeTarget(activeElement)) { event.preventDefault() activeElement.blur() requestAnimationFrame(() => { focusNoteListContainer(document) }) return } if (isEditableElement(activeElement)) return if (handleNeighborhoodHistoryBack()) { event.preventDefault() } } window.addEventListener('keydown', handleWindowKeyDown) return () => window.removeEventListener('keydown', handleWindowKeyDown) }, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape]) const handleSaveExplicitOrganization = useCallback((enabled: boolean) => { updateConfig('inbox', { noteListProperties: vaultConfig.inbox?.noteListProperties ?? null, explicitOrganization: enabled, }) }, [updateConfig, vaultConfig.inbox?.noteListProperties]) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() const aiAgentPreferences = useAiAgentPreferences({ settings, saveSettings, aiAgentsStatus, onToast: setToastMessage, }) useTelemetry(settings, settingsLoaded) const vaultOpenedRef = useRef('') useEffect(() => { if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) { vaultOpenedRef.current = resolvedPath trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length }) } }, [vault.entries.length, gitRepoState, resolvedPath]) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) const gitRemoteStatus = useGitRemoteStatus(resolvedPath) // Detect external file renames on window focus const [detectedRenames, setDetectedRenames] = useState([]) useEffect(() => { if (!isTauri() || !resolvedPath) return const handleFocus = () => { invoke('detect_renames', { vaultPath: resolvedPath }) .then(renames => { if (renames.length > 0) setDetectedRenames(renames) }) .catch(() => {}) // ignore errors (e.g., no git) } window.addEventListener('focus', handleFocus) return () => window.removeEventListener('focus', handleFocus) }, [resolvedPath]) const handleUpdateWikilinks = useCallback(async () => { if (!isTauri()) return try { const count = await invoke('update_wikilinks_for_renames', { vaultPath: resolvedPath, renames: detectedRenames }) setDetectedRenames([]) vault.reloadVault() setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`) } catch (err) { setToastMessage(`Failed to update wikilinks: ${err}`) } }, [resolvedPath, detectedRenames, vault, setToastMessage]) const handleDismissRenames = useCallback(() => setDetectedRenames([]), []) const conflictResolver = useConflictResolver({ vaultPath: resolvedPath, onResolved: () => { dialogs.closeConflictResolver() autoSync.resumePull() vault.reloadVault() autoSync.triggerSync() }, onToast: (msg) => setToastMessage(msg), onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath), }) const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null) const flushEditorStateBeforeAction = async (path: string) => { flushPendingRawContentRef.current?.(path) await appSave.flushBeforeAction(path) } const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, flushBeforeNoteSwitch: flushEditorStateBeforeAction, flushBeforeFrontmatterChange: flushEditorStateBeforeAction, flushBeforePathRename: flushEditorStateBeforeAction, reloadVault: vault.reloadVault, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles, onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath), }) const { handleSelectNote, handleReplaceActiveTab, closeAllTabs, openTabWithContent, } = notes const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => { await refreshPulledVaultState({ activeTabPath: notes.activeTabPath, closeAllTabs, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), reloadFolders: vault.reloadFolders, reloadVault: vault.reloadVault, reloadViews: vault.reloadViews, replaceActiveTab: handleReplaceActiveTab, updatedFiles, vaultPath: resolvedPath, }) }, [ closeAllTabs, handleReplaceActiveTab, notes.activeTabPath, resolvedPath, vault.reloadFolders, vault.reloadVault, vault.reloadViews, vault.unsavedPaths, ]) const autoSync = useAutoSync({ vaultPath: resolvedPath, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: handlePulledVaultUpdate, onConflict: (files) => { const names = files.map((f) => f.split('/').pop()).join(', ') setToastMessage(`Conflict in ${names} — click to resolve`) }, onToast: (msg) => setToastMessage(msg), }) const pulseCommitDiffRequestIdRef = useRef(0) const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState(null) // Note window: auto-open the note from URL params once vault entries load const noteWindowOpenedRef = useRef(false) const noteWindowMissingPathRef = useRef(null) useEffect(() => { if (!noteWindowParams || noteWindowOpenedRef.current) return let cancelled = false void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => { if (cancelled || noteWindowOpenedRef.current) return if (entry) { noteWindowOpenedRef.current = true noteWindowMissingPathRef.current = null void handleSelectNote(entry) return } if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return noteWindowMissingPathRef.current = noteWindowParams.notePath setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`) }) return () => { cancelled = true } }, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries]) // Note window: update window title when active note changes useEffect(() => { if (!noteWindowParams) return const activeEntry = notes.tabs.find(t => t.entry.path === notes.activeTabPath)?.entry const title = activeEntry?.title ?? noteWindowParams.noteTitle if (!isTauri()) { document.title = title; return } import('@tauri-apps/api/window').then(({ getCurrentWindow }) => { getCurrentWindow().setTitle(title) }).catch(() => {}) }, [noteWindowParams, notes.tabs, notes.activeTabPath]) // Keep note entry 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 { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({ entries: vault.entries, activeTabPath: notes.activeTabPath, onSelectNote: notes.handleSelectNote, }) const queuePulseCommitDiff = useCallback((path: string, commitHash: string) => { pulseCommitDiffRequestIdRef.current += 1 setPulseCommitDiffRequest({ requestId: pulseCommitDiffRequestIdRef.current, path, commitHash, }) }, []) const handlePulseCommitDiffHandled = useCallback((requestId: number) => { setPulseCommitDiffRequest((current) => current?.requestId === requestId ? null : current, ) }, []) const handlePulseOpenNote = useCallback((relativePath: string, commitHash?: string) => { const fullPath = `${resolvedPath}/${relativePath}` const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath) if (commitHash) { const targetPath = entry?.path ?? fullPath queuePulseCommitDiff(targetPath, commitHash) if (entry) { void handleSelectNote(entry) } else { openTabWithContent(createPulseDeletedNoteEntry(fullPath, relativePath), 'Content not available') } return } if (entry) { void handleSelectNote(entry) } }, [entriesByPath, resolvedPath, queuePulseCommitDiff, handleSelectNote, openTabWithContent]) const handleOpenFavorite = useCallback(async (entry: VaultEntry) => { await handleReplaceActiveTab(entry) handleEnterNeighborhood(entry) }, [handleEnterNeighborhood, handleReplaceActiveTab]) const vaultBridge = useVaultBridge({ entriesByPath, resolvedPath, reloadVault: vault.reloadVault, reloadFolders: vault.reloadFolders, reloadViews: vault.reloadViews, closeAllTabs, replaceActiveTab: handleReplaceActiveTab, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), onSelectNote: notes.handleSelectNote, activeTabPath: notes.activeTabPath, }) const conflictFlow = useConflictFlow({ resolvedPath, entries: vault.entries, conflictFiles: autoSync.conflictFiles, pausePull: autoSync.pausePull, resumePull: autoSync.resumePull, triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault, initConflictFiles: conflictResolver.initFiles, openConflictResolver: dialogs.openConflictResolver, closeConflictResolver: dialogs.closeConflictResolver, onSelectNote: notes.handleSelectNote, activeTabPath: notes.activeTabPath, setToastMessage, }) const appSave = useAppSave({ updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage, loadModifiedFiles: vault.loadModifiedFiles, reloadViews: async () => { await vault.reloadViews() }, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, tabs: notes.tabs, activeTabPath: notes.activeTabPath, handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, }) const aiActivity = useAiActivity({ onOpenNote: vaultBridge.openNoteByPath, onOpenTab: vaultBridge.openNoteByPath, onSetFilter: (filterType) => { handleSetSelection({ kind: 'sectionGroup', type: filterType }) }, onVaultChanged: () => { vault.reloadVault() }, }) const handleInitializeProperties = useCallback(async (path: string) => { await initializeNoteProperties(notes.handleUpdateFrontmatter, path) }, [notes]) const handleRemoveNoteIcon = useCallback(async (path: string) => { await notes.handleDeleteProperty(path, 'icon') }, [notes]) const handleSetNoteIconCommand = useCallback(() => { setInspectorCollapsed(false) window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { focusNoteIconPropertyEditor() }) }) }, [setInspectorCollapsed]) const handleCustomizeNoteListColumns = useCallback(() => { if (effectiveSelection.kind === 'view') { openNoteListPropertiesPicker('view') return } if (effectiveSelection.kind !== 'filter') return if (effectiveSelection.filter === 'all') { openNoteListPropertiesPicker('all') return } if (effectiveSelection.filter === 'inbox') { openNoteListPropertiesPicker('inbox') } }, [effectiveSelection]) const handleUpdateAllNotesNoteListProperties = useCallback((value: string[] | null) => { updateConfig('allNotes', { ...(vaultConfig.allNotes ?? { noteListProperties: null }), noteListProperties: value && value.length > 0 ? value : null, }) }, [updateConfig, vaultConfig.allNotes]) const handleUpdateInboxNoteListProperties = useCallback((value: string[] | null) => { updateConfig('inbox', { ...(vaultConfig.inbox ?? { noteListProperties: null }), noteListProperties: value && value.length > 0 ? value : null, }) }, [updateConfig, vaultConfig.inbox]) const handleCreateFolder = useCallback(async (name: string) => { try { if (isTauri()) { await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name }) } else { await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name }) } await vault.reloadFolders() setToastMessage(`Created folder "${name}"`) } catch (e) { setToastMessage(`Failed to create folder: ${e}`) } }, [resolvedPath, vault, setToastMessage]) const handleRemoveNoteIconCommand = useCallback(() => { if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath) }, [notes.activeTabPath, handleRemoveNoteIcon]) const handleOpenInNewWindow = useCallback(() => { const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath) if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title) }, [notes.tabs, notes.activeTabPath, resolvedPath]) const handleOpenEntryInNewWindow = useCallback((entry: { path: string; title: string }) => { openNoteInNewWindow(entry.path, resolvedPath, entry.title) }, [resolvedPath]) const handleDiscardFile = useCallback(async (relativePath: string) => { const targetFile = vault.modifiedFiles.find((file) => file.relativePath === relativePath) const activePathBefore = notes.activeTabPath try { if (isTauri()) { await invoke('git_discard_file', { vaultPath: resolvedPath, relativePath }) } else { await mockInvoke('git_discard_file', { vaultPath: resolvedPath, relativePath }) } const reloadedEntries = await vault.reloadVault() const affectedActiveTab = !!activePathBefore && (activePathBefore === targetFile?.path || activePathBefore.endsWith('/' + relativePath)) if (!affectedActiveTab) return const refreshedEntry = reloadedEntries.find((entry) => entry.path === targetFile?.path || entry.path.endsWith('/' + relativePath), ) if (refreshedEntry) { await notes.handleReplaceActiveTab(refreshedEntry) } else { notes.closeAllTabs() } } catch (err) { setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes') } }, [resolvedPath, vault, notes, setToastMessage]) const handleOpenDeletedNote = useCallback(async (entry: DeletedNoteEntry) => { let previewContent = 'Content not available (untracked)' let hasDiff = false try { const diff = await vault.loadDiff(entry.path) hasDiff = diff.length > 0 previewContent = extractDeletedContentFromDiff(diff) ?? previewContent } catch (err) { console.warn('Failed to load deleted note preview:', err) } notes.openTabWithContent(entry, previewContent) if (hasDiff) { setTimeout(() => diffToggleRef.current(), 50) } else { setToastMessage('Content not available (untracked)') } }, [vault, notes, setToastMessage]) const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, resolveRemoteStatus: gitRemoteStatus.refreshRemoteStatus, setToastMessage, onPushRejected: autoSync.handlePushRejected, vaultPath: resolvedPath, }) const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles]) const isGitVault = !vault.modifiedFilesError const modifiedFilesSignature = useMemo( () => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'), [vault.modifiedFiles], ) const autoGit = useAutoGit({ enabled: settings.autogit_enabled === true, idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90, inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30, isGitVault, hasPendingChanges: vault.modifiedFiles.length > 0 || ((autoSync.remoteStatus?.hasRemote ?? false) && (autoSync.remoteStatus?.ahead ?? 0) > 0), hasUnsavedChanges: vault.unsavedPaths.size > 0, onCheckpoint: () => commitFlow.runAutomaticCheckpoint(), }) const recordAutoGitActivity = autoGit.recordActivity const openCommitDialog = commitFlow.openCommitDialog const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint const handleAppContentChange = appSave.handleContentChange const handleAppSave = appSave.handleSave const loadModifiedFiles = vault.loadModifiedFiles useEffect(() => { if (modifiedFilesSignature.length === 0) return recordAutoGitActivity() }, [modifiedFilesSignature, recordAutoGitActivity]) const handleCommitPush = useCallback(() => { triggerCommitEntryAction({ autoGitEnabled: settings.autogit_enabled === true, openCommitDialog, runAutomaticCheckpoint, }) }, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled]) const handleTrackedContentChange = useCallback((path: string, content: string) => { recordAutoGitActivity() handleAppContentChange(path, content) }, [handleAppContentChange, recordAutoGitActivity]) const handleTrackedSave = useCallback(async (...args: Parameters) => { const result = await handleAppSave(...args) recordAutoGitActivity() return result }, [handleAppSave, recordAutoGitActivity]) const seedAutoGitSavedChange = useCallback(async () => { if (isTauri()) { throw new Error('seedAutoGitSavedChange is only available in browser smoke tests') } const activePath = notes.activeTabPath const activeTab = activePath ? notes.tabs.find((tab) => tab.entry.path === activePath) : null if (!activePath || !activeTab) { throw new Error('No active note is available for the AutoGit test bridge') } const saveNoteContent = window.__mockHandlers?.save_note_content if (typeof saveNoteContent === 'function') { await Promise.resolve(saveNoteContent({ path: activePath, content: activeTab.content })) } else { await mockInvoke('save_note_content', { path: activePath, content: activeTab.content }) } await loadModifiedFiles() recordAutoGitActivity() }, [loadModifiedFiles, notes.activeTabPath, notes.tabs, recordAutoGitActivity]) useEffect(() => { window.__laputaTest = { ...window.__laputaTest, seedAutoGitSavedChange, } return () => { if (window.__laputaTest?.seedAutoGitSavedChange === seedAutoGitSavedChange) { delete window.__laputaTest.seedAutoGitSavedChange } } }, [seedAutoGitSavedChange]) const entryActions = useEntryActions({ entries: vault.entries, updateEntry: vault.updateEntry, handleUpdateFrontmatter: notes.handleUpdateFrontmatter, handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, createTypeEntry: notes.createTypeEntrySilent, onBeforeAction: appSave.flushBeforeAction, }) const deleteActions = useDeleteActions({ onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() }, removeEntry: vault.removeEntry, removeEntries: vault.removeEntries, refreshModifiedFiles: vault.loadModifiedFiles, reloadVault: vault.reloadVault, setToastMessage, }) const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory) const handleCreateType = useCallback((name: string) => { notes.handleCreateType(name) setToastMessage(`Type "${name}" created`) }, [notes, setToastMessage]) const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => { const trimmed = nextTypeName.trim() if (!trimmed) return const targetFilename = `${slugify(trimmed)}.md` const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed) const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed)) const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename) const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed if (filenameCollision && filenameCollision.isA !== 'Type') { setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`) throw new Error(`Type filename collision for ${targetFilename}`) } if (!exactType && !slugMatch) { await notes.createTypeEntrySilent(trimmed) } await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName) setToastMessage( resolvedTypeName === missingType ? `Type "${resolvedTypeName}" created` : `Type set to "${resolvedTypeName}"`, ) }, [notes, setToastMessage, vault.entries]) const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => { const editing = dialogs.editingView const filename = editing ? editing.filename : definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml' const nextDefinition = editing ? { ...editing.definition, ...definition } : definition const target = isTauri() ? invoke : mockInvoke await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition }) trackEvent(editing ? 'view_updated' : 'view_created') await vault.reloadViews() await vault.reloadVault() vault.reloadFolders() setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`) handleSetSelection({ kind: 'view', filename }) }, [resolvedPath, vault, handleSetSelection, dialogs.editingView]) const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial) => { const existing = vault.views.find((view) => view.filename === filename) if (!existing) return const target = isTauri() ? invoke : mockInvoke await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: { ...existing.definition, ...patch }, }) await vault.reloadViews() }, [resolvedPath, vault]) const handleEditView = useCallback((filename: string) => { const view = vault.views.find((v) => v.filename === filename) if (view) dialogs.openEditView(filename, view.definition) }, [vault.views, dialogs]) const handleDeleteView = useCallback(async (filename: string) => { const target = isTauri() ? invoke : mockInvoke await target('delete_view_cmd', { vaultPath: resolvedPath, filename }) await vault.reloadViews() await vault.reloadVault() vault.reloadFolders() if (selection.kind === 'view' && selection.filename === filename) { handleSetSelection({ kind: 'filter', filter: 'all' }) } setToastMessage('View deleted') }, [resolvedPath, vault, selection, handleSetSelection]) const availableFields = useMemo(() => { const builtIn = ['type', 'status', 'title', 'favorite', 'body'] if (!vault.entries?.length) return builtIn const customFields = new Set() for (const e of vault.entries) { if (e.properties) { for (const key of Object.keys(e.properties)) customFields.add(key) } if (e.relationships) { for (const key of Object.keys(e.relationships)) customFields.add(key) } } return [...builtIn, ...Array.from(customFields).sort()] }, [vault.entries]) const bulkActions = useBulkActions(entryActions, vault.entries, 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(noteWindowParams ? 'editor-only' : undefined) const zoom = useZoom() const buildNumber = useBuildNumber() const updateMainWindowConstraints = useCallback(( nextSidebarVisible: boolean, nextNoteListVisible: boolean, nextInspectorCollapsed: boolean = layout.inspectorCollapsed, ) => { if (noteWindowParams) return const minWidth = getMainWindowMinWidth({ sidebarVisible: nextSidebarVisible, noteListVisible: nextNoteListVisible, inspectorCollapsed: nextInspectorCollapsed, }) void applyMainWindowSizeConstraints(minWidth).catch(() => {}) }, [layout.inspectorCollapsed, noteWindowParams]) const handleSetViewMode = useCallback((mode: ViewMode) => { setViewMode(mode) updateMainWindowConstraints(mode === 'all', mode !== 'editor-only') }, [setViewMode, updateMainWindowConstraints]) const handleToggleInspector = useCallback(() => { const nextInspectorCollapsed = !layout.inspectorCollapsed layout.setInspectorCollapsed(nextInspectorCollapsed) updateMainWindowConstraints(sidebarVisible, noteListVisible, nextInspectorCollapsed) }, [ layout, noteListVisible, sidebarVisible, updateMainWindowConstraints, ]) useMainWindowSizeConstraints({ enabled: !noteWindowParams, sidebarVisible, noteListVisible, inspectorCollapsed: layout.inspectorCollapsed, }) const { status: updateStatus, actions: updateActions } = useUpdater(settings.release_channel) 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') } }, [updateActions, updateStatus.state, setToastMessage]) const handleRepairVault = useCallback(async () => { if (!resolvedPath) return try { const tauriInvoke = isTauri() ? invoke : mockInvoke const msg = await tauriInvoke('repair_vault', { vaultPath: resolvedPath }) await vault.reloadVault() await refreshVaultAiGuidance() setToastMessage(msg) } catch (err) { setToastMessage(`Failed to repair vault: ${err}`) } }, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage]) const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => { if (!resolvedPath) return try { const tauriInvoke = isTauri() ? invoke : mockInvoke await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath }) await vault.reloadVault() await refreshVaultAiGuidance() if (successToast) setToastMessage(successToast) } catch (err) { setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`) } }, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage]) const activeDeletedFile = useMemo(() => { const activeTabPath = notes.activeTabPath if (!activeTabPath) return null return vault.modifiedFiles.find((file) => file.status === 'deleted' && (file.path === activeTabPath || activeTabPath.endsWith('/' + file.relativePath)), ) ?? null }, [notes.activeTabPath, vault.modifiedFiles]) const activeCommandEntry = useMemo(() => { if (!notes.activeTabPath) return null return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry ?? vault.entries.find((entry) => entry.path === notes.activeTabPath) ?? null }, [notes.activeTabPath, notes.tabs, vault.entries]) const canToggleRichEditor = !!activeCommandEntry && activeCommandEntry.filename.toLowerCase().endsWith('.md') && !activeDeletedFile const noteListColumnsLabel = useMemo(() => { if (effectiveSelection.kind === 'view') { const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename) return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns' } return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all' ? 'Customize All Notes columns' : 'Customize Inbox columns' }, [effectiveSelection, vault.views]) const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, entries: vault.entries, visibleNotesRef, multiSelectionCommandRef, modifiedCount: vault.modifiedFiles.length, activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath), selection: effectiveSelection, onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette, onSearch: dialogs.openSearch, onCreateNote: notes.handleCreateNoteImmediate, onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: appSave.handleSave, onOpenSettings: dialogs.openSettings, onOpenFeedback: openFeedback, onDeleteNote: deleteActions.handleDeleteNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: handleCommitPush, onPull: autoSync.triggerSync, onResolveConflicts: conflictFlow.handleOpenConflictResolver, onSetViewMode: handleSetViewMode, onToggleInspector: handleToggleInspector, onToggleDiff: () => diffToggleRef.current(), onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined, onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, onSelect: handleSetSelection, showInbox: explicitOrganizationEnabled, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, onGoBack: handleGoBack, onGoForward: handleGoForward, canGoBack: canGoBack, canGoForward: canGoForward, onOpenVault: vaultSwitcher.handleOpenLocalFolder, onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault, onCreateType: dialogs.openCreateType, onToggleAIChat: dialogs.toggleAIChat, onCheckForUpdates: handleCheckForUpdates, onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath), onRestoreGettingStarted: cloneGettingStartedVault, isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, vaultCount: vaultSwitcher.allVaults.length, mcpStatus, onInstallMcp: installMcp, onOpenAiAgents: dialogs.openSettings, aiAgentsStatus, vaultAiGuidanceStatus, onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() }, selectedAiAgent: aiAgentPreferences.defaultAiAgent, onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent, onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent, selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel, onReloadVault: vault.reloadVault, onRepairVault: handleRepairVault, onSetNoteIcon: handleSetNoteIconCommand, onRemoveNoteIcon: handleRemoveNoteIconCommand, activeNoteHasIcon: (() => { const ae = vault.entries.find(e => e.path === notes.activeTabPath) return hasNoteIconValue(ae?.icon) })(), noteListFilter, onSetNoteListFilter: setNoteListFilter, onOpenInNewWindow: handleOpenInNewWindow, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined, onCustomizeNoteListColumns: handleCustomizeNoteListColumns, canCustomizeNoteListColumns: effectiveSelection.kind === 'view' || ( effectiveSelection.kind === 'filter' && (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')) ), noteListColumnsLabel, onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, canRestoreDeletedNote: !!activeDeletedFile, }) const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod]) const aiNoteList = useMemo(() => { const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox' const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, effectiveSelection, undefined, vault.views) return filtered.map(e => ({ path: e.path, title: e.title, type: e.isA ?? 'Note', })) }, [vault.entries, vault.views, effectiveSelection, inboxPeriod]) const aiNoteListFilter = useMemo(() => { if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' } if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title } return { type: null, query: '' } }, [effectiveSelection]) const shouldResumeFreshStartOnboarding = useMemo(() => { if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath return remembersOnlyDefaultVault && vaultSwitcher.allVaults.length === 1 && vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath && onboarding.state.vaultPath === vaultSwitcher.vaultPath }, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath]) // Show loading spinner while checking vault (skip for note windows) if (!noteWindowParams && onboarding.state.status === 'loading') { return } // Show telemetry consent dialog on first launch (skip for note windows). // After the user answers, the next render can continue into onboarding. if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) { return ( { const id = crypto.randomUUID() saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id }) }} onDecline={() => { saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null }) }} /> ) } // Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known) if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) { const welcomeOnboarding = shouldResumeFreshStartOnboarding ? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } } : onboarding return } if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) { return ( <> setToastMessage(null)} /> ) } // Show git-required modal when vault has no git repo (skip for note windows) if (!noteWindowParams && gitRepoState === 'required') { return (
) } // Show loading spinner while checking git status if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') { return } return (
{sidebarVisible && ( <>
)} {noteListVisible && ( <>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( handleSetViewMode('all')} /> ) : ( diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} /> )}
)}
handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} /> setToastMessage(null)} /> {deleteActions.confirmDelete && ( deleteActions.setConfirmDelete(null)} /> )}
) } type OnboardingState = ReturnType /** Welcome screen view - extracted from main App component */ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) { const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string } return (
) } function AiAgentsOnboardingView({ statuses, onContinue, }: { statuses: ReturnType onContinue: () => void }) { return (
) } /** Loading spinner view - extracted from main App component */ function LoadingView() { return (
Loading…
) } export default App