From 3e05a9425a5d2f7b574ff95f1e6bf97447e1bf2b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 8 Mar 2026 22:15:08 +0100 Subject: [PATCH] test: add Playwright smoke test for exact-match search ranking Verifies that searching "Writing" in Quick Open shows the exact title match first, followed by prefix matches. Also adds Refactoring test entries to mock data and demo vault. Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 25 ++-- src/components/AIChatPanel.tsx | 9 +- src/components/AiPanel.test.tsx | 9 +- src/components/AiPanel.tsx | 6 +- src/components/DynamicPropertiesPanel.tsx | 5 +- src/components/Editor.test.tsx | 1 - src/components/Editor.tsx | 12 +- src/components/EditorRightPanel.tsx | 5 +- src/components/Inspector.tsx | 15 +- src/components/NoteList.test.tsx | 164 +++++++++++----------- src/components/NoteList.tsx | 13 +- src/components/useNoteListSort.test.tsx | 1 - src/hooks/useAIChat.test.ts | 17 +-- src/hooks/useAIChat.ts | 5 +- src/hooks/useAppCommands.ts | 2 - src/hooks/useEditorSaveWithLinks.ts | 6 +- src/hooks/useKeyboardNavigation.test.ts | 20 ++- src/hooks/useKeyboardNavigation.ts | 10 +- src/hooks/useNoteActions.test.ts | 25 ++-- src/hooks/useNoteActions.ts | 27 ++-- src/hooks/usePropertyPanelState.ts | 29 ++-- src/hooks/useTabManagement.test.ts | 56 -------- src/hooks/useTabManagement.ts | 31 +--- src/hooks/useThemeManager.test.ts | 39 ----- src/hooks/useThemeManager.ts | 17 ++- src/hooks/useVaultLoader.test.ts | 35 ++--- src/hooks/useVaultLoader.ts | 24 ++-- src/utils/ai-chat.test.ts | 12 +- src/utils/ai-chat.ts | 26 +--- src/utils/ai-context.test.ts | 119 ++++------------ src/utils/ai-context.ts | 26 +--- src/utils/autoSave.test.ts | 9 +- src/utils/autoSave.ts | 3 +- src/utils/mergeTabContent.test.ts | 61 -------- src/utils/mergeTabContent.ts | 18 --- src/utils/noteListHelpers.test.ts | 26 ++-- src/utils/noteListHelpers.ts | 18 +-- 37 files changed, 269 insertions(+), 657 deletions(-) delete mode 100644 src/utils/mergeTabContent.test.ts delete mode 100644 src/utils/mergeTabContent.ts diff --git a/src/App.tsx b/src/App.tsx index 843dc066..2c10e498 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -118,7 +118,7 @@ function App() { const vault = useVaultLoader(resolvedPath) useVaultConfig(resolvedPath) const { settings, saveSettings } = useSettings() - const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent) + const themeManager = useThemeManager(resolvedPath, vault.entries) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) @@ -182,12 +182,7 @@ function App() { // Read at callback time, so it's always current when user presses Cmd+N. const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) - // Stable ref for allContent so getCachedContent callback never changes identity - const allContentForCacheRef = useRef(vault.allContent) - allContentForCacheRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern - const getCachedContent = useCallback((path: string) => allContentForCacheRef.current[path], []) - - const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, getCachedContent, replaceEntry: vault.replaceEntry }) + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry }) // Keep tab entries in sync with vault entries so banners (trash/archive) // and read-only state react immediately without reopening the note. @@ -312,7 +307,7 @@ function App() { }, [vault, triggerIncrementalIndex]) const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({ - updateContent: vault.updateContent, updateEntry: vault.updateEntry, + updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage, onAfterSave, onNotePersisted: vault.clearUnsaved, }) @@ -321,27 +316,24 @@ function App() { // Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change) const tabsRef = useRef(notes.tabs) tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern - const allContentRef = useRef(vault.allContent) - allContentRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern const unsavedPathsRef = useRef(vault.unsavedPaths) unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern /** Auto-save unsaved editor content before a destructive action (trash/archive). */ - const { updateContent: vaultUpdateContent, clearUnsaved: vaultClearUnsaved } = vault + const { clearUnsaved: vaultClearUnsaved } = vault const flushBeforeAction = useCallback(async (path: string) => { try { await flushEditorContent(path, { savePendingForPath, getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content, isUnsaved: (p) => unsavedPathsRef.current.has(p), - getSavedContent: (p) => allContentRef.current[p], - onSaved: (p, c) => { vaultUpdateContent(p, c); vaultClearUnsaved(p) }, + onSaved: (p) => { vaultClearUnsaved(p) }, }) } catch (err) { setToastMessage(`Auto-save failed: ${err}`) throw err } - }, [savePendingForPath, vaultUpdateContent, vaultClearUnsaved, setToastMessage]) + }, [savePendingForPath, vaultClearUnsaved, setToastMessage]) // Wire conflict file opener now that notes is available useEffect(() => { @@ -472,7 +464,7 @@ function App() { const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs, - entries: vault.entries, allContent: vault.allContent, + entries: vault.entries, modifiedCount: vault.modifiedFiles.length, activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath), selection, @@ -589,7 +581,7 @@ function App() { {selection.kind === 'filter' && selection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - + )} @@ -614,7 +606,6 @@ function App() { onInspectorResize={layout.handleInspectorResize} inspectorEntry={activeTab?.entry ?? null} inspectorContent={activeTab?.content ?? null} - allContent={vault.allContent} gitHistory={gitHistory} onUpdateFrontmatter={notes.handleUpdateFrontmatter} onDeleteProperty={notes.handleDeleteProperty} diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index f9b146d0..847eecdc 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -15,7 +15,6 @@ import { MarkdownContent } from './MarkdownContent' interface AIChatPanelProps { entry: VaultEntry | null - allContent: Record entries?: VaultEntry[] onClose: () => void onNavigateWikilink?: (target: string) => void @@ -177,17 +176,17 @@ function useContextNotes(entry: VaultEntry | null) { // --- Main component --- -export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) { +export function AIChatPanel({ entry, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) { const [input, setInput] = useState('') const [showSearch, setShowSearch] = useState(false) const messagesEndRef = useRef(null) const ctx = useContextNotes(entry) - const chat = useAIChat(allContent, ctx.contextNotes) + const chat = useAIChat(ctx.contextNotes) const contextInfo = useMemo( - () => buildSystemPrompt(ctx.contextNotes, allContent), - [ctx.contextNotes, allContent], + () => buildSystemPrompt(ctx.contextNotes), + [ctx.contextNotes], ) useEffect(() => { diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index ddaa25f5..c5542cec 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -81,7 +81,7 @@ describe('AiPanel', () => { it('renders contextual empty state when active entry is provided', () => { const entry = makeEntry({ title: 'My Note' }) render( - + ) expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy() }) @@ -89,7 +89,7 @@ describe('AiPanel', () => { it('shows context bar with active entry title', () => { const entry = makeEntry({ title: 'My Note' }) render( - + ) expect(screen.getByTestId('context-bar')).toBeTruthy() expect(screen.getByText('My Note')).toBeTruthy() @@ -102,8 +102,7 @@ describe('AiPanel', () => { + /> ) expect(screen.getByText('+ 1 linked')).toBeTruthy() }) @@ -129,7 +128,7 @@ describe('AiPanel', () => { it('shows contextual placeholder when active entry exists', () => { const entry = makeEntry({ title: 'My Note' }) render( - + ) const input = screen.getByTestId('agent-input') as HTMLInputElement expect(input.placeholder).toBe('Ask about this note...') diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 9dd99f52..470f9758 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -19,7 +19,6 @@ interface AiPanelProps { /** Direct content of the active note from the editor tab. */ activeNoteContent?: string | null entries?: VaultEntry[] - allContent?: Record openTabs?: VaultEntry[] noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } @@ -112,7 +111,7 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha ) } -export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) { +export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) { const [input, setInput] = useState('') const [pendingRefs, setPendingRefs] = useState([]) const inputRef = useRef(null) @@ -127,7 +126,6 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on if (!activeEntry || !entries) return undefined return buildContextSnapshot({ activeEntry, - allContent: allContent ?? {}, activeNoteContent: activeNoteContent ?? undefined, openTabs, noteList, @@ -135,7 +133,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on entries, references: pendingRefs.length > 0 ? pendingRefs : undefined, }) - }, [activeEntry, activeNoteContent, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs]) + }, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs]) const fileCallbacks = useMemo(() => ({ onFileCreated, diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index f795616f..e91335b9 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -658,14 +658,13 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n } export function DynamicPropertiesPanel({ - entry, content, frontmatter, entries, allContent, + entry, content, frontmatter, entries, onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, }: { entry: VaultEntry content: string | null frontmatter: ParsedFrontmatter entries?: VaultEntry[] - allContent?: Record onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void @@ -675,7 +674,7 @@ export function DynamicPropertiesPanel({ editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides, availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries, handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange, - } = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty }) + } = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty }) const wordCount = countWords(content ?? '') diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 8bc395d0..587038bf 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -105,7 +105,6 @@ const defaultProps = { onInspectorResize: vi.fn(), inspectorEntry: null as VaultEntry | null, inspectorContent: null as string | null, - allContent: {} as Record, gitHistory: [], onCreateNote: vi.fn(), } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a6ffe719..81b78e31 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useCallback, memo, useMemo } from 'react' +import { useRef, useEffect, useCallback, memo } from 'react' import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap' import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync' import { useCreateBlockNote } from '@blocknote/react' @@ -15,7 +15,6 @@ import { useEditorFocus } from '../hooks/useEditorFocus' import { EditorRightPanel } from './EditorRightPanel' import { EditorContent } from './EditorContent' import { schema } from './editorSchema' -import { mergeTabContent } from '../utils/mergeTabContent' import './Editor.css' import './EditorTheme.css' @@ -42,7 +41,6 @@ interface EditorProps { onInspectorResize: (delta: number) => void inspectorEntry: VaultEntry | null inspectorContent: string | null - allContent: Record gitHistory: GitCommit[] onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise onDeleteProperty?: (path: string, key: string) => Promise @@ -121,7 +119,7 @@ export const Editor = memo(function Editor({ tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote, inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize, - inspectorEntry, inspectorContent, allContent, gitHistory, + inspectorEntry, inspectorContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, showAIChat, onToggleAIChat, vaultPath, noteList, noteListFilter, @@ -173,11 +171,6 @@ export const Editor = memo(function Editor({ diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef, }) - const enrichedAllContent = useMemo( - () => mergeTabContent(allContent, tabs), - [allContent, tabs], - ) - const isLoadingNewTab = activeTabPath !== null && !activeTab const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean' const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified')) @@ -240,7 +233,6 @@ export const Editor = memo(function Editor({ inspectorEntry={inspectorEntry} inspectorContent={inspectorContent} entries={entries} - allContent={enrichedAllContent} gitHistory={gitHistory} vaultPath={vaultPath ?? ''} openTabs={tabs.map(t => t.entry)} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index fbb927d4..5a57c800 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -10,7 +10,6 @@ interface EditorRightPanelProps { inspectorEntry: VaultEntry | null inspectorContent: string | null entries: VaultEntry[] - allContent: Record gitHistory: GitCommit[] vaultPath: string openTabs?: VaultEntry[] @@ -31,7 +30,7 @@ interface EditorRightPanelProps { export function EditorRightPanel({ showAIChat, inspectorCollapsed, inspectorWidth, - inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs, + inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs, noteList, noteListFilter, onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote, @@ -53,7 +52,6 @@ export function EditorRightPanel({ activeEntry={inspectorEntry} activeNoteContent={inspectorContent} entries={entries} - allContent={allContent} openTabs={openTabs} noteList={noteList} noteListFilter={noteListFilter} @@ -75,7 +73,6 @@ export function EditorRightPanel({ entry={inspectorEntry} content={inspectorContent} entries={entries} - allContent={allContent} gitHistory={gitHistory} onNavigate={onNavigateWikilink} onViewCommitDiff={onViewCommitDiff} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 5fc3fe1b..731086c9 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -7,7 +7,6 @@ import { parseFrontmatter } from '../utils/frontmatter' import { DynamicPropertiesPanel } from './DynamicPropertiesPanel' import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels' import { wikilinkTarget } from '../utils/wikilink' -import { extractBacklinkContext } from '../utils/wikilinks' import type { ReferencedByItem, BacklinkItem } from './InspectorPanels' export type FrontmatterValue = string | number | boolean | string[] | null @@ -18,7 +17,6 @@ interface InspectorProps { entry: VaultEntry | null content: string | null entries: VaultEntry[] - allContent?: Record gitHistory: GitCommit[] onNavigate: (target: string) => void onViewCommitDiff?: (commitHash: string) => void @@ -31,7 +29,6 @@ function useBacklinks( entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[], - allContent?: Record, ): BacklinkItem[] { return useMemo(() => { if (!entry) return [] @@ -53,11 +50,9 @@ function useBacklinks( }) .map((e) => ({ entry: e, - context: allContent?.[e.path] - ? extractBacklinkContext(allContent[e.path], matchTargets) - : null, + context: null, })) - }, [entry, entries, referencedBy, allContent]) + }, [entry, entries, referencedBy]) } function refsMatchTargets(refs: string[], targets: Set): boolean { @@ -118,11 +113,11 @@ function EmptyInspector() { } export function Inspector({ - collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate, + collapsed, onToggle, entry, content, entries, gitHistory, onNavigate, onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, }: InspectorProps) { const referencedBy = useReferencedBy(entry, entries) - const backlinks = useBacklinks(entry, entries, referencedBy, allContent) + const backlinks = useBacklinks(entry, entries, referencedBy) const frontmatter = useMemo(() => parseFrontmatter(content), [content]) const typeEntryMap = useMemo(() => { const map: Record = {} @@ -151,7 +146,7 @@ export function Inspector({ <> { it('shows empty state when no entries', () => { - render() + render() expect(screen.getByText('No notes found')).toBeInTheDocument() }) it('renders all entries with All Notes filter', () => { - render() + render() expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() }) it('filters by People (section group)', () => { - render() + render() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('filters by Events (section group)', () => { - render() + render() expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('filters by section group type', () => { - render() + render() expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() }) it('shows entity pinned at top with grouped children', () => { render( - + ) // Entity title appears in header and pinned card expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1) @@ -199,7 +199,7 @@ describe('NoteList', () => { it('filters by topic (relatedTo references)', () => { render( - + ) // Build Laputa App has relatedTo: [[topic/software-development]] expect(screen.getByText('Build Laputa App')).toBeInTheDocument() @@ -207,7 +207,7 @@ describe('NoteList', () => { }) it('shows search input when search icon is clicked', () => { - render() + render() // Search is hidden by default expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument() // Click search icon to show it @@ -216,7 +216,7 @@ describe('NoteList', () => { }) it('filters by search query (case-insensitive substring)', () => { - render() + render() // Open search fireEvent.click(screen.getByTitle('Search notes')) const input = screen.getByPlaceholderText('Search notes...') @@ -231,31 +231,31 @@ describe('NoteList', () => { { ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' }, { ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' }, ] - render() + render() const titles = screen.getAllByText(/Oldest|Newest|Middle/) const titleTexts = titles.map((el) => el.textContent) expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest']) }) it('does not render type badge or status on note items', () => { - render() + render() // Type badges like "Project", "Note" etc. should not appear as separate badge elements // The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge expect(screen.queryByText('Active')).not.toBeInTheDocument() }) it('header shows search and plus icons instead of count badge', () => { - render() + render() expect(screen.getByTitle('Search notes')).toBeInTheDocument() expect(screen.getByTitle('Create new note')).toBeInTheDocument() }) - it('context view shows backlinks from allContent', () => { - const allContent = { - [mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.', - } + it('context view shows backlinks from outgoingLinks', () => { + const entriesWithBacklink = mockEntries.map(e => + e.path === mockEntries[2].path ? { ...e, outgoingLinks: ['Build Laputa App'] } : e + ) render( - + ) expect(screen.getByText('Backlinks')).toBeInTheDocument() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() @@ -263,7 +263,7 @@ describe('NoteList', () => { it('context view collapses and expands groups', () => { render( - + ) // Children group is expanded by default expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() @@ -278,7 +278,7 @@ describe('NoteList', () => { it('context view shows prominent card with snippet subtitle', () => { render( - + ) // Snippet text appears in the prominent card expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument() @@ -292,21 +292,21 @@ describe('NoteList click behavior', () => { }) it('regular click calls onReplaceActiveTab (opens in current tab)', () => { - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) expect(noopReplace).toHaveBeenCalledWith(mockEntries[0]) expect(noopSelect).not.toHaveBeenCalled() }) it('Cmd+Click calls onSelectNote (opens in new tab)', () => { - render() + render() fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true }) expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) expect(noopReplace).not.toHaveBeenCalled() }) it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => { - render() + render() fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true }) expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) expect(noopReplace).not.toHaveBeenCalled() @@ -314,7 +314,7 @@ describe('NoteList click behavior', () => { it('Cmd+Click on entity pinned card calls onSelectNote', () => { render( - + ) const titles = screen.getAllByText('Build Laputa App') fireEvent.click(titles[titles.length - 1], { metaKey: true }) @@ -324,7 +324,7 @@ describe('NoteList click behavior', () => { it('regular click on entity pinned card calls onReplaceActiveTab', () => { render( - + ) // Title appears in both header and pinned card — use getAllByText and click the pinned card instance const titles = screen.getAllByText('Build Laputa App') @@ -335,7 +335,7 @@ describe('NoteList click behavior', () => { it('click on child note in entity view calls onReplaceActiveTab', () => { render( - + ) fireEvent.click(screen.getByText('Facebook Ads Strategy')) expect(noopReplace).toHaveBeenCalledWith(mockEntries[1]) @@ -489,21 +489,21 @@ describe('NoteList sort controls', () => { it('shows sort button in note list header for flat view', () => { render( - + ) expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument() }) it('shows sort dropdown per relationship subsection in entity view', () => { render( - + ) expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument() }) it('opens sort menu on click and shows all options', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() @@ -520,7 +520,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), ] render( - + ) // Default sort: by modified (Zebra first) let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) @@ -537,7 +537,7 @@ describe('NoteList sort controls', () => { it('closes sort menu after selecting an option', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() @@ -547,7 +547,7 @@ describe('NoteList sort controls', () => { it('shows direction arrows in sort dropdown menu', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) // Each option should have asc and desc direction buttons @@ -564,7 +564,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), ] render( - + ) // Default sort: modified descending (Zebra first at 3000) let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) @@ -585,7 +585,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }), ] render( - + ) // Select title sort with desc direction fireEvent.click(screen.getByTestId('sort-button-__list__')) @@ -598,7 +598,7 @@ describe('NoteList sort controls', () => { it('shows direction icon on the sort button that reflects current direction', () => { render( - + ) // Default: modified desc → should have ArrowDown icon expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument() @@ -635,7 +635,7 @@ describe('NoteList sort controls', () => { const entries = [parent, child1, child2] render( - + ) // Default sort: by modified — Zebra Note (3000) before Alpha Note (1000) @@ -657,7 +657,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }), ] render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) expect(screen.getByTestId('sort-separator')).toBeInTheDocument() @@ -668,7 +668,7 @@ describe('NoteList sort controls', () => { it('omits separator when no custom properties exist', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument() @@ -681,7 +681,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }), ] render( - + ) // Default: modified desc → A, B, C let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) @@ -703,7 +703,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }), ] render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) fireEvent.click(screen.getByTestId('sort-option-property:Priority')) @@ -821,7 +821,7 @@ describe('NoteList — status indicators', () => { it('shows modified indicator dot for modified notes', () => { const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const render( - + ) const indicators = screen.getAllByTestId('modified-indicator') expect(indicators).toHaveLength(1) @@ -832,7 +832,7 @@ describe('NoteList — status indicators', () => { it('does not show indicator when all notes are clean', () => { const getNoteStatus = () => 'clean' as const render( - + ) expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument() @@ -842,14 +842,14 @@ describe('NoteList — status indicators', () => { const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path]) const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const render( - + ) expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2) }) it('does not show indicator when getNoteStatus prop is undefined', () => { render( - + ) expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument() @@ -858,7 +858,7 @@ describe('NoteList — status indicators', () => { it('shows green new indicator for new notes', () => { const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const render( - + ) expect(screen.getAllByTestId('new-indicator')).toHaveLength(1) expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() @@ -869,31 +869,31 @@ describe('NoteList — trash view', () => { const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' } it('shows "Trash" header when trash filter is active', () => { - render() + render() expect(screen.getByText('Trash')).toBeInTheDocument() }) it('shows only trashed entries in trash view', () => { - render() + render() expect(screen.getByText('Old Draft Notes')).toBeInTheDocument() expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('shows TRASHED badge on trashed entries', () => { - render() + render() const badges = screen.getAllByText('TRASHED') expect(badges.length).toBeGreaterThanOrEqual(1) }) it('shows 30-day warning banner when expired notes exist', () => { - render() + render() expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument() expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument() }) it('shows "Trash is empty" when no trashed entries', () => { - render() + render() expect(screen.getByText('Trash is empty')).toBeInTheDocument() }) }) @@ -933,7 +933,7 @@ describe('NoteList — virtual list with large datasets', () => { it('renders 9000 entries without crashing', { timeout: 30000 }, () => { const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i)) const { container } = render( - + ) // Virtuoso mock renders all items; the real component only renders visible ones expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument() @@ -942,7 +942,7 @@ describe('NoteList — virtual list with large datasets', () => { it('renders items from a large dataset via Virtuoso', () => { const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i)) render( - + ) expect(screen.getByText('Note 0')).toBeInTheDocument() expect(screen.getByText('Note 499')).toBeInTheDocument() @@ -955,7 +955,7 @@ describe('NoteList — virtual list with large datasets', () => { makeEntry(999, { title: 'Beta Strategy' }), ] render( - + ) fireEvent.click(screen.getByTitle('Search notes')) fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } }) @@ -971,7 +971,7 @@ describe('NoteList — virtual list with large datasets', () => { ...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })), ] render( - + ) // Default sort is modified desc — Alpha (3000) should come first const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0] @@ -984,7 +984,7 @@ describe('NoteList — virtual list with large datasets', () => { ...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })), ] render( - + ) expect(screen.getByText('Project 0')).toBeInTheDocument() expect(screen.queryByText('Note 0')).not.toBeInTheDocument() @@ -994,7 +994,7 @@ describe('NoteList — virtual list with large datasets', () => { const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i)) const selected = entries[5] render( - + ) expect(screen.getByText('Note 5')).toBeInTheDocument() }) @@ -1003,7 +1003,7 @@ describe('NoteList — virtual list with large datasets', () => { noopReplace.mockClear() const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i)) render( - + ) fireEvent.click(screen.getByText('Note 50')) expect(noopReplace).toHaveBeenCalledWith(entries[50]) @@ -1018,7 +1018,7 @@ describe('NoteList — virtual list with large datasets', () => { it('shows only modified notes in changes view', () => { render( - + ) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() @@ -1028,21 +1028,21 @@ describe('NoteList — virtual list with large datasets', () => { it('shows header title "Changes"', () => { render( - + ) expect(screen.getByText('Changes')).toBeInTheDocument() }) it('shows empty state when no modified files', () => { render( - + ) expect(screen.getByText('No pending changes')).toBeInTheDocument() }) it('updates list when modifiedFiles changes', () => { const { rerender } = render( - + ) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() @@ -1050,7 +1050,7 @@ describe('NoteList — virtual list with large datasets', () => { // Simulate one file being committed (removed from modifiedFiles) const fewerModified = [modifiedFiles[0]] rerender( - + ) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument() @@ -1061,7 +1061,7 @@ describe('NoteList — virtual list with large datasets', () => { // The changes filter must use modifiedFiles for filtering even when getNoteStatus is present. const getNoteStatus = (path: string) => modifiedFiles.some((f) => f.path === path) ? 'modified' as const : 'clean' as const render( - + ) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() @@ -1079,7 +1079,7 @@ describe('NoteList — virtual list with large datasets', () => { { path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const }, ] render( - + ) // Even though absolute paths differ, entries should match via relative path suffix expect(screen.getByText('Build Laputa App')).toBeInTheDocument() @@ -1089,7 +1089,7 @@ describe('NoteList — virtual list with large datasets', () => { it('shows error message when modifiedFilesError is set', () => { render( - + ) expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument() expect(screen.getByText(/git status failed/)).toBeInTheDocument() @@ -1101,7 +1101,7 @@ describe('NoteList — virtual list with large datasets', () => { { path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const }, ] render( - + ) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() @@ -1119,7 +1119,7 @@ describe('NoteList — multi-select', () => { }) it('Shift+Click selects a range of notes', () => { - render() + render() // Regular click to set anchor fireEvent.click(screen.getByText('Build Laputa App')) // Shift+Click to select range @@ -1130,7 +1130,7 @@ describe('NoteList — multi-select', () => { }) it('regular click clears multi-select and opens note', () => { - render() + render() // Select range via Shift+click fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) @@ -1142,7 +1142,7 @@ describe('NoteList — multi-select', () => { }) it('Cmd+Click clears multi-select and opens in new tab', () => { - render() + render() // Select range via Shift+click fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) @@ -1154,7 +1154,7 @@ describe('NoteList — multi-select', () => { }) it('shows bulk action bar with correct count', () => { - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() @@ -1163,7 +1163,7 @@ describe('NoteList — multi-select', () => { it('bulk archive calls onBulkArchive and clears selection', () => { const onBulkArchive = vi.fn() - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) fireEvent.click(screen.getByTestId('bulk-archive-btn')) @@ -1173,7 +1173,7 @@ describe('NoteList — multi-select', () => { it('bulk trash calls onBulkTrash and clears selection', () => { const onBulkTrash = vi.fn() - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) fireEvent.click(screen.getByTestId('bulk-trash-btn')) @@ -1182,7 +1182,7 @@ describe('NoteList — multi-select', () => { }) it('clear button on bulk action bar clears selection', () => { - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() @@ -1193,7 +1193,7 @@ describe('NoteList — multi-select', () => { it('Cmd+E archives selected notes when multiselect is active', () => { const onBulkArchive = vi.fn() - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() @@ -1204,7 +1204,7 @@ describe('NoteList — multi-select', () => { it('Cmd+Backspace trashes selected notes when multiselect is active', () => { const onBulkTrash = vi.fn() - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument() @@ -1215,7 +1215,7 @@ describe('NoteList — multi-select', () => { it('Cmd+Delete trashes selected notes when multiselect is active', () => { const onBulkTrash = vi.fn() - render() + render() fireEvent.click(screen.getByText('Build Laputa App')) fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true }) fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) @@ -1224,7 +1224,7 @@ describe('NoteList — multi-select', () => { }) it('no bulk action bar when nothing is selected', () => { - render() + render() expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() }) }) @@ -1269,7 +1269,7 @@ describe('NoteList — type note filtering', () => { it('does not show type note PinnedCard when browsing a sectionGroup', () => { render( - + ) // The type note snippet should NOT be visible (PinnedCard was removed) expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument() @@ -1279,7 +1279,7 @@ describe('NoteList — type note filtering', () => { it('shows clickable header title that navigates to type note', () => { render( - + ) const headerLink = screen.getByTestId('type-header-link') expect(headerLink).toBeInTheDocument() @@ -1291,7 +1291,7 @@ describe('NoteList — type note filtering', () => { it('header is not clickable when not viewing a type section', () => { render( - + ) expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument() }) @@ -1300,7 +1300,7 @@ describe('NoteList — type note filtering', () => { describe('NoteList — traffic light padding when sidebar collapsed', () => { it('adds left padding to header when sidebarCollapsed is true', () => { const { container } = render( - + ) const header = container.querySelector('.h-\\[52px\\]') as HTMLElement expect(header.style.paddingLeft).toBe('80px') @@ -1308,7 +1308,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => { it('does not add extra left padding when sidebarCollapsed is false', () => { const { container } = render( - + ) const header = container.querySelector('.h-\\[52px\\]') as HTMLElement expect(header.style.paddingLeft).toBe('') @@ -1316,7 +1316,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => { it('does not add extra left padding when sidebarCollapsed is not provided', () => { const { container } = render( - + ) const header = container.querySelector('.h-\\[52px\\]') as HTMLElement expect(header.style.paddingLeft).toBe('') diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 87b781e2..fe11bb36 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -25,7 +25,6 @@ interface NoteListProps { entries: VaultEntry[] selection: SidebarSelection selectedNote: VaultEntry | null - allContent: Record modifiedFiles?: ModifiedFile[] modifiedFilesError?: string | null getNoteStatus?: (path: string) => NoteStatus @@ -241,7 +240,7 @@ function toggleSetMember(set: Set, member: T): Set { // --- Data hooks --- interface NoteListDataParams { - entries: VaultEntry[]; selection: SidebarSelection; allContent: Record + entries: VaultEntry[]; selection: SidebarSelection query: string; listSort: SortOption; listDirection: SortDirection modifiedPathSet: Set; modifiedSuffixes: string[] } @@ -261,7 +260,7 @@ function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, }, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes]) } -function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) { +function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' @@ -274,9 +273,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list const searchedGroups = useMemo(() => { if (!isEntityView) return [] - const groups = buildRelationshipGroups(selection.entry, entries, allContent) + const groups = buildRelationshipGroups(selection.entry, entries) return filterGroupsByQuery(groups, query) - }, [isEntityView, selection, entries, allContent, query]) + }, [isEntityView, selection, entries, query]) const expiredTrashCount = useMemo( () => isTrashView ? countExpiredTrash(searched) : 0, @@ -484,14 +483,14 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } } -function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }) const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const typeEntryMap = useTypeEntryMap(entries) - const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) + const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null diff --git a/src/components/useNoteListSort.test.tsx b/src/components/useNoteListSort.test.tsx index 60df1702..53c51bed 100644 --- a/src/components/useNoteListSort.test.tsx +++ b/src/components/useNoteListSort.test.tsx @@ -41,7 +41,6 @@ function renderNoteList(props: { entries={props.entries} selection={props.selection} selectedNote={null} - allContent={{}} onSelectNote={noop} onReplaceActiveTab={noop} onCreateNote={noop} diff --git a/src/hooks/useAIChat.test.ts b/src/hooks/useAIChat.test.ts index 3d497227..cf6ac74b 100644 --- a/src/hooks/useAIChat.test.ts +++ b/src/hooks/useAIChat.test.ts @@ -36,10 +36,8 @@ afterEach(() => { }) describe('useAIChat', () => { - const emptyContent: Record = {} - it('sends first message as raw text without history', async () => { - const { result } = renderHook(() => useAIChat(emptyContent, [])) + const { result } = renderHook(() => useAIChat([])) act(() => { result.current.sendMessage('hello') }) @@ -51,7 +49,7 @@ describe('useAIChat', () => { }) it('embeds conversation history in second message', async () => { - const { result } = renderHook(() => useAIChat(emptyContent, [])) + const { result } = renderHook(() => useAIChat([])) // First exchange act(() => { result.current.sendMessage('What is 2+2?') }) @@ -69,7 +67,7 @@ describe('useAIChat', () => { }) it('accumulates history across multiple exchanges', async () => { - const { result } = renderHook(() => useAIChat(emptyContent, [])) + const { result } = renderHook(() => useAIChat([])) // Exchange 1 act(() => { result.current.sendMessage('Q1') }) @@ -98,7 +96,7 @@ describe('useAIChat', () => { }) it('never passes session_id (no --resume)', async () => { - const { result } = renderHook(() => useAIChat(emptyContent, [])) + const { result } = renderHook(() => useAIChat([])) act(() => { result.current.sendMessage('Q1') }) await act(async () => { vi.advanceTimersByTime(50) }) @@ -111,7 +109,7 @@ describe('useAIChat', () => { }) it('resets history after clearConversation', async () => { - const { result } = renderHook(() => useAIChat(emptyContent, [])) + const { result } = renderHook(() => useAIChat([])) // Build up some history act(() => { result.current.sendMessage('hello') }) @@ -130,10 +128,9 @@ describe('useAIChat', () => { }) it('includes system prompt on every message when context notes exist', async () => { - const content = { 'note.md': 'Some note content' } const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[] - const { result } = renderHook(() => useAIChat(content, notes)) + const { result } = renderHook(() => useAIChat(notes)) // First message act(() => { result.current.sendMessage('hello') }) @@ -153,7 +150,7 @@ describe('useAIChat', () => { }) it('retries with correct history (excludes retried exchange)', async () => { - const { result } = renderHook(() => useAIChat(emptyContent, [])) + const { result } = renderHook(() => useAIChat([])) // First exchange act(() => { result.current.sendMessage('hello') }) diff --git a/src/hooks/useAIChat.ts b/src/hooks/useAIChat.ts index 67fce305..a0794203 100644 --- a/src/hooks/useAIChat.ts +++ b/src/hooks/useAIChat.ts @@ -51,7 +51,6 @@ function makeStreamCallbacks( } export function useAIChat( - allContent: Record, contextNotes: VaultEntry[], ) { const [messages, setMessages] = useState([]) @@ -69,7 +68,7 @@ export function useAIChat( abortRef.current = false // Always include system prompt (each request is a fresh subprocess). - const systemPrompt = buildSystemPrompt(contextNotes, allContent).prompt || undefined + const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined // Embed conversation history in the prompt for continuity. const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS) @@ -81,7 +80,7 @@ export function useAIChat( streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks) .catch(() => { /* errors forwarded via onError */ }) - }, [isStreaming, allContent, contextNotes]) + }, [isStreaming, contextNotes]) const sendMessage = useCallback((text: string) => { doSend(text, messages) diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index fe930e45..f3f625a3 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -15,7 +15,6 @@ interface AppCommandsConfig { handleCloseTabRef: React.MutableRefObject<(path: string) => void> tabs: Tab[] entries: VaultEntry[] - allContent: Record modifiedCount: number selection: SidebarSelection onQuickOpen: () => void @@ -214,7 +213,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { activeTabPath: config.activeTabPath, entries: config.entries, selection: config.selection, - allContent: config.allContent, onSwitchTab: config.onSwitchTab, onReplaceActiveTab: config.onReplaceActiveTab, onSelectNote: config.onSelectNote, diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index e8578997..dc388996 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -4,18 +4,16 @@ import { extractOutgoingLinks } from '../utils/wikilinks' import type { VaultEntry } from '../types' export function useEditorSaveWithLinks(config: { - updateContent: (path: string, content: string) => void updateEntry: (path: string, patch: Partial) => void setTabs: Parameters[0]['setTabs'] setToastMessage: (msg: string | null) => void onAfterSave: () => void onNotePersisted?: (path: string) => void }) { - const { updateContent, updateEntry } = config + const { updateEntry } = config const saveContent = useCallback((path: string, content: string) => { - updateContent(path, content) updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) }) - }, [updateContent, updateEntry]) + }, [updateEntry]) const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted }) const { handleContentChange: rawOnChange } = editor const prevLinksKeyRef = useRef('') diff --git a/src/hooks/useKeyboardNavigation.test.ts b/src/hooks/useKeyboardNavigation.test.ts index 5b39562d..1da51e8e 100644 --- a/src/hooks/useKeyboardNavigation.test.ts +++ b/src/hooks/useKeyboardNavigation.test.ts @@ -59,8 +59,6 @@ describe('useKeyboardNavigation', () => { ] const selection: SidebarSelection = { kind: 'filter', filter: 'all' } - const allContent: Record = {} - let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = [] beforeEach(() => { @@ -85,7 +83,7 @@ describe('useKeyboardNavigation', () => { it('registers keydown listener on mount', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/a.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/a.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -96,7 +94,7 @@ describe('useKeyboardNavigation', () => { it('switches to next tab on Cmd+Shift+ArrowRight (browser mode)', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/a.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/a.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -113,7 +111,7 @@ describe('useKeyboardNavigation', () => { it('switches to previous tab on Cmd+Shift+ArrowLeft (browser mode)', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/b.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/b.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -130,7 +128,7 @@ describe('useKeyboardNavigation', () => { it('wraps around when navigating past last tab', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/c.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/c.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -147,7 +145,7 @@ describe('useKeyboardNavigation', () => { it('navigates to next note on Cmd+Alt+ArrowDown', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/a.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/a.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -164,7 +162,7 @@ describe('useKeyboardNavigation', () => { it('navigates to previous note on Cmd+Alt+ArrowUp', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/b.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/b.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -181,7 +179,7 @@ describe('useKeyboardNavigation', () => { it('selects first note when no active tab', () => { renderHook(() => useKeyboardNavigation({ - tabs: [], activeTabPath: null, entries, selection, allContent, + tabs: [], activeTabPath: null, entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -198,7 +196,7 @@ describe('useKeyboardNavigation', () => { it('does nothing without modifier keys', () => { renderHook(() => useKeyboardNavigation({ - tabs, activeTabPath: '/vault/a.md', entries, selection, allContent, + tabs, activeTabPath: '/vault/a.md', entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) @@ -217,7 +215,7 @@ describe('useKeyboardNavigation', () => { it('does nothing with empty tabs for tab navigation', () => { renderHook(() => useKeyboardNavigation({ - tabs: [], activeTabPath: null, entries, selection, allContent, + tabs: [], activeTabPath: null, entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }) ) diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts index 8e93baf2..6e337058 100644 --- a/src/hooks/useKeyboardNavigation.ts +++ b/src/hooks/useKeyboardNavigation.ts @@ -13,7 +13,6 @@ interface KeyboardNavigationOptions { activeTabPath: string | null entries: VaultEntry[] selection: SidebarSelection - allContent: Record onSwitchTab: (path: string) => void onReplaceActiveTab: (entry: VaultEntry) => void onSelectNote: (entry: VaultEntry) => void @@ -22,10 +21,9 @@ interface KeyboardNavigationOptions { function computeVisibleNotes( entries: VaultEntry[], selection: SidebarSelection, - allContent: Record, ): VaultEntry[] { if (selection.kind === 'entity') { - return buildRelationshipGroups(selection.entry, entries, allContent) + return buildRelationshipGroups(selection.entry, entries) .flatMap((g) => g.entries) } return [...filterEntries(entries, selection)].sort(sortByModified) @@ -93,12 +91,12 @@ function useLatestRef(value: T): React.RefObject { } export function useKeyboardNavigation({ - tabs, activeTabPath, entries, selection, allContent, + tabs, activeTabPath, entries, selection, onSwitchTab, onReplaceActiveTab, onSelectNote, }: KeyboardNavigationOptions) { const visibleNotes = useMemo( - () => computeVisibleNotes(entries, selection, allContent), - [entries, selection, allContent], + () => computeVisibleNotes(entries, selection), + [entries, selection], ) const tabsRef = useLatestRef(tabs) diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 241ead1f..19500021 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -426,12 +426,11 @@ describe('findDailyNote', () => { describe('useNoteActions hook', () => { const addEntry = vi.fn() const removeEntry = vi.fn() - const updateContent = vi.fn() const updateEntry = vi.fn() const setToastMessage = vi.fn() const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({ - addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, vaultPath: '/test/vault', + addEntry, removeEntry, entries, setToastMessage, updateEntry, vaultPath: '/test/vault', }) beforeEach(() => { @@ -447,11 +446,10 @@ describe('useNoteActions hook', () => { }) expect(addEntry).toHaveBeenCalledTimes(1) - const [createdEntry, createdContent] = addEntry.mock.calls[0] + const [createdEntry] = addEntry.mock.calls[0] expect(createdEntry.title).toBe('Test Note') expect(createdEntry.isA).toBe('Note') expect(createdEntry.path).toContain('note/test-note.md') - expect(createdContent).toContain('title: Test Note') }) it('handleCreateNote opens tab immediately (before addEntry resolves)', () => { @@ -517,7 +515,6 @@ describe('useNoteActions hook', () => { await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done') }) - expect(updateContent).toHaveBeenCalled() expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' }) expect(setToastMessage).toHaveBeenCalledWith('Property updated') }) @@ -544,7 +541,6 @@ describe('useNoteActions hook', () => { await result.current.handleDeleteProperty('/vault/note.md', 'status') }) - expect(updateContent).toHaveBeenCalled() expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: null }) expect(setToastMessage).toHaveBeenCalledWith('Property deleted') }) @@ -598,9 +594,9 @@ describe('useNoteActions hook', () => { result.current.handleCreateNote('My Project', 'Project') }) - const [, createdContent] = addEntry.mock.calls[0] - expect(createdContent).toContain('## Objective') - expect(createdContent).toContain('## Key Results') + const tabContent = result.current.tabs[0].content + expect(tabContent).toContain('## Objective') + expect(tabContent).toContain('## Key Results') }) it('handleCreateNote uses custom template from type entry', () => { @@ -611,9 +607,9 @@ describe('useNoteActions hook', () => { result.current.handleCreateNote('Pasta', 'Recipe') }) - const [, createdContent] = addEntry.mock.calls[0] - expect(createdContent).toContain('## Ingredients') - expect(createdContent).toContain('## Steps') + const tabContent = result.current.tabs[0].content + expect(tabContent).toContain('## Ingredients') + expect(tabContent).toContain('## Steps') }) it('handleCreateNoteImmediate uses template for typed notes', () => { @@ -624,8 +620,8 @@ describe('useNoteActions hook', () => { result.current.handleCreateNoteImmediate('Project') }) - const [, createdContent] = addEntry.mock.calls[0] - expect(createdContent).toContain('## Custom Template') + const tabContent = result.current.tabs[0].content + expect(tabContent).toContain('## Custom Template') }) it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => { @@ -881,7 +877,6 @@ describe('useNoteActions hook', () => { expect(replaceEntry).toHaveBeenCalledWith( '/test/vault/note/my-note.md', expect.objectContaining({ path: '/test/vault/quarter/my-note.md' }), - expect.any(String), ) expect(setToastMessage).toHaveBeenCalledWith('Note moved to quarter/') }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index b3bb5533..4530f4c2 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -26,9 +26,8 @@ interface MoveResult { } export interface NoteActionsConfig { - addEntry: (entry: VaultEntry, content: string) => void + addEntry: (entry: VaultEntry) => void removeEntry: (path: string) => void - updateContent: (path: string, content: string) => void entries: VaultEntry[] setToastMessage: (msg: string | null) => void updateEntry: (path: string, patch: Partial) => void @@ -42,10 +41,8 @@ export interface NoteActionsConfig { markContentPending?: (path: string, content: string) => void /** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */ onNewNotePersisted?: () => void - /** Return cached content for a path (from allContent), or undefined on cache miss. */ - getCachedContent?: (path: string) => string | undefined - /** Replace an entry at oldPath with a patch (handles path changes in entries + content). */ - replaceEntry?: (oldPath: string, patch: Partial & { path: string }, newContent: string) => void + /** Replace an entry at oldPath with a patch (handles path changes in entries). */ + replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void } async function performMoveToTypeFolder( @@ -201,9 +198,9 @@ export function frontmatterToEntryPatch( return updates[k] ?? {} } -function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) { +function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry) => void) { if (!isTauri()) addMockEntry(entry, content) - addEntry(entry, content) + addEntry(entry) } export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string { @@ -300,7 +297,7 @@ function persistOptimistic(path: string, content: string, cbs: PersistCallbacks) * deferred and doesn't block the tab from rendering. */ function createAndPersist( resolved: { entry: VaultEntry; content: string }, - addFn: (e: VaultEntry, c: string) => void, + addFn: (e: VaultEntry) => void, openTab: (e: VaultEntry, c: string) => void, cbs: PersistCallbacks, ): void { @@ -350,11 +347,8 @@ async function runFrontmatterAndApply( } export function useNoteActions(config: NoteActionsConfig) { - const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config - const tabMgmt = useTabManagement({ - getCachedContent: config.getCachedContent, - onContentLoaded: updateContent, - }) + const { addEntry, removeEntry, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config + const tabMgmt = useTabManagement() const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt const tabsRef = useRef(tabMgmt.tabs) // eslint-disable-next-line react-hooks/refs @@ -365,8 +359,7 @@ export function useNoteActions(config: NoteActionsConfig) { 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]) + }, [setTabs]) const handleNavigateWikilink = useCallback( (target: string) => navigateWikilink(entries, target, handleSelectNote), @@ -481,7 +474,7 @@ export function useNoteActions(config: NoteActionsConfig) { if (entry) { const newFilename = result.new_path.split('/').pop() ?? entry.filename const newContent = await loadNoteContent(result.new_path) - config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename }, newContent) + config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename }) setTabs(prev => prev.map(t => t.entry.path === path ? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent } : t)) diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index f202705c..36ced726 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -2,7 +2,6 @@ import { useMemo, useState, useCallback } from 'react' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' import type { ParsedFrontmatter } from '../utils/frontmatter' -import { parseFrontmatter } from '../utils/frontmatter' import { type PropertyDisplayMode, loadDisplayModeOverrides, @@ -62,22 +61,17 @@ function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] { return Array.from(seen).sort((a, b) => a.localeCompare(b)) } -function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map>): void { - for (const [key, value] of Object.entries(fm)) { - if (!Array.isArray(value)) continue - let set = tagsByKey.get(key) - if (!set) { set = new Set(); tagsByKey.set(key, set) } - for (const tag of value) set.add(String(tag)) - } -} - -function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record | undefined): Record { - if (!entries || !allContent) return {} +function collectAllVaultTags(entries: VaultEntry[] | undefined): Record { + if (!entries) return {} const tagsByKey = new Map>() for (const entry of entries) { - const content = allContent[entry.path] - if (!content) continue - mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey) + if (!entry.properties) continue + for (const [key, value] of Object.entries(entry.properties)) { + if (!Array.isArray(value)) continue + let set = tagsByKey.get(key) + if (!set) { set = new Set(); tagsByKey.set(key, set) } + for (const tag of value) set.add(String(tag)) + } } const result: Record = {} for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b)) @@ -106,21 +100,20 @@ export interface PropertyPanelDeps { entries: VaultEntry[] | undefined entryIsA: string | null frontmatter: ParsedFrontmatter - allContent: Record | undefined onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void } export function usePropertyPanelState(deps: PropertyPanelDeps) { - const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps + const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps const [editingKey, setEditingKey] = useState(null) const [showAddDialog, setShowAddDialog] = useState(false) const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides()) const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA]) const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries]) - const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent]) + const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries]) const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter]) const handleSaveValue = useCallback((key: string, newValue: string) => { diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 9fa179dd..e2180d40 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -74,38 +74,6 @@ describe('useTabManagement', () => { expect(result.current.activeTabPath).toBe('/vault/note/a.md') }) - it('uses cached content when getCachedContent returns a value', async () => { - const getCachedContent = vi.fn().mockReturnValue('# Cached content') - const onContentLoaded = vi.fn() - const { result } = renderHook(() => useTabManagement({ getCachedContent, onContentLoaded })) - const entry = makeEntry({ path: '/vault/note/a.md' }) - - await act(async () => { - await result.current.handleSelectNote(entry) - }) - - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].content).toBe('# Cached content') - expect(getCachedContent).toHaveBeenCalledWith('/vault/note/a.md') - // Should NOT call onContentLoaded — content came from cache, no disk read - expect(onContentLoaded).not.toHaveBeenCalled() - }) - - it('falls back to disk read and calls onContentLoaded when cache misses', async () => { - const getCachedContent = vi.fn().mockReturnValue(undefined) - const onContentLoaded = vi.fn() - const { result } = renderHook(() => useTabManagement({ getCachedContent, onContentLoaded })) - const entry = makeEntry({ path: '/vault/note/a.md' }) - - await act(async () => { - await result.current.handleSelectNote(entry) - }) - - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].content).toBe('# Mock content') - expect(onContentLoaded).toHaveBeenCalledWith('/vault/note/a.md', '# Mock content') - }) - it('switches to existing tab without duplicating', async () => { const { result } = renderHook(() => useTabManagement()) const entry = makeEntry({ path: '/vault/note/a.md' }) @@ -328,30 +296,6 @@ describe('useTabManagement', () => { expect(result.current.activeTabPath).toBe('/vault/a.md') }) - it('uses cached content when replacing active tab', async () => { - const getCachedContent = vi.fn((path: string) => - path === '/vault/b.md' ? '# B cached' : undefined, - ) - const onContentLoaded = vi.fn() - const { result } = renderHook(() => useTabManagement({ getCachedContent, onContentLoaded })) - - await act(async () => { - await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })) - }) - // Reset after the first disk read (for A) - onContentLoaded.mockClear() - - const replacement = makeEntry({ path: '/vault/b.md', title: 'B' }) - await act(async () => { - await result.current.handleReplaceActiveTab(replacement) - }) - - expect(result.current.tabs).toHaveLength(1) - expect(result.current.tabs[0].content).toBe('# B cached') - // Cache hit for B — no disk read, no onContentLoaded call - expect(onContentLoaded).not.toHaveBeenCalled() - }) - it('switches to existing tab instead of replacing when note is already open', async () => { const { result } = renderHook(() => useTabManagement()) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index df9dcbda..76b82f33 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -84,28 +84,19 @@ async function loadAndSetTab( entry: VaultEntry, updater: (prev: Tab[], content: string) => Tab[], setTabs: React.Dispatch>, - onContentLoaded?: (path: string, content: string) => void, ) { try { const content = await loadNoteContent(entry.path) setTabs((prev) => updater(prev, content)) - onContentLoaded?.(entry.path, content) } catch (err) { console.warn('Failed to load note content:', err) setTabs((prev) => updater(prev, '')) } } -export interface TabManagementOptions { - /** Return cached content for a path, or undefined for a cache miss. */ - getCachedContent?: (path: string) => string | undefined - /** Called after a disk read so the caller can populate its cache. */ - onContentLoaded?: (path: string, content: string) => void -} - export type { Tab } -export function useTabManagement(options?: TabManagementOptions) { +export function useTabManagement() { const [tabs, setTabs] = useState([]) const [activeTabPath, setActiveTabPath] = useState(null) const activeTabPathRef = useRef(activeTabPath) @@ -113,20 +104,10 @@ export function useTabManagement(options?: TabManagementOptions) { const tabsRef = useRef(tabs) useEffect(() => { tabsRef.current = tabs }) const handleCloseTabRef = useRef<(path: string) => void>(() => {}) - const getCachedContentRef = useRef(options?.getCachedContent) - useEffect(() => { getCachedContentRef.current = options?.getCachedContent }) - const onContentLoadedRef = useRef(options?.onContentLoaded) - useEffect(() => { onContentLoadedRef.current = options?.onContentLoaded }) const handleSelectNote = useCallback(async (entry: VaultEntry) => { if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } - const cached = getCachedContentRef.current?.(entry.path) - if (cached !== undefined) { - setTabs((prev) => addTabIfAbsent(prev, entry, cached)) - setActiveTabPath(entry.path) - return - } - await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs, onContentLoadedRef.current) + await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs) setActiveTabPath(entry.path) }, []) @@ -156,13 +137,7 @@ export function useTabManagement(options?: TabManagementOptions) { if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } const currentPath = activeTabPathRef.current if (!currentPath) { handleSelectNote(entry); return } - const cached = getCachedContentRef.current?.(entry.path) - if (cached !== undefined) { - setTabs((prev) => replaceTabEntry(prev, currentPath, entry, cached)) - setActiveTabPath(entry.path) - return - } - await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs, onContentLoadedRef.current) + await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs) setActiveTabPath(entry.path) }, [handleSelectNote]) diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts index cf0be02a..0658a1b9 100644 --- a/src/hooks/useThemeManager.test.ts +++ b/src/hooks/useThemeManager.test.ts @@ -332,25 +332,6 @@ describe('useThemeManager', () => { expect(newPath).toBe('') }) - it('re-applies theme when active content changes in allContent', async () => { - const { result, rerender } = renderHook( - ({ content }) => useThemeManager('/vault', entries, content), - { initialProps: { content: allContent } }, - ) - await waitFor(() => { - expect(result.current.activeTheme).not.toBeNull() - }) - - const newContent = { - [THEME_PATH_DEFAULT]: `---\ntype: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`, - } - rerender({ content: newContent }) - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000') - }) - }) - it('reloadThemes re-reads vault settings', async () => { const { result } = renderHook(() => useThemeManager('/vault', entries, allContent) @@ -422,26 +403,6 @@ describe('useThemeManager', () => { expect(document.documentElement.dataset.themeMode).toBe('dark') }) - it('populates theme colors from allContent when available', async () => { - const contentWithColors = { - [THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT, - [THEME_PATH_DARK]: DARK_THEME_CONTENT, - } - const { result } = renderHook(() => - useThemeManager('/vault', entries, contentWithColors) - ) - await waitFor(() => { - expect(result.current.themes).toHaveLength(2) - }) - - const defaultTheme = result.current.themes.find(t => t.id === THEME_PATH_DEFAULT) - expect(defaultTheme?.colors.background).toBe('#FFFFFF') - expect(defaultTheme?.colors.primary).toBe('#155DFF') - - const darkTheme = result.current.themes.find(t => t.id === THEME_PATH_DARK) - expect(darkTheme?.colors.background).toBe('#0f0f1a') - }) - it('isDark detects dark theme from cached content', async () => { const contentWithColors = { [THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT, diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts index 1581ac66..e67cfc64 100644 --- a/src/hooks/useThemeManager.ts +++ b/src/hooks/useThemeManager.ts @@ -197,8 +197,6 @@ function useThemeApplier( export function useThemeManager( vaultPath: string | null, entries: VaultEntry[], - allContent: Record, - updateContent?: (path: string, content: string) => void, ): ThemeManager { // Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing) useEffect(() => { @@ -206,7 +204,12 @@ export function useThemeManager( }, [vaultPath]) const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath) - const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined + const [cachedThemeContent, setCachedThemeContent] = useState(undefined) + + // Clear cached content when theme changes — useThemeApplier will fetch from disk + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId]) + const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent) // Track IDs set by user actions (switchTheme/createTheme) so the stale-ID @@ -216,8 +219,8 @@ export function useThemeManager( const themes = useMemo( () => entries .filter(e => e.isA === 'Theme' && !e.trashed && !e.archived) - .map(e => entryToThemeFile(e, allContent[e.path])), - [entries, allContent], + .map(e => entryToThemeFile(e, e.path === activeThemeId ? cachedThemeContent : undefined)), + [entries, activeThemeId, cachedThemeContent], ) const activeTheme = useMemo( @@ -280,9 +283,9 @@ export function useThemeManager( key, value, }) - updateContent?.(activeThemeId, newContent) + setCachedThemeContent(newContent) } catch (err) { console.error('Failed to update theme property:', err) } - }, [activeThemeId, updateContent]) + }, [activeThemeId]) return { themes, activeThemeId, activeTheme, diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 02783f62..661fcda4 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -61,11 +61,10 @@ describe('useVaultLoader', () => { mockInvokeFn.mockImplementation(defaultMockInvoke) }) - it('loads entries and content on mount', async () => { + it('loads entries on mount', async () => { const { result } = await renderVaultLoader() expect(result.current.entries[0].title).toBe('Hello') - expect(result.current.allContent['/vault/note/hello.md']).toContain('# Hello') }) it('loads modified files on mount', async () => { @@ -79,15 +78,14 @@ describe('useVaultLoader', () => { }) describe('addEntry', () => { - it('prepends new entry and adds content', async () => { + it('prepends new entry', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' } - act(() => { result.current.addEntry(newEntry, '# New Note') }) + act(() => { result.current.addEntry(newEntry) }) expect(result.current.entries).toHaveLength(2) expect(result.current.entries[0].title).toBe('New Note') - expect(result.current.allContent['/vault/note/new.md']).toBe('# New Note') }) it('ignores duplicate entry with same path', async () => { @@ -95,32 +93,21 @@ describe('useVaultLoader', () => { const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' } act(() => { - result.current.addEntry(newEntry, '# New Note') - result.current.addEntry(newEntry, '# New Note') + result.current.addEntry(newEntry) + result.current.addEntry(newEntry) }) expect(result.current.entries).toHaveLength(2) }) }) - describe('updateContent', () => { - it('updates content for an existing path', async () => { - const { result } = await renderVaultLoader() - - act(() => { result.current.updateContent('/vault/note/hello.md', '# Updated') }) - - expect(result.current.allContent['/vault/note/hello.md']).toBe('# Updated') - }) - }) - describe('removeEntry', () => { - it('removes entry and content by path', async () => { + it('removes entry by path', async () => { const { result } = await renderVaultLoader() act(() => { result.current.removeEntry('/vault/note/hello.md') }) expect(result.current.entries).toHaveLength(0) - expect(result.current.allContent['/vault/note/hello.md']).toBeUndefined() }) it('is a no-op for non-existent paths', async () => { @@ -159,7 +146,7 @@ describe('useVaultLoader', () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/brand-new.md', filename: 'brand-new.md', title: 'Brand New' } - act(() => { result.current.addEntry(newEntry, '# Brand New') }) + act(() => { result.current.addEntry(newEntry) }) expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new') }) @@ -227,7 +214,7 @@ describe('useVaultLoader', () => { } act(() => { - result.current.addEntry(newEntry, '# New') + result.current.addEntry(newEntry) }) expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new') @@ -238,7 +225,7 @@ describe('useVaultLoader', () => { const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' } act(() => { - result.current.addEntry(newEntry, '# Draft') + result.current.addEntry(newEntry) result.current.trackUnsaved('/vault/note/draft.md') }) @@ -250,7 +237,7 @@ describe('useVaultLoader', () => { const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' } act(() => { - result.current.addEntry(newEntry, '# Draft') + result.current.addEntry(newEntry) result.current.trackUnsaved('/vault/note/draft.md') }) @@ -263,7 +250,7 @@ describe('useVaultLoader', () => { const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' } act(() => { - result.current.addEntry(newEntry, '# Draft') + result.current.addEntry(newEntry) result.current.trackUnsaved('/vault/note/draft.md') }) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index b416dabb..8ba50f75 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -11,8 +11,7 @@ async function loadVaultData(vaultPath: string) { if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing') const entries = await tauriCall('list_vault', { path: vaultPath }) console.log(`Vault scan complete: ${entries.length} entries found`) - const allContent = isTauri() ? {} : await mockInvoke>('get_all_content', { path: vaultPath }) - return { entries, allContent } + return { entries } } async function commitWithPush(vaultPath: string, message: string): Promise { @@ -91,7 +90,6 @@ export function resolveNoteStatus( export function useVaultLoader(vaultPath: string) { const [entries, setEntries] = useState([]) - const [allContent, setAllContent] = useState>({}) const [modifiedFiles, setModifiedFiles] = useState([]) const [modifiedFilesError, setModifiedFilesError] = useState(null) const tracker = useNewNoteTracker() @@ -100,9 +98,9 @@ export function useVaultLoader(vaultPath: string) { useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault - setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll() + setEntries([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll() loadVaultData(vaultPath) - .then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) }) + .then(({ entries: e }) => { setEntries(e) }) .catch((err) => console.warn('Vault scan failed:', err)) }, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable @@ -122,31 +120,25 @@ export function useVaultLoader(vaultPath: string) { // PERF: startTransition defers the expensive entries update (filter/sort on // 9000+ entries) so the high-priority tab render completes in <50ms first. - const addEntry = useCallback((entry: VaultEntry, content: string) => { + const addEntry = useCallback((entry: VaultEntry) => { startTransition(() => { setEntries((prev) => { if (prev.some(e => e.path === entry.path)) return prev return [entry, ...prev] }) - setAllContent((prev) => ({ ...prev, [entry.path]: content })) tracker.trackNew(entry.path) }) }, [tracker]) - const updateContent = useCallback((path: string, content: string) => - setAllContent((prev) => ({ ...prev, [path]: content })), []) - const updateEntry = useCallback((path: string, patch: Partial) => setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), []) const removeEntry = useCallback((path: string) => { setEntries((prev) => prev.filter((e) => e.path !== path)) - setAllContent((prev) => { const next = { ...prev }; delete next[path]; return next }) }, []) - const replaceEntry = useCallback((oldPath: string, patch: Partial & { path: string }, newContent: string) => { + const replaceEntry = useCallback((oldPath: string, patch: Partial & { path: string }) => { setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e)) - setAllContent((prev) => { const next = { ...prev }; delete next[oldPath]; next[patch.path] = newContent; return next }) }, []) const loadGitHistory = useCallback(async (path: string): Promise => { @@ -168,14 +160,14 @@ export function useVaultLoader(vaultPath: string) { const reloadVault = useCallback( () => loadVaultData(vaultPath) - .then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles(); return data.entries }) + .then((data) => { setEntries(data.entries); loadModifiedFiles(); return data.entries }) .catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }), [vaultPath, loadModifiedFiles], ) return { - entries, allContent, modifiedFiles, modifiedFilesError, - addEntry, updateEntry, removeEntry, replaceEntry, updateContent, + entries, modifiedFiles, modifiedFilesError, + addEntry, updateEntry, removeEntry, replaceEntry, loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit, getNoteStatus, commitAndPush, reloadVault, addPendingSave: pendingSave.addPendingSave, diff --git a/src/utils/ai-chat.test.ts b/src/utils/ai-chat.test.ts index 61f385a2..42dbd929 100644 --- a/src/utils/ai-chat.test.ts +++ b/src/utils/ai-chat.test.ts @@ -38,25 +38,23 @@ describe('buildSystemPrompt', () => { }) it('returns empty prompt for no notes', () => { - const result = buildSystemPrompt([], {}) + const result = buildSystemPrompt([]) expect(result.prompt).toBe('') expect(result.totalTokens).toBe(0) expect(result.truncated).toBe(false) }) - it('includes note content in the prompt', () => { + it('includes note metadata in the prompt', () => { const notes = [makeEntry('/test.md', 'Test Note')] - const content = { '/test.md': '# Test Note\nHello world' } - const result = buildSystemPrompt(notes, content) + const result = buildSystemPrompt(notes) expect(result.prompt).toContain('Test Note') - expect(result.prompt).toContain('Hello world') + expect(result.prompt).toContain('/test.md') expect(result.totalTokens).toBeGreaterThan(0) }) it('instructs AI to use wikilink syntax', () => { const notes = [makeEntry('/test.md', 'Test Note')] - const content = { '/test.md': 'content' } - const result = buildSystemPrompt(notes, content) + const result = buildSystemPrompt(notes) expect(result.prompt).toContain('[[') expect(result.prompt).toMatch(/wikilink/i) }) diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index 1132776b..dd537bab 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -21,47 +21,31 @@ export function getContextLimit(): number { // --- Context building --- -/** Build system prompt from selected context notes. */ +/** Build system prompt from selected context notes (metadata only — content loaded via MCP). */ export function buildSystemPrompt( notes: VaultEntry[], - allContent: Record, ): { prompt: string; totalTokens: number; truncated: boolean } { if (notes.length === 0) { return { prompt: '', totalTokens: 0, truncated: false } } - const contextBudget = Math.floor(getContextLimit() * 0.6) const preamble = [ 'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.', 'The user has selected the following notes as context. Use them to answer questions accurately.', + 'You can use MCP tools to read the full content of any note.', 'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.', '', ].join('\n') const parts: string[] = [preamble] - let totalChars = preamble.length - let truncated = false for (const note of notes) { - const content = allContent[note.path] ?? '' - const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) ---` - const noteText = `${header}\n${content}\n` - - if (estimateTokens(totalChars + noteText.length) > contextBudget) { - const remaining = (contextBudget - estimateTokens(totalChars)) * 4 - if (remaining > 200) { - parts.push(`${header}\n${content.slice(0, remaining)}\n[... truncated ...]`) - } - truncated = true - break - } - - parts.push(noteText) - totalChars += noteText.length + const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) | Path: ${note.path} ---` + parts.push(header) } const prompt = parts.join('\n') - return { prompt, totalTokens: estimateTokens(prompt), truncated } + return { prompt, totalTokens: estimateTokens(prompt), truncated: false } } // --- Message types --- diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index ca26b81d..fc800e82 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -133,53 +133,25 @@ describe('collectLinkedEntries', () => { }) describe('buildContextualPrompt', () => { - it('includes active note title and content', () => { + it('includes active note title and type', () => { const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project' }) - const content = { '/vault/a.md': '# Alpha\nThis is the alpha project.' } - const prompt = buildContextualPrompt(active, [], content) + const prompt = buildContextualPrompt(active, []) expect(prompt).toContain('Alpha') expect(prompt).toContain('Project') - expect(prompt).toContain('This is the alpha project.') }) - it('includes linked note content', () => { + it('includes linked note titles', () => { const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' }) const linked = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }) - const content = { - '/vault/a.md': '# Alpha\nMain note.', - '/vault/b.md': '# Beta\nLinked person.', - } - const prompt = buildContextualPrompt(active, [linked], content) + const prompt = buildContextualPrompt(active, [linked]) expect(prompt).toContain('Beta') expect(prompt).toContain('Person') - expect(prompt).toContain('Linked person.') expect(prompt).toContain('Linked Notes') }) - it('shows (no content) when content is missing', () => { - const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' }) - const prompt = buildContextualPrompt(active, [], {}) - expect(prompt).toContain('(no content)') - }) - - it('truncates linked note content to 2000 chars', () => { - const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' }) - const linked = makeEntry({ path: '/vault/b.md', title: 'Beta' }) - const longContent = 'x'.repeat(3000) - const content = { - '/vault/a.md': '# Alpha', - '/vault/b.md': longContent, - } - const prompt = buildContextualPrompt(active, [linked], content) - // The linked note content should be truncated - const betaIdx = prompt.indexOf('### Beta') - const afterBeta = prompt.slice(betaIdx) - expect(afterBeta.length).toBeLessThan(2200) - }) - it('includes the system preamble', () => { const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' }) - const prompt = buildContextualPrompt(active, [], { '/vault/a.md': 'content' }) + const prompt = buildContextualPrompt(active, []) expect(prompt).toContain('AI assistant integrated into Laputa') }) }) @@ -191,14 +163,9 @@ describe('buildContextSnapshot', () => { makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }), makeEntry({ path: '/vault/c.md', title: 'Gamma', isA: 'Note' }), ] - const allContent: Record = { - '/vault/a.md': '# Alpha\nProject content.', - '/vault/b.md': '# Beta\nPerson content.', - '/vault/c.md': '# Gamma\nNote content.', - } it('includes activeNote with body and frontmatter', () => { - const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: active, entries, activeNoteContent: '---\ntitle: Alpha\n---\n# Alpha\nProject content.' }) expect(result).toContain('Alpha') expect(result).toContain('Project content.') expect(result).toContain('"type": "Project"') @@ -207,13 +174,13 @@ describe('buildContextSnapshot', () => { }) it('includes system preamble', () => { - const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: active, entries }) expect(result).toContain('AI assistant integrated into Laputa') expect(result).toContain('Context Snapshot') }) it('includes vault summary with types and totalNotes', () => { - const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: active, entries }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.vault.totalNotes).toBe(3) expect(json.vault.types).toContain('Project') @@ -224,7 +191,7 @@ describe('buildContextSnapshot', () => { it('includes openTabs excluding active note', () => { const tab = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }) const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, openTabs: [active, tab], }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) @@ -234,7 +201,7 @@ describe('buildContextSnapshot', () => { it('omits openTabs when none besides active', () => { const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, openTabs: [active], }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) @@ -243,7 +210,7 @@ describe('buildContextSnapshot', () => { it('includes noteListFilter when present', () => { const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, noteListFilter: { type: 'Project', query: 'search' }, }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) @@ -253,16 +220,16 @@ describe('buildContextSnapshot', () => { it('omits noteListFilter when empty', () => { const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, noteListFilter: { type: null, query: '' }, }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.noteListFilter).toBeUndefined() }) - it('includes referencedNotes with bodies', () => { + it('includes referencedNotes metadata', () => { const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, references: [ { title: 'Beta', path: '/vault/b.md', type: 'Person' }, { title: 'Gamma', path: '/vault/c.md', type: null }, @@ -271,25 +238,11 @@ describe('buildContextSnapshot', () => { const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.referencedNotes).toHaveLength(2) expect(json.referencedNotes[0].title).toBe('Beta') - expect(json.referencedNotes[0].body).toContain('Person content.') expect(json.referencedNotes[1].type).toBe('Note') // null fallback }) - it('filters out references with no content', () => { - const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, - references: [ - { title: 'Beta', path: '/vault/b.md', type: 'Person' }, - { title: 'Missing', path: '/vault/missing.md', type: null }, - ], - }) - const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) - expect(json.referencedNotes).toHaveLength(1) - expect(json.referencedNotes[0].title).toBe('Beta') - }) - it('omits referencedNotes when no references provided', () => { - const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: active, entries }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.referencedNotes).toBeUndefined() }) @@ -300,7 +253,7 @@ describe('buildContextSnapshot', () => { { path: '/vault/b.md', title: 'Beta', type: 'Person' }, ] const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, noteList, }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) @@ -314,7 +267,7 @@ describe('buildContextSnapshot', () => { path: `/vault/note-${i}.md`, title: `Note ${i}`, type: 'Note', })) const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, noteList, }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) @@ -324,7 +277,7 @@ describe('buildContextSnapshot', () => { it('omits noteList when empty', () => { const result = buildContextSnapshot({ - activeEntry: active, allContent, entries, + activeEntry: active, entries, noteList: [], }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) @@ -332,10 +285,8 @@ describe('buildContextSnapshot', () => { }) it('strips frontmatter from activeNoteContent before setting body', () => { - const emptyAllContent: Record = {} const result = buildContextSnapshot({ activeEntry: active, - allContent: emptyAllContent, entries, activeNoteContent: '---\ntitle: Alpha\n---\n\n# Alpha\nProject content from tab.', }) @@ -348,7 +299,6 @@ describe('buildContextSnapshot', () => { it('returns empty body when raw content is frontmatter-only (bug case)', () => { const result = buildContextSnapshot({ activeEntry: active, - allContent: {}, entries, activeNoteContent: '---\ntitle: Alpha\nis_a: Project\nstatus: active\n---\n', }) @@ -356,10 +306,9 @@ describe('buildContextSnapshot', () => { expect(json.activeNote.body).toBe('') }) - it('prefers activeNoteContent body over allContent when both present', () => { + it('uses activeNoteContent for body', () => { const result = buildContextSnapshot({ activeEntry: active, - allContent, entries, activeNoteContent: '---\ntitle: Alpha\n---\nFresh editor content', }) @@ -367,15 +316,15 @@ describe('buildContextSnapshot', () => { expect(json.activeNote.body).toBe('Fresh editor content') }) - it('falls back to allContent when activeNoteContent is undefined', () => { - const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + it('returns empty body when no activeNoteContent', () => { + const result = buildContextSnapshot({ activeEntry: active, entries }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) - expect(json.activeNote.body).toBe('# Alpha\nProject content.') + expect(json.activeNote.body).toBe('') }) it('includes wordCount in activeNote', () => { const entryWithWords = makeEntry({ path: '/vault/a.md', title: 'Alpha', wordCount: 206 }) - const result = buildContextSnapshot({ activeEntry: entryWithWords, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: entryWithWords, entries }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.activeNote.wordCount).toBe(206) }) @@ -383,7 +332,6 @@ describe('buildContextSnapshot', () => { it('handles content with no frontmatter (plain markdown)', () => { const result = buildContextSnapshot({ activeEntry: active, - allContent: {}, entries, activeNoteContent: '# Just a heading\n\nSome plain content.', }) @@ -391,29 +339,12 @@ describe('buildContextSnapshot', () => { expect(json.activeNote.body).toBe('# Just a heading\n\nSome plain content.') }) - it('falls back to allContent when activeNoteContent is empty string (|| fix)', () => { - // Regression: `??` does not fall through on empty string '', only on null/undefined. - // The `||` fix ensures empty string falls through to allContent. - const result = buildContextSnapshot({ - activeEntry: active, - allContent, - entries, - activeNoteContent: '', - }) - const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) - expect(json.activeNote.body).toContain('Project content.') - expect(json.activeNote.body).not.toBe('') - }) - it('includes defensive body when body is empty but wordCount > 0', () => { - // When body is empty (e.g. timing issue) but note has content on disk, - // body field should instruct Claude to use get_note const entryWithWords = makeEntry({ path: '/vault/a.md', title: 'Alpha', wordCount: 206, }) const result = buildContextSnapshot({ activeEntry: entryWithWords, - allContent: {}, entries, activeNoteContent: '---\ntitle: Alpha\n---\n', }) @@ -423,7 +354,7 @@ describe('buildContextSnapshot', () => { }) it('includes wikilink instruction in preamble', () => { - const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: active, entries }) expect(result).toContain('[[Note Title]]') expect(result).toContain('wikilink') }) @@ -435,7 +366,7 @@ describe('buildContextSnapshot', () => { relatedTo: ['[[Sibling]]'], relationships: { people: ['[[Alice]]'] }, }) - const result = buildContextSnapshot({ activeEntry: entryWithRels, allContent, entries }) + const result = buildContextSnapshot({ activeEntry: entryWithRels, entries }) const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) expect(json.activeNote.frontmatter.belongsTo).toEqual(['[[Parent]]']) expect(json.activeNote.frontmatter.relatedTo).toEqual(['[[Sibling]]']) diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index 832fffa0..a871b76b 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -78,7 +78,6 @@ export interface NoteListItem { /** Parameters for building the structured context snapshot. */ export interface ContextSnapshotParams { activeEntry: VaultEntry - allContent: Record /** Direct content of the active note from the editor tab (most reliable source). */ activeNoteContent?: string openTabs?: VaultEntry[] @@ -103,12 +102,9 @@ const MAX_NOTE_LIST_ITEMS = 100 /** Build a structured context snapshot as a system prompt for Claude. */ export function buildContextSnapshot(params: ContextSnapshotParams): string { - const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params + const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params - // Use `||` (not `??`) so empty string '' falls through to allContent. - // This handles the case where handleEditorChange temporarily overwrites - // tab.content with frontmatter-only content during async content swaps. - const rawContent = activeNoteContent || allContent[activeEntry.path] || '' + const rawContent = activeNoteContent || '' let body = extractBody(rawContent) // Defence-in-depth: when body is empty but the note has content on disk, @@ -161,14 +157,11 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string { } if (references && references.length > 0) { - snapshot.referencedNotes = references - .filter(ref => allContent[ref.path] !== undefined) - .map(ref => ({ - path: ref.path, - title: ref.title, - type: ref.type ?? 'Note', - body: extractBody(allContent[ref.path] ?? ''), - })) + snapshot.referencedNotes = references.map(ref => ({ + path: ref.path, + title: ref.title, + type: ref.type ?? 'Note', + })) } const preamble = [ @@ -186,7 +179,6 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string { export function buildContextualPrompt( active: VaultEntry, linkedEntries: VaultEntry[], - allContent: Record, ): string { const parts: string[] = [ 'You are an AI assistant integrated into Laputa, a personal knowledge management app.', @@ -195,18 +187,14 @@ export function buildContextualPrompt( '', `## Active Note: ${active.title}`, `Type: ${active.isA ?? 'Note'} | Path: ${active.path}`, - '', - allContent[active.path] ?? '(no content)', ] if (linkedEntries.length > 0) { parts.push('', '## Linked Notes') for (const entry of linkedEntries) { - const content = allContent[entry.path] parts.push( '', `### ${entry.title} (${entry.isA ?? 'Note'})`, - content ? content.slice(0, 2000) : '(no content loaded)', ) } } diff --git a/src/utils/autoSave.test.ts b/src/utils/autoSave.test.ts index ca7b8a71..209bcf15 100644 --- a/src/utils/autoSave.test.ts +++ b/src/utils/autoSave.test.ts @@ -22,7 +22,6 @@ describe('flushEditorContent', () => { savePendingForPath: vi.fn().mockResolvedValue(false), getTabContent: vi.fn().mockReturnValue(undefined), isUnsaved: vi.fn().mockReturnValue(false), - getSavedContent: vi.fn().mockReturnValue(undefined), onSaved: vi.fn(), } }) @@ -51,9 +50,9 @@ describe('flushEditorContent', () => { expect(deps.onSaved).toHaveBeenCalledWith('/vault/note.md', '# New note content') }) - it('saves tab content when it differs from saved content (dirty editor)', async () => { + it('saves tab content when note is marked unsaved (dirty editor)', async () => { ;(deps.getTabContent as ReturnType).mockReturnValue('edited body') - ;(deps.getSavedContent as ReturnType).mockReturnValue('original body') + ;(deps.isUnsaved as ReturnType).mockReturnValue(true) await flushEditorContent('/vault/note.md', deps) @@ -64,9 +63,9 @@ describe('flushEditorContent', () => { expect(deps.onSaved).toHaveBeenCalledWith('/vault/note.md', 'edited body') }) - it('does not save when tab content matches saved content (no changes)', async () => { + it('does not save when note is not unsaved (clean editor)', async () => { ;(deps.getTabContent as ReturnType).mockReturnValue('same content') - ;(deps.getSavedContent as ReturnType).mockReturnValue('same content') + ;(deps.isUnsaved as ReturnType).mockReturnValue(false) await flushEditorContent('/vault/note.md', deps) diff --git a/src/utils/autoSave.ts b/src/utils/autoSave.ts index 055be669..2e4e386c 100644 --- a/src/utils/autoSave.ts +++ b/src/utils/autoSave.ts @@ -4,7 +4,6 @@ export interface FlushDeps { savePendingForPath: (path: string) => Promise getTabContent: (path: string) => string | undefined isUnsaved: (path: string) => boolean - getSavedContent: (path: string) => string | undefined onSaved?: (path: string, content: string) => void } @@ -22,7 +21,7 @@ export async function flushEditorContent(path: string, deps: FlushDeps): Promise const tabContent = deps.getTabContent(path) if (tabContent === undefined) return - if (deps.isUnsaved(path) || tabContent !== deps.getSavedContent(path)) { + if (deps.isUnsaved(path)) { await persistContent(path, tabContent) deps.onSaved?.(path, tabContent) } diff --git a/src/utils/mergeTabContent.test.ts b/src/utils/mergeTabContent.test.ts deleted file mode 100644 index b0993434..00000000 --- a/src/utils/mergeTabContent.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { mergeTabContent } from './mergeTabContent' - -describe('mergeTabContent', () => { - it('adds tab content when allContent is empty', () => { - const result = mergeTabContent({}, [ - { entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' }, - ]) - expect(result['/vault/a.md']).toBe('# Hello\nBody text') - }) - - it('overrides allContent with tab content (editor state is fresher)', () => { - const result = mergeTabContent( - { '/vault/a.md': 'old saved content' }, - [{ entry: { path: '/vault/a.md' }, content: 'current editor content' }], - ) - expect(result['/vault/a.md']).toBe('current editor content') - }) - - it('preserves allContent for paths without open tabs', () => { - const result = mergeTabContent( - { '/vault/b.md': 'other note content' }, - [{ entry: { path: '/vault/a.md' }, content: '# Hello' }], - ) - expect(result['/vault/b.md']).toBe('other note content') - expect(result['/vault/a.md']).toBe('# Hello') - }) - - it('returns original object when no tabs have content to merge', () => { - const original = { '/vault/a.md': 'content' } - expect(mergeTabContent(original, [])).toBe(original) - }) - - it('skips tabs with empty content', () => { - const original = { '/vault/a.md': 'content' } - const result = mergeTabContent(original, [ - { entry: { path: '/vault/b.md' }, content: '' }, - ]) - expect(result).toBe(original) - expect(result['/vault/b.md']).toBeUndefined() - }) - - it('handles multiple tabs', () => { - const result = mergeTabContent({}, [ - { entry: { path: '/vault/a.md' }, content: 'Note A' }, - { entry: { path: '/vault/b.md' }, content: 'Note B' }, - ]) - expect(result['/vault/a.md']).toBe('Note A') - expect(result['/vault/b.md']).toBe('Note B') - }) - - it('does not mutate the original allContent object', () => { - const original = { '/vault/a.md': 'old' } - const result = mergeTabContent(original, [ - { entry: { path: '/vault/a.md' }, content: 'new' }, - ]) - expect(original['/vault/a.md']).toBe('old') - expect(result['/vault/a.md']).toBe('new') - expect(result).not.toBe(original) - }) -}) diff --git a/src/utils/mergeTabContent.ts b/src/utils/mergeTabContent.ts deleted file mode 100644 index 44580346..00000000 --- a/src/utils/mergeTabContent.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Merge open tab content into the allContent dictionary. - * Tab content takes priority (it reflects the current editor state). - * Returns the original object if no changes are needed (stable reference for useMemo). - */ -export function mergeTabContent( - allContent: Record, - tabs: ReadonlyArray<{ entry: { path: string }; content: string }>, -): Record { - let merged: Record | null = null - for (const tab of tabs) { - if (!tab.content) continue - if (allContent[tab.entry.path] === tab.content) continue - if (!merged) merged = { ...allContent } - merged[tab.entry.path] = tab.content - } - return merged ?? allContent -} diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index ea916d5f..ee2c07db 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -174,7 +174,7 @@ describe('buildRelationshipGroups', () => { path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', relationships: { 'Belongs to': ['[[responsibility/building]]'] }, }) - const groups = buildRelationshipGroups(entity, [entity, building], {}) + const groups = buildRelationshipGroups(entity, [entity, building]) const labels = groups.map((g) => g.label) expect(labels).toContain('Belongs to') expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building') @@ -190,7 +190,7 @@ describe('buildRelationshipGroups', () => { path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] }, }) - const groups = buildRelationshipGroups(entity, [entity, note1, note2], {}) + const groups = buildRelationshipGroups(entity, [entity, note1, note2]) const labels = groups.map((g) => g.label) expect(labels).toContain('Notes') expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2) @@ -215,7 +215,7 @@ describe('buildRelationshipGroups', () => { Topics: ['[[topic/rust]]'], }, }) - const groups = buildRelationshipGroups(entity, [entity, ...entries], {}) + const groups = buildRelationshipGroups(entity, [entity, ...entries]) const labels = groups.map((g) => g.label) expect(labels).toContain('Belongs to') expect(labels).toContain('Notes') @@ -230,7 +230,7 @@ describe('buildRelationshipGroups', () => { path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', relationships: {}, }) - const groups = buildRelationshipGroups(entity, [entity, child], {}) + const groups = buildRelationshipGroups(entity, [entity, child]) const labels = groups.map((g) => g.label) expect(labels).toContain('Children') expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child') @@ -241,14 +241,14 @@ describe('buildRelationshipGroups', () => { path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', relationships: { Type: ['[[type/project]]'] }, }) - const groups = buildRelationshipGroups(entity, [entity], {}) + const groups = buildRelationshipGroups(entity, [entity]) const labels = groups.map((g) => g.label) expect(labels).not.toContain('Type') }) it('returns empty groups for entity with no relationships', () => { const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} }) - const groups = buildRelationshipGroups(entity, [entity], {}) + const groups = buildRelationshipGroups(entity, [entity]) expect(groups).toHaveLength(0) }) @@ -263,7 +263,7 @@ describe('buildRelationshipGroups', () => { Notes: ['[[note/n1]]', '[[note/n2]]'], }, }) - const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {}) + const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2]) expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1) expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2) }) @@ -275,7 +275,7 @@ describe('buildRelationshipGroups', () => { path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type', relationships: {}, }) - const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {}) + const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2]) const labels = groups.map((g) => g.label) expect(labels).toContain('Instances') expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2) @@ -293,7 +293,7 @@ describe('buildRelationshipGroups', () => { Middle: ['[[note/b]]'], }, }) - const groups = buildRelationshipGroups(entity, [entity, a, b, c], {}) + const groups = buildRelationshipGroups(entity, [entity, a, b, c]) const directLabels = groups.map((g) => g.label) expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle')) expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra')) @@ -308,20 +308,20 @@ describe('buildRelationshipGroups', () => { path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', relationships: {}, }) - const groups = buildRelationshipGroups(entity, [entity, referer], {}) + const groups = buildRelationshipGroups(entity, [entity, referer]) expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer') }) - it('Backlinks shows entries that mention the entity via wikilinks in content', () => { + it('Backlinks shows entries that mention the entity via outgoingLinks', () => { const linker = makeEntry({ path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000, + outgoingLinks: ['Alpha'], }) const entity = makeEntry({ path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', relationships: {}, }) - const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' } - const groups = buildRelationshipGroups(entity, [entity, linker], allContent) + const groups = buildRelationshipGroups(entity, [entity, linker]) expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker') }) }) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 7adfc5af..2862279f 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -242,21 +242,16 @@ export function clearListSortFromLocalStorage(): void { } catch { /* ignore */ } } -function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record): VaultEntry[] { +function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[]): VaultEntry[] { const stem = entity.filename.replace(/\.md$/, '') const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - const targets = [entity.title, ...entity.aliases] + const targets = new Set([entity.title, ...entity.aliases, stem, pathStem]) 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}|`) + return e.outgoingLinks.some((link) => + targets.has(link) || targets.has(link.split('/').pop() ?? ''), + ) }) } @@ -292,7 +287,6 @@ class GroupBuilder { export function buildRelationshipGroups( entity: VaultEntry, allEntries: VaultEntry[], - allContent: Record, ): RelationshipGroup[] { const b = new GroupBuilder(entity.path, allEntries) const rels = entity.relationships ?? {} @@ -312,7 +306,7 @@ export function buildRelationshipGroups( 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.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity)) - b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified)) + b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified)) return b.groups }