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 }