refactor: extract useVaultLoader and useNoteActions hooks from App.tsx

This commit is contained in:
lucaronin
2026-02-17 12:10:21 +01:00
parent e805970c17
commit 47deb6f3b7
4 changed files with 445 additions and 477 deletions

View File

@@ -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<VaultEntry[]>([])
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [tabs, setTabs] = useState<{ entry: VaultEntry; content: string }[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(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<Record<string, string>>({})
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showQuickOpen, setShowQuickOpen] = useState(false)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(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<VaultEntry[]>('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<VaultEntry[]>('list_vault', {})
}
console.log(`Vault scan complete: ${result.length} entries found`)
setEntries(result)
// Load all content for backlink scanning
let content: Record<string, string>
if (isTauri()) {
// TODO: Add Tauri command for batch content loading
content = {}
} else {
content = await mockInvoke<Record<string, string>>('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<ModifiedFile[]>('get_modified_files', { vaultPath })
} else {
files = await mockInvoke<ModifiedFile[]>('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<GitCommit[]>('get_file_history', { vaultPath, path: activeTabPath })
} else {
history = await mockInvoke<GitCommit[]>('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<string>('get_note_content', { path: entry.path })
} else {
content = await mockInvoke<string>('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<string, string> = {
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<string>('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<string>('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<string> => {
if (isTauri()) {
const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca')
return invoke<string>('get_file_diff', { vaultPath, path })
} else {
return mockInvoke<string>('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<string>('git_commit', { vaultPath, message })
setToastMessage('Changes committed')
try {
await invoke<string>('git_push', { vaultPath })
setToastMessage('Committed and pushed')
} catch (pushErr) {
console.warn('Push failed:', pushErr)
setToastMessage('Committed (push failed)')
}
} else {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('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 (
<div className="app-shell">
<div className="app">
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<Sidebar entries={entries} selection={selection} onSelect={setSelection} onSelectNote={handleSelectNote} modifiedCount={modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>
<NoteList entries={entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={allContent} modifiedFiles={modifiedFiles} onSelectNote={handleSelectNote} onCreateNote={() => setShowCreateDialog(true)} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} onSelectNote={notes.handleSelectNote} onCreateNote={() => setShowCreateDialog(true)} />
</div>
<ResizeHandle onResize={handleNoteListResize} />
<div className="app__editor">
<Editor
tabs={tabs}
activeTabPath={activeTabPath}
entries={entries}
onSwitchTab={handleSwitchTab}
onCloseTab={handleCloseTab}
onNavigateWikilink={handleNavigateWikilink}
onLoadDiff={handleLoadDiff}
isModified={isFileModified}
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onSwitchTab={notes.handleSwitchTab}
onCloseTab={notes.handleCloseTab}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
isModified={vault.isFileModified}
onCreateNote={() => 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}
/>
</div>
</div>
@@ -571,18 +135,18 @@ function App() {
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette
open={showQuickOpen}
entries={entries}
onSelect={handleSelectNote}
entries={vault.entries}
onSelect={notes.handleSelectNote}
onClose={() => setShowQuickOpen(false)}
/>
<CreateNoteDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onCreate={handleCreateNote}
onCreate={notes.handleCreateNote}
/>
<CommitDialog
open={showCommitDialog}
modifiedCount={modifiedFiles.length}
modifiedCount={vault.modifiedFiles.length}
onCommit={handleCommitPush}
onClose={() => setShowCommitDialog(false)}
/>

View File

@@ -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])

282
src/hooks/useNoteActions.ts Normal file
View File

@@ -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<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(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<string>('get_note_content', { path: entry.path })
} else {
content = await mockInvoke<string>('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<string, string> = {
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<string>('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<string>('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,
}
}

126
src/hooks/useVaultLoader.ts Normal file
View File

@@ -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<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
useEffect(() => {
const loadVault = async () => {
try {
let result: VaultEntry[]
if (isTauri()) {
const path = TEST_VAULT_PATH.replace('~', '/Users/luca')
result = await invoke<VaultEntry[]>('list_vault', { path })
} else {
console.info('[mock] Using mock Tauri data for browser testing')
result = await mockInvoke<VaultEntry[]>('list_vault', {})
}
console.log(`Vault scan complete: ${result.length} entries found`)
setEntries(result)
let content: Record<string, string>
if (isTauri()) {
content = {}
} else {
content = await mockInvoke<Record<string, string>>('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<ModifiedFile[]>('get_modified_files', { vaultPath })
} else {
files = await mockInvoke<ModifiedFile[]>('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<GitCommit[]> => {
try {
if (isTauri()) {
const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca')
return await invoke<GitCommit[]>('get_file_history', { vaultPath, path })
} else {
return await mockInvoke<GitCommit[]>('get_file_history', { path })
}
} catch (err) {
console.warn('Failed to load git history:', err)
return []
}
}, [])
const loadDiff = useCallback(async (path: string): Promise<string> => {
if (isTauri()) {
const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca')
return invoke<string>('get_file_diff', { vaultPath, path })
} else {
return mockInvoke<string>('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<string> => {
const vaultPath = TEST_VAULT_PATH.replace('~', '/Users/luca')
if (isTauri()) {
await invoke<string>('git_commit', { vaultPath, message })
try {
await invoke<string>('git_push', { vaultPath })
return 'Committed and pushed'
} catch {
return 'Committed (push failed)'
}
} else {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('git_push', {})
return 'Committed and pushed'
}
}, [])
return {
entries,
allContent,
modifiedFiles,
addEntry,
updateContent,
loadModifiedFiles,
loadGitHistory,
loadDiff,
isFileModified,
commitAndPush,
}
}