2026-02-17 12:10:21 +01:00
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-17 17:32:57 +01:00
|
|
|
const tabsRef = useRef(tabs)
|
|
|
|
|
tabsRef.current = tabs
|
2026-02-17 12:10:21 +01:00
|
|
|
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
|
|
|
|
|
|
|
|
|
|
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
2026-02-17 17:32:57 +01:00
|
|
|
// If already open, just switch — instant
|
|
|
|
|
if (tabsRef.current.some((t) => t.entry.path === entry.path)) {
|
|
|
|
|
setActiveTabPath(entry.path)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-17 17:52:10 +01:00
|
|
|
// Load content async, then add tab and set active together
|
2026-02-17 12:10:21 +01:00
|
|
|
try {
|
2026-02-17 17:32:57 +01:00
|
|
|
const content = isTauri()
|
|
|
|
|
? await invoke<string>('get_note_content', { path: entry.path })
|
|
|
|
|
: await mockInvoke<string>('get_note_content', { path: entry.path })
|
2026-02-17 12:10:21 +01:00
|
|
|
setTabs((prev) => {
|
|
|
|
|
if (prev.some((t) => t.entry.path === entry.path)) return prev
|
|
|
|
|
return [...prev, { entry, content }]
|
|
|
|
|
})
|
2026-02-17 17:52:10 +01:00
|
|
|
setActiveTabPath(entry.path)
|
2026-02-17 12:10:21 +01:00
|
|
|
} 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: '' }]
|
|
|
|
|
})
|
2026-02-17 17:52:10 +01:00
|
|
|
setActiveTabPath(entry.path)
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
const handleCreateNote = useCallback(async (title: string, type: string) => {
|
2026-02-17 12:10:21 +01:00
|
|
|
const typeToFolder: Record<string, string> = {
|
|
|
|
|
Note: 'note', Project: 'project', Experiment: 'experiment',
|
|
|
|
|
Responsibility: 'responsibility', Procedure: 'procedure',
|
|
|
|
|
Person: 'person', Event: 'event', Topic: 'topic',
|
|
|
|
|
}
|
2026-02-21 09:41:46 +01:00
|
|
|
// Custom types use lowercased type name as folder
|
|
|
|
|
const folder = typeToFolder[type] || type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
2026-02-17 12:10:21 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
const noStatusTypes = new Set(['Topic', 'Person'])
|
2026-02-17 12:10:21 +01:00
|
|
|
const newEntry: VaultEntry = {
|
|
|
|
|
path, filename: `${slug}.md`, title, isA: type,
|
|
|
|
|
aliases: [], belongsTo: [], relatedTo: [],
|
2026-02-21 09:41:46 +01:00
|
|
|
status: noStatusTypes.has(type) ? null : 'Active',
|
2026-02-17 12:10:21 +01:00
|
|
|
owner: null, cadence: null, modifiedAt: now, createdAt: now, fileSize: 0,
|
2026-02-17 19:11:01 +01:00
|
|
|
snippet: '', relationships: {},
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
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,
|
|
|
|
|
modifiedAt: now, createdAt: now, fileSize: 0,
|
|
|
|
|
snippet: '', relationships: {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = `---\nIs A: Type\n---\n\n# ${typeName}\n\n`
|
|
|
|
|
|
|
|
|
|
if (!isTauri()) {
|
|
|
|
|
addMockEntry(newEntry, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addEntry(newEntry, content)
|
|
|
|
|
handleSelectNote(newEntry)
|
|
|
|
|
}, [handleSelectNote, addEntry])
|
|
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
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])
|
|
|
|
|
|
2026-02-17 17:45:31 +01:00
|
|
|
const closeAllTabs = useCallback(() => {
|
|
|
|
|
setTabs([])
|
|
|
|
|
setActiveTabPath(null)
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
return {
|
|
|
|
|
tabs,
|
|
|
|
|
activeTabPath,
|
|
|
|
|
activeTabPathRef,
|
|
|
|
|
handleCloseTabRef,
|
|
|
|
|
handleSelectNote,
|
|
|
|
|
handleCloseTab,
|
|
|
|
|
handleSwitchTab,
|
|
|
|
|
handleNavigateWikilink,
|
|
|
|
|
handleCreateNote,
|
2026-02-21 09:41:46 +01:00
|
|
|
handleCreateType,
|
2026-02-17 12:10:21 +01:00
|
|
|
handleUpdateFrontmatter,
|
|
|
|
|
handleDeleteProperty,
|
|
|
|
|
handleAddProperty,
|
2026-02-17 17:45:31 +01:00
|
|
|
closeAllTabs,
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
}
|