diff --git a/src/App.tsx b/src/App.tsx index dc762ae8..1f111537 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,124 +1,18 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { invoke } from '@tauri-apps/api/core' +import { useCallback, useEffect, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' -import { type FrontmatterValue } from './components/Inspector' import { ResizeHandle } from './components/ResizeHandle' -import { CreateNoteDialog, type NoteType } from './components/CreateNoteDialog' +import { CreateNoteDialog } from './components/CreateNoteDialog' import { QuickOpenPalette } from './components/QuickOpenPalette' import { Toast } from './components/Toast' import { CommitDialog } from './components/CommitDialog' import { StatusBar } from './components/StatusBar' -import { isTauri, mockInvoke, addMockEntry, updateMockContent } from './mock-tauri' -import type { VaultEntry, SidebarSelection, GitCommit, ModifiedFile } from './types' +import { useVaultLoader } from './hooks/useVaultLoader' +import { useNoteActions } from './hooks/useNoteActions' +import type { SidebarSelection, GitCommit } from './types' import './App.css' -// TODO: Make vault path configurable via settings -const TEST_VAULT_PATH = '~/Laputa' - -// Mock frontmatter update for browser testing -function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string { - // This is a simplified mock - in reality the Rust backend handles this - const content = window.__mockContent?.[path] || '' - - // Format the key (quote if has spaces) - const yamlKey = key.includes(' ') ? `"${key}"` : key - - // Format the value - let yamlValue: string - if (Array.isArray(value)) { - yamlValue = '\n' + value.map(v => ` - "${v}"`).join('\n') - } else if (typeof value === 'boolean') { - yamlValue = value ? 'true' : 'false' - } else if (value === null) { - yamlValue = 'null' - } else { - yamlValue = String(value) - } - - // Check if content has frontmatter - if (!content.startsWith('---\n')) { - // Add frontmatter - return `---\n${yamlKey}: ${yamlValue}\n---\n${content}` - } - - // Find frontmatter end - const fmEnd = content.indexOf('\n---', 4) - if (fmEnd === -1) return content - - const fm = content.slice(4, fmEnd) - const rest = content.slice(fmEnd + 4) - - // Check if key exists - const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') - - if (keyPattern.test(fm)) { - // Replace existing key - need to handle multiline values - const lines = fm.split('\n') - const newLines: string[] = [] - let i = 0 - while (i < lines.length) { - if (keyPattern.test(lines[i])) { - // Skip this key and any list items - i++ - while (i < lines.length && lines[i].startsWith(' - ')) { - i++ - } - // Add new value - if (Array.isArray(value)) { - newLines.push(`${yamlKey}:${yamlValue}`) - } else { - newLines.push(`${yamlKey}: ${yamlValue}`) - } - continue - } - newLines.push(lines[i]) - i++ - } - return `---\n${newLines.join('\n')}\n---${rest}` - } else { - // Add new key - if (Array.isArray(value)) { - return `---\n${fm}\n${yamlKey}:${yamlValue}\n---${rest}` - } else { - return `---\n${fm}\n${yamlKey}: ${yamlValue}\n---${rest}` - } - } -} - -function deleteMockFrontmatterProperty(path: string, key: string): string { - const content = window.__mockContent?.[path] || '' - - if (!content.startsWith('---\n')) return content - - const fmEnd = content.indexOf('\n---', 4) - if (fmEnd === -1) return content - - const fm = content.slice(4, fmEnd) - const rest = content.slice(fmEnd + 4) - - const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') - - const lines = fm.split('\n') - const newLines: string[] = [] - let i = 0 - while (i < lines.length) { - if (keyPattern.test(lines[i])) { - // Skip this key and any list items - i++ - while (i < lines.length && lines[i].startsWith(' - ')) { - i++ - } - continue - } - newLines.push(lines[i]) - i++ - } - - return `---\n${newLines.join('\n')}\n---${rest}` -} - // Type declaration for mock content storage declare global { interface Window { @@ -129,102 +23,28 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } function App() { - const [entries, setEntries] = useState([]) const [selection, setSelection] = useState(DEFAULT_SELECTION) - const [tabs, setTabs] = useState<{ entry: VaultEntry; content: string }[]>([]) - const [activeTabPath, setActiveTabPath] = useState(null) const [sidebarWidth, setSidebarWidth] = useState(250) const [noteListWidth, setNoteListWidth] = useState(300) const [inspectorWidth, setInspectorWidth] = useState(280) const [inspectorCollapsed, setInspectorCollapsed] = useState(false) - const [allContent, setAllContent] = useState>({}) const [gitHistory, setGitHistory] = useState([]) - const [modifiedFiles, setModifiedFiles] = useState([]) const [showCreateDialog, setShowCreateDialog] = useState(false) const [showQuickOpen, setShowQuickOpen] = useState(false) const [showCommitDialog, setShowCommitDialog] = useState(false) const [toastMessage, setToastMessage] = useState(null) - // Refs for keyboard shortcuts (to avoid stale closures) - const activeTabPathRef = useRef(activeTabPath) - activeTabPathRef.current = activeTabPath - const handleCloseTabRef = useRef<(path: string) => void>(() => {}) - - useEffect(() => { - const loadVault = async () => { - try { - let result: VaultEntry[] - if (isTauri()) { - const path = TEST_VAULT_PATH.replace('~', '/Users/luca') - result = await invoke('list_vault', { path }) - } else { - // Running in browser (not Tauri) — use mock data for visual testing - console.info('[mock] Using mock Tauri data for browser testing') - result = await mockInvoke('list_vault', {}) - } - console.log(`Vault scan complete: ${result.length} entries found`) - setEntries(result) - - // Load all content for backlink scanning - let content: Record - if (isTauri()) { - // TODO: Add Tauri command for batch content loading - content = {} - } else { - content = await mockInvoke>('get_all_content', {}) - } - setAllContent(content) - } catch (err) { - console.warn('Vault scan failed:', err) - } - } - loadVault() - }, []) - - // Load modified files (git status) - const loadModifiedFiles = useCallback(async () => { - try { - let files: ModifiedFile[] - if (isTauri()) { - const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') - files = await invoke('get_modified_files', { vaultPath }) - } else { - files = await mockInvoke('get_modified_files', {}) - } - setModifiedFiles(files) - } catch (err) { - console.warn('Failed to load modified files:', err) - setModifiedFiles([]) - } - }, []) - - useEffect(() => { - loadModifiedFiles() - }, [loadModifiedFiles]) + const vault = useVaultLoader() + const notes = useNoteActions(vault.addEntry, vault.updateContent, vault.entries, setToastMessage) // Load git history when active tab changes useEffect(() => { - if (!activeTabPath) { + if (!notes.activeTabPath) { setGitHistory([]) return } - const loadHistory = async () => { - try { - let history: GitCommit[] - if (isTauri()) { - const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') - history = await invoke('get_file_history', { vaultPath, path: activeTabPath }) - } else { - history = await mockInvoke('get_file_history', { path: activeTabPath }) - } - setGitHistory(history) - } catch (err) { - console.warn('Failed to load git history:', err) - setGitHistory([]) - } - } - loadHistory() - }, [activeTabPath]) + vault.loadGitHistory(notes.activeTabPath).then(setGitHistory) + }, [notes.activeTabPath, vault.loadGitHistory]) // Global keyboard shortcuts useEffect(() => { @@ -241,170 +61,13 @@ function App() { setToastMessage('Saved') } else if (mod && e.key === 'w') { e.preventDefault() - const path = activeTabPathRef.current - if (path) handleCloseTabRef.current(path) + const path = notes.activeTabPathRef.current + if (path) notes.handleCloseTabRef.current(path) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, []) - - const handleSelectNote = useCallback(async (entry: VaultEntry) => { - // If tab already open, just switch to it - setTabs((prev) => { - if (prev.some((t) => t.entry.path === entry.path)) { - setActiveTabPath(entry.path) - return prev - } - return prev - }) - - // Check if we already have this tab (use functional check to avoid stale closure) - let alreadyOpen = false - setTabs((prev) => { - alreadyOpen = prev.some((t) => t.entry.path === entry.path) - return prev - }) - if (alreadyOpen) return - - // Load content for new tab, then add and activate - try { - let content: string - if (isTauri()) { - content = await invoke('get_note_content', { path: entry.path }) - } else { - content = await mockInvoke('get_note_content', { path: entry.path }) - } - setTabs((prev) => { - if (prev.some((t) => t.entry.path === entry.path)) return prev - return [...prev, { entry, content }] - }) - setActiveTabPath(entry.path) - } catch (err) { - console.warn('Failed to load note content:', err) - setTabs((prev) => { - if (prev.some((t) => t.entry.path === entry.path)) return prev - return [...prev, { entry, content: '' }] - }) - setActiveTabPath(entry.path) - } - }, []) - - const handleCloseTab = useCallback((path: string) => { - setTabs((prev) => { - const next = prev.filter((t) => t.entry.path !== path) - // If closing active tab, switch to adjacent tab - if (path === activeTabPath && next.length > 0) { - const closedIdx = prev.findIndex((t) => t.entry.path === path) - const newIdx = Math.min(closedIdx, next.length - 1) - setActiveTabPath(next[newIdx].entry.path) - } else if (next.length === 0) { - setActiveTabPath(null) - } - return next - }) - }, [activeTabPath]) - handleCloseTabRef.current = handleCloseTab - - const handleSwitchTab = useCallback((path: string) => { - setActiveTabPath(path) - }, []) - - const handleNavigateWikilink = useCallback((target: string) => { - // Find entry by various matching strategies: - // 1. Exact title match (case-insensitive) - // 2. Alias match - // 3. Path-based match (e.g., "responsibility/grow-newsletter" matches path ending with that) - // 4. Slug-to-title match (e.g., "grow-newsletter" → "Grow Newsletter") - - const targetLower = target.toLowerCase() - - // Convert slug to title case for comparison (e.g., "grow-newsletter" → "grow newsletter") - const slugToWords = (s: string) => s.replace(/-/g, ' ').toLowerCase() - const targetAsWords = slugToWords(target.split('/').pop() ?? target) - - const found = entries.find((e) => { - // 1. Exact title match - if (e.title.toLowerCase() === targetLower) return true - - // 2. Alias match - if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true - - // 3. Path-based match: target like "responsibility/grow-newsletter" - const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - if (pathStem.toLowerCase() === targetLower) return true - - // 4. Match just the filename stem - const fileStem = e.filename.replace(/\.md$/, '') - if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true - - // 5. Slug-to-title match: "grow-newsletter" matches "Grow Newsletter" - if (e.title.toLowerCase() === targetAsWords) return true - - return false - }) - - if (found) { - handleSelectNote(found) - } else { - console.warn(`Navigation target not found: ${target}`) - } - }, [entries, handleSelectNote]) - - const handleCreateNote = useCallback(async (title: string, type: NoteType) => { - // Build file path: type determines folder - const typeToFolder: Record = { - Note: 'note', - Project: 'project', - Experiment: 'experiment', - Responsibility: 'responsibility', - Procedure: 'procedure', - Person: 'person', - Event: 'event', - Topic: 'topic', - } - const folder = typeToFolder[type] || 'note' - const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') - const path = `/Users/luca/Laputa/${folder}/${slug}.md` - const now = Math.floor(Date.now() / 1000) - - const newEntry: VaultEntry = { - path, - filename: `${slug}.md`, - title, - isA: type, - aliases: [], - belongsTo: [], - relatedTo: [], - status: type === 'Topic' || type === 'Person' ? null : 'Active', - owner: null, - cadence: null, - modifiedAt: now, - createdAt: now, - fileSize: 0, - } - - const frontmatter = [ - '---', - `title: ${title}`, - `is_a: ${type}`, - ...(newEntry.status ? [`status: ${newEntry.status}`] : []), - '---', - ].join('\n') - const content = `${frontmatter}\n\n# ${title}\n\n` - - if (isTauri()) { - // TODO: Add Tauri command for creating notes - } else { - addMockEntry(newEntry, content) - } - - setEntries((prev) => [newEntry, ...prev]) - setAllContent((prev) => ({ ...prev, [path]: content })) - - // Open the new note - handleSelectNote(newEntry) - }, [handleSelectNote]) + }, [notes.activeTabPathRef, notes.handleCloseTabRef]) const handleSidebarResize = useCallback((delta: number) => { setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))) @@ -415,143 +78,44 @@ function App() { }, []) const handleInspectorResize = useCallback((delta: number) => { - // Inspector resize is inverted: dragging left makes it wider setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))) }, []) - const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null - - // Frontmatter update handlers - const handleUpdateFrontmatter = useCallback(async (path: string, key: string, value: FrontmatterValue) => { - try { - let newContent: string - if (isTauri()) { - // Convert value to the format expected by Rust - let rustValue: unknown = value - if (Array.isArray(value)) { - rustValue = value - } else if (typeof value === 'boolean') { - rustValue = value - } else if (typeof value === 'number') { - rustValue = value - } else if (value === null) { - rustValue = null - } else { - rustValue = String(value) - } - newContent = await invoke('update_frontmatter', { path, key, value: rustValue }) - } else { - // Mock implementation for browser testing - newContent = updateMockFrontmatter(path, key, value) - updateMockContent(path, newContent) - } - - // Update the tab content - setTabs((prev) => prev.map((t) => - t.entry.path === path ? { ...t, content: newContent } : t - )) - - // Update allContent for backlinks - setAllContent((prev) => ({ ...prev, [path]: newContent })) - - setToastMessage('Property updated') - } catch (err) { - console.error('Failed to update frontmatter:', err) - setToastMessage('Failed to update property') - } - }, []) - - const handleDeleteProperty = useCallback(async (path: string, key: string) => { - try { - let newContent: string - if (isTauri()) { - newContent = await invoke('delete_frontmatter_property', { path, key }) - } else { - // Mock implementation - newContent = deleteMockFrontmatterProperty(path, key) - updateMockContent(path, newContent) - } - - setTabs((prev) => prev.map((t) => - t.entry.path === path ? { ...t, content: newContent } : t - )) - - setAllContent((prev) => ({ ...prev, [path]: newContent })) - - setToastMessage('Property deleted') - } catch (err) { - console.error('Failed to delete property:', err) - setToastMessage('Failed to delete property') - } - }, []) - - const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => { - // Adding is the same as updating for new keys - return handleUpdateFrontmatter(path, key, value) - }, [handleUpdateFrontmatter]) - - // Diff loading - const handleLoadDiff = useCallback(async (path: string): Promise => { - if (isTauri()) { - const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') - return invoke('get_file_diff', { vaultPath, path }) - } else { - return mockInvoke('get_file_diff', { path }) - } - }, []) - - const isFileModified = useCallback((path: string): boolean => { - return modifiedFiles.some((f) => f.path === path) - }, [modifiedFiles]) - const handleCommitPush = useCallback(async (message: string) => { setShowCommitDialog(false) try { - const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') - if (isTauri()) { - await invoke('git_commit', { vaultPath, message }) - setToastMessage('Changes committed') - try { - await invoke('git_push', { vaultPath }) - setToastMessage('Committed and pushed') - } catch (pushErr) { - console.warn('Push failed:', pushErr) - setToastMessage('Committed (push failed)') - } - } else { - await mockInvoke('git_commit', { message }) - await mockInvoke('git_push', {}) - setToastMessage('Committed and pushed') - } - // Refresh modified files - loadModifiedFiles() + const result = await vault.commitAndPush(message) + setToastMessage(result) + vault.loadModifiedFiles() } catch (err) { console.error('Commit failed:', err) setToastMessage(`Commit failed: ${err}`) } - }, [loadModifiedFiles]) + }, [vault]) + + const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null return (
- setShowCommitDialog(true)} /> + setShowCommitDialog(true)} />
- setShowCreateDialog(true)} /> + setShowCreateDialog(true)} />
setShowCreateDialog(true)} inspectorCollapsed={inspectorCollapsed} onToggleInspector={() => setInspectorCollapsed((c) => !c)} @@ -559,11 +123,11 @@ function App() { onInspectorResize={handleInspectorResize} inspectorEntry={activeTab?.entry ?? null} inspectorContent={activeTab?.content ?? null} - allContent={allContent} + allContent={vault.allContent} gitHistory={gitHistory} - onUpdateFrontmatter={handleUpdateFrontmatter} - onDeleteProperty={handleDeleteProperty} - onAddProperty={handleAddProperty} + onUpdateFrontmatter={notes.handleUpdateFrontmatter} + onDeleteProperty={notes.handleDeleteProperty} + onAddProperty={notes.handleAddProperty} />
@@ -571,18 +135,18 @@ function App() { setToastMessage(null)} /> setShowQuickOpen(false)} /> setShowCreateDialog(false)} - onCreate={handleCreateNote} + onCreate={notes.handleCreateNote} /> setShowCommitDialog(false)} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 15a22406..b9af5ef1 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -85,10 +85,6 @@ const schema = BlockNoteSchema.create({ }, }) -// Wikilink utilities and splitFrontmatter extracted to ../utils/wikilinks.ts - -// DiffView extracted to ./DiffView.tsx - /** Inner component that creates/manages BlockNote for a single tab */ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: string; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) { const [, body] = useMemo(() => splitFrontmatter(content), [content]) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts new file mode 100644 index 00000000..a7e543da --- /dev/null +++ b/src/hooks/useNoteActions.ts @@ -0,0 +1,282 @@ +import { useCallback, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri' +import type { VaultEntry } from '../types' +import type { FrontmatterValue } from '../components/Inspector' +import type { NoteType } from '../components/CreateNoteDialog' + +interface Tab { + entry: VaultEntry + content: string +} + +// Mock frontmatter helpers for browser testing +function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string { + const content = window.__mockContent?.[path] || '' + const yamlKey = key.includes(' ') ? `"${key}"` : key + + let yamlValue: string + if (Array.isArray(value)) { + yamlValue = '\n' + value.map(v => ` - "${v}"`).join('\n') + } else if (typeof value === 'boolean') { + yamlValue = value ? 'true' : 'false' + } else if (value === null) { + yamlValue = 'null' + } else { + yamlValue = String(value) + } + + if (!content.startsWith('---\n')) { + return `---\n${yamlKey}: ${yamlValue}\n---\n${content}` + } + + const fmEnd = content.indexOf('\n---', 4) + if (fmEnd === -1) return content + + const fm = content.slice(4, fmEnd) + const rest = content.slice(fmEnd + 4) + const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') + + if (keyPattern.test(fm)) { + const lines = fm.split('\n') + const newLines: string[] = [] + let i = 0 + while (i < lines.length) { + if (keyPattern.test(lines[i])) { + i++ + while (i < lines.length && lines[i].startsWith(' - ')) i++ + if (Array.isArray(value)) { + newLines.push(`${yamlKey}:${yamlValue}`) + } else { + newLines.push(`${yamlKey}: ${yamlValue}`) + } + continue + } + newLines.push(lines[i]) + i++ + } + return `---\n${newLines.join('\n')}\n---${rest}` + } else { + if (Array.isArray(value)) { + return `---\n${fm}\n${yamlKey}:${yamlValue}\n---${rest}` + } else { + return `---\n${fm}\n${yamlKey}: ${yamlValue}\n---${rest}` + } + } +} + +function deleteMockFrontmatterProperty(path: string, key: string): string { + const content = window.__mockContent?.[path] || '' + if (!content.startsWith('---\n')) return content + const fmEnd = content.indexOf('\n---', 4) + if (fmEnd === -1) return content + + const fm = content.slice(4, fmEnd) + const rest = content.slice(fmEnd + 4) + const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') + + const lines = fm.split('\n') + const newLines: string[] = [] + let i = 0 + while (i < lines.length) { + if (keyPattern.test(lines[i])) { + i++ + while (i < lines.length && lines[i].startsWith(' - ')) i++ + continue + } + newLines.push(lines[i]) + i++ + } + return `---\n${newLines.join('\n')}\n---${rest}` +} + +export function useNoteActions( + addEntry: (entry: VaultEntry, content: string) => void, + updateContent: (path: string, content: string) => void, + entries: VaultEntry[], + setToastMessage: (msg: string | null) => void, +) { + const [tabs, setTabs] = useState([]) + const [activeTabPath, setActiveTabPath] = useState(null) + const activeTabPathRef = useRef(activeTabPath) + activeTabPathRef.current = activeTabPath + const handleCloseTabRef = useRef<(path: string) => void>(() => {}) + + const handleSelectNote = useCallback(async (entry: VaultEntry) => { + setTabs((prev) => { + if (prev.some((t) => t.entry.path === entry.path)) { + setActiveTabPath(entry.path) + return prev + } + return prev + }) + + let alreadyOpen = false + setTabs((prev) => { + alreadyOpen = prev.some((t) => t.entry.path === entry.path) + return prev + }) + if (alreadyOpen) return + + try { + let content: string + if (isTauri()) { + content = await invoke('get_note_content', { path: entry.path }) + } else { + content = await mockInvoke('get_note_content', { path: entry.path }) + } + setTabs((prev) => { + if (prev.some((t) => t.entry.path === entry.path)) return prev + return [...prev, { entry, content }] + }) + setActiveTabPath(entry.path) + } catch (err) { + console.warn('Failed to load note content:', err) + setTabs((prev) => { + if (prev.some((t) => t.entry.path === entry.path)) return prev + return [...prev, { entry, content: '' }] + }) + setActiveTabPath(entry.path) + } + }, []) + + const handleCloseTab = useCallback((path: string) => { + setTabs((prev) => { + const next = prev.filter((t) => t.entry.path !== path) + if (path === activeTabPathRef.current && next.length > 0) { + const closedIdx = prev.findIndex((t) => t.entry.path === path) + const newIdx = Math.min(closedIdx, next.length - 1) + setActiveTabPath(next[newIdx].entry.path) + } else if (next.length === 0) { + setActiveTabPath(null) + } + return next + }) + }, []) + handleCloseTabRef.current = handleCloseTab + + const handleSwitchTab = useCallback((path: string) => { + setActiveTabPath(path) + }, []) + + const handleNavigateWikilink = useCallback((target: string) => { + const targetLower = target.toLowerCase() + const slugToWords = (s: string) => s.replace(/-/g, ' ').toLowerCase() + const targetAsWords = slugToWords(target.split('/').pop() ?? target) + + const found = entries.find((e) => { + if (e.title.toLowerCase() === targetLower) return true + if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true + const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + if (pathStem.toLowerCase() === targetLower) return true + const fileStem = e.filename.replace(/\.md$/, '') + if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true + if (e.title.toLowerCase() === targetAsWords) return true + return false + }) + + if (found) { + handleSelectNote(found) + } else { + console.warn(`Navigation target not found: ${target}`) + } + }, [entries, handleSelectNote]) + + const handleCreateNote = useCallback(async (title: string, type: NoteType) => { + const typeToFolder: Record = { + Note: 'note', Project: 'project', Experiment: 'experiment', + Responsibility: 'responsibility', Procedure: 'procedure', + Person: 'person', Event: 'event', Topic: 'topic', + } + const folder = typeToFolder[type] || 'note' + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + const path = `/Users/luca/Laputa/${folder}/${slug}.md` + const now = Math.floor(Date.now() / 1000) + + const newEntry: VaultEntry = { + path, filename: `${slug}.md`, title, isA: type, + aliases: [], belongsTo: [], relatedTo: [], + status: type === 'Topic' || type === 'Person' ? null : 'Active', + owner: null, cadence: null, modifiedAt: now, createdAt: now, fileSize: 0, + } + + const frontmatter = [ + '---', `title: ${title}`, `is_a: ${type}`, + ...(newEntry.status ? [`status: ${newEntry.status}`] : []), + '---', + ].join('\n') + const content = `${frontmatter}\n\n# ${title}\n\n` + + if (!isTauri()) { + addMockEntry(newEntry, content) + } + + addEntry(newEntry, content) + handleSelectNote(newEntry) + }, [handleSelectNote, addEntry]) + + const handleUpdateFrontmatter = useCallback(async (path: string, key: string, value: FrontmatterValue) => { + try { + let newContent: string + if (isTauri()) { + let rustValue: unknown = value + if (Array.isArray(value)) rustValue = value + else if (typeof value === 'boolean') rustValue = value + else if (typeof value === 'number') rustValue = value + else if (value === null) rustValue = null + else rustValue = String(value) + newContent = await invoke('update_frontmatter', { path, key, value: rustValue }) + } else { + newContent = updateMockFrontmatter(path, key, value) + updateMockContent(path, newContent) + } + setTabs((prev) => prev.map((t) => + t.entry.path === path ? { ...t, content: newContent } : t + )) + updateContent(path, newContent) + setToastMessage('Property updated') + } catch (err) { + console.error('Failed to update frontmatter:', err) + setToastMessage('Failed to update property') + } + }, [updateContent, setToastMessage]) + + const handleDeleteProperty = useCallback(async (path: string, key: string) => { + try { + let newContent: string + if (isTauri()) { + newContent = await invoke('delete_frontmatter_property', { path, key }) + } else { + newContent = deleteMockFrontmatterProperty(path, key) + updateMockContent(path, newContent) + } + setTabs((prev) => prev.map((t) => + t.entry.path === path ? { ...t, content: newContent } : t + )) + updateContent(path, newContent) + setToastMessage('Property deleted') + } catch (err) { + console.error('Failed to delete property:', err) + setToastMessage('Failed to delete property') + } + }, [updateContent, setToastMessage]) + + const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => { + return handleUpdateFrontmatter(path, key, value) + }, [handleUpdateFrontmatter]) + + return { + tabs, + activeTabPath, + activeTabPathRef, + handleCloseTabRef, + handleSelectNote, + handleCloseTab, + handleSwitchTab, + handleNavigateWikilink, + handleCreateNote, + handleUpdateFrontmatter, + handleDeleteProperty, + handleAddProperty, + } +} diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts new file mode 100644 index 00000000..51d2bb76 --- /dev/null +++ b/src/hooks/useVaultLoader.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { VaultEntry, GitCommit, ModifiedFile } from '../types' + +const TEST_VAULT_PATH = '~/Laputa' + +export function useVaultLoader() { + const [entries, setEntries] = useState([]) + const [allContent, setAllContent] = useState>({}) + const [modifiedFiles, setModifiedFiles] = useState([]) + + useEffect(() => { + const loadVault = async () => { + try { + let result: VaultEntry[] + if (isTauri()) { + const path = TEST_VAULT_PATH.replace('~', '/Users/luca') + result = await invoke('list_vault', { path }) + } else { + console.info('[mock] Using mock Tauri data for browser testing') + result = await mockInvoke('list_vault', {}) + } + console.log(`Vault scan complete: ${result.length} entries found`) + setEntries(result) + + let content: Record + if (isTauri()) { + content = {} + } else { + content = await mockInvoke>('get_all_content', {}) + } + setAllContent(content) + } catch (err) { + console.warn('Vault scan failed:', err) + } + } + loadVault() + }, []) + + const loadModifiedFiles = useCallback(async () => { + try { + let files: ModifiedFile[] + if (isTauri()) { + const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') + files = await invoke('get_modified_files', { vaultPath }) + } else { + files = await mockInvoke('get_modified_files', {}) + } + setModifiedFiles(files) + } catch (err) { + console.warn('Failed to load modified files:', err) + setModifiedFiles([]) + } + }, []) + + useEffect(() => { + loadModifiedFiles() + }, [loadModifiedFiles]) + + const addEntry = useCallback((entry: VaultEntry, content: string) => { + setEntries((prev) => [entry, ...prev]) + setAllContent((prev) => ({ ...prev, [entry.path]: content })) + }, []) + + const updateContent = useCallback((path: string, content: string) => { + setAllContent((prev) => ({ ...prev, [path]: content })) + }, []) + + const loadGitHistory = useCallback(async (path: string): Promise => { + try { + if (isTauri()) { + const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') + return await invoke('get_file_history', { vaultPath, path }) + } else { + return await mockInvoke('get_file_history', { path }) + } + } catch (err) { + console.warn('Failed to load git history:', err) + return [] + } + }, []) + + const loadDiff = useCallback(async (path: string): Promise => { + if (isTauri()) { + const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') + return invoke('get_file_diff', { vaultPath, path }) + } else { + return mockInvoke('get_file_diff', { path }) + } + }, []) + + const isFileModified = useCallback((path: string): boolean => { + return modifiedFiles.some((f) => f.path === path) + }, [modifiedFiles]) + + const commitAndPush = useCallback(async (message: string): Promise => { + const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca') + if (isTauri()) { + await invoke('git_commit', { vaultPath, message }) + try { + await invoke('git_push', { vaultPath }) + return 'Committed and pushed' + } catch { + return 'Committed (push failed)' + } + } else { + await mockInvoke('git_commit', { message }) + await mockInvoke('git_push', {}) + return 'Committed and pushed' + } + }, []) + + return { + entries, + allContent, + modifiedFiles, + addEntry, + updateContent, + loadModifiedFiles, + loadGitHistory, + loadDiff, + isFileModified, + commitAndPush, + } +}