handleSelectNote and handleReplaceActiveTab now check the in-memory allContent cache before issuing a Tauri IPC call. Cache hits open the tab synchronously (zero latency). Cache misses fall back to the disk read and populate allContent via onContentLoaded so subsequent opens of the same note are instant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
683 lines
33 KiB
TypeScript
683 lines
33 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Sidebar } from './components/Sidebar'
|
|
import { NoteList } from './components/NoteList'
|
|
import { Editor } from './components/Editor'
|
|
import { ResizeHandle } from './components/ResizeHandle'
|
|
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
|
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
|
import { CommandPalette } from './components/CommandPalette'
|
|
import { SearchPanel } from './components/SearchPanel'
|
|
import { Toast } from './components/Toast'
|
|
import { CommitDialog } from './components/CommitDialog'
|
|
import { PulseView } from './components/PulseView'
|
|
import { StatusBar } from './components/StatusBar'
|
|
import { SettingsPanel } from './components/SettingsPanel'
|
|
import { GitHubVaultModal } from './components/GitHubVaultModal'
|
|
import { WelcomeScreen } from './components/WelcomeScreen'
|
|
import { useMcpStatus } from './hooks/useMcpStatus'
|
|
import { useVaultLoader } from './hooks/useVaultLoader'
|
|
import { useSettings } from './hooks/useSettings'
|
|
import { useNoteActions } from './hooks/useNoteActions'
|
|
import { useCommitFlow } from './hooks/useCommitFlow'
|
|
import { useViewMode } from './hooks/useViewMode'
|
|
import { useEntryActions } from './hooks/useEntryActions'
|
|
import { useAppCommands } from './hooks/useAppCommands'
|
|
import { useDialogs } from './hooks/useDialogs'
|
|
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
|
import { useGitHistory } from './hooks/useGitHistory'
|
|
import { useUpdater, restartApp } from './hooks/useUpdater'
|
|
import { useNavigationHistory } from './hooks/useNavigationHistory'
|
|
import { useAutoSync } from './hooks/useAutoSync'
|
|
import { useConflictResolver } from './hooks/useConflictResolver'
|
|
import { useIndexing } from './hooks/useIndexing'
|
|
import { useZoom } from './hooks/useZoom'
|
|
import { useVaultConfig } from './hooks/useVaultConfig'
|
|
import { useBuildNumber } from './hooks/useBuildNumber'
|
|
import { useOnboarding } from './hooks/useOnboarding'
|
|
import { useThemeManager } from './hooks/useThemeManager'
|
|
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
|
import { useNavigationGestures } from './hooks/useNavigationGestures'
|
|
import { useAiActivity } from './hooks/useAiActivity'
|
|
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
|
import { UpdateBanner } from './components/UpdateBanner'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { isTauri, mockInvoke } from './mock-tauri'
|
|
import type { SidebarSelection, VaultEntry } from './types'
|
|
import type { NoteListItem } from './utils/ai-context'
|
|
import { filterEntries } from './utils/noteListHelpers'
|
|
import { openLocalFile } from './utils/url'
|
|
import { flushEditorContent } from './utils/autoSave'
|
|
import './App.css'
|
|
|
|
// Type declarations for mock content storage and test overrides
|
|
declare global {
|
|
interface Window {
|
|
__mockContent?: Record<string, string>
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
|
|
__mockHandlers?: Record<string, (args: any) => any>
|
|
}
|
|
}
|
|
|
|
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
|
|
|
function useBulkActions(
|
|
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
|
|
setToastMessage: (msg: string | null) => void,
|
|
) {
|
|
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
|
let ok = 0
|
|
for (const path of paths) {
|
|
try { await entryActions.handleArchiveNote(path); ok++ }
|
|
catch { /* error toast already shown by flushBeforeAction */ }
|
|
}
|
|
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
|
|
}, [entryActions, setToastMessage])
|
|
|
|
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
|
let ok = 0
|
|
for (const path of paths) {
|
|
try { await entryActions.handleTrashNote(path); ok++ }
|
|
catch { /* error toast already shown by flushBeforeAction */ }
|
|
}
|
|
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
|
|
}, [entryActions, setToastMessage])
|
|
|
|
return { handleBulkArchive, handleBulkTrash }
|
|
}
|
|
|
|
function useLayoutPanels() {
|
|
const [sidebarWidth, setSidebarWidth] = useState(250)
|
|
const [noteListWidth, setNoteListWidth] = useState(300)
|
|
const [inspectorWidth, setInspectorWidth] = useState(280)
|
|
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
|
|
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
|
|
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
|
|
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
|
|
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
|
|
}
|
|
|
|
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
|
function App() {
|
|
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
|
const layout = useLayoutPanels()
|
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
|
const dialogs = useDialogs()
|
|
|
|
// onSwitch closure captures `notes` declared below — safe because it's only
|
|
// called on user interaction, never during render (refs inside the hook
|
|
// guarantee the latest closure is always used).
|
|
const vaultSwitcher = useVaultSwitcher({
|
|
onSwitch: () => { setSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
|
onToast: (msg) => setToastMessage(msg),
|
|
})
|
|
|
|
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
|
|
|
// When onboarding resolves to a different vault path, update the switcher
|
|
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
|
|
const vault = useVaultLoader(resolvedPath)
|
|
useVaultConfig(resolvedPath)
|
|
const { settings, saveSettings } = useSettings()
|
|
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
|
|
|
|
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
|
|
|
const indexing = useIndexing(resolvedPath)
|
|
|
|
const autoSync = useAutoSync({
|
|
vaultPath: resolvedPath,
|
|
intervalMinutes: settings.auto_pull_interval_minutes,
|
|
onVaultUpdated: vault.reloadVault,
|
|
onSyncUpdated: indexing.triggerIncrementalIndex,
|
|
onConflict: (files) => {
|
|
const names = files.map((f) => f.split('/').pop()).join(', ')
|
|
setToastMessage(`Conflict in ${names} — click to resolve`)
|
|
},
|
|
onToast: (msg) => setToastMessage(msg),
|
|
})
|
|
|
|
// Ref bridges for conflict resolution callbacks (notes declared below)
|
|
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
|
|
|
|
const conflictResolver = useConflictResolver({
|
|
vaultPath: resolvedPath,
|
|
onResolved: () => {
|
|
dialogs.closeConflictResolver()
|
|
autoSync.resumePull()
|
|
vault.reloadVault()
|
|
autoSync.triggerSync()
|
|
},
|
|
onToast: (msg) => setToastMessage(msg),
|
|
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
|
|
})
|
|
|
|
const handleOpenConflictResolver = useCallback(async () => {
|
|
let files = autoSync.conflictFiles
|
|
// If no cached conflicts, check directly — there may be pre-existing
|
|
// conflicts from a prior session that the pull flow didn't detect.
|
|
if (files.length === 0) {
|
|
try {
|
|
files = isTauri()
|
|
? await invoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
|
|
: await mockInvoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
|
|
} catch {
|
|
return
|
|
}
|
|
if (files.length === 0) {
|
|
setToastMessage('No merge conflicts to resolve')
|
|
return
|
|
}
|
|
}
|
|
autoSync.pausePull()
|
|
conflictResolver.initFiles(files)
|
|
dialogs.openConflictResolver()
|
|
}, [autoSync, conflictResolver, dialogs, resolvedPath])
|
|
|
|
const handleCloseConflictResolver = useCallback(() => {
|
|
autoSync.resumePull()
|
|
dialogs.closeConflictResolver()
|
|
}, [autoSync, dialogs])
|
|
|
|
// Ref bridges handleContentChange (created after notes) into useNoteActions.
|
|
// Read at callback time, so it's always current when user presses Cmd+N.
|
|
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
|
|
|
// Stable ref for allContent so getCachedContent callback never changes identity
|
|
const allContentForCacheRef = useRef(vault.allContent)
|
|
allContentForCacheRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern
|
|
const getCachedContent = useCallback((path: string) => allContentForCacheRef.current[path], [])
|
|
|
|
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, getCachedContent })
|
|
|
|
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
|
// and read-only state react immediately without reopening the note.
|
|
useEffect(() => {
|
|
notes.setTabs(prev => {
|
|
let changed = false
|
|
const next = prev.map(tab => {
|
|
const fresh = vault.entries.find(e => e.path === tab.entry.path)
|
|
if (fresh && fresh !== tab.entry) {
|
|
changed = true
|
|
return { ...tab, entry: fresh }
|
|
}
|
|
return tab
|
|
})
|
|
return changed ? next : prev
|
|
})
|
|
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
|
|
|
|
const navHistory = useNavigationHistory()
|
|
|
|
// Push to navigation history whenever the active tab changes (user-initiated)
|
|
const navFromHistoryRef = useRef(false)
|
|
useEffect(() => {
|
|
if (notes.activeTabPath && !navFromHistoryRef.current) {
|
|
navHistory.push(notes.activeTabPath)
|
|
}
|
|
navFromHistoryRef.current = false
|
|
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
|
|
|
|
const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
|
|
|
|
const handleGoBack = useCallback(() => {
|
|
const target = navHistory.goBack(isEntryExists)
|
|
if (target) {
|
|
navFromHistoryRef.current = true
|
|
if (notes.tabs.some(t => t.entry.path === target)) {
|
|
notes.handleSwitchTab(target)
|
|
} else {
|
|
const entry = vault.entries.find(e => e.path === target)
|
|
if (entry) notes.handleSelectNote(entry)
|
|
}
|
|
}
|
|
}, [navHistory, isEntryExists, vault.entries, notes])
|
|
|
|
const handleGoForward = useCallback(() => {
|
|
const target = navHistory.goForward(isEntryExists)
|
|
if (target) {
|
|
navFromHistoryRef.current = true
|
|
if (notes.tabs.some(t => t.entry.path === target)) {
|
|
notes.handleSwitchTab(target)
|
|
} else {
|
|
const entry = vault.entries.find(e => e.path === target)
|
|
if (entry) notes.handleSelectNote(entry)
|
|
}
|
|
}
|
|
}, [navHistory, isEntryExists, vault.entries, notes])
|
|
|
|
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
|
|
|
// O(1) path lookup map — rebuilt only when vault.entries changes
|
|
const entriesByPath = useMemo(() => {
|
|
const map = new Map<string, VaultEntry>()
|
|
for (const e of vault.entries) map.set(e.path, e)
|
|
return map
|
|
}, [vault.entries])
|
|
|
|
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
|
const openNoteByPath = useCallback((path: string) => {
|
|
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
|
if (entry) {
|
|
notes.handleSelectNote(entry)
|
|
} else {
|
|
// Entry not yet in vault (just created) — reload then open
|
|
vault.reloadVault().then(freshEntries => {
|
|
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
|
if (fresh) notes.handleSelectNote(fresh)
|
|
})
|
|
}
|
|
}, [entriesByPath, vault, notes, resolvedPath])
|
|
|
|
const aiActivity = useAiActivity({
|
|
onOpenNote: openNoteByPath,
|
|
onOpenTab: openNoteByPath,
|
|
onSetFilter: (filterType) => {
|
|
setSelection({ kind: 'sectionGroup', type: filterType })
|
|
},
|
|
onVaultChanged: () => { vault.reloadVault() },
|
|
})
|
|
|
|
// Stable callback for Pulse "open note" — never triggers reloadVault.
|
|
// Pulse files always exist in the vault; if somehow not found, silently skip.
|
|
const handlePulseOpenNote = useCallback((relativePath: string) => {
|
|
const fullPath = `${resolvedPath}/${relativePath}`
|
|
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
|
if (entry) notes.handleSelectNote(entry)
|
|
}, [entriesByPath, resolvedPath, notes])
|
|
|
|
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
|
|
const handleAgentFileCreated = useCallback((relativePath: string) => {
|
|
vault.reloadVault().then(freshEntries => {
|
|
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
|
if (entry) notes.handleSelectNote(entry)
|
|
})
|
|
}, [vault, notes, resolvedPath])
|
|
|
|
const handleAgentFileModified = useCallback((relativePath: string) => {
|
|
const fullPath = `${resolvedPath}/${relativePath}`
|
|
const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath
|
|
if (notes.tabs.some(t => t.entry.path === matchPath)) {
|
|
vault.reloadVault()
|
|
}
|
|
}, [vault, notes, resolvedPath])
|
|
|
|
const handleAgentVaultChanged = useCallback(() => {
|
|
vault.reloadVault()
|
|
}, [vault])
|
|
|
|
const { triggerIncrementalIndex } = indexing
|
|
const onAfterSave = useCallback(() => {
|
|
vault.loadModifiedFiles()
|
|
triggerIncrementalIndex()
|
|
}, [vault, triggerIncrementalIndex])
|
|
|
|
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
|
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
|
|
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
|
onNotePersisted: vault.clearUnsaved,
|
|
})
|
|
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
|
|
|
// Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change)
|
|
const tabsRef = useRef(notes.tabs)
|
|
tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
|
|
const allContentRef = useRef(vault.allContent)
|
|
allContentRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern
|
|
const unsavedPathsRef = useRef(vault.unsavedPaths)
|
|
unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
|
|
|
|
/** Auto-save unsaved editor content before a destructive action (trash/archive). */
|
|
const { updateContent: vaultUpdateContent, clearUnsaved: vaultClearUnsaved } = vault
|
|
const flushBeforeAction = useCallback(async (path: string) => {
|
|
try {
|
|
await flushEditorContent(path, {
|
|
savePendingForPath,
|
|
getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content,
|
|
isUnsaved: (p) => unsavedPathsRef.current.has(p),
|
|
getSavedContent: (p) => allContentRef.current[p],
|
|
onSaved: (p, c) => { vaultUpdateContent(p, c); vaultClearUnsaved(p) },
|
|
})
|
|
} catch (err) {
|
|
setToastMessage(`Auto-save failed: ${err}`)
|
|
throw err
|
|
}
|
|
}, [savePendingForPath, vaultUpdateContent, vaultClearUnsaved, setToastMessage])
|
|
|
|
// Wire conflict file opener now that notes is available
|
|
useEffect(() => {
|
|
openConflictFileRef.current = (relativePath: string) => {
|
|
const fullPath = `${resolvedPath}/${relativePath}`
|
|
const entry = vault.entries.find(e => e.path === fullPath)
|
|
if (entry) {
|
|
// Markdown note — open inside Laputa editor
|
|
notes.handleSelectNote(entry)
|
|
dialogs.closeConflictResolver()
|
|
} else {
|
|
// Non-note file (e.g. .laputa-cache.json, settings.json) —
|
|
// open with system default app so the user can inspect/edit it
|
|
openLocalFile(fullPath)
|
|
}
|
|
}
|
|
}, [resolvedPath, vault.entries, notes, dialogs])
|
|
|
|
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
|
|
const handleSave = useCallback(async () => {
|
|
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
|
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
|
|
? { path: activeTab.entry.path, content: activeTab.content }
|
|
: undefined
|
|
await handleSaveRaw(fallback)
|
|
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
|
|
|
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
|
|
|
const entryActions = useEntryActions({
|
|
entries: vault.entries, updateEntry: vault.updateEntry,
|
|
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
|
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
|
createTypeEntry: notes.createTypeEntrySilent,
|
|
onFrontmatterPersisted: vault.loadModifiedFiles,
|
|
onBeforeAction: flushBeforeAction,
|
|
})
|
|
|
|
const handleDeleteNote = useCallback(async (path: string) => {
|
|
try {
|
|
if (isTauri()) await invoke('delete_note', { path })
|
|
else await mockInvoke('delete_note', { path })
|
|
notes.handleCloseTab(path)
|
|
vault.removeEntry(path)
|
|
setToastMessage('Note permanently deleted')
|
|
} catch (e) {
|
|
setToastMessage(`Failed to delete note: ${e}`)
|
|
}
|
|
}, [notes, vault, setToastMessage])
|
|
|
|
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
|
|
|
const handleCreateType = useCallback((name: string) => {
|
|
notes.handleCreateType(name)
|
|
setToastMessage(`Type "${name}" created`)
|
|
}, [notes])
|
|
|
|
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
|
await savePendingForPath(path)
|
|
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
|
}, [notes, resolvedPath, vault, savePendingForPath])
|
|
|
|
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
|
|
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
|
vault.updateEntry(path, { title: newTitle })
|
|
notes.setTabs(prev => prev.map(t =>
|
|
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
|
|
))
|
|
}, [vault, notes])
|
|
|
|
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
|
|
|
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
|
const rawToggleRef = useRef<() => void>(() => {})
|
|
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
|
|
const diffToggleRef = useRef<() => void>(() => {})
|
|
|
|
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
|
const zoom = useZoom()
|
|
const buildNumber = useBuildNumber()
|
|
|
|
const { status: updateStatus, actions: updateActions } = useUpdater()
|
|
|
|
const handleCheckForUpdates = useCallback(async () => {
|
|
if (updateStatus.state === 'downloading') {
|
|
setToastMessage('Update is downloading…')
|
|
return
|
|
}
|
|
if (updateStatus.state === 'ready') {
|
|
await restartApp()
|
|
return
|
|
}
|
|
const result = await updateActions.checkForUpdates()
|
|
if (result === 'up-to-date') {
|
|
setToastMessage("You're on the latest version")
|
|
} else if (result === 'error') {
|
|
setToastMessage('Could not check for updates')
|
|
}
|
|
// 'available' → UpdateBanner handles it automatically
|
|
}, [updateActions, updateStatus.state, setToastMessage])
|
|
|
|
const handleRestoreDefaultThemes = useCallback(async () => {
|
|
if (!resolvedPath) return
|
|
try {
|
|
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
|
const msg = await tauriInvoke<string>('restore_default_themes', { vaultPath: resolvedPath })
|
|
await vault.reloadVault()
|
|
await themeManager.reloadThemes()
|
|
setToastMessage(msg)
|
|
} catch (err) {
|
|
setToastMessage(`Failed to restore themes: ${err}`)
|
|
}
|
|
}, [resolvedPath, vault, themeManager, setToastMessage])
|
|
|
|
const handleRepairVault = useCallback(async () => {
|
|
if (!resolvedPath) return
|
|
try {
|
|
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
|
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
|
await vault.reloadVault()
|
|
await themeManager.reloadThemes()
|
|
setToastMessage(msg)
|
|
} catch (err) {
|
|
setToastMessage(`Failed to repair vault: ${err}`)
|
|
}
|
|
}, [resolvedPath, vault, themeManager, setToastMessage])
|
|
|
|
const commands = useAppCommands({
|
|
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
|
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
|
entries: vault.entries, allContent: vault.allContent,
|
|
modifiedCount: vault.modifiedFiles.length,
|
|
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
|
selection,
|
|
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
|
onSearch: dialogs.openSearch,
|
|
onCreateNote: notes.handleCreateNoteImmediate,
|
|
onOpenDailyNote: notes.handleOpenDailyNote,
|
|
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
|
onSave: handleSave,
|
|
onOpenSettings: dialogs.openSettings,
|
|
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
|
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
|
onCommitPush: commitFlow.openCommitDialog,
|
|
onResolveConflicts: handleOpenConflictResolver,
|
|
onSetViewMode: setViewMode,
|
|
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
|
onToggleDiff: () => diffToggleRef.current(),
|
|
onToggleRawEditor: () => rawToggleRef.current(),
|
|
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
|
zoomLevel: zoom.zoomLevel,
|
|
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
|
|
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
|
|
onSelectNote: notes.handleSelectNote,
|
|
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
|
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
|
|
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
|
|
onSwitchTheme: themeManager.switchTheme,
|
|
onCreateTheme: async () => {
|
|
const path = await themeManager.createTheme()
|
|
const freshEntries = await vault.reloadVault()
|
|
setSelection({ kind: 'sectionGroup', type: 'Theme' })
|
|
if (path) {
|
|
const entry = freshEntries.find(e => e.path === path)
|
|
if (entry) notes.handleSelectNote(entry)
|
|
}
|
|
},
|
|
onOpenTheme: (themeId: string) => {
|
|
const entry = vault.entries.find(e => e.path === themeId)
|
|
if (entry) notes.handleSelectNote(entry)
|
|
},
|
|
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
|
onCreateType: dialogs.openCreateType,
|
|
onToggleAIChat: dialogs.toggleAIChat,
|
|
onCheckForUpdates: handleCheckForUpdates,
|
|
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
|
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
|
|
onRestoreDefaultThemes: handleRestoreDefaultThemes,
|
|
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
|
vaultCount: vaultSwitcher.allVaults.length,
|
|
mcpStatus,
|
|
onInstallMcp: installMcp,
|
|
onReindexVault: indexing.triggerFullReindex,
|
|
onRepairVault: handleRepairVault,
|
|
})
|
|
|
|
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
|
|
|
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
|
return filterEntries(vault.entries, selection).map(e => ({
|
|
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
|
}))
|
|
}, [vault.entries, selection])
|
|
|
|
const aiNoteListFilter = useMemo(() => {
|
|
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
|
|
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
|
|
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
|
|
return { type: null, query: '' }
|
|
}, [selection])
|
|
|
|
// Show welcome/onboarding screen when vault doesn't exist
|
|
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
|
|
const defaultPath = onboarding.state.defaultPath
|
|
return (
|
|
<div className="app-shell">
|
|
<WelcomeScreen
|
|
mode={onboarding.state.status === 'welcome' ? 'welcome' : 'vault-missing'}
|
|
missingPath={onboarding.state.status === 'vault-missing' ? onboarding.state.vaultPath : undefined}
|
|
defaultVaultPath={defaultPath}
|
|
onCreateVault={onboarding.handleCreateVault}
|
|
onOpenFolder={onboarding.handleOpenFolder}
|
|
creating={onboarding.creating}
|
|
error={onboarding.error}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Show loading spinner while checking vault
|
|
if (onboarding.state.status === 'loading') {
|
|
return (
|
|
<div className="app-shell">
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
|
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<div className="app">
|
|
{sidebarVisible && (
|
|
<>
|
|
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
|
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
|
</div>
|
|
<ResizeHandle onResize={layout.handleSidebarResize} />
|
|
</>
|
|
)}
|
|
{noteListVisible && (
|
|
<>
|
|
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
|
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
|
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
|
) : (
|
|
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
|
)}
|
|
</div>
|
|
<ResizeHandle onResize={layout.handleNoteListResize} />
|
|
</>
|
|
)}
|
|
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
|
|
<Editor
|
|
tabs={notes.tabs}
|
|
activeTabPath={notes.activeTabPath}
|
|
entries={vault.entries}
|
|
onSwitchTab={notes.handleSwitchTab}
|
|
onCloseTab={notes.handleCloseTab}
|
|
onReorderTabs={notes.handleReorderTabs}
|
|
onNavigateWikilink={notes.handleNavigateWikilink}
|
|
onLoadDiff={vault.loadDiff}
|
|
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
|
getNoteStatus={vault.getNoteStatus}
|
|
onCreateNote={notes.handleCreateNoteImmediate}
|
|
inspectorCollapsed={layout.inspectorCollapsed}
|
|
onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)}
|
|
inspectorWidth={layout.inspectorWidth}
|
|
onInspectorResize={layout.handleInspectorResize}
|
|
inspectorEntry={activeTab?.entry ?? null}
|
|
inspectorContent={activeTab?.content ?? null}
|
|
allContent={vault.allContent}
|
|
gitHistory={gitHistory}
|
|
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
|
onDeleteProperty={notes.handleDeleteProperty}
|
|
onAddProperty={notes.handleAddProperty}
|
|
showAIChat={dialogs.showAIChat}
|
|
onToggleAIChat={dialogs.toggleAIChat}
|
|
vaultPath={resolvedPath}
|
|
noteList={aiNoteList}
|
|
noteListFilter={aiNoteListFilter}
|
|
onTrashNote={entryActions.handleTrashNote}
|
|
onRestoreNote={entryActions.handleRestoreNote}
|
|
onDeleteNote={handleDeleteNote}
|
|
onArchiveNote={entryActions.handleArchiveNote}
|
|
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
|
onRenameTab={handleRenameTab}
|
|
onContentChange={handleContentChange}
|
|
onSave={handleSave}
|
|
onTitleSync={handleTitleSync}
|
|
rawToggleRef={rawToggleRef}
|
|
diffToggleRef={diffToggleRef}
|
|
canGoBack={navHistory.canGoBack}
|
|
canGoForward={navHistory.canGoForward}
|
|
onGoBack={handleGoBack}
|
|
onGoForward={handleGoForward}
|
|
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
|
isDarkTheme={themeManager.isDark}
|
|
onFileCreated={handleAgentFileCreated}
|
|
onFileModified={handleAgentFileModified}
|
|
onVaultChanged={handleAgentVaultChanged}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<UpdateBanner status={updateStatus} actions={updateActions} />
|
|
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
|
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
|
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
|
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
|
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
|
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
|
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} 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={handleCloseConflictResolver}
|
|
/>
|
|
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
|
|
<GitHubVaultModal
|
|
open={dialogs.showGitHubVault}
|
|
githubToken={settings.github_token}
|
|
onClose={dialogs.closeGitHubVault}
|
|
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
|
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
|
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|