diff --git a/src/App.tsx b/src/App.tsx index 625ef9b8..cc6b0869 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' @@ -16,19 +16,16 @@ import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' import { useEditorSave } from './hooks/useEditorSave' import { useCommitFlow } from './hooks/useCommitFlow' -import { useAppKeyboard } from './hooks/useAppKeyboard' import { useViewMode } from './hooks/useViewMode' import { useEntryActions } from './hooks/useEntryActions' -import { useCommandRegistry } from './hooks/useCommandRegistry' -import { isTauri } from './mock-tauri' -import { pickFolder } from './utils/vault-dialog' - -import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' +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 { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' -import type { SidebarSelection, GitCommit } from './types' -import type { VaultOption } from './components/StatusBar' +import type { SidebarSelection } from './types' import './App.css' // Type declaration for mock content storage @@ -40,15 +37,6 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } -const DEFAULT_VAULTS: VaultOption[] = isTauri() - ? [ - { label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' }, - { label: 'Laputa', path: '/Users/luca/Laputa' }, - ] - : [ - { label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' }, - ] - function useLayoutPanels() { const [sidebarWidth, setSidebarWidth] = useState(250) const [noteListWidth, setNoteListWidth] = useState(300) @@ -63,26 +51,21 @@ function useLayoutPanels() { function App() { const [selection, setSelection] = useState(DEFAULT_SELECTION) const layout = useLayoutPanels() - const [gitHistory, setGitHistory] = useState([]) - const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false) - const [showQuickOpen, setShowQuickOpen] = useState(false) - const [showCommandPalette, setShowCommandPalette] = useState(false) const [toastMessage, setToastMessage] = useState(null) - const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path) - const [showAIChat, setShowAIChat] = useState(false) - const [showSettings, setShowSettings] = useState(false) - const [showGitHubVault, setShowGitHubVault] = useState(false) - const [extraVaults, setExtraVaults] = useState([]) + const dialogs = useDialogs() - const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults]) + // 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(vaultPath) + const vault = useVaultLoader(vaultSwitcher.vaultPath) const { settings, saveSettings } = useSettings() - // Sync Anthropic key from settings to localStorage for AIChatPanel - useEffect(() => { - setApiKey(settings.anthropic_key ?? '') - }, [settings.anthropic_key]) + useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) const notes = useNoteActions({ addEntry: vault.addEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry }) @@ -93,69 +76,15 @@ function App() { onAfterSave: vault.loadModifiedFiles, }) - const commitFlow = useCommitFlow({ - savePending, - loadModifiedFiles: vault.loadModifiedFiles, - commitAndPush: vault.commitAndPush, - setToastMessage, - }) + const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage }) const entryActions = useEntryActions({ - entries: vault.entries, - updateEntry: vault.updateEntry, + entries: vault.entries, updateEntry: vault.updateEntry, handleUpdateFrontmatter: notes.handleUpdateFrontmatter, - handleDeleteProperty: notes.handleDeleteProperty, - setToastMessage, + handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, }) - const handleCreateNoteImmediate = notes.handleCreateNoteImmediate - - const handleSwitchVault = useCallback((path: string) => { - setVaultPath(path) - setSelection(DEFAULT_SELECTION) - setGitHistory([]) - notes.closeAllTabs() - }, [notes]) - - const handleVaultCloned = useCallback((path: string, label: string) => { - setExtraVaults(prev => { - if (prev.some(v => v.path === path)) return prev - return [...prev, { label, path }] - }) - handleSwitchVault(path) - setToastMessage(`Vault "${label}" cloned and opened`) - }, [handleSwitchVault]) - - const addAndSwitchVault = useCallback((path: string, label: string) => { - setExtraVaults(prev => { - if (prev.some(v => v.path === path)) return prev - return [...prev, { label, path }] - }) - handleSwitchVault(path) - }, [handleSwitchVault]) - - const handleOpenLocalFolder = useCallback(async () => { - try { - const path = await pickFolder('Open vault folder') - if (!path) return - const label = path.split('/').pop() || 'Local Vault' - addAndSwitchVault(path, label) - setToastMessage(`Vault "${label}" opened`) - } catch (err) { - console.error('Failed to open local folder:', err) - setToastMessage(`Failed to open folder: ${err}`) - } - }, [addAndSwitchVault]) - - useEffect(() => { - if (!notes.activeTabPath) { setGitHistory([]); return } - vault.loadGitHistory(notes.activeTabPath).then(setGitHistory) - // eslint-disable-next-line react-hooks/exhaustive-deps -- vault object is unstable; loadGitHistory is the actual dep - }, [notes.activeTabPath, vault.loadGitHistory]) - - const openCreateTypeDialog = useCallback(() => { - setShowCreateTypeDialog(true) - }, []) + const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory) const handleCreateType = useCallback((name: string) => { notes.handleCreateType(name) @@ -164,55 +93,30 @@ function App() { const handleRenameTab = useCallback(async (path: string, newTitle: string) => { await savePendingForPath(path) - await notes.handleRenameNote(path, newTitle, vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles) - }, [notes, vaultPath, vault, savePendingForPath]) + await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles) + }, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath]) const { setViewMode } = useViewMode() - useAppKeyboard({ - onQuickOpen: () => setShowQuickOpen(true), - onCommandPalette: () => setShowCommandPalette(true), - onCreateNote: handleCreateNoteImmediate, - onSave: handleSave, - onOpenSettings: () => setShowSettings(true), - onTrashNote: entryActions.handleTrashNote, - onArchiveNote: entryActions.handleArchiveNote, - onSetViewMode: setViewMode, - activeTabPathRef: notes.activeTabPathRef, - handleCloseTabRef: notes.handleCloseTabRef, - }) - - const commands = useCommandRegistry({ - activeTabPath: notes.activeTabPath, - entries: vault.entries, - modifiedCount: vault.modifiedFiles.length, - onQuickOpen: () => setShowQuickOpen(true), - onCreateNote: handleCreateNoteImmediate, - onSave: handleSave, - onOpenSettings: () => setShowSettings(true), - onTrashNote: entryActions.handleTrashNote, - onArchiveNote: entryActions.handleArchiveNote, + 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, + onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave, + onOpenSettings: dialogs.openSettings, + onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, - onCommitPush: commitFlow.openCommitDialog, - onSetViewMode: setViewMode, + onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode, onToggleInspector: () => layout.setInspectorCollapsed(c => !c), - onSelect: setSelection, - onCloseTab: notes.handleCloseTab, + onSelect: setSelection, onCloseTab: notes.handleCloseTab, + onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab, + onSelectNote: notes.handleSelectNote, }) const { status: updateStatus, actions: updateActions } = useUpdater() - useKeyboardNavigation({ - tabs: notes.tabs, - activeTabPath: notes.activeTabPath, - entries: vault.entries, - selection, - allContent: vault.allContent, - onSwitchTab: notes.handleSwitchTab, - onReplaceActiveTab: notes.handleReplaceActiveTab, - onSelectNote: notes.handleSelectNote, - }) - const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null return ( @@ -220,11 +124,11 @@ function App() {
- +
- +
@@ -239,7 +143,7 @@ function App() { onLoadDiff={vault.loadDiff} onLoadDiffAtCommit={vault.loadDiffAtCommit} getNoteStatus={vault.getNoteStatus} - onCreateNote={handleCreateNoteImmediate} + onCreateNote={notes.handleCreateNoteImmediate} inspectorCollapsed={layout.inspectorCollapsed} onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)} inspectorWidth={layout.inspectorWidth} @@ -251,9 +155,9 @@ function App() { onUpdateFrontmatter={notes.handleUpdateFrontmatter} onDeleteProperty={notes.handleDeleteProperty} onAddProperty={notes.handleAddProperty} - showAIChat={showAIChat} - onToggleAIChat={() => setShowAIChat(c => !c)} - vaultPath={vaultPath} + showAIChat={dialogs.showAIChat} + onToggleAIChat={dialogs.toggleAIChat} + vaultPath={vaultSwitcher.vaultPath} onTrashNote={entryActions.handleTrashNote} onRestoreNote={entryActions.handleRestoreNote} onArchiveNote={entryActions.handleArchiveNote} @@ -263,19 +167,19 @@ function App() { />
- setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onConnectGitHub={() => setShowGitHubVault(true)} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} /> + setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} /> setToastMessage(null)} /> - setShowQuickOpen(false)} /> - setShowCommandPalette(false)} /> - setShowCreateTypeDialog(false)} onCreate={handleCreateType} /> + + + - setShowSettings(false)} /> + setShowGitHubVault(false)} - onVaultCloned={handleVaultCloned} - onOpenSettings={() => { setShowGitHubVault(false); setShowSettings(true) }} + onClose={dialogs.closeGitHubVault} + onVaultCloned={vaultSwitcher.handleVaultCloned} + onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }} /> ) diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts new file mode 100644 index 00000000..fe1dae18 --- /dev/null +++ b/src/hooks/useAppCommands.ts @@ -0,0 +1,82 @@ +import { useAppKeyboard } from './useAppKeyboard' +import { useCommandRegistry } from './useCommandRegistry' +import type { CommandAction } from './useCommandRegistry' +import { useKeyboardNavigation } from './useKeyboardNavigation' +import type { SidebarSelection, VaultEntry } from '../types' +import type { ViewMode } from './useViewMode' + +interface Tab { entry: VaultEntry; content: string } + +interface AppCommandsConfig { + activeTabPath: string | null + activeTabPathRef: React.MutableRefObject + handleCloseTabRef: React.MutableRefObject<(path: string) => void> + tabs: Tab[] + entries: VaultEntry[] + allContent: Record + modifiedCount: number + selection: SidebarSelection + onQuickOpen: () => void + onCommandPalette: () => void + onCreateNote: () => void + onSave: () => void + onOpenSettings: () => void + onTrashNote: (path: string) => void + onArchiveNote: (path: string) => void + onUnarchiveNote: (path: string) => void + onCommitPush: () => void + onSetViewMode: (mode: ViewMode) => void + onToggleInspector: () => void + onSelect: (sel: SidebarSelection) => void + onCloseTab: (path: string) => void + onSwitchTab: (path: string) => void + onReplaceActiveTab: (entry: VaultEntry) => void + onSelectNote: (entry: VaultEntry) => void +} + +/** Sets up keyboard shortcuts, command registry, and keyboard navigation. */ +export function useAppCommands(config: AppCommandsConfig): CommandAction[] { + useAppKeyboard({ + onQuickOpen: config.onQuickOpen, + onCommandPalette: config.onCommandPalette, + onCreateNote: config.onCreateNote, + onSave: config.onSave, + onOpenSettings: config.onOpenSettings, + onTrashNote: config.onTrashNote, + onArchiveNote: config.onArchiveNote, + onSetViewMode: config.onSetViewMode, + activeTabPathRef: config.activeTabPathRef, + handleCloseTabRef: config.handleCloseTabRef, + }) + + const commands = useCommandRegistry({ + activeTabPath: config.activeTabPath, + entries: config.entries, + modifiedCount: config.modifiedCount, + onQuickOpen: config.onQuickOpen, + onCreateNote: config.onCreateNote, + onSave: config.onSave, + onOpenSettings: config.onOpenSettings, + onTrashNote: config.onTrashNote, + onArchiveNote: config.onArchiveNote, + onUnarchiveNote: config.onUnarchiveNote, + onCommitPush: config.onCommitPush, + onSetViewMode: config.onSetViewMode, + onToggleInspector: config.onToggleInspector, + onSelect: config.onSelect, + onCloseTab: config.onCloseTab, + }) + + useKeyboardNavigation({ + tabs: config.tabs, + activeTabPath: config.activeTabPath, + entries: config.entries, + selection: config.selection, + allContent: config.allContent, + onSwitchTab: config.onSwitchTab, + onReplaceActiveTab: config.onReplaceActiveTab, + onSelectNote: config.onSelectNote, + }) + + return commands +} diff --git a/src/hooks/useDialogs.ts b/src/hooks/useDialogs.ts index 92164006..931b534a 100644 --- a/src/hooks/useDialogs.ts +++ b/src/hooks/useDialogs.ts @@ -3,25 +3,29 @@ import { useState, useCallback } from 'react' export function useDialogs() { const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false) const [showQuickOpen, setShowQuickOpen] = useState(false) - const [showCommitDialog, setShowCommitDialog] = useState(false) + const [showCommandPalette, setShowCommandPalette] = useState(false) const [showAIChat, setShowAIChat] = useState(false) const [showSettings, setShowSettings] = useState(false) + const [showGitHubVault, setShowGitHubVault] = useState(false) const openCreateType = useCallback(() => setShowCreateTypeDialog(true), []) const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), []) const openQuickOpen = useCallback(() => setShowQuickOpen(true), []) const closeQuickOpen = useCallback(() => setShowQuickOpen(false), []) - const openCommitDialog = useCallback(() => setShowCommitDialog(true), []) - const closeCommitDialog = useCallback(() => setShowCommitDialog(false), []) + const openCommandPalette = useCallback(() => setShowCommandPalette(true), []) + const closeCommandPalette = useCallback(() => setShowCommandPalette(false), []) const openSettings = useCallback(() => setShowSettings(true), []) const closeSettings = useCallback(() => setShowSettings(false), []) + const openGitHubVault = useCallback(() => setShowGitHubVault(true), []) + const closeGitHubVault = useCallback(() => setShowGitHubVault(false), []) const toggleAIChat = useCallback(() => setShowAIChat((c) => !c), []) return { showCreateTypeDialog, openCreateType, closeCreateType, showQuickOpen, openQuickOpen, closeQuickOpen, - showCommitDialog, openCommitDialog, closeCommitDialog, + showCommandPalette, openCommandPalette, closeCommandPalette, showAIChat, toggleAIChat, showSettings, openSettings, closeSettings, + showGitHubVault, openGitHubVault, closeGitHubVault, } } diff --git a/src/hooks/useGitHistory.ts b/src/hooks/useGitHistory.ts new file mode 100644 index 00000000..37c03503 --- /dev/null +++ b/src/hooks/useGitHistory.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import type { GitCommit } from '../types' + +export function useGitHistory(activeTabPath: string | null, loadGitHistory: (path: string) => Promise) { + const [gitHistory, setGitHistory] = useState([]) + + useEffect(() => { + if (!activeTabPath) return + loadGitHistory(activeTabPath).then(setGitHistory) + }, [activeTabPath, loadGitHistory]) + + return activeTabPath ? gitHistory : [] +} diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts new file mode 100644 index 00000000..bba46f69 --- /dev/null +++ b/src/hooks/useVaultSwitcher.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isTauri } from '../mock-tauri' +import { pickFolder } from '../utils/vault-dialog' +import type { VaultOption } from '../components/StatusBar' + +export const DEFAULT_VAULTS: VaultOption[] = isTauri() + ? [ + { label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' }, + { label: 'Laputa', path: '/Users/luca/Laputa' }, + ] + : [ + { label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' }, + ] + +interface UseVaultSwitcherOptions { + onSwitch: () => void + onToast: (msg: string) => void +} + +/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */ +export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) { + const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path) + const [extraVaults, setExtraVaults] = useState([]) + const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults]) + + // Refs ensure stable callbacks that always invoke the latest closures, + // breaking the circular dependency between useVaultSwitcher and downstream hooks. + const onSwitchRef = useRef(onSwitch) + const onToastRef = useRef(onToast) + useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast }) + + const addVault = useCallback((path: string, label: string) => { + setExtraVaults(prev => prev.some(v => v.path === path) ? prev : [...prev, { label, path }]) + }, []) + + const switchVault = useCallback((path: string) => { + setVaultPath(path) + onSwitchRef.current() + }, []) + + const handleVaultCloned = useCallback((path: string, label: string) => { + addVault(path, label) + switchVault(path) + onToastRef.current(`Vault "${label}" cloned and opened`) + }, [addVault, switchVault]) + + const handleOpenLocalFolder = useCallback(async () => { + try { + const path = await pickFolder('Open vault folder') + if (!path) return + const label = path.split('/').pop() || 'Local Vault' + addVault(path, label) + switchVault(path) + onToastRef.current(`Vault "${label}" opened`) + } catch (err) { + console.error('Failed to open local folder:', err) + onToastRef.current(`Failed to open folder: ${err}`) + } + }, [addVault, switchVault]) + + return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder } +}