hololake-system-architecture/product-source/hololake-platform/src/App.tsx

1897 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { GUANGHU_THEME_INTENT_EVENT, HoloLakeHome } from './components/HoloLakeHome'
import { GuanghuThemeMenu } from './components/GuanghuThemeMenu'
import guanghuLogoLight from './assets/guanghu-logo-light.png'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
import { useWorkspaceAiGuidanceStatus } from './hooks/useWorkspaceAiGuidanceStatus'
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 { openAiWorkspaceWindow } from './utils/openAiWorkspaceWindow'
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 {
buildWorkspaceAiGuidanceRefreshKey,
} from './lib/workspaceAiGuidance'
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'
import './GalaxyPhase1.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
export interface AppProps {
distribution?: 'private' | 'team'
onReturnToLighthouse?: () => void
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App({ distribution = 'private', onReturnToLighthouse }: AppProps) {
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
const aiWorkspaceWindow = useMemo(() => isAiWorkspaceWindow(), [])
if (aiWorkspaceWindow) return <AiWorkspaceWindowApp />
return <MainApp distribution={distribution} noteWindowParams={noteWindowParams} onReturnToLighthouse={onReturnToLighthouse} />
}
function MainApp({
distribution,
noteWindowParams,
onReturnToLighthouse,
}: {
distribution: 'private' | 'team'
noteWindowParams: NoteWindowParams | null
onReturnToLighthouse?: () => void
}) {
const aiWorkspaceWindow = false
const isTeamDistribution = distribution === 'team'
const [showHoloLakeHome, setShowHoloLakeHome] = useState(() => {
const smokeTestSkipsHome = import.meta.env.DEV && localStorage.getItem('tolaria:smoke-skip-hololake-home') === '1'
return !isTeamDistribution && noteWindowParams === null && !smokeTestSkipsHome
})
const { dragRegionRef } = useDragRegion<HTMLElement>()
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const [pendingNoteListPdfExportPath, setPendingNoteListPdfExportPath] = useState<string | null>(null)
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 = []
}
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<VaultEntry[]>([])
const multiSelectionCommandRef = useRef<NoteListMultiSelectionCommands | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(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<string | null>(null)
const {
effectiveShowAIChat,
handleOpenAiSettings,
handleOpenDockedAiWorkspace,
} = useAppAiWorkspaceBridge({
aiFeaturesEnabled,
aiWorkspaceWindow,
closeAIChat,
openAIChat,
openSettings: dialogs.openSettings,
setSettingsInitialSectionId,
showAIChat,
})
const [lastAiWorkspaceConversationId, setLastAiWorkspaceConversationId] = useState<string | null>(null)
const handleActiveAiWorkspaceConversationChange = useCallback((id: string) => {
setLastAiWorkspaceConversationId(id)
}, [])
const handleOpenIndependentAiWorkspace = useCallback(async (context?: { activeConversationId?: string }) => {
const opened = await openAiWorkspaceWindow({
...aiWorkspaceWindowContext,
activeConversationId: context?.activeConversationId ?? lastAiWorkspaceConversationId ?? undefined,
}).catch(() => false)
if (opened) {
closeAIChat()
return
}
handleOpenDockedAiWorkspace()
}, [aiWorkspaceWindowContext, closeAIChat, handleOpenDockedAiWorkspace, lastAiWorkspaceConversationId])
const handleToggleAiWorkspace = useCallback(() => {
if (effectiveShowAIChat) {
closeAIChat()
return
}
void handleOpenIndependentAiWorkspace()
}, [closeAIChat, effectiveShowAIChat, handleOpenIndependentAiWorkspace])
const [lastAiWorkspaceTarget, setLastAiWorkspaceTarget] = useState<AiTarget | null>(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: workspaceAiGuidanceStatus,
refresh: refreshWorkspaceAiGuidance,
} = useWorkspaceAiGuidanceStatus(
aiFeaturesEnabled ? resolvedPath : null,
buildWorkspaceAiGuidanceRefreshKey(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, setTheme: setGuanghuTheme } = 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<unknown>) | 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<VaultEntry, 'path' | 'title' | 'workspace'>) => {
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<typeof handleAppSave>) => {
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(`HoloLake Era ${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<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault()
await refreshWorkspaceAiGuidance()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [refreshWorkspaceAiGuidance, resolvedPath, vault])
const restoreWorkspaceAiGuidance = useCallback(async (successToast: string | null = 'HoloLake AI guidance restored') => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath })
await vault.reloadVault()
await refreshWorkspaceAiGuidance()
if (successToast) setToastMessage(successToast)
} catch (err) {
setToastMessage(`Failed to restore HoloLake AI guidance: ${err}`)
}
}, [refreshWorkspaceAiGuidance, 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 restoreWorkspaceAiGuidanceCommand = useCallback(() => {
void restoreWorkspaceAiGuidance()
}, [restoreWorkspaceAiGuidance])
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({
// HoloLake is the human entry into the world. Optional Agent setup belongs
// inside the Agent workspace/settings and must never replace that entry.
aiAgentsPromptVisible: false,
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, workspaceAiGuidanceStatus, restoreWorkspaceAiGuidanceCommand, 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 = (
<AppAiWorkspaceSurface
mode="side"
open={effectiveShowAIChat}
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={settings.ai_model_providers ?? []}
conversationSettings={settings.ai_workspace_conversations ?? null}
conversationSettingsReady={settingsLoaded}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiTarget={aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
initialActiveConversationId={lastAiWorkspaceConversationId ?? undefined}
activeEntry={activeTab?.entry ?? null}
activeNoteContent={activeTab?.content ?? null}
entries={visibleEntries}
openTabs={notes.tabs.map((tab) => tab.entry)}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onActiveConversationChange={handleActiveAiWorkspaceConversationChange}
onActiveTargetChange={handleActiveAiWorkspaceTargetChange}
onClose={closeAIChat}
onConversationSettingsChange={handleAiWorkspaceConversationsChange}
onOpenAiSettings={handleOpenAiSettings}
onPopOut={handleOpenIndependentAiWorkspace}
onOpenNote={notes.handleNavigateWikilink}
onRestoreWorkspaceAiGuidance={aiFeaturesEnabled ? () => { void restoreWorkspaceAiGuidance() } : undefined}
onUnsupportedAiPaste={setToastMessage}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
workspaceAiGuidanceStatus={workspaceAiGuidanceStatus}
vaultPath={resolvedPath}
vaultPaths={[resolvedPath]}
locale={appLocale}
/>
)
if (shouldShowStartupScreen) {
return (
<StartupScreen
aiAgentsOnboarding={aiAgentsOnboarding}
aiAgentsStatus={aiAgentsStatus}
isOffline={networkStatus.isOffline}
isStartupLoading={isStartupLoading}
locale={appLocale}
noteWindowParams={noteWindowParams}
onboarding={onboarding}
runtimeMissingVaultPath={runtimeMissingVaultPath}
saveSettings={saveSettings}
settings={settings}
settingsLoaded={settingsLoaded}
shouldResumeFreshStartOnboarding={shouldResumeFreshStartOnboarding}
showMcpSetupDialog={mcpSetupDialog.open}
setToastMessage={setToastMessage}
toastMessage={toastMessage}
vaultSwitcher={vaultSwitcher}
/>
)
}
if (aiWorkspaceWindow) {
return (
<AppPreferencesProvider dateDisplayFormat={dateDisplayFormat}>
{aiWorkspaceSurface}
</AppPreferencesProvider>
)
}
const noteListModifiedFiles = isChangesSelection ? selectedChangesModifiedFiles : undefined
const noteListModifiedFilesError = isChangesSelection ? gitSurfaces.changesModifiedFilesError : null
return (
<AppPreferencesProvider dateDisplayFormat={dateDisplayFormat}>
<div className="app-shell">
<header ref={dragRegionRef} className="hololake-bar" data-tauri-drag-region>
<button
className="hololake-bar__brand"
onClick={() => isTeamDistribution ? onReturnToLighthouse?.() : setShowHoloLakeHome(true)}
>
<img className="hololake-bar__mark" src={guanghuLogoLight} alt="光湖" />
<span>{isTeamDistribution ? '光湖灯塔' : <>HoloLake <b>Era</b></>}</span>
</button>
<span className="hololake-bar__line" />
<span className="hololake-bar__context">{isTeamDistribution ? '光湖零感域 · AI 知识空间' : showHoloLakeHome ? '光湖语言人格驱动操作系统' : '永恒湖心系统 · 知识湖模块'}</span>
<GuanghuThemeMenu value={guanghuTheme} onChange={(theme) => {
setGuanghuTheme(theme)
window.dispatchEvent(new CustomEvent(GUANGHU_THEME_INTENT_EVENT, { detail: theme }))
}} />
</header>
<div className="app">
{!showHoloLakeHome && noteWindowParams === null && (
<nav className="channel-dock" aria-label="频道模块控制栏">
<button
onClick={() => isTeamDistribution ? onReturnToLighthouse?.() : setShowHoloLakeHome(true)}
title={isTeamDistribution ? '返回光湖灯塔' : '返回心跳核心频道'}
><span></span><small>{isTeamDistribution ? '灯塔' : '频道'}</small></button>
<button data-active="true" title="当前模块AI 知识空间"><span></span><small></small></button>
<button disabled title="AI 创作空间尚未接入"><span></span><small></small></button>
<button disabled title="更多模块尚未接入"><span></span><small></small></button>
</nav>
)}
{sidebarVisible && !showHoloLakeHome && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={visibleEntries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={canReorderSavedViews ? viewOrdering.onReorderViews : undefined} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} pluralizeTypeLabels={settings.sidebar_type_pluralization_enabled ?? true} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} workspaceOrder={vaultWorkspaceOrder} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && !showHoloLakeHome && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} refreshKey={gitHistoryRefreshKey} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
) : (
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} 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} onRenameFilename={appSave.handleFilenameRename} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} canCopyGitUrl={noteGitUrls.canCopyEntryGitUrl} onCopyGitUrl={noteGitUrls.copyEntryGitUrl} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
{showHoloLakeHome && !isTeamDistribution ? <HoloLakeHome locale={appLocale} livingSystemTarget={aiFeaturesEnabled && quickPromptTargetReady && quickPromptTarget.kind === 'api_model' ? quickPromptTarget : null} onEnterKnowledgeBase={() => setShowHoloLakeHome(false)} onOpenAiWorkspace={openAIChat} onOpenLocalWorkspace={async () => {
await vaultSwitcher.handleOpenLocalFolder()
setShowHoloLakeHome(false)
}} /> : <Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
isVaultLoading={isVaultContentLoading}
entries={visibleEntries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={loadDiffForPath}
onLoadDiffAtCommit={loadDiffAtCommitForPath}
pendingCommitDiffRequest={pendingDiffRequest}
onPendingCommitDiffHandled={handlePendingDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiTarget={aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onUnsupportedAiPaste={setToastMessage}
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}
onChangeWorkspace={activeDeletedFile ? undefined : handleChangeWorkspace}
onInitializeProperties={handleInitializeProperties}
showAIChat={effectiveShowAIChat}
onToggleAIChat={aiFeaturesEnabled ? handleToggleAiWorkspace : undefined}
aiWorkspaceSurface={aiWorkspaceSurface}
vaultPath={activeEditorVaultPath}
vaultPaths={writableVaultPaths}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand}
onEnterNeighborhood={activeDeletedFile ? undefined : handleEnterNeighborhood}
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onCopyDeepLink={activeDeletedFile ? undefined : deepLinks.copyEntryDeepLink}
onCopyGitUrl={activeDeletedFile || !activeTabEntry || !noteGitUrls.canCopyEntryGitUrl(activeTabEntry) ? undefined : noteGitUrls.copyEntryGitUrl}
onOpenExternalFile={fileActions.openExternalFile}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
noteWidth={activeNoteWidth}
onToggleNoteWidth={handleToggleNoteWidth}
rawToggleRef={rawToggleRef}
tableOfContentsToggleRef={tableOfContentsToggleRef}
pdfExportRef={pdfExportRef}
turnCurrentBlockIntoRef={turnCurrentBlockIntoRef}
findInNoteRef={findInNoteRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
workspaces={inspectorWorkspaces}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingEditorContentRef={flushPendingEditorContentRef}
flushPendingRawContentRef={flushPendingRawContentRef}
onToast={setToastMessage}
locale={appLocale}
/>}
</div>
{showHoloLakeHome && effectiveShowAIChat ? aiWorkspaceSurface : null}
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
{!showHoloLakeHome ? (
<StatusBar galaxyMode={false} noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} 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} />
) : null}
{!showHoloLakeHome && aiFeaturesEnabled && !effectiveShowAIChat ? (
<AiWorkspaceFloatingButton
statuses={aiAgentsStatus}
defaultAgent={aiAgentPreferences.defaultAiAgent}
defaultTarget={settings.default_ai_target ?? undefined}
providers={settings.ai_model_providers ?? []}
locale={appLocale}
updateBannerVisible={updateStatus.state !== 'idle' && updateStatus.state !== 'error'}
onOpen={showHoloLakeHome ? openAIChat : handleToggleAiWorkspace}
/>
) : null}
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} locale={appLocale} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={visibleEntries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onCreateNote={(title) => notes.handleCreateNote(title, 'Note', 'quick_open')} onClose={dialogs.closeQuickOpen} locale={appLocale} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={visibleEntries}
aiAgentReady={quickPromptTargetReady}
aiAgentLabel={quickPromptTarget.label}
aiModeEnabled={aiFeaturesEnabled}
aiPromptTargetId={quickPromptTarget.id}
locale={appLocale}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={visibleEntries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<NoteRetargetingDialogs
dialogState={noteRetargetingUi.dialogState}
dialogEntry={noteRetargetingUi.dialogEntry}
typeOptions={noteRetargetingUi.typeOptions}
folderOptions={noteRetargetingUi.folderOptions}
onClose={noteRetargetingUi.closeDialog}
onSelectType={noteRetargetingUi.selectType}
onSelectFolder={noteRetargetingUi.selectFolder}
/>
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} locale={appLocale} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={commitModifiedFiles.length}
commitMode={commitFlow.commitMode}
authorIdentity={commitFlow.authorIdentity}
locale={appLocale}
repositories={gitRepositories}
selectedRepositoryPath={gitSurfaces.commitRepositoryPath}
generatedMessage={commitFlow.generatedCommitMessage}
generatedMessageKey={commitFlow.generatedCommitMessageKey}
isGeneratingMessage={commitFlow.isGeneratingCommitMessage}
suggestedMessage={suggestedCommitMessage}
onGenerateMessage={commitFlow.generateCommitMessageForDialog}
onRepositoryChange={gitSurfaces.setCommitRepositoryPath}
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} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={gitRepoState !== 'missing'} onSave={saveSettings} onCopyMcpConfig={mcpSetupDialog.copyManualConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} locale={appLocale} />
<McpSetupDialog open={mcpSetupDialog.open} status={mcpSetupDialog.status} busyAction={mcpSetupDialog.busyAction} manualConfigSnippet={mcpSetupDialog.manualConfigSnippet} opencodeManualConfigSnippet={mcpSetupDialog.opencodeManualConfigSnippet} manualConfigLoading={mcpSetupDialog.manualConfigLoading} manualConfigError={mcpSetupDialog.manualConfigError} locale={appLocale} onClose={mcpSetupDialog.closeDialog} onConnect={mcpSetupDialog.connect} onCopyManualConfig={mcpSetupDialog.copyManualConfig} onCopyOpenCodeManualConfig={mcpSetupDialog.copyOpenCodeManualConfig} onDisconnect={mcpSetupDialog.disconnect} onLoadManualConfig={mcpSetupDialog.loadManualConfig} />
<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)}
/>
)}
{folderActions.confirmDeleteFolder && (
<ConfirmDeleteDialog
open={true}
title={folderActions.confirmDeleteFolder.title}
message={folderActions.confirmDeleteFolder.message}
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
onConfirm={folderActions.confirmDeleteSelectedFolder}
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
</AppPreferencesProvider>
)
}
export default App