import { useCallback, useEffect, 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' 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}` } const TAB_ORDER_KEY = 'laputa-tab-order' function saveTabOrder(tabs: Tab[]) { try { localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabs.map(t => t.entry.path))) } catch { /* localStorage may be unavailable */ } } function loadTabOrder(): string[] { try { const stored = localStorage.getItem(TAB_ORDER_KEY) return stored ? JSON.parse(stored) : [] } catch { return [] } } async function loadNoteContent(path: string): Promise { return isTauri() ? invoke('get_note_content', { path }) : mockInvoke('get_note_content', { path }) } async function replaceTabWithEntry( entry: VaultEntry, currentPath: string, setTabs: React.Dispatch>, setActiveTabPath: React.Dispatch>, ) { const applyReplace = (content: string) => { setTabs((prev) => prev.map((t) => t.entry.path === currentPath ? { entry, content } : t )) setActiveTabPath(entry.path) } try { applyReplace(await loadNoteContent(entry.path)) } catch (err) { console.warn('Failed to load note content for replace:', err) applyReplace('') } } 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 tabsRef = useRef(tabs) tabsRef.current = tabs const handleCloseTabRef = useRef<(path: string) => void>(() => {}) const handleSelectNote = useCallback(async (entry: VaultEntry) => { // If already open, just switch — instant if (tabsRef.current.some((t) => t.entry.path === entry.path)) { setActiveTabPath(entry.path) return } // Load content async, then add tab and set active together try { const content = await loadNoteContent(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: string) => { const typeToFolder: Record = { Note: 'note', Project: 'project', Experiment: 'experiment', Responsibility: 'responsibility', Procedure: 'procedure', Person: 'person', Event: 'event', Topic: 'topic', } // Custom types use lowercased type name as folder const folder = typeToFolder[type] || type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') 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 noStatusTypes = new Set(['Topic', 'Person']) const newEntry: VaultEntry = { path, filename: `${slug}.md`, title, isA: type, aliases: [], belongsTo: [], relatedTo: [], status: noStatusTypes.has(type) ? null : 'Active', owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: now, createdAt: now, fileSize: 0, snippet: '', relationships: {}, icon: null, color: null, order: null, } 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 handleCreateType = useCallback(async (typeName: string) => { const slug = typeName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const path = `/Users/luca/Laputa/type/${slug}.md` const now = Math.floor(Date.now() / 1000) const newEntry: VaultEntry = { path, filename: `${slug}.md`, title: typeName, isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: now, createdAt: now, fileSize: 0, snippet: '', relationships: {}, icon: null, color: null, order: null, } const content = `---\nIs A: Type\n---\n\n# ${typeName}\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]) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { const currentPath = activeTabPathRef.current if (!currentPath) { handleSelectNote(entry); return } if (currentPath === entry.path) return replaceTabWithEntry(entry, currentPath, setTabs, setActiveTabPath) }, [handleSelectNote]) const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => { setTabs((prev) => { const next = [...prev] const [moved] = next.splice(fromIndex, 1) next.splice(toIndex, 0, moved) saveTabOrder(next) return next }) }, []) // Persist tab order to localStorage whenever tabs change useEffect(() => { if (tabs.length > 0) { saveTabOrder(tabs) } else { try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ } } }, [tabs]) // Restore tab order from localStorage on mount useEffect(() => { const savedOrder = loadTabOrder() if (savedOrder.length === 0) return setTabs((prev) => { if (prev.length <= 1) return prev const pathToTab = new Map(prev.map(t => [t.entry.path, t])) const ordered: Tab[] = [] for (const path of savedOrder) { const tab = pathToTab.get(path) if (tab) { ordered.push(tab) pathToTab.delete(path) } } // Append any tabs not in saved order (newly opened) for (const tab of pathToTab.values()) { ordered.push(tab) } return ordered }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const closeAllTabs = useCallback(() => { setTabs([]) setActiveTabPath(null) }, []) return { tabs, activeTabPath, activeTabPathRef, handleCloseTabRef, handleSelectNote, handleCloseTab, handleSwitchTab, handleReorderTabs, handleNavigateWikilink, handleCreateNote, handleCreateType, handleUpdateFrontmatter, handleDeleteProperty, handleAddProperty, handleReplaceActiveTab, closeAllTabs, } }