import { useCallback, useEffect, 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 { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' import { GitHubVaultModal } from './components/GitHubVaultModal' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' import { useEditorSave } from './hooks/useEditorSave' 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 } from './hooks/useUpdater' import { useNavigationHistory } from './hooks/useNavigationHistory' import { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' import { extractOutgoingLinks } from './utils/wikilinks' import type { SidebarSelection } from './types' import './App.css' // Type declaration for mock content storage declare global { interface Window { __mockContent?: Record } } const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } 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 useEditorSaveWithLinks(config: { updateContent: (path: string, content: string) => void updateEntry: (path: string, patch: Partial) => void setTabs: Parameters[0]['setTabs'] setToastMessage: (msg: string | null) => void onAfterSave: () => void }) { const { updateContent, updateEntry } = config const saveContent = useCallback((path: string, content: string) => { updateContent(path, content) updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) }) }, [updateContent, updateEntry]) const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave }) const { handleContentChange: rawOnChange } = editor const prevLinksKeyRef = useRef('') const handleContentChange = useCallback((path: string, content: string) => { rawOnChange(path, content) const links = extractOutgoingLinks(content) const key = links.join('\0') if (key !== prevLinksKeyRef.current) { prevLinksKeyRef.current = key updateEntry(path, { outgoingLinks: links }) } }, [rawOnChange, updateEntry]) return { ...editor, handleContentChange } } function App() { const [selection, setSelection] = useState(DEFAULT_SELECTION) const layout = useLayoutPanels() const [toastMessage, setToastMessage] = useState(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 vault = useVaultLoader(vaultSwitcher.vaultPath) const { settings, saveSettings } = useSettings() useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry }) 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 isTabOpen = useCallback((path: string) => notes.tabs.some(t => t.entry.path === path), [notes.tabs]) const handleGoBack = useCallback(() => { const target = navHistory.goBack(isTabOpen) if (target) { navFromHistoryRef.current = true notes.handleSwitchTab(target) } }, [navHistory, isTabOpen, notes]) const handleGoForward = useCallback(() => { const target = navHistory.goForward(isTabOpen) if (target) { navFromHistoryRef.current = true notes.handleSwitchTab(target) } }, [navHistory, isTabOpen, notes]) // Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe useEffect(() => { const handleMouseBack = (e: MouseEvent) => { if (e.button === 3) { e.preventDefault(); handleGoBack() } if (e.button === 4) { e.preventDefault(); handleGoForward() } } window.addEventListener('mouseup', handleMouseBack) // Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold let accumulatedDeltaX = 0 let resetTimer: ReturnType | null = null const SWIPE_THRESHOLD = 120 const handleWheel = (e: WheelEvent) => { // Only handle horizontal-dominant gestures (trackpad swipe) if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom accumulatedDeltaX += e.deltaX if (resetTimer) clearTimeout(resetTimer) resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300) if (accumulatedDeltaX > SWIPE_THRESHOLD) { accumulatedDeltaX = 0 handleGoForward() } else if (accumulatedDeltaX < -SWIPE_THRESHOLD) { accumulatedDeltaX = 0 handleGoBack() } } window.addEventListener('wheel', handleWheel, { passive: true }) return () => { window.removeEventListener('mouseup', handleMouseBack) window.removeEventListener('wheel', handleWheel) if (resetTimer) clearTimeout(resetTimer) } }, [handleGoBack, handleGoForward]) const { handleSave, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({ updateContent: vault.updateContent, updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles, }) 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, }) 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, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles) }, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath]) const { setViewMode, sidebarVisible, noteListVisible } = useViewMode() 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, selection, onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette, onSearch: dialogs.openSearch, onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave, onOpenSettings: dialogs.openSettings, onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode, onToggleInspector: () => layout.setInspectorCollapsed(c => !c), onSelect: setSelection, onCloseTab: notes.handleCloseTab, onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, onGoBack: handleGoBack, onGoForward: handleGoForward, canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward, }) const { status: updateStatus, actions: updateActions } = useUpdater() const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null return (
{sidebarVisible && ( <>
)} {noteListVisible && ( <>
)}
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={vaultSwitcher.vaultPath} onTrashNote={entryActions.handleTrashNote} onRestoreNote={entryActions.handleRestoreNote} onArchiveNote={entryActions.handleArchiveNote} onUnarchiveNote={entryActions.handleUnarchiveNote} onRenameTab={handleRenameTab} onContentChange={handleContentChange} canGoBack={navHistory.canGoBack} canGoForward={navHistory.canGoForward} onGoBack={handleGoBack} onGoForward={handleGoForward} />
setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} /> setToastMessage(null)} /> { dialogs.closeGitHubVault(); dialogs.openSettings() }} />
) } export default App