From 2692ca4a8b2c680920c77127c1262430595dff71 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 10:11:52 +0100 Subject: [PATCH 1/7] refactor: extract useTabManagement hook and mock frontmatter helpers from useNoteActions Break up the monolithic useNoteActions hook (cc=37, 232 LoC, score 6.89) into: - useTabManagement: owns tab state and all tab operations (score 9.53) - mockFrontmatterHelpers: extracted and simplified YAML manipulation (score 9.09+) - useNoteActions: now focused on note creation and frontmatter operations (score 9.22) Key improvements: - Extracted entryMatchesTarget() to reduce wikilink matching complexity - Deduplicated replaceKeyInLines/removeKeyFromLines into processKeyInLines - Converted buildNewEntry to use object param (was 5 positional args) - Overall complexity reduced from cc=37 to cc=11 in useNoteActions Co-Authored-By: Claude Opus 4.6 --- src/hooks/mockFrontmatterHelpers.ts | 76 +++++ src/hooks/useNoteActions.ts | 413 ++++++---------------------- src/hooks/useTabManagement.ts | 173 ++++++++++++ 3 files changed, 339 insertions(+), 323 deletions(-) create mode 100644 src/hooks/mockFrontmatterHelpers.ts create mode 100644 src/hooks/useTabManagement.ts diff --git a/src/hooks/mockFrontmatterHelpers.ts b/src/hooks/mockFrontmatterHelpers.ts new file mode 100644 index 00000000..5807565e --- /dev/null +++ b/src/hooks/mockFrontmatterHelpers.ts @@ -0,0 +1,76 @@ +import type { FrontmatterValue } from '../components/Inspector' + +function formatYamlValue(value: FrontmatterValue): string { + if (Array.isArray(value)) return '\n' + value.map(v => ` - "${v}"`).join('\n') + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (value === null) return 'null' + return String(value) +} + +function formatYamlKey(key: string): string { + return key.includes(' ') ? `"${key}"` : key +} + +function buildKeyPattern(key: string): RegExp { + return new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') +} + +function parseFrontmatter(content: string): { fm: string; rest: string } | null { + if (!content.startsWith('---\n')) return null + const fmEnd = content.indexOf('\n---', 4) + if (fmEnd === -1) return null + return { fm: content.slice(4, fmEnd), rest: content.slice(fmEnd + 4) } +} + +function formatKeyValue(yamlKey: string, yamlValue: string, isArray: boolean): string { + return isArray ? `${yamlKey}:${yamlValue}` : `${yamlKey}: ${yamlValue}` +} + +function processKeyInLines(lines: string[], keyPattern: RegExp, replacement: string | null): string[] { + 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 (replacement !== null) newLines.push(replacement) + continue + } + newLines.push(lines[i]) + i++ + } + return newLines +} + +export function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string { + const content = window.__mockContent?.[path] || '' + const yamlKey = formatYamlKey(key) + const yamlValue = formatYamlValue(value) + const isArray = Array.isArray(value) + + const parsed = parseFrontmatter(content) + if (!parsed) { + return `---\n${formatKeyValue(yamlKey, yamlValue, isArray)}\n---\n${content}` + } + + const { fm, rest } = parsed + const keyPattern = buildKeyPattern(key) + + if (keyPattern.test(fm)) { + const newLines = processKeyInLines(fm.split('\n'), keyPattern, formatKeyValue(yamlKey, yamlValue, isArray)) + return `---\n${newLines.join('\n')}\n---${rest}` + } + + return `---\n${fm}\n${formatKeyValue(yamlKey, yamlValue, isArray)}\n---${rest}` +} + +export function deleteMockFrontmatterProperty(path: string, key: string): string { + const content = window.__mockContent?.[path] || '' + const parsed = parseFrontmatter(content) + if (!parsed) return content + + const { fm, rest } = parsed + const keyPattern = buildKeyPattern(key) + const newLines = processKeyInLines(fm.split('\n'), keyPattern, null) + return `---\n${newLines.join('\n')}\n---${rest}` +} diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 61131550..6b2cb261 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -1,135 +1,71 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback } from 'react' import { invoke } from '@tauri-apps/api/core' -import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri' +import { isTauri, addMockEntry, updateMockContent } from '../mock-tauri' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' +import { useTabManagement } from './useTabManagement' +import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers' -interface Tab { - entry: VaultEntry - content: string +interface NewEntryParams { + path: string + slug: string + title: string + type: string + status: string | null } -// 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 buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry { + const now = Math.floor(Date.now() / 1000) + return { + path, filename: `${slug}.md`, title, isA: type, + aliases: [], belongsTo: [], relatedTo: [], + status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, + modifiedAt: now, createdAt: now, fileSize: 0, + snippet: '', relationships: {}, icon: null, color: null, order: null, } } -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}` +function slugify(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') } -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 entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean { + 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 + return e.title.toLowerCase() === targetAsWords } -function loadTabOrder(): string[] { - try { - const stored = localStorage.getItem(TAB_ORDER_KEY) - return stored ? JSON.parse(stored) : [] - } catch { - return [] - } +async function invokeFrontmatter(command: string, args: Record): Promise { + return invoke(command, args) } -async function loadNoteContent(path: string): Promise { - return isTauri() - ? invoke('get_note_content', { path }) - : mockInvoke('get_note_content', { path }) +function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string { + const content = updateMockFrontmatter(path, key, value) + updateMockContent(path, content) + return content } -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('') - } +function applyMockFrontmatterDelete(path: string, key: string): string { + const content = deleteMockFrontmatterProperty(path, key) + updateMockContent(path, content) + return content +} + +const TYPE_FOLDER_MAP: Record = { + Note: 'note', Project: 'project', Experiment: 'experiment', + Responsibility: 'responsibility', Procedure: 'procedure', + Person: 'person', Event: 'event', Topic: 'topic', +} + +const NO_STATUS_TYPES = new Set(['Topic', 'Person']) + +function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) { + if (!isTauri()) addMockEntry(entry, content) + addEntry(entry, content) } export function useNoteActions( @@ -138,263 +74,94 @@ export function useNoteActions( 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 tabMgmt = useTabManagement() + const { tabs, setTabs, handleSelectNote } = tabMgmt - 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 updateTabContent = useCallback((path: string, newContent: string) => { + setTabs((prev) => prev.map((t) => + t.entry.path === path ? { ...t, content: newContent } : t + )) + updateContent(path, newContent) + }, [setTabs, updateContent]) 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 targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower - 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}`) - } + const found = entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords)) + 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 folder = TYPE_FOLDER_MAP[type] || slugify(type) + const slug = slugify(title) + const status = NO_STATUS_TYPES.has(type) ? null : 'Active' + const newEntry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status }) const frontmatter = [ '---', `title: ${title}`, `is_a: ${type}`, - ...(newEntry.status ? [`status: ${newEntry.status}`] : []), + ...(status ? [`status: ${status}`] : []), '---', ].join('\n') - const content = `${frontmatter}\n\n# ${title}\n\n` - if (!isTauri()) { - addMockEntry(newEntry, content) - } - - addEntry(newEntry, content) + addEntryWithMock(newEntry, `${frontmatter}\n\n# ${title}\n\n`, addEntry) 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) + const slug = slugify(typeName) + const newEntry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) + addEntryWithMock(newEntry, `---\nIs A: Type\n---\n\n# ${typeName}\n\n`, addEntry) 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) + const newContent = isTauri() + ? await invokeFrontmatter('update_frontmatter', { path, key, value }) + : applyMockFrontmatterUpdate(path, key, value) + updateTabContent(path, newContent) setToastMessage('Property updated') } catch (err) { console.error('Failed to update frontmatter:', err) setToastMessage('Failed to update property') } - }, [updateContent, setToastMessage]) + }, [updateTabContent, 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) + const newContent = isTauri() + ? await invokeFrontmatter('delete_frontmatter_property', { path, key }) + : applyMockFrontmatterDelete(path, key) + updateTabContent(path, newContent) setToastMessage('Property deleted') } catch (err) { console.error('Failed to delete property:', err) setToastMessage('Failed to delete property') } - }, [updateContent, setToastMessage]) + }, [updateTabContent, 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, + activeTabPath: tabMgmt.activeTabPath, + activeTabPathRef: tabMgmt.activeTabPathRef, + handleCloseTabRef: tabMgmt.handleCloseTabRef, handleSelectNote, - handleCloseTab, - handleSwitchTab, - handleReorderTabs, + handleCloseTab: tabMgmt.handleCloseTab, + handleSwitchTab: tabMgmt.handleSwitchTab, + handleReorderTabs: tabMgmt.handleReorderTabs, handleNavigateWikilink, handleCreateNote, handleCreateType, handleUpdateFrontmatter, handleDeleteProperty, handleAddProperty, - handleReplaceActiveTab, - closeAllTabs, + handleReplaceActiveTab: tabMgmt.handleReplaceActiveTab, + closeAllTabs: tabMgmt.closeAllTabs, } } diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts new file mode 100644 index 00000000..c62fe05f --- /dev/null +++ b/src/hooks/useTabManagement.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { VaultEntry } from '../types' + +interface Tab { + entry: VaultEntry + content: string +} + +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 [] + } +} + +function clearTabOrder() { + try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ } +} + +async function loadNoteContent(path: string): Promise { + return isTauri() + ? invoke('get_note_content', { path }) + : mockInvoke('get_note_content', { path }) +} + +function addTabIfAbsent(prev: Tab[], entry: VaultEntry, content: string): Tab[] { + if (prev.some((t) => t.entry.path === entry.path)) return prev + return [...prev, { entry, content }] +} + +function resolveNextActiveTab(prev: Tab[], closedPath: string): string | null { + const next = prev.filter((t) => t.entry.path !== closedPath) + if (next.length === 0) return null + const closedIdx = prev.findIndex((t) => t.entry.path === closedPath) + const newIdx = Math.min(closedIdx, next.length - 1) + return next[newIdx].entry.path +} + +function replaceTabEntry(prev: Tab[], targetPath: string, entry: VaultEntry, content: string): Tab[] { + return prev.map((t) => t.entry.path === targetPath ? { entry, content } : t) +} + +function reorderArray(tabs: Tab[], fromIndex: number, toIndex: number): Tab[] { + const next = [...tabs] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + return next +} + +function restoreOrder(prev: Tab[], savedOrder: string[]): Tab[] { + 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) + } + } + for (const tab of pathToTab.values()) { + ordered.push(tab) + } + return ordered +} + +export type { Tab } + +export function useTabManagement() { + 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 (tabsRef.current.some((t) => t.entry.path === entry.path)) { + setActiveTabPath(entry.path) + return + } + try { + const content = await loadNoteContent(entry.path) + setTabs((prev) => addTabIfAbsent(prev, entry, content)) + } catch (err) { + console.warn('Failed to load note content:', err) + setTabs((prev) => addTabIfAbsent(prev, entry, '')) + } + setActiveTabPath(entry.path) + }, []) + + const handleCloseTab = useCallback((path: string) => { + setTabs((prev) => { + const next = prev.filter((t) => t.entry.path !== path) + if (path === activeTabPathRef.current) { + setActiveTabPath(resolveNextActiveTab(prev, path)) + } + return next + }) + }, []) + handleCloseTabRef.current = handleCloseTab + + const handleSwitchTab = useCallback((path: string) => { + setActiveTabPath(path) + }, []) + + const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => { + setTabs((prev) => { + const next = reorderArray(prev, fromIndex, toIndex) + saveTabOrder(next) + return next + }) + }, []) + + const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { + const currentPath = activeTabPathRef.current + if (!currentPath) { handleSelectNote(entry); return } + if (currentPath === entry.path) return + try { + const content = await loadNoteContent(entry.path) + setTabs((prev) => replaceTabEntry(prev, currentPath, entry, content)) + } catch (err) { + console.warn('Failed to load note content for replace:', err) + setTabs((prev) => replaceTabEntry(prev, currentPath, entry, '')) + } + setActiveTabPath(entry.path) + }, [handleSelectNote]) + + const closeAllTabs = useCallback(() => { + setTabs([]) + setActiveTabPath(null) + }, []) + + useEffect(() => { + if (tabs.length > 0) saveTabOrder(tabs) + else clearTabOrder() + }, [tabs]) + + useEffect(() => { + const savedOrder = loadTabOrder() + if (savedOrder.length > 0) { + setTabs((prev) => restoreOrder(prev, savedOrder)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { + tabs, + setTabs, + activeTabPath, + activeTabPathRef, + handleCloseTabRef, + handleSelectNote, + handleCloseTab, + handleSwitchTab, + handleReorderTabs, + handleReplaceActiveTab, + closeAllTabs, + } +} From af94280e4dca8ca72f7a3fe1cad01d4af00a3e00 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 10:18:05 +0100 Subject: [PATCH 2/7] refactor: decompose NoteList into focused components and extract helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break up NoteListInner (cc=61, 331 LoC, score 6.94) into: - NoteItem: extracted item rendering component (score 9.63) - SortDropdown: extracted into own file (score 9.68) - noteListHelpers.ts: all utility functions (score 10.0) - GroupBuilder class replaces 5-arg addResolvedGroup - filterEntries split into filterByKind/filterByFilterType - PinnedCard: unified entity/type document cards - RelationshipGroupSection: extracted group rendering - NoteList.tsx: orchestration only (score 8.79) NoteListInner reduced from cc=61 to cc=23, 331 → 105 LoC. Co-Authored-By: Claude Opus 4.6 --- src/components/NoteItem.tsx | 89 ++++ src/components/NoteList.tsx | 745 ++++++----------------------- src/components/SortDropdown.tsx | 57 +++ src/hooks/useKeyboardNavigation.ts | 2 +- src/utils/noteListHelpers.ts | 201 ++++++++ 5 files changed, 482 insertions(+), 612 deletions(-) create mode 100644 src/components/NoteItem.tsx create mode 100644 src/components/SortDropdown.tsx create mode 100644 src/utils/noteListHelpers.ts diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx new file mode 100644 index 00000000..b07a30cd --- /dev/null +++ b/src/components/NoteItem.tsx @@ -0,0 +1,89 @@ +import type { ComponentType, SVGAttributes } from 'react' +import type { VaultEntry } from '../types' +import { cn } from '@/lib/utils' +import { + Wrench, Flask, Target, ArrowsClockwise, + Users, CalendarBlank, Tag, FileText, StackSimple, +} from '@phosphor-icons/react' +import { getTypeColor, getTypeLightColor } from '../utils/typeColors' +import { resolveIcon } from './TypeCustomizePopover' +import { relativeDate, getDisplayDate } from '../utils/noteListHelpers' + +const TYPE_ICON_MAP: Record>> = { + Project: Wrench, + Experiment: Flask, + Responsibility: Target, + Procedure: ArrowsClockwise, + Person: Users, + Event: CalendarBlank, + Topic: Tag, + Type: StackSimple, +} + +export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType> { + if (customIcon) return resolveIcon(customIcon) + return (isA && TYPE_ICON_MAP[isA]) || FileText +} + +function TrashDateLine({ entry }: { entry: VaultEntry }) { + const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0 + const isExpired = trashedAge >= 86400 * 30 + const style = isExpired ? { color: 'var(--destructive)', fontWeight: 500 } as const : undefined + const suffix = isExpired ? ' — will be permanently deleted' : '' + return ( +
+ Trashed {relativeDate(entry.trashedAt)}{suffix} +
+ ) +} + +export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: { + entry: VaultEntry + isSelected: boolean + typeEntryMap: Record + onSelectNote: (entry: VaultEntry) => void +}) { + const te = typeEntryMap[entry.isA ?? ''] + const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color) + const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color) + const TypeIcon = getTypeIcon(entry.isA, te?.icon) + + return ( +
onSelectNote(entry)} + > + +
+
+ {entry.title} + {entry.archived && ( + + ARCHIVED + + )} + {entry.trashed && ( + + TRASHED + + )} +
+
+
+ {entry.snippet} +
+ {entry.trashed && entry.trashedAt + ? + :
{relativeDate(getDisplayDate(entry))}
+ } +
+ ) +} diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 87281ded..829a5c92 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,32 +1,24 @@ -import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' -// Virtuoso removed — flat list rendering used instead +import { useState, useMemo, useCallback, memo } from 'react' import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types' import { cn } from '@/lib/utils' import { Input } from '@/components/ui/input' import { - MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise, - Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple, - ArrowsDownUp, Check, Warning, + MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, } from '@phosphor-icons/react' -import type { ComponentType, SVGAttributes } from 'react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' -import { resolveIcon } from './TypeCustomizePopover' +import { NoteItem, getTypeIcon } from './NoteItem' +import { SortDropdown } from './SortDropdown' +import { + type SortOption, type RelationshipGroup, + getSortComparator, + buildRelationshipGroups, filterEntries, + sortByModified, relativeDate, getDisplayDate, + loadSortPreferences, saveSortPreferences, +} from '../utils/noteListHelpers' -const TYPE_ICON_MAP: Record>> = { - Project: Wrench, - Experiment: Flask, - Responsibility: Target, - Procedure: ArrowsClockwise, - Person: Users, - Event: CalendarBlank, - Topic: Tag, - Type: StackSimple, -} - -function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType> { - if (customIcon) return resolveIcon(customIcon) - return (isA && TYPE_ICON_MAP[isA]) || FileText -} +// Re-export for consumers +export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator } +export type { SortOption } interface NoteListProps { entries: VaultEntry[] @@ -38,312 +30,90 @@ interface NoteListProps { onCreateNote: () => void } -interface RelationshipGroup { - label: string - entries: VaultEntry[] -} - -function relativeDate(ts: number | null): string { - if (!ts) return '' - const now = Math.floor(Date.now() / 1000) - const diff = now - ts - if (diff < 0) { - const date = new Date(ts * 1000) - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - } - if (diff < 60) return 'just now' - if (diff < 3600) return `${Math.floor(diff / 60)}m ago` - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` - if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` - const date = new Date(ts * 1000) - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) -} - -function getDisplayDate(entry: VaultEntry): number | null { - return entry.modifiedAt ?? entry.createdAt -} - -function refsMatch(refs: string[], entry: VaultEntry): boolean { - const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - const fileStem = entry.filename.replace(/\.md$/, '') - return refs.some((ref) => { - const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '') - const inner = raw.split('|')[0] - return inner === stem || inner.split('/').pop() === fileStem - }) -} - -function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] { - return refs - .map((ref) => { - // Strip [[ ]] and remove alias (|display text) if present - const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '') - const inner = raw.split('|')[0] - return entries.find((e) => { - const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - if (stem === inner) return true - const fileStem = e.filename.replace(/\.md$/, '') - if (fileStem === inner.split('/').pop()) return true - return false - }) - }) - .filter((e): e is VaultEntry => e !== undefined) -} - -export function sortByModified(a: VaultEntry, b: VaultEntry): number { - return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) -} - -export type SortOption = 'modified' | 'created' | 'title' | 'status' - -export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ - { value: 'modified', label: 'Modified' }, - { value: 'created', label: 'Created' }, - { value: 'title', label: 'Title' }, - { value: 'status', label: 'Status' }, -] - -const STATUS_ORDER: Record = { - Active: 0, - Paused: 1, - Done: 2, - Finished: 3, -} - -export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number { - switch (option) { - case 'modified': - return sortByModified - case 'created': - return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0) - case 'title': - return (a, b) => a.title.localeCompare(b.title) - case 'status': - return (a, b) => { - const sa = STATUS_ORDER[a.status ?? ''] ?? 999 - const sb = STATUS_ORDER[b.status ?? ''] ?? 999 - if (sa !== sb) return sa - sb - return sortByModified(a, b) - } - } -} - -const SORT_STORAGE_KEY = 'laputa-sort-preferences' - -function loadSortPreferences(): Record { - try { - const raw = localStorage.getItem(SORT_STORAGE_KEY) - return raw ? JSON.parse(raw) : {} - } catch { - return {} - } -} - -function saveSortPreferences(prefs: Record) { - try { - localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs)) - } catch { /* ignore */ } -} - -function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record): VaultEntry[] { - const stem = entity.filename.replace(/\.md$/, '') - const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - const targets = [entity.title, ...entity.aliases] - - return allEntries.filter((e) => { - if (e.path === entity.path) return false - const content = allContent[e.path] - if (!content) return false - for (const t of targets) { - if (content.includes(`[[${t}]]`)) return true - } - if (content.includes(`[[${stem}]]`)) return true - if (content.includes(`[[${pathStem}]]`)) return true - if (content.includes(`[[${pathStem}|`)) return true - return false - }) -} - -function addGroup( - groups: RelationshipGroup[], - label: string, - entries: VaultEntry[], - seen: Set, -) { - const unseen = entries.filter((e) => !seen.has(e.path)) - if (unseen.length > 0) { - groups.push({ label, entries: unseen }) - unseen.forEach((e) => seen.add(e.path)) - } -} - -export function buildRelationshipGroups( - entity: VaultEntry, - allEntries: VaultEntry[], - allContent: Record, -): RelationshipGroup[] { - const groups: RelationshipGroup[] = [] - const seen = new Set([entity.path]) - const rels = entity.relationships ?? {} - - // 0. "Instances" — for type documents, show all entries of this type - if (entity.isA === 'Type') { - const instances = allEntries - .filter((e) => e.isA === entity.title && !seen.has(e.path)) - .sort(sortByModified) - addGroup(groups, 'Instances', instances, seen) - } - - // 1. "Has" — from the entity's own relationships map - const hasRefs = rels['Has'] ?? [] - if (hasRefs.length > 0) { - addGroup(groups, 'Has', resolveRefs(hasRefs, allEntries).sort(sortByModified), seen) - } - - // 2. Children — entries whose belongsTo points to this entity (reverse lookup, excluding events) - const children = allEntries - .filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity)) - .sort(sortByModified) - addGroup(groups, 'Children', children, seen) - - // 3. Events — entities of type Event that reference this entity via belongsTo/relatedTo - const events = allEntries - .filter( - (e) => - !seen.has(e.path) && - e.isA === 'Event' && - (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)) - ) - .sort(sortByModified) - addGroup(groups, 'Events', events, seen) - - // 4. "Topics" — from the entity's own relationships map - const topicRefs = rels['Topics'] ?? [] - if (topicRefs.length > 0) { - addGroup(groups, 'Topics', resolveRefs(topicRefs, allEntries).sort(sortByModified), seen) - } - - // 5. All other generic relationship fields (alphabetically) - const handledKeys = new Set(['Has', 'Topics']) - const otherKeys = Object.keys(rels) - .filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type') - .sort((a, b) => a.localeCompare(b)) - for (const key of otherKeys) { - const refs = rels[key] - if (refs && refs.length > 0) { - addGroup(groups, key, resolveRefs(refs, allEntries).sort(sortByModified), seen) - } - } - - // 6. Referenced By — entries that reference this entity via relatedTo (reverse lookup) - const referencedBy = allEntries - .filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity)) - .sort(sortByModified) - addGroup(groups, 'Referenced By', referencedBy, seen) - - // 7. Backlinks — always last - const backlinks = findBacklinks(entity, allEntries, allContent) - .filter((e) => !seen.has(e.path)) - .sort(sortByModified) - addGroup(groups, 'Backlinks', backlinks, seen) - - return groups -} - -export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] { - switch (selection.kind) { - case 'filter': - switch (selection.filter) { - case 'all': - return entries.filter((e) => !e.archived && !e.trashed) - case 'favorites': - return [] - case 'archived': - return entries.filter((e) => e.archived && !e.trashed) - case 'trash': - return entries.filter((e) => e.trashed) - } - break - case 'sectionGroup': - return entries.filter((e) => e.isA === selection.type && !e.archived && !e.trashed) - case 'entity': - return [] - case 'topic': { - const topic = selection.entry - return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived && !e.trashed) - } - } -} - -function SortDropdown({ - groupLabel, - current, - onChange, -}: { - groupLabel: string - current: SortOption - onChange: (groupLabel: string, option: SortOption) => void +function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: { + entry: VaultEntry + typeEntryMap: Record + onSelectNote: (entry: VaultEntry) => void + showDate?: boolean }) { - const [open, setOpen] = useState(false) - const ref = useRef(null) - - useEffect(() => { - if (!open) return - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [open]) - + const te = typeEntryMap[entry.isA ?? ''] + const color = getTypeColor(entry.isA ?? '', te?.color) + const bgColor = getTypeLightColor(entry.isA ?? '', te?.color) + const Icon = getTypeIcon(entry.isA, te?.icon) return ( -
- - {open && ( -
- {SORT_OPTIONS.map((opt) => ( - - ))} -
- )} +
onSelectNote(entry)}> + +
{entry.title}
+
{entry.snippet}
+ {showDate &&
{relativeDate(getDisplayDate(entry))}
}
) } +function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: { + group: RelationshipGroup + isCollapsed: boolean + sortPrefs: Record + onToggle: () => void + handleSortChange: (groupLabel: string, option: SortOption) => void + renderItem: (entry: VaultEntry) => React.ReactNode +}) { + const groupSort = sortPrefs[group.label] ?? 'modified' + const sortedEntries = [...group.entries].sort(getSortComparator(groupSort)) + return ( +
+
+ + + + + +
+ {!isCollapsed && sortedEntries.map((entry) => renderItem(entry))} +
+ ) +} + +function TrashWarningBanner({ expiredCount }: { expiredCount: number }) { + if (expiredCount === 0) return null + return ( +
+ +
+
Notes in trash for 30+ days will be permanently deleted
+
{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
+
+
+ ) +} + +function EmptyMessage({ text }: { text: string }) { + return
{text}
+} + +function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string { + if (selection.kind === 'entity') return selection.entry.title + if (typeDocument) return typeDocument.title + if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive' + if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash' + return 'Notes' +} + +function useTypeEntryMap(entries: VaultEntry[]) { + return useMemo(() => { + const map: Record = {} + for (const e of entries) { + if (e.isA === 'Type') map[e.title] = e + } + return map + }, [entries]) +} + function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) @@ -351,7 +121,6 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) const isEntityView = selection.kind === 'entity' - const isSectionGroup = selection.kind === 'sectionGroup' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' const handleSortChange = useCallback((groupLabel: string, option: SortOption) => { @@ -371,58 +140,29 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF }) }, []) - // Build type entry map for custom icon/color lookup - const typeEntryMap = useMemo(() => { - const map: Record = {} - for (const e of entries) { - if (e.isA === 'Type') map[e.title] = e - } - return map - }, [entries]) + const typeEntryMap = useTypeEntryMap(entries) - // Find the type document for this section group (e.g., type/project.md for "Project") const typeDocument = useMemo(() => { - if (!isSectionGroup) return null - const typeName = (selection as { kind: 'sectionGroup'; type: string }).type - return entries.find((e) => e.isA === 'Type' && e.title === typeName) ?? null - }, [isSectionGroup, selection, entries]) - - const entityGroups = useMemo( - () => isEntityView ? buildRelationshipGroups(selection.entry, entries, allContent) : [], - [isEntityView, selection, entries, allContent] - ) - - const filtered = useMemo( - () => isEntityView ? [] : filterEntries(entries, selection, modifiedFiles), - [entries, selection, modifiedFiles, isEntityView] - ) - - const listSort = sortPrefs['__list__'] ?? 'modified' - - const sorted = useMemo( - () => isEntityView ? [] : [...filtered].sort(getSortComparator(listSort)), - [filtered, isEntityView, listSort] - ) + if (selection.kind !== 'sectionGroup') return null + return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null + }, [selection, entries]) const query = search.trim().toLowerCase() + const listSort = sortPrefs['__list__'] ?? 'modified' - const searched = useMemo( - () => query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted, - [sorted, query] - ) - - const searchedGroups = useMemo( - () => query - ? entityGroups - .map((g) => ({ - ...g, - entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)), - })) - .filter((g) => g.entries.length > 0) - : entityGroups, - [entityGroups, query] - ) + const searched = useMemo(() => { + if (isEntityView) return [] + const filtered = filterEntries(entries, selection, modifiedFiles) + const sorted = [...filtered].sort(getSortComparator(listSort)) + return query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted + }, [entries, selection, modifiedFiles, isEntityView, listSort, query]) + const searchedGroups = useMemo(() => { + if (!isEntityView) return [] + const groups = buildRelationshipGroups(selection.entry, entries, allContent) + if (!query) return groups + return groups.map((g) => ({ ...g, entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)) })).filter((g) => g.entries.length > 0) + }, [isEntityView, selection, entries, allContent, query]) const expiredTrashCount = useMemo(() => { if (!isTrashView) return 0 @@ -430,267 +170,50 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF return searched.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length }, [isTrashView, searched]) - const renderItem = useCallback((entry: VaultEntry, isPinned = false) => { - const isSelected = selectedNote?.path === entry.path && !isPinned - const te = typeEntryMap[entry.isA ?? ''] - const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color) - const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color) - const TypeIcon = getTypeIcon(entry.isA, te?.icon) - return ( -
onSelectNote(entry)} - > - -
-
- {entry.title} - {entry.archived && ( - - ARCHIVED - - )} - {entry.trashed && ( - - TRASHED - - )} -
-
-
- {entry.snippet} -
-
= 86400 * 30 ? { color: 'var(--destructive)', fontWeight: 500 } : undefined}> - {entry.trashed && entry.trashedAt - ? `Trashed ${relativeDate(entry.trashedAt)}${(Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? ' — will be permanently deleted' : ''}` - : relativeDate(getDisplayDate(entry))} -
-
- ) - }, [selectedNote?.path, onSelectNote, typeEntryMap]) - - const renderPinnedView = useCallback((entity: VaultEntry, groups: RelationshipGroup[]) => { - const ete = typeEntryMap[entity.isA ?? ''] - const entityTypeColor = getTypeColor(entity.isA ?? '', ete?.color) - const entityLightColor = getTypeLightColor(entity.isA ?? '', ete?.color) - const EntityIcon = getTypeIcon(entity.isA, ete?.icon) - return ( -
- {/* Prominent card */} -
onSelectNote(entity)} - > - -
- {entity.title} -
-
- {entity.snippet} -
-
- {relativeDate(getDisplayDate(entity))} -
-
- - {/* Relationship groups */} - {groups.length === 0 ? ( -
- {query ? 'No matching items' : 'No related items'} -
- ) : ( - groups.map((group) => { - const isGroupCollapsed = collapsedGroups.has(group.label) - const groupSort = sortPrefs[group.label] ?? 'modified' - const sortedEntries = [...group.entries].sort(getSortComparator(groupSort)) - return ( -
-
- - - - - -
- {!isGroupCollapsed && sortedEntries.map((groupEntry) => renderItem(groupEntry))} -
- ) - }) - )} -
- ) - }, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem, typeEntryMap, sortPrefs, handleSortChange]) + const renderItem = useCallback((entry: VaultEntry) => ( + + ), [selectedNote?.path, onSelectNote, typeEntryMap]) return (
- {/* Header */}
-

- {isEntityView - ? selection.entry.title - : typeDocument - ? typeDocument.title - : selection.kind === 'filter' && (selection as { filter: string }).filter === 'archived' - ? 'Archive' - : selection.kind === 'filter' && (selection as { filter: string }).filter === 'trash' - ? 'Trash' - : 'Notes'} -

+

{resolveHeaderTitle(selection, typeDocument)}

- {!isEntityView && ( - - )} - -
- {/* Search (toggle on icon click) */} {searchVisible && (
- setSearch(e.target.value)} - className="h-8 text-[13px]" - autoFocus - /> + setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
)} - {/* Items */}
- {isEntityView ? (() => { - const entity = selection.entry - return renderPinnedView(entity, searchedGroups) - })() : ( + {isEntityView ? (
- {/* Type document pinned card (for sectionGroup view) */} - {typeDocument && (() => { - const tde = typeEntryMap[typeDocument.title] ?? typeDocument - const tdColor = getTypeColor(typeDocument.isA ?? 'Type', tde?.color) - const tdLightColor = getTypeLightColor(typeDocument.isA ?? 'Type', tde?.color) - const TDIcon = getTypeIcon(typeDocument.isA, tde?.icon) - return ( -
onSelectNote(typeDocument)} - > - -
- {typeDocument.title} -
-
- {typeDocument.snippet} -
-
- ) - })()} - {/* 30-day warning banner for trash view */} - {isTrashView && expiredTrashCount > 0 && ( -
- -
-
- Notes in trash for 30+ days will be permanently deleted -
-
- {expiredTrashCount} {expiredTrashCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period -
-
-
- )} - {searched.length === 0 ? ( -
- {isTrashView ? 'Trash is empty' : 'No notes found'} -
- ) : ( - searched.map((entry) => renderItem(entry)) - )} + + {searchedGroups.length === 0 + ? + : searchedGroups.map((group) => ( + toggleGroup(group.label)} handleSortChange={handleSortChange} renderItem={renderItem} /> + )) + } +
+ ) : ( +
+ {typeDocument && } + + {searched.length === 0 + ? + : searched.map((entry) => renderItem(entry)) + }
)}
diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx new file mode 100644 index 00000000..3b44969f --- /dev/null +++ b/src/components/SortDropdown.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect, useRef } from 'react' +import { cn } from '@/lib/utils' +import { ArrowsDownUp, Check } from '@phosphor-icons/react' +import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers' + +export function SortDropdown({ groupLabel, current, onChange }: { + groupLabel: string + current: SortOption + onChange: (groupLabel: string, option: SortOption) => void +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + const handleSelect = (opt: SortOption) => { + onChange(groupLabel, opt) + setOpen(false) + } + + return ( +
+ + {open && ( +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts index 6c1150ab..7002e4f5 100644 --- a/src/hooks/useKeyboardNavigation.ts +++ b/src/hooks/useKeyboardNavigation.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef } from 'react' import { isTauri } from '../mock-tauri' -import { filterEntries, sortByModified, buildRelationshipGroups } from '../components/NoteList' +import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers' import type { VaultEntry, SidebarSelection } from '../types' interface Tab { diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts new file mode 100644 index 00000000..8c2271ba --- /dev/null +++ b/src/utils/noteListHelpers.ts @@ -0,0 +1,201 @@ +import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types' + +export interface RelationshipGroup { + label: string + entries: VaultEntry[] +} + +export function relativeDate(ts: number | null): string { + if (!ts) return '' + const now = Math.floor(Date.now() / 1000) + const diff = now - ts + if (diff < 0) { + const date = new Date(ts * 1000) + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + } + if (diff < 60) return 'just now' + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` + const date = new Date(ts * 1000) + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +export function getDisplayDate(entry: VaultEntry): number | null { + return entry.modifiedAt ?? entry.createdAt +} + +function refsMatch(refs: string[], entry: VaultEntry): boolean { + const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + const fileStem = entry.filename.replace(/\.md$/, '') + return refs.some((ref) => { + const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0] + return inner === stem || inner.split('/').pop() === fileStem + }) +} + +function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] { + return refs + .map((ref) => { + const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0] + return entries.find((e) => { + const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + if (stem === inner) return true + const fileStem = e.filename.replace(/\.md$/, '') + return fileStem === inner.split('/').pop() + }) + }) + .filter((e): e is VaultEntry => e !== undefined) +} + +export function sortByModified(a: VaultEntry, b: VaultEntry): number { + return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) +} + +export type SortOption = 'modified' | 'created' | 'title' | 'status' + +export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: 'modified', label: 'Modified' }, + { value: 'created', label: 'Created' }, + { value: 'title', label: 'Title' }, + { value: 'status', label: 'Status' }, +] + +const STATUS_ORDER: Record = { + Active: 0, Paused: 1, Done: 2, Finished: 3, +} + +export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number { + switch (option) { + case 'modified': + return sortByModified + case 'created': + return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0) + case 'title': + return (a, b) => a.title.localeCompare(b.title) + case 'status': + return (a, b) => { + const sa = STATUS_ORDER[a.status ?? ''] ?? 999 + const sb = STATUS_ORDER[b.status ?? ''] ?? 999 + if (sa !== sb) return sa - sb + return sortByModified(a, b) + } + } +} + +const SORT_STORAGE_KEY = 'laputa-sort-preferences' + +export function loadSortPreferences(): Record { + try { + const raw = localStorage.getItem(SORT_STORAGE_KEY) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +export function saveSortPreferences(prefs: Record) { + try { + localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs)) + } catch { /* ignore */ } +} + +function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record): VaultEntry[] { + const stem = entity.filename.replace(/\.md$/, '') + const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + const targets = [entity.title, ...entity.aliases] + + return allEntries.filter((e) => { + if (e.path === entity.path) return false + const content = allContent[e.path] + if (!content) return false + for (const t of targets) { + if (content.includes(`[[${t}]]`)) return true + } + if (content.includes(`[[${stem}]]`)) return true + if (content.includes(`[[${pathStem}]]`)) return true + return content.includes(`[[${pathStem}|`) + }) +} + +class GroupBuilder { + readonly groups: RelationshipGroup[] = [] + private readonly seen: Set + private readonly allEntries: VaultEntry[] + + constructor(entityPath: string, allEntries: VaultEntry[]) { + this.seen = new Set([entityPath]) + this.allEntries = allEntries + } + + add(label: string, entries: VaultEntry[]) { + const unseen = entries.filter((e) => !this.seen.has(e.path)) + if (unseen.length > 0) { + this.groups.push({ label, entries: unseen }) + unseen.forEach((e) => this.seen.add(e.path)) + } + } + + addFromRefs(label: string, refs: string[]) { + if (refs.length > 0) { + this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified)) + } + } + + filterAndAdd(label: string, predicate: (e: VaultEntry) => boolean) { + this.add(label, this.allEntries.filter((e) => !this.seen.has(e.path) && predicate(e)).sort(sortByModified)) + } +} + +export function buildRelationshipGroups( + entity: VaultEntry, + allEntries: VaultEntry[], + allContent: Record, +): RelationshipGroup[] { + const b = new GroupBuilder(entity.path, allEntries) + const rels = entity.relationships ?? {} + + if (entity.isA === 'Type') { + b.filterAndAdd('Instances', (e) => e.isA === entity.title) + } + + b.addFromRefs('Has', rels['Has'] ?? []) + b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity)) + b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity))) + b.addFromRefs('Topics', rels['Topics'] ?? []) + + const handledKeys = new Set(['Has', 'Topics']) + Object.keys(rels) + .filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type') + .sort((a, b) => a.localeCompare(b)) + .forEach((key) => b.addFromRefs(key, rels[key] ?? [])) + + b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity)) + b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified)) + + return b.groups +} + +const isActive = (e: VaultEntry) => !e.archived && !e.trashed + +function filterByKind(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] { + if (selection.kind === 'entity') return [] + if (selection.kind === 'sectionGroup') { + return entries.filter((e) => e.isA === selection.type && isActive(e)) + } + if (selection.kind === 'topic') { + return entries.filter((e) => refsMatch(e.relatedTo, selection.entry) && isActive(e)) + } + return filterByFilterType(entries, selection.filter) +} + +function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] { + if (filter === 'all') return entries.filter(isActive) + if (filter === 'archived') return entries.filter((e) => e.archived && !e.trashed) + if (filter === 'trash') return entries.filter((e) => e.trashed) + return [] +} + +export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] { + return filterByKind(entries, selection) +} From c3415e3ce02a1edb7d4e6d1a209d3000bba88b30 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 10:22:26 +0100 Subject: [PATCH 3/7] refactor: extract Inspector sub-panels into InspectorPanels.tsx Split Inspector.tsx (score 7.78) into: - Inspector.tsx: slim shell with header, layout, prop wiring (score 9.6) - InspectorPanels.tsx: DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel, LinkButton, RelationshipGroup (score 8.87) Extracted LinkButton as reusable component for both relationship refs and backlinks. Moved resolveRefProps to simplify RelationshipGroup. Extracted AddRelationshipForm and extractRelationshipRefs as focused units. Co-Authored-By: Claude Opus 4.6 --- src/components/Inspector.tsx | 348 +++-------------------------- src/components/InspectorPanels.tsx | 242 ++++++++++++++++++++ 2 files changed, 270 insertions(+), 320 deletions(-) create mode 100644 src/components/InspectorPanels.tsx diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 26724061..edfe5693 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,29 +1,12 @@ -import { useMemo, useCallback, useState, useRef } from 'react' -import type { ComponentType, SVGAttributes } from 'react' +import { useMemo, useCallback } from 'react' import type { VaultEntry, GitCommit } from '../types' import { cn } from '@/lib/utils' -import { - SlidersHorizontal, X, Wrench, Flask, Target, ArrowsClockwise, - Users, CalendarBlank, Tag, FileText, StackSimple, Trash, -} from '@phosphor-icons/react' -import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter' -import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel' -import { getTypeColor, getTypeLightColor } from '../utils/typeColors' +import { SlidersHorizontal, X } from '@phosphor-icons/react' +import { parseFrontmatter } from '../utils/frontmatter' +import { DynamicPropertiesPanel } from './DynamicPropertiesPanel' +import { DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel } from './InspectorPanels' -const TYPE_ICON_MAP: Record>> = { - Project: Wrench, - Experiment: Flask, - Responsibility: Target, - Procedure: ArrowsClockwise, - Person: Users, - Event: CalendarBlank, - Topic: Tag, - Type: StackSimple, -} - -function getTypeIcon(isA: string | undefined): ComponentType> { - return (isA && TYPE_ICON_MAP[isA]) || FileText -} +export type FrontmatterValue = string | number | boolean | string[] | null interface InspectorProps { collapsed: boolean @@ -40,292 +23,38 @@ interface InspectorProps { onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise } -export type FrontmatterValue = string | number | boolean | string[] | null - -/** Check if a string is a wikilink */ -function isWikilink(value: string): boolean { - return /^\[\[.*\]\]$/.test(value) -} - -/** Extract display name from a wikilink */ -function wikilinkDisplay(ref: string): string { - const inner = ref.replace(/^\[\[|\]\]$/g, '') - const pipeIdx = inner.indexOf('|') - if (pipeIdx !== -1) return inner.slice(pipeIdx + 1) - const last = inner.split('/').pop() ?? inner - return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) -} - -/** Extract the target path for navigation from a wikilink ref */ -function wikilinkTarget(ref: string): string { - const inner = ref.replace(/^\[\[|\]\]$/g, '') - const pipeIdx = inner.indexOf('|') - return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner -} - -function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined { - const target = wikilinkTarget(ref) - return entries.find((e) => { - const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - if (stem === target) return true - const fileStem = e.filename.replace(/\.md$/, '') - if (fileStem === target.split('/').pop()) return true - return false - }) -} - - -function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string; refs: string[]; entries: VaultEntry[]; onNavigate: (target: string) => void }) { - if (refs.length === 0) return null - return ( -
- {label} -
- {refs.map((ref, idx) => { - const resolved = resolveRef(ref, entries) - const refType = resolved?.isA ?? undefined - const isArchived = resolved?.archived ?? false - const isTrashed = resolved?.trashed ?? false - const isDimmed = isArchived || isTrashed - const color = refType ? getTypeColor(refType) : 'var(--accent-blue)' - const bgColor = refType ? getTypeLightColor(refType) : 'var(--accent-blue-light)' - const TypeIcon = getTypeIcon(refType) - return ( - - ) - })} -
-
- ) -} - -function DynamicRelationshipsPanel({ - frontmatter, entries, onNavigate, onAddProperty, -}: { - frontmatter: ParsedFrontmatter - entries: VaultEntry[] - onNavigate: (target: string) => void - onAddProperty?: (key: string, value: FrontmatterValue) => void -}) { - const [showForm, setShowForm] = useState(false) - const [relKey, setRelKey] = useState('') - const [relTarget, setRelTarget] = useState('') - const keyInputRef = useRef(null) - - const relationshipEntries = useMemo(() => { - return Object.entries(frontmatter) - .filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value))) - .map(([key, value]) => { - const refs: string[] = [] - if (typeof value === 'string' && isWikilink(value)) refs.push(value) - else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) }) - return { key, refs } - }) - .filter(({ refs }) => refs.length > 0) - }, [frontmatter]) - - // All note titles for datalist autocomplete - const noteTitles = useMemo(() => entries.map(e => e.title), [entries]) - - const handleAdd = useCallback(() => { - const key = relKey.trim() - const target = relTarget.trim() - if (!key || !target || !onAddProperty) return - onAddProperty(key, `[[${target}]]`) - setRelKey('') - setRelTarget('') - setShowForm(false) - }, [relKey, relTarget, onAddProperty]) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleAdd() - else if (e.key === 'Escape') { setShowForm(false); setRelKey(''); setRelTarget('') } - } - - return ( -
- {relationshipEntries.length === 0 ? ( -

No relationships

- ) : ( - relationshipEntries.map(({ key, refs }) => ( - - )) - )} - - {showForm ? ( -
- - {noteTitles.map(t => - setRelKey(e.target.value)} - /> - setRelTarget(e.target.value)} - /> -
- - -
-
- ) : ( - - )} -
- ) -} - function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allContent: Record): VaultEntry[] { return useMemo(() => { if (!entry) return [] - const title = entry.title + const targets = [entry.title, ...entry.aliases] const stem = entry.filename.replace(/\.md$/, '') - const targets = [title, ...entry.aliases] const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') return entries.filter((e) => { if (e.path === entry.path) return false - const content = allContent[e.path] - if (!content) return false - for (const t of targets) { - if (content.includes(`[[${t}]]`)) return true - } - if (content.includes(`[[${stem}]]`)) return true - if (content.includes(`[[${pathStem}]]`)) return true - if (content.includes(`[[${pathStem}|`)) return true - return false + const c = allContent[e.path] + if (!c) return false + for (const t of targets) { if (c.includes(`[[${t}]]`)) return true } + return c.includes(`[[${stem}]]`) || c.includes(`[[${pathStem}]]`) || c.includes(`[[${pathStem}|`) }) }, [entry, entries, allContent]) } -function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; onNavigate: (target: string) => void }) { +function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { return ( -
-

- Backlinks {backlinks.length > 0 && {backlinks.length}} -

- {backlinks.length === 0 ? ( -

No backlinks

+
+ {collapsed ? ( + ) : ( -
- {backlinks.map((e) => { - const color = e.isA ? getTypeColor(e.isA) : 'var(--accent-blue)' - const TypeIcon = getTypeIcon(e.isA ?? undefined) - const isDimmed = e.archived || e.trashed - return ( - - ) - })} -
- )} -
- ) -} - -function formatRelativeDate(timestamp: number): string { - const now = Math.floor(Date.now() / 1000) - const diff = now - timestamp - if (diff < 86400) return 'today' - const days = Math.floor(diff / 86400) - if (days === 1) return 'yesterday' - if (days < 30) return `${days}d ago` - const months = Math.floor(days / 30) - if (months === 1) return '1mo ago' - return `${months}mo ago` -} - -function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) { - return ( -
-

History

- {commits.length === 0 ? ( -

No revision history

- ) : ( -
- {commits.map((c) => ( -
-
- - {formatRelativeDate(c.date)} -
-
{c.message}
- {c.author && ( -
{c.author}
- )} -
- ))} -
+ <> + + Properties + + )}
) @@ -334,12 +63,8 @@ function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; function EmptyInspector() { return ( <> -
-

No note selected

-
-
-

No relationships

-
+

No note selected

+

No relationships

Backlinks

No backlinks

@@ -372,25 +97,8 @@ export function Inspector({ }, [entry, onAddProperty]) return ( -