import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' import { ResizeHandle } from './components/ResizeHandle' import { CreateTypeDialog } from './components/CreateTypeDialog' import { 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 { AppAiWorkspaceSurface } from './components/AppAiWorkspaceSurface' import { AiWorkspaceFloatingButton } from './components/AiWorkspaceFloatingButton' import { AiWorkspaceWindowApp } from './components/AiWorkspaceWindowApp' import { SettingsPanel } from './components/SettingsPanel' import { CloneVaultModal } from './components/CloneVaultModal' import { FeedbackDialog } from './components/FeedbackDialog' import { McpSetupDialog } from './components/McpSetupDialog' import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs' import { StartupScreen } from './components/StartupScreen' import { HoloLakeHome } from './components/HoloLakeHome' import { GUANGHU_THEME_LABELS } from './lib/guanghuTheme' import guanghuLogoLight from './assets/guanghu-logo-light.png' 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 { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher' import { useSettings } from './hooks/useSettings' import { useNoteWidthMode } from './hooks/useNoteWidthMode' import { useNoteActions } from './hooks/useNoteActions' import { useCommitFlow } from './hooks/useCommitFlow' import { useGitRepositories } from './hooks/useGitRepositories' 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 { useGuanghuTheme } from './hooks/useGuanghuTheme' import { useDragRegion } from './hooks/useDragRegion' import { useConflictResolver } from './hooks/useConflictResolver' import { useVaultConfig } from './hooks/useVaultConfig' import { useOnboarding } from './hooks/useOnboarding' import { useGettingStartedClone } from './hooks/useGettingStartedClone' import { useNetworkStatus } from './hooks/useNetworkStatus' import { useAppNavigation } from './hooks/useAppNavigation' import { useAiActivity } from './hooks/useAiActivity' import { useBulkActions } from './hooks/useBulkActions' import { useDeleteActions } from './hooks/useDeleteActions' import { useFolderActions } from './hooks/useFolderActions' import { useFileActions } from './hooks/useFileActions' import { useDeepLinks } from './hooks/useDeepLinks' import { useNoteGitUrls } from './hooks/useNoteGitUrls' import { useLayoutPanels } from './hooks/useLayoutPanels' import { useConflictFlow } from './hooks/useConflictFlow' import { useAppSave } from './hooks/useAppSave' import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi' import { useVaultBridge } from './hooks/useVaultBridge' import { useSavedViewOrdering } from './hooks/useSavedViewOrdering' import { useAppViewActions } from './hooks/useAppViewActions' import { useAppWindowControls } from './hooks/useAppWindowControls' import { useAiWorkspacePublishedContext } from './hooks/useAiWorkspacePublishedContext' import { useNeighborhoodEntry, useNeighborhoodEscape, useNeighborhoodHistoryBack, useSelectionSanitizer, } from './hooks/useNeighborhoodSelection' 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 { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelection, InboxPeriod, VaultEntry, WorkspaceIdentity } from './types' import { initializeNoteProperties } from './utils/initializeNoteProperties' import { type NoteListFilter } from './utils/noteListHelpers' import { openNoteInNewWindow } from './utils/openNoteWindow' import { refreshPulledVaultState } from './utils/pulledVaultRefresh' import { viewMatchesSelection } from './utils/viewIdentity' import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams, type NoteWindowParams } from './utils/windowMode' import { GitSetupDialog } from './components/GitRequiredModal' import { RenameDetectedBanner } 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 { areAutomaticUpdateChecksEnabled } from './lib/automaticUpdateChecks' import { areAiFeaturesEnabled } from './lib/aiFeatures' import { aiTargetReady, type AiTarget } from './lib/aiTargets' import { areGitFeaturesEnabled } from './lib/gitSettings' import { useAppCommandAiActions } from './hooks/useAppCommandAiActions' import { TOLARIA_DOCS_URL } from './constants/feedback' import { openExternalUrl } from './utils/url' import { translate, } from './lib/i18n' import { normalizeReleaseChannel } from './lib/releaseChannel' import { buildVaultAiGuidanceRefreshKey, } from './lib/vaultAiGuidance' import { hasNoteIconValue } from './utils/noteIcon' import { INBOX_SELECTION, isExplicitOrganizationEnabled, sanitizeSelectionForOrganization, } from './utils/organizationWorkflow' import { requestPlainTextPaste } from './utils/plainTextPaste' import { SETTINGS_SECTION_IDS } from './components/settingsSectionIds' import { vaultPathForEntry, } from './utils/workspaces' import { activeGitRepositories } from './utils/gitRepositories' import { isMarkdownEntry } from './utils/typeDefinitions' import type { RichEditorBlockTypeDefinition } from './utils/richEditorBlockTypes' import { resolveTypeDeleteRequest, typeDeleteBlockedMessageKey } from './utils/typeDeletion' import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useWorkspaceGraphState' import { useGitSetupState } from './hooks/useGitSetupState' import { AppPreferencesProvider, useAppPreferences } from './hooks/useAppPreferences' import { useInboxOrganizeAdvance } from './hooks/useInboxOrganizeAdvance' import { syncVaultAssetScope, useNoteWindowLifecycle } from './hooks/useNoteWindowLifecycle' import { useVaultRenameDetection } from './hooks/useVaultRenameDetection' import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry' import { useStartupScreenState } from './hooks/useStartupScreenState' import { useGitFileWorkflows } from './hooks/useGitFileWorkflows' import { useAutoGitWork } from './hooks/useAutoGitWork' import { useAppAiWorkspaceBridge } from './hooks/useAppAiWorkspaceBridge' import { useAiWorkspaceWindowBridgeEvents } from './hooks/useAiWorkspaceWindowBridgeEvents' import { useMcpSetupDialogController } from './hooks/useMcpSetupDialogController' import { shouldReplaceSyncedTabEntry } from './utils/tabEntrySync' import { activeVaultModifiedFiles, aiWorkspaceWindowContextForPath, canCustomizeColumnsForSelection, isActiveElementInsideEditorSurface, mergeModifiedFiles, runNativeTextHistoryCommand, shouldPreferOnboardingVaultPath, } from './utils/appOrchestration' 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 /** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */ function App() { const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, []) const aiWorkspaceWindow = useMemo(() => isAiWorkspaceWindow(), []) if (aiWorkspaceWindow) return return } function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | null }) { const aiWorkspaceWindow = false const [showHoloLakeHome, setShowHoloLakeHome] = useState(true) const { dragRegionRef } = useDragRegion() const [selection, setSelection] = useState(DEFAULT_SELECTION) const [noteListFilter, setNoteListFilter] = useState('open') const [pendingNoteListPdfExportPath, setPendingNoteListPdfExportPath] = useState(null) 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 = [] } selectionRef.current = sel setSelection(sel) setNoteListFilter('open') }, []) const handleEnterNeighborhood = useNeighborhoodEntry({ neighborhoodHistoryRef, selectionRef, setSelection: handleSetSelection, }) const layout = useLayoutPanels(noteWindowParams || aiWorkspaceWindow ? { initialInspectorCollapsed: true } : undefined) const { setInspectorCollapsed } = layout const visibleNotesRef = useRef([]) const multiSelectionCommandRef = useRef(null) const [toastMessage, setToastMessage] = useState(null) const [gitHistoryRefreshKey, setGitHistoryRefreshKey] = useState(0) const dialogs = useDialogs() const { closeAIChat, openAIChat, showAIChat } = dialogs const [showFeedback, setShowFeedback] = useState(false) const openFeedback = useCallback(() => setShowFeedback(true), []) const closeFeedback = useCallback(() => setShowFeedback(false), []) const openDocs = useCallback(() => { void openExternalUrl(TOLARIA_DOCS_URL) }, []) const networkStatus = useNetworkStatus() const { settings, loaded: settingsLoaded, saveSettings } = useSettings() const aiFeaturesEnabled = areAiFeaturesEnabled(settings) // 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: () => { if (noteWindowParams || aiWorkspaceWindow) return handleSetSelection(DEFAULT_SELECTION) notes.closeAllTabs() }, onToast: (msg) => setToastMessage(msg), }) const { allVaults, defaultWorkspacePath, registerVaultSelection, selectedVaultPath, syncVaultSelection, switchVault, } = vaultSwitcher const rememberVaultChoice = 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' syncVaultSelection(vaultPath, label) }, [allVaults, switchVault, syncVaultSelection]) const handleGettingStartedVaultReady = useCallback((vaultPath: string) => { rememberVaultChoice(vaultPath) setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`) }, [rememberVaultChoice]) const handleOnboardingVaultReady = useCallback((vaultPath: string, source: 'template' | 'empty' | 'existing') => { rememberVaultChoice(vaultPath) if (source === 'template') { setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`) } }, [rememberVaultChoice]) const cloneGettingStartedVault = useGettingStartedClone({ onError: (message) => setToastMessage(message), onSuccess: handleGettingStartedVaultReady, }) const onboarding = useOnboarding(vaultSwitcher.vaultPath, { onVaultReady: handleOnboardingVaultReady, registerVault: registerVaultSelection, }, vaultSwitcher.loaded) const aiAgentsStatus = useAiAgentsStatus({ enabled: aiFeaturesEnabled && !aiWorkspaceWindow, }) const aiAgentsOnboarding = useAiAgentsOnboarding( aiFeaturesEnabled && onboarding.state.status === 'ready' && !noteWindowParams && !aiWorkspaceWindow, ) // 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 ) const aiWorkspaceWindowContext = useMemo(() => aiWorkspaceWindowContextForPath(resolvedPath), [resolvedPath]) const [settingsInitialSectionId, setSettingsInitialSectionId] = useState(null) const { effectiveShowAIChat, handleOpenAiSettings, handleOpenDockedAiWorkspace, } = useAppAiWorkspaceBridge({ aiFeaturesEnabled, aiWorkspaceWindow, closeAIChat, openAIChat, openSettings: dialogs.openSettings, setSettingsInitialSectionId, showAIChat, }) const handleToggleAiWorkspace = useCallback(() => { if (effectiveShowAIChat) { closeAIChat() return } handleOpenDockedAiWorkspace() }, [closeAIChat, effectiveShowAIChat, handleOpenDockedAiWorkspace]) const [lastAiWorkspaceConversationId, setLastAiWorkspaceConversationId] = useState(null) const handleActiveAiWorkspaceConversationChange = useCallback((id: string) => { setLastAiWorkspaceConversationId(id) }, []) const [lastAiWorkspaceTarget, setLastAiWorkspaceTarget] = useState(null) const handleActiveAiWorkspaceTargetChange = useCallback((target: AiTarget) => { setLastAiWorkspaceTarget((current) => (current?.id === target.id ? current : target)) }, []) const { folderVaults, graphDefaultWorkspacePath, graphVaults, inspectorWorkspaces, multiWorkspaceEnabled, visibleWorkspacePathList, writableVaultPaths, } = useWorkspaceGraphState({ allVaults, defaultWorkspacePath, resolvedPath, settings, vaultSwitcherLoaded: vaultSwitcher.loaded, windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow, }) const vaultWorkspaceOrder = useMemo( () => vaultSwitcher.allVaults.map((vault) => vault.path), [vaultSwitcher.allVaults], ) const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) const gitFeaturesEnabled = areGitFeaturesEnabled(settings) const handleGitSetupPreferenceChange = useCallback((preference: GitSetupPreference) => { updateConfig('git_setup_preference', preference) }, [updateConfig]) const { dismissGitSetupDialog, gitRepoState, handleInitGitRepo, neverForVaultGitSetupDialog, openGitSetupDialog, shouldShowGitSetupDialog, } = useGitSetupState({ gitSetupPreference: vaultConfig.git_setup_preference, onGitSetupPreferenceChange: handleGitSetupPreferenceChange, onToast: setToastMessage, resolvedPath, windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow, }) const vault = useVaultLoader(resolvedPath, graphVaults, multiWorkspaceEnabled ? defaultWorkspacePath : null, folderVaults) const gitRepositories = useMemo(() => activeGitRepositories({ defaultVaultPath: graphDefaultWorkspacePath, multiWorkspaceEnabled, vaults: allVaults, }), [allVaults, graphDefaultWorkspacePath, multiWorkspaceEnabled]) const activeGitRepositoryPaths = useMemo( () => gitRepositories.map((repository) => repository.path), [gitRepositories], ) const gitSurfaces = useGitRepositories({ defaultVaultPath: graphDefaultWorkspacePath, repositories: gitRepositories, }) const watchedVaultPaths = useMemo(() => { if (visibleWorkspacePathList && visibleWorkspacePathList.length > 0) return visibleWorkspacePathList return resolvedPath.trim() ? [resolvedPath] : [] }, [resolvedPath, visibleWorkspacePathList]) const visibleEntries = useVisibleWorkspaceEntries({ entries: vault.entries, multiWorkspaceEnabled, visibleWorkspacePathList, }) const runtimeMissingVaultPath = vault.unavailableVaultPath const { markInternalWrite: markRecentVaultWrite, filterExternalPaths: filterExternalVaultPaths, } = useRecentVaultWrites({ vaultPath: resolvedPath, vaultPaths: watchedVaultPaths, }) const { status: vaultAiGuidanceStatus, refresh: refreshVaultAiGuidance, } = useVaultAiGuidanceStatus( aiFeaturesEnabled ? resolvedPath : null, buildVaultAiGuidanceRefreshKey(vault.entries), ) const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes' useSelectionSanitizer({ effectiveSelection, neighborhoodHistoryRef, selection, selectionRef, setNoteListFilter, setSelection, }) const handleNeighborhoodHistoryBack = useNeighborhoodHistoryBack({ neighborhoodHistoryRef, setSelection: handleSetSelection, }) const handleSaveExplicitOrganization = useCallback((enabled: boolean) => { updateConfig('inbox', { noteListProperties: vaultConfig.inbox?.noteListProperties ?? null, explicitOrganization: enabled, }) }, [updateConfig, vaultConfig.inbox?.noteListProperties]) const { aiAgentPreferences, allNotesFileVisibility, appLocale, dateDisplayFormat, documentThemeMode, handleSetThemeMode, handleSetUiLanguage, handleToggleThemeMode, selectedUiLanguage, systemLocale, } = useAppPreferences({ aiAgentsStatus, onToast: setToastMessage, saveSettings, settings, settingsLoaded, }) const { theme: guanghuTheme, cycleTheme } = useGuanghuTheme() const quickPromptTarget = lastAiWorkspaceTarget ?? aiAgentPreferences.defaultAiTarget const quickPromptTargetReady = aiTargetReady(quickPromptTarget, aiAgentsStatus) useVaultOpenedTelemetry({ entryCount: vault.entries.length, gitRepoState, resolvedPath, }) const mcpSetupDialog = useMcpSetupDialogController(resolvedPath, setToastMessage, appLocale) const loadDefaultVaultModifiedFiles = vault.loadModifiedFiles const loadAllGitModifiedFiles = gitSurfaces.loadAllModifiedFiles const loadModifiedFilesForRepository = gitSurfaces.loadModifiedFilesForRepository const refreshAllGitRemoteStatuses = gitSurfaces.refreshAllRemoteStatuses const refreshRemoteStatusForRepository = gitSurfaces.refreshRemoteStatusForRepository const refreshGitRemoteStatus = useCallback( () => refreshRemoteStatusForRepository(resolvedPath), [refreshRemoteStatusForRepository, resolvedPath], ) const refreshGitModifiedFiles = useCallback(async () => { if (!gitFeaturesEnabled) return await Promise.all([ loadDefaultVaultModifiedFiles(), loadAllGitModifiedFiles({ includeStats: isChangesSelection }), ]) }, [gitFeaturesEnabled, isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles]) const loadVaultModifiedFiles = refreshGitModifiedFiles useEffect(() => { if (!gitFeaturesEnabled) return if (gitRepoState !== 'ready') return void loadVaultModifiedFiles() void refreshGitRemoteStatus() void refreshAllGitRemoteStatuses() }, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshAllGitRemoteStatuses, refreshGitRemoteStatus]) const handleOpenSettings = useCallback(() => { setSettingsInitialSectionId(null) dialogs.openSettings() }, [dialogs]) const handleOpenVaultSettings = useCallback(() => { setSettingsInitialSectionId(SETTINGS_SECTION_IDS.workspaces) dialogs.openSettings() }, [dialogs]) const { detectedRenames, handleUpdateWikilinks, handleDismissRenames, } = useVaultRenameDetection({ reloadVault: vault.reloadVault, setToastMessage, vaultPath: resolvedPath, }) 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 flushPendingEditorContentRef = useRef<((path: string) => void) | null>(null) const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null) const appSaveFlushBeforeActionRef = useRef<((path: string) => Promise) | null>(null) const flushEditorStateBeforeAction = useCallback(async (path: string) => { flushPendingEditorContentRef.current?.(path) flushPendingRawContentRef.current?.(path) await appSaveFlushBeforeActionRef.current?.(path) }, []) const handleCreatedVaultEntryPersisting = useCallback((path: string) => { markRecentVaultWrite(path) vault.addPendingSave(path) }, [markRecentVaultWrite, vault]) const handleCreatedVaultEntryPersisted = useCallback((path: string) => { markRecentVaultWrite(path) void refreshGitModifiedFiles() }, [markRecentVaultWrite, refreshGitModifiedFiles]) const handleMissingActiveVault = useCallback(() => { if (!noteWindowParams && !aiWorkspaceWindow && resolvedPath) vault.markVaultUnavailable(resolvedPath) }, [aiWorkspaceWindow, noteWindowParams, resolvedPath, vault]) const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: visibleEntries, flushBeforeNoteSwitch: flushEditorStateBeforeAction, flushBeforeNoteMutation: flushEditorStateBeforeAction, reloadVault: vault.reloadVault, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, defaultWorkspacePath: multiWorkspaceEnabled ? defaultWorkspacePath : null, vaults: graphVaults ?? [], addPendingSave: handleCreatedVaultEntryPersisting, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: handleCreatedVaultEntryPersisted, onMissingActiveVault: handleMissingActiveVault, onTypeStateChanged: async () => { await vault.reloadVault() }, replaceEntry: vault.replaceEntry, onInternalVaultWrite: markRecentVaultWrite, onFrontmatterPersisted: refreshGitModifiedFiles, onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath), }) const { handleSelectNote, handleReplaceActiveTab, closeAllTabs, openTabWithContent, } = notes const noteActiveTabPath = notes.activeTabPath const noteActiveTabPathRef = notes.activeTabPathRef const refocusActiveEditor = useCallback((path: string) => { window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path } })) }, []) useNoteWindowLifecycle({ activeTabPath: notes.activeTabPath, handleSelectNote, noteWindowParams, openTabWithContent, setToastMessage, tabs: notes.tabs, }) const handleVaultUpdate = useCallback(async ( updatedFiles: string[], options: { vaultPath?: string } = {}, ) => { const updateVaultPath = options.vaultPath ?? resolvedPath await refreshPulledVaultState({ activeTabPath: noteActiveTabPath, closeAllTabs, getActiveTabPath: () => noteActiveTabPathRef.current, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), reloadFolders: vault.reloadFolders, reloadVault: vault.reloadVault, reloadViews: vault.reloadViews, replaceActiveTab: handleReplaceActiveTab, refocusActiveEditor, shouldRefocusActiveEditor: isActiveElementInsideEditorSurface, updatedFiles, vaultPath: updateVaultPath, }) await refreshGitModifiedFiles() }, [ closeAllTabs, handleReplaceActiveTab, noteActiveTabPath, noteActiveTabPathRef, refocusActiveEditor, refreshGitModifiedFiles, resolvedPath, vault.reloadFolders, vault.reloadVault, vault.reloadViews, vault.unsavedPaths, ]) const handlePulledVaultUpdate = useCallback( (updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, { vaultPath }), [handleVaultUpdate], ) const refreshGitHistorySurfaces = useCallback(() => { setGitHistoryRefreshKey((key) => key + 1) }, []) const handleFocusedVaultUpdate = useCallback( (updatedFiles: string[]) => handleVaultUpdate(updatedFiles), [handleVaultUpdate], ) useEffect(() => { if (watchedVaultPaths.length === 0) return let cancelled = false for (const vaultPath of watchedVaultPaths) { void syncVaultAssetScope(vaultPath).catch((err) => { if (!cancelled) console.warn('[vault] Failed to sync asset scope:', err) }) } return () => { cancelled = true } }, [watchedVaultPaths]) useVaultWatcher({ vaultPath: resolvedPath, vaultPaths: watchedVaultPaths, onVaultChanged: handleFocusedVaultUpdate, filterChangedPaths: filterExternalVaultPaths, }) const autoSync = useAutoSync({ enabled: gitFeaturesEnabled && gitRepoState === 'ready', vaultPath: gitSurfaces.syncRepositoryPath, vaultPaths: activeGitRepositoryPaths, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: handlePulledVaultUpdate, onSyncUpdated: refreshGitHistorySurfaces, onConflict: (files) => { const names = files.map((f) => f.split('/').pop()).join(', ') setToastMessage(`Conflict in ${names} — click to resolve`) }, onToast: (msg) => setToastMessage(msg), }) // 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 = visibleEntries.find(e => e.path === tab.entry.path) if (fresh && shouldReplaceSyncedTabEntry(tab.entry, fresh)) { changed = true return { ...tab, entry: fresh } } return tab }) return changed ? next : prev }) }, [visibleEntries, notes.setTabs]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter) const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({ entries: visibleEntries, activeTabPath: notes.activeTabPath, onSelectNote: notes.handleSelectNote, }) 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, refocusActiveEditor, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), shouldRefocusActiveEditor: isActiveElementInsideEditorSurface, onSelectNote: notes.handleSelectNote, activeTabPath: notes.activeTabPath, getActiveTabPath: () => notes.activeTabPathRef.current, }) const handleAiWorkspaceWindowOpenNote = notes.handleNavigateWikilink const { handleAgentFileCreated: handleAiWorkspaceWindowFileCreated, handleAgentFileModified: handleAiWorkspaceWindowFileModified, handleAgentVaultChanged: handleAiWorkspaceWindowVaultChanged, } = vaultBridge useAiWorkspaceWindowBridgeEvents({ onFileCreated: handleAiWorkspaceWindowFileCreated, onFileModified: handleAiWorkspaceWindowFileModified, onOpenNote: handleAiWorkspaceWindowOpenNote, onVaultChanged: handleAiWorkspaceWindowVaultChanged, }) const conflictFlow = useConflictFlow({ resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath, entries: visibleEntries, 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: refreshGitModifiedFiles, reloadViews: async () => { await vault.reloadViews() }, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, tabs: notes.tabs, activeTabPath: notes.activeTabPath, handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, writableVaultPaths, initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, onInternalVaultWrite: markRecentVaultWrite, locale: appLocale, }) useEffect(() => { appSaveFlushBeforeActionRef.current = appSave.flushBeforeAction }, [appSave.flushBeforeAction]) const handleChangeWorkspace = useCallback(async (entry: VaultEntry, workspace: WorkspaceIdentity) => { const sourceVaultPath = vaultPathForEntry(entry, resolvedPath) if (sourceVaultPath === workspace.path) return try { await flushEditorStateBeforeAction(entry.path) const result = await notes.handleMoveNoteToWorkspace( entry.path, workspace, sourceVaultPath, (oldPath, newEntry) => { appSave.trackRenamedPath(oldPath, newEntry.path) vault.replaceEntry(oldPath, newEntry) if (effectiveSelection.kind === 'entity' && effectiveSelection.entry.path === oldPath) { handleSetSelection({ kind: 'entity', entry: { ...effectiveSelection.entry, ...newEntry, }, }) } }, ) if (!result) return markRecentVaultWrite(entry.path) markRecentVaultWrite(result.new_path) await refreshGitModifiedFiles() } catch (err) { console.error('Failed to change note workspace:', err) setToastMessage(`Failed to move note: ${String(err)}`) } }, [ appSave, effectiveSelection, flushEditorStateBeforeAction, handleSetSelection, markRecentVaultWrite, notes, refreshGitModifiedFiles, resolvedPath, vault ]) const aiActivity = useAiActivity({ onOpenNote: vaultBridge.openNoteByPath, onOpenTab: vaultBridge.openNoteByPath, onSetFilter: (filterType) => { handleSetSelection({ kind: 'sectionGroup', type: filterType }) }, onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) }, }) const handleInitializeProperties = useCallback((path: string) => { void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => { console.warn('Failed to initialize note properties:', err) }) }, [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, parent?: { path: string; rootPath?: string }, ) => { try { const vaultPath = parent?.rootPath?.trim() ? parent.rootPath : resolvedPath const parentPath = parent?.path && parent.path.length > 0 ? parent.path : null const args = { vaultPath, folderName: name, parentPath } if (isTauri()) { await invoke('create_vault_folder', args) } else { await mockInvoke('create_vault_folder', args) } await vault.reloadFolders() setToastMessage(`Created folder "${name}"`) return true } catch (e) { setToastMessage(`Failed to create folder: ${e}`) return false } }, [resolvedPath, vault]) const folderActions = useFolderActions({ vaultPath: resolvedPath, selection: effectiveSelection, setSelection: handleSetSelection, setTabs: notes.setTabs, activeTabPathRef: notes.activeTabPathRef, handleSwitchTab: notes.handleSwitchTab, closeAllTabs: notes.closeAllTabs, reloadVault: vault.reloadVault, reloadFolders: vault.reloadFolders, setToastMessage, }) const fileActions = useFileActions({ selection: effectiveSelection, setToastMessage, vaultPath: resolvedPath, }) 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, vaultPathForEntry(activeTab.entry, resolvedPath), activeTab.entry.title, ) } }, [notes.tabs, notes.activeTabPath, resolvedPath]) const handleOpenEntryInNewWindow = useCallback((entry: Pick) => { openNoteInNewWindow(entry.path, vaultPathForEntry(entry, resolvedPath), entry.title) }, [resolvedPath]) const allGitModifiedFiles = useMemo( () => mergeModifiedFiles( gitSurfaces.allModifiedFiles, activeVaultModifiedFiles(vault.modifiedFiles, resolvedPath), ), [gitSurfaces.allModifiedFiles, resolvedPath, vault.modifiedFiles], ) const selectedChangesModifiedFiles = gitSurfaces.changesModifiedFiles const commitModifiedFiles = gitSurfaces.commitModifiedFiles const changesRepositoryPath = gitSurfaces.changesRepositoryPath const gitModifiedCount = gitFeaturesEnabled ? allGitModifiedFiles.length : 0 const { activeDeletedFile, activeNoteModified, handleDiscardFile, handleOpenDeletedNote, handlePendingDiffHandled, handlePulseOpenNote, handleReplaceActiveTabWithQueuedDiff, loadDiffAtCommitForPath, loadDiffForPath, loadGitHistoryForPath, pendingDiffRequest, } = useGitFileWorkflows({ activeTabPath: notes.activeTabPath, allGitModifiedFiles, changesRepositoryPath, effectiveSelection, entriesByPath, historyRepositoryPath: gitSurfaces.historyRepositoryPath, loadModifiedFilesForRepository, onCloseAllTabs: notes.closeAllTabs, onOpenTabWithContent: notes.openTabWithContent, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, reloadVault: vault.reloadVault, resolvedPath, selectedChangesModifiedFiles, setToastMessage, tabs: notes.tabs, vaultEntries: vault.entries, visibleEntries, }) const commitFlow = useCommitFlow({ aiFeaturesEnabled, commitMessageTarget: quickPromptTarget, commitMessageTargetReady: quickPromptTargetReady, savePending: appSave.savePending, loadModifiedFiles: refreshGitModifiedFiles, loadModifiedFilesForVaultPath: loadModifiedFilesForRepository, resolveRemoteStatusForVaultPath: refreshRemoteStatusForRepository, setToastMessage, onPushRejected: autoSync.handlePushRejected, automaticVaultPaths: gitFeaturesEnabled ? activeGitRepositoryPaths : [], locale: appLocale, manualVaultPath: gitSurfaces.commitRepositoryPath, vaultPath: resolvedPath, }) const suggestedCommitMessage = useMemo(() => generateCommitMessage(commitModifiedFiles), [commitModifiedFiles]) const isGitVault = gitFeaturesEnabled && gitRepoState !== 'missing' const { activitySignature: autoGitActivitySignature, hasPendingWork: autoGitHasPendingWork, } = useAutoGitWork({ activeRemoteStatus: autoSync.remoteStatus, activeVaultPath: resolvedPath, modifiedFiles: allGitModifiedFiles, repositoryPaths: activeGitRepositoryPaths, remoteStatusForRepository: gitSurfaces.remoteStatusForRepository, }) const autoGit = useAutoGit({ enabled: settings.autogit_enabled === true, idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90, inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30, isGitVault, hasPendingChanges: autoGitHasPendingWork, 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 = refreshGitModifiedFiles const triggerSync = autoSync.triggerSync const pullAndPush = autoSync.pullAndPush useEffect(() => { if (!gitFeaturesEnabled) return if (!isChangesSelection) return void loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true }) }, [changesRepositoryPath, gitFeaturesEnabled, isChangesSelection, loadModifiedFilesForRepository]) useEffect(() => { if (autoGitActivitySignature.length === 0) return recordAutoGitActivity() }, [autoGitActivitySignature, recordAutoGitActivity]) const handleCommitPush = useCallback(() => { if (!gitFeaturesEnabled) return triggerCommitEntryAction({ autoGitEnabled: settings.autogit_enabled === true, openCommitDialog, runAutomaticCheckpoint, }) }, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled]) const handlePullRepository = useCallback((targetVaultPath: string) => { if (!gitFeaturesEnabled) return triggerSync(targetVaultPath) }, [gitFeaturesEnabled, triggerSync]) const handlePullSelectedRepository = useCallback(() => { if (!gitFeaturesEnabled) return triggerSync() }, [gitFeaturesEnabled, triggerSync]) const handlePullAndPushSelectedRepository = useCallback(() => { pullAndPush() }, [pullAndPush]) const handleTrackedContentChange = useCallback((path: string, content: string) => { recordAutoGitActivity() handleAppContentChange(path, content) }, [handleAppContentChange, recordAutoGitActivity]) const handleTrackedSave = useCallback(async (...args: Parameters) => { if (notes.activeTabPath) { flushPendingEditorContentRef.current?.(notes.activeTabPath) flushPendingRawContentRef.current?.(notes.activeTabPath) } const result = await handleAppSave(...args) const activeTab = notes.activeTabPath ? notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath) : null if (activeTab) { await loadModifiedFilesForRepository(vaultPathForEntry(activeTab.entry, resolvedPath), { includeStats: isChangesSelection, }) } recordAutoGitActivity() return result }, [ handleAppSave, isChangesSelection, loadModifiedFilesForRepository, notes.activeTabPath, notes.tabs, recordAutoGitActivity, resolvedPath, ]) 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 const activeVaultPath = vaultPathForEntry(activeTab.entry, resolvedPath) if (typeof saveNoteContent === 'function') { await Promise.resolve(saveNoteContent({ path: activePath, content: activeTab.content, vaultPath: activeVaultPath })) } else { await mockInvoke('save_note_content', { path: activePath, content: activeTab.content, vaultPath: activeVaultPath }) } await loadModifiedFiles() recordAutoGitActivity() }, [loadModifiedFiles, notes.activeTabPath, notes.tabs, recordAutoGitActivity, resolvedPath]) useEffect(() => { window.__laputaTest = { ...window.__laputaTest, activeTabPath: notes.activeTabPath, seedAutoGitSavedChange, } return () => { if (window.__laputaTest?.seedAutoGitSavedChange === seedAutoGitSavedChange) { delete window.__laputaTest.seedAutoGitSavedChange } } }, [notes.activeTabPath, seedAutoGitSavedChange]) const entryActions = useEntryActions({ entries: visibleEntries, updateEntry: vault.updateEntry, handleUpdateFrontmatter: notes.handleUpdateFrontmatter, handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, createTypeEntry: notes.createTypeEntrySilent, onBeforeAction: flushEditorStateBeforeAction, actionHistory: notes.actionHistory, }) const resolveVaultPathForNotePath = useCallback((path: string) => { const entry = vault.entries.find((candidate) => candidate.path === path) return entry ? vaultPathForEntry(entry, resolvedPath) : resolvedPath }, [resolvedPath, vault.entries]) const deleteActions = useDeleteActions({ onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() }, removeEntry: vault.removeEntry, removeEntries: vault.removeEntries, resolveVaultPathForPath: resolveVaultPathForNotePath, refreshModifiedFiles: refreshGitModifiedFiles, reloadVault: vault.reloadVault, setToastMessage, }) const handleDeleteType = useCallback((typeName: string) => { const request = resolveTypeDeleteRequest(vault.entries, typeName) if (request.kind === 'blocked') { trackEvent('sidebar_type_delete_blocked', { reason: request.reason, instance_count: request.instanceCount, }) setToastMessage(translate(appLocale, typeDeleteBlockedMessageKey(request), { count: request.instanceCount, type: typeName, })) return } trackEvent('sidebar_type_delete_requested') deleteActions.handleDeleteNote(request.typeEntry.path) }, [appLocale, deleteActions, vault.entries]) const shouldLoadGitHistory = !layout.inspectorCollapsed && !effectiveShowAIChat const gitHistory = useGitHistory(notes.activeTabPath, loadGitHistoryForPath, shouldLoadGitHistory, gitHistoryRefreshKey) const { availableFields, handleCreateMissingType, handleCreateOrUpdateView, handleCreateType, handleDeleteView, handleEditView, handleSidebarUpdateViewDefinition, handleUpdateViewDefinition, } = useAppViewActions({ editingView: dialogs.editingView, graphDefaultWorkspacePath, handleSetSelection, multiWorkspaceEnabled, notes, onOpenEditView: dialogs.openEditView, resolvedPath, selection, setToastMessage, vault, visibleEntries, }) const bulkActions = useBulkActions(entryActions, visibleEntries, setToastMessage) const { buildNumber, diffToggleRef, findInNoteRef, handleCollapseSidebar, handleSetViewMode, handleToggleInspector, noteListVisible, pdfExportRef, rawToggleRef, sidebarVisible, tableOfContentsToggleRef, zoom, } = useAppWindowControls({ layout, windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow, }) const turnCurrentBlockIntoRef = useRef<((target: RichEditorBlockTypeDefinition) => void) | null>(null) const { status: updateStatus, actions: updateActions } = useUpdater( settings.release_channel, areAutomaticUpdateChecksEnabled(settings), ) const handleCheckForUpdates = useCallback(async () => { if (updateStatus.state === 'downloading') { setToastMessage('Update is downloading…') return } if (updateStatus.state === 'ready') { await restartApp() return } setToastMessage(translate(appLocale, 'update.checking')) const result = await updateActions.checkForUpdates() if (result.kind === 'up-to-date') { const checkedChannel = normalizeReleaseChannel(settings.release_channel) setToastMessage(`No newer ${checkedChannel} update is available right now`) } else if (result.kind === 'available') { setToastMessage(`Tolaria ${result.displayVersion} is available`) } else { setToastMessage(result.message) } }, [appLocale, settings.release_channel, updateActions, updateStatus.state]) 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]) 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]) 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 noteRetargetingUi = useNoteRetargetingUi({ activeEntry: activeCommandEntry, activeNoteBlocked: !!activeDeletedFile, entries: visibleEntries, folders: vault.folders, selection: effectiveSelection, setSelection: handleSetSelection, setToastMessage, vaultPath: resolvedPath, updateFrontmatter: notes.handleUpdateFrontmatter, moveNoteToFolder: notes.handleMoveNoteToFolder, }) const canToggleRichEditor = !!activeCommandEntry && activeCommandEntry.filename.toLowerCase().endsWith('.md') && !activeDeletedFile const shouldBlockNeighborhoodEscape = ( dialogs.showCreateTypeDialog || dialogs.showQuickOpen || dialogs.showCommandPalette || effectiveShowAIChat || dialogs.showSettings || dialogs.showCloneVault || dialogs.showSearch || dialogs.showConflictResolver || dialogs.showCreateViewDialog || noteRetargetingUi.isDialogOpen || showFeedback ) useNeighborhoodEscape({ onBack: handleNeighborhoodHistoryBack, selectionRef, shouldBlockNeighborhoodEscape, }) const noteListColumnsLabel = useMemo(() => { if (effectiveSelection.kind === 'view') { const selectedView = vault.views.find((view) => viewMatchesSelection(view, effectiveSelection)) 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 viewOrdering = useSavedViewOrdering({ views: vault.views, selection: effectiveSelection, vaultPath: resolvedPath, reloadViews: vault.reloadViews, loadModifiedFiles: refreshGitModifiedFiles, onToast: setToastMessage, locale: appLocale, }) const canReorderSavedViews = useMemo(() => ( vault.views.every((view) => !view.rootPath) ), [vault.views]) const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [diffToggleRef]) const toggleRawEditorCommand = useMemo( () => canToggleRichEditor ? () => rawToggleRef.current() : undefined, [canToggleRichEditor, rawToggleRef], ) const toggleTableOfContentsCommand = useCallback(() => { if (notes.activeTabPath) tableOfContentsToggleRef.current() }, [notes.activeTabPath, tableOfContentsToggleRef]) const exportNotePdfCommand = useCallback(() => { pdfExportRef.current?.('app_command') }, [pdfExportRef]) const findInNoteCommand = useCallback(() => { findInNoteRef.current?.({ replace: false }) }, [findInNoteRef]) const replaceInNoteCommand = useCallback(() => { findInNoteRef.current?.({ replace: true }) }, [findInNoteRef]) const turnCurrentBlockIntoCommand = useCallback((target: RichEditorBlockTypeDefinition) => { turnCurrentBlockIntoRef.current?.(target) }, []) const pastePlainTextCommand = useCallback(() => { void requestPlainTextPaste().catch((error) => { console.warn('[paste] Failed to paste plain text:', error) }) }, []) const removeActiveVaultCommand = useCallback(() => { vaultSwitcher.removeVault(vaultSwitcher.vaultPath) }, [vaultSwitcher]) const restoreVaultAiGuidanceCommand = useCallback(() => { void restoreVaultAiGuidance() }, [restoreVaultAiGuidance]) const changeNoteTypeCommand = useMemo( () => noteRetargetingUi.canChangeActiveNoteType ? noteRetargetingUi.openChangeNoteTypeDialog : undefined, [noteRetargetingUi.canChangeActiveNoteType, noteRetargetingUi.openChangeNoteTypeDialog], ) const moveNoteToFolderCommand = useMemo( () => noteRetargetingUi.canMoveActiveNoteToFolder ? noteRetargetingUi.openMoveNoteToFolderDialog : undefined, [noteRetargetingUi.canMoveActiveNoteToFolder, noteRetargetingUi.openMoveNoteToFolderDialog], ) const activeNoteHasIcon = useMemo(() => { const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath) return hasNoteIconValue(entry?.icon) }, [notes.activeTabPath, vault.entries]) const handleToggleOrganizedWithInboxAdvance = useInboxOrganizeAdvance({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, autoAdvanceEnabled: settings.auto_advance_inbox_after_organize === true, entries: visibleEntries, onSelectNote: notes.handleSelectNote, onToggleOrganized: entryActions.handleToggleOrganized, requestedActiveTabPathRef: notes.requestedActiveTabPathRef, selection: effectiveSelection, visibleNotesRef, }) const toggleOrganizedCommand = explicitOrganizationEnabled ? handleToggleOrganizedWithInboxAdvance : undefined const canCustomizeNoteListColumns = useMemo(() => ( canCustomizeColumnsForSelection(effectiveSelection, explicitOrganizationEnabled) ), [effectiveSelection, explicitOrganizationEnabled]) const restoreDeletedNoteCommand = useMemo( () => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, [activeDeletedFile, handleDiscardFile], ) const reloadVaultForCommand = vault.reloadVault const handleManualVaultReload = useCallback(async () => { const entries = await reloadVaultForCommand() setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`) return entries }, [reloadVaultForCommand]) const { activeTab, defaultNoteWidth, noteWidth: activeNoteWidth, setDefaultNoteWidth: handleSetDefaultNoteWidth, setNoteWidth: handleSetActiveNoteWidth, toggleNoteWidth: handleToggleNoteWidth, } = useNoteWidthMode({ tabs: notes.tabs, activeTabPath: notes.activeTabPath, settings, saveSettings, updateFrontmatter: notes.handleUpdateFrontmatter, setToastMessage, }) const activeTabEntry = activeTab?.entry ?? null const activeTabPath = activeTabEntry?.path const handleSelectNoteForPdfExport = notes.handleSelectNote const handleExportNotePdfFromList = useCallback((entry: VaultEntry) => { if (!isMarkdownEntry(entry)) return if (activeTabPath === entry.path) { pdfExportRef.current?.('note_list_context_menu') return } setPendingNoteListPdfExportPath(entry.path) handleSelectNoteForPdfExport(entry) }, [activeTabPath, handleSelectNoteForPdfExport, pdfExportRef]) useEffect(() => { if (!pendingNoteListPdfExportPath) return if (!activeTabEntry || activeTabPath !== pendingNoteListPdfExportPath) return const frameId = requestAnimationFrame(() => { if (isMarkdownEntry(activeTabEntry)) pdfExportRef.current?.('note_list_context_menu') setPendingNoteListPdfExportPath(null) }) return () => cancelAnimationFrame(frameId) }, [activeTabEntry, activeTabPath, pendingNoteListPdfExportPath, pdfExportRef]) const { isStartupLoading, isVaultContentLoading, shouldResumeFreshStartOnboarding, shouldShowStartupScreen, } = useStartupScreenState({ aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt, isNoteWindow: Boolean(noteWindowParams) || aiWorkspaceWindow, onboardingState: onboarding.state, runtimeMissingVaultPath, selectedVaultPath, settingsLoaded, showMcpSetupDialog: mcpSetupDialog.open, telemetryConsent: settings.telemetry_consent, vaultIsLoading: vault.isLoading, vaultSwitcher, }) const deepLinks = useDeepLinks({ activeEntry: activeTab?.entry ?? null, currentVaultPath: resolvedPath, enabled: !noteWindowParams && !aiWorkspaceWindow, entries: visibleEntries, isVaultContentLoading, locale: appLocale, onSelectNote: notes.handleSelectNote, onSwitchVault: vaultSwitcher.switchVault, reloadVault: vault.reloadVault, setToastMessage, vaultListLoaded: vaultSwitcher.loaded, vaults: vaultSwitcher.allVaults, }) const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath const noteGitUrls = useNoteGitUrls({ currentVaultPath: resolvedPath, locale: appLocale, remoteStatusForRepository: gitSurfaces.remoteStatusForRepository, setToastMessage, }) const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences) const undoCommand = useCallback(() => { if (runNativeTextHistoryCommand('undo')) return void notes.handleUndo() }, [notes]) const redoCommand = useCallback(() => { if (runNativeTextHistoryCommand('redo')) return void notes.handleRedo() }, [notes]) const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, entries: visibleEntries, visibleNotesRef, multiSelectionCommandRef, modifiedCount: gitModifiedCount, activeNoteModified, selection: effectiveSelection, onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette, onSearch: dialogs.openSearch, onFindInNote: findInNoteCommand, onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand, onTurnCurrentBlockInto: activeDeletedFile ? undefined : turnCurrentBlockIntoCommand, onPastePlainText: pastePlainTextCommand, onCreateNote: notes.handleCreateNoteImmediate, onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: appSave.handleSave, onUndo: undoCommand, onRedo: redoCommand, canUndo: notes.canUndo, canRedo: notes.canRedo, undoLabel: notes.undoLabel, redoLabel: notes.redoLabel, onOpenSettings: handleOpenSettings, onOpenFeedback: openFeedback, onDeleteNote: deleteActions.handleDeleteNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: handleCommitPush, onGenerateCommitMessage: commitFlow.openCommitDialogWithGeneratedMessage, gitRepositories, gitFeaturesEnabled, isGitVault, onInitializeGit: openGitSetupDialog, onPull: handlePullSelectedRepository, onPullRepository: handlePullRepository, onResolveConflicts: conflictFlow.handleOpenConflictResolver, onSetViewMode: handleSetViewMode, onToggleInspector: handleToggleInspector, onToggleDiff: toggleDiffCommand, onToggleRawEditor: toggleRawEditorCommand, onToggleTableOfContents: toggleTableOfContentsCommand, onExportNoteAsPdf: activeDeletedFile ? undefined : exportNotePdfCommand, noteWidth: activeNoteWidth, defaultNoteWidth, onSetNoteWidth: handleSetActiveNoteWidth, onSetDefaultNoteWidth: handleSetDefaultNoteWidth, selectedViewName: viewOrdering.selectedViewName, onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp, onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown, canMoveSelectedViewUp: viewOrdering.canMoveSelectedViewUp, canMoveSelectedViewDown: viewOrdering.canMoveSelectedViewDown, onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, onSelect: handleSetSelection, onRenameFolder: folderActions.renameSelectedFolder, onDeleteFolder: folderActions.deleteSelectedFolder, onRevealSelectedFolder: fileActions.revealSelectedFolder, onCopySelectedFolderPath: fileActions.copySelectedFolderPath, 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, ...commandAiActions, onCheckForUpdates: handleCheckForUpdates, onRemoveActiveVault: removeActiveVaultCommand, onRestoreGettingStarted: cloneGettingStartedVault, isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, vaultCount: vaultSwitcher.allVaults.length, locale: appLocale, systemLocale, selectedUiLanguage, onSetUiLanguage: handleSetUiLanguage, onSetThemeMode: handleSetThemeMode, mcpStatus: mcpSetupDialog.status, onInstallMcp: mcpSetupDialog.openDialog, onReloadVault: handleManualVaultReload, onRepairVault: handleRepairVault, onSetNoteIcon: handleSetNoteIconCommand, onRemoveNoteIcon: handleRemoveNoteIconCommand, onChangeNoteType: changeNoteTypeCommand, onMoveNoteToFolder: moveNoteToFolderCommand, canMoveNoteToFolder: noteRetargetingUi.canMoveActiveNoteToFolder, activeNoteHasIcon, noteListFilter, onSetNoteListFilter: setNoteListFilter, onOpenInNewWindow: handleOpenInNewWindow, onRevealActiveFile: fileActions.revealFile, onCopyActiveFilePath: fileActions.copyFilePath, onCopyActiveDeepLink: deepLinks.copyPathDeepLink, onOpenActiveFileExternal: fileActions.openExternalFile, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: toggleOrganizedCommand, onCustomizeNoteListColumns: handleCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, onRestoreDeletedNote: restoreDeletedNoteCommand, canRestoreDeletedNote: !!activeDeletedFile, }) const { inboxCount, noteList: aiNoteList, noteListFilter: aiNoteListFilter, } = useAiWorkspacePublishedContext({ activeTab, allNotesFileVisibility, context: aiWorkspaceWindowContext, effectiveSelection, entries: visibleEntries, inboxPeriod, tabs: notes.tabs, views: vault.views, }) const handleAiWorkspaceConversationsChange = useCallback((conversations: AiWorkspaceConversationSetting[]) => { void saveSettings({ ...settings, ai_workspace_conversations: conversations }) }, [saveSettings, settings]) const aiWorkspaceSurface = ( tab.entry)} noteList={aiNoteList} noteListFilter={aiNoteListFilter} onActiveConversationChange={handleActiveAiWorkspaceConversationChange} onActiveTargetChange={handleActiveAiWorkspaceTargetChange} onClose={closeAIChat} onConversationSettingsChange={handleAiWorkspaceConversationsChange} onOpenAiSettings={handleOpenAiSettings} onOpenNote={notes.handleNavigateWikilink} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} onUnsupportedAiPaste={setToastMessage} onFileCreated={vaultBridge.handleAgentFileCreated} onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} vaultAiGuidanceStatus={vaultAiGuidanceStatus} vaultPath={activeEditorVaultPath} vaultPaths={writableVaultPaths} locale={appLocale} /> ) if (shouldShowStartupScreen) { return ( ) } if (aiWorkspaceWindow) { return ( {aiWorkspaceSurface} ) } const noteListModifiedFiles = isChangesSelection ? selectedChangesModifiedFiles : undefined const noteListModifiedFilesError = isChangesSelection ? gitSurfaces.changesModifiedFilesError : null return (
光湖语言人格驱动操作系统
{sidebarVisible && ( <>
)} {noteListVisible && ( <>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} /> ) : ( )}
)}
{showHoloLakeHome ? setShowHoloLakeHome(false)} /> : }
handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpSetupDialog.status} onInstallMcp={mcpSetupDialog.openDialog} locale={appLocale} /> {aiFeaturesEnabled && !effectiveShowAIChat ? ( ) : null} setToastMessage(null)} /> notes.handleCreateNote(title, 'Note', 'quick_open')} onClose={dialogs.closeQuickOpen} locale={appLocale} /> {deleteActions.confirmDelete && ( deleteActions.setConfirmDelete(null)} /> )} {folderActions.confirmDeleteFolder && ( )}
) } export default App