Files
tolaria/src/App.tsx
2026-04-19 18:46:10 +02:00

1417 lines
62 KiB
TypeScript

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 { 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<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 = 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<VaultEntry | undefined> {
const fallbackEntry = () =>
findNoteWindowEntry(entries, noteWindowParams)
if (!isTauri()) {
return fallbackEntry()
}
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
try {
return await invoke<VaultEntry>('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<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const selectionRef = useRef<SidebarSelection>(DEFAULT_SELECTION)
const neighborhoodHistoryRef = useRef<SidebarSelection[]>([])
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<VaultEntry[]>([])
const multiSelectionCommandRef = useRef<NoteListMultiSelectionCommands | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(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)
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(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<boolean>('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 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),
})
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
if (!isTauri() || !resolvedPath) return
const handleFocus = () => {
invoke<DetectedRename[]>('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<number>('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 notes = useNoteActions({
addEntry: vault.addEntry,
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforeNoteSwitch: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforePathRename: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
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, openTabWithContent } = notes
const pulseCommitDiffRequestIdRef = useRef(0)
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
// Note window: auto-open the note from URL params once vault entries load
const noteWindowOpenedRef = useRef(false)
const noteWindowMissingPathRef = useRef<string | null>(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 vaultBridge = useVaultBridge({
entriesByPath, resolvedPath,
reloadVault: vault.reloadVault,
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: 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<typeof handleAppSave>) => {
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 gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
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<ViewDefinition>) => {
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<string>()
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<string>('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<NoteListItem[]>(() => {
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 <LoadingView />
}
// 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 (
<TelemetryConsentDialog
onAccept={() => {
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 <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
return (
<>
<AiAgentsOnboardingView
statuses={aiAgentsStatus}
onContinue={aiAgentsOnboarding.dismissPrompt}
/>
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</>
)
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
return (
<div className="app-shell">
<GitRequiredModal
onCreateRepo={handleInitGitRepo}
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
/>
</div>
)
}
// Show loading spinner while checking git status
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
return <LoadingView />
}
return (
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => 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} />
)}
</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}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<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}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
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} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
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={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
</div>
)
}
type OnboardingState = ReturnType<typeof useOnboarding>
/** 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 (
<div className="app-shell">
<WelcomeScreen
mode={state.status === 'welcome' ? 'welcome' : 'vault-missing'}
missingPath={state.status === 'vault-missing' ? state.vaultPath : undefined}
defaultVaultPath={state.defaultPath}
onCreateVault={onboarding.handleCreateVault}
onRetryCreateVault={onboarding.retryCreateVault}
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
onOpenFolder={onboarding.handleOpenFolder}
isOffline={isOffline}
creatingAction={onboarding.creatingAction}
error={onboarding.error}
canRetryTemplate={onboarding.canRetryTemplate}
/>
</div>
)
}
function AiAgentsOnboardingView({
statuses,
onContinue,
}: {
statuses: ReturnType<typeof useAiAgentsStatus>
onContinue: () => void
}) {
return (
<div className="app-shell">
<AiAgentsOnboardingPrompt statuses={statuses} onContinue={onContinue} />
</div>
)
}
/** Loading spinner view - extracted from main App component */
function LoadingView() {
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>
)
}
export default App