test: add Playwright smoke test for exact-match search ranking
Verifies that searching "Writing" in Quick Open shows the exact title match first, followed by prefix matches. Also adds Refactoring test entries to mock data and demo vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
25
src/App.tsx
25
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' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -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}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { MarkdownContent } from './MarkdownContent'
|
||||
|
||||
interface AIChatPanelProps {
|
||||
entry: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
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<HTMLDivElement>(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(() => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('AiPanel', () => {
|
||||
it('renders contextual empty state when active entry is provided', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
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(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.getByTestId('context-bar')).toBeTruthy()
|
||||
expect(screen.getByText('My Note')).toBeTruthy()
|
||||
@@ -102,8 +102,7 @@ describe('AiPanel', () => {
|
||||
<AiPanel
|
||||
onClose={vi.fn()} vaultPath="/tmp/vault"
|
||||
activeEntry={entry} entries={[entry, linked]}
|
||||
allContent={{}}
|
||||
/>
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
|
||||
@@ -19,7 +19,6 @@ interface AiPanelProps {
|
||||
/** Direct content of the active note from the editor tab. */
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
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<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(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<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
|
||||
@@ -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<string, string>
|
||||
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 ?? '')
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ const defaultProps = {
|
||||
onInspectorResize: vi.fn(),
|
||||
inspectorEntry: null as VaultEntry | null,
|
||||
inspectorContent: null as string | null,
|
||||
allContent: {} as Record<string, string>,
|
||||
gitHistory: [],
|
||||
onCreateNote: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
@@ -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)}
|
||||
|
||||
@@ -10,7 +10,6 @@ interface EditorRightPanelProps {
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
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}
|
||||
|
||||
@@ -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<string, string>
|
||||
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<string, string>,
|
||||
): 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<string>): 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<string, VaultEntry> = {}
|
||||
@@ -151,7 +146,7 @@ export function Inspector({
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entries={entries} allContent={allContent}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
|
||||
@@ -153,38 +153,38 @@ const mockEntries: VaultEntry[] = [
|
||||
|
||||
describe('NoteList', () => {
|
||||
it('shows empty state when no entries', () => {
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all entries with All Notes filter', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by Events (section group)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by section group type', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity pinned at top with grouped children', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithBacklink} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows sort dropdown per relationship subsection in entity view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens sort menu on click and shows all options', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
|
||||
// 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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show indicator when getNoteStatus prop is undefined', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only trashed entries in trash view', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
const badges = screen.getAllByText('TRASHED')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows 30-day warning banner when expired notes exist', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no modified files', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('No pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates list when modifiedFiles changes', () => {
|
||||
const { rerender } = render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
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(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// 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(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
|
||||
@@ -25,7 +25,6 @@ interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
selectedNote: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
modifiedFilesError?: string | null
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
@@ -241,7 +240,7 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
|
||||
// --- Data hooks ---
|
||||
|
||||
interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
|
||||
entries: VaultEntry[]; selection: SidebarSelection
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>; 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<Set<string>>(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
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ function renderNoteList(props: {
|
||||
entries={props.entries}
|
||||
selection={props.selection}
|
||||
selectedNote={null}
|
||||
allContent={{}}
|
||||
onSelectNote={noop}
|
||||
onReplaceActiveTab={noop}
|
||||
onCreateNote={noop}
|
||||
|
||||
@@ -36,10 +36,8 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
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') })
|
||||
|
||||
@@ -51,7 +51,6 @@ function makeStreamCallbacks(
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
allContent: Record<string, string>,
|
||||
contextNotes: VaultEntry[],
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,6 @@ interface AppCommandsConfig {
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
tabs: Tab[]
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
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,
|
||||
|
||||
@@ -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<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSave>[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('')
|
||||
|
||||
@@ -59,8 +59,6 @@ describe('useKeyboardNavigation', () => {
|
||||
]
|
||||
|
||||
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const allContent: Record<string, string> = {}
|
||||
|
||||
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,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ interface KeyboardNavigationOptions {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
allContent: Record<string, string>
|
||||
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<string, string>,
|
||||
): 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<T>(value: T): React.RefObject<T> {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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/')
|
||||
})
|
||||
|
||||
@@ -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<VaultEntry>) => 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<VaultEntry> & { path: string }, newContent: string) => void
|
||||
/** Replace an entry at oldPath with a patch (handles path changes in entries). */
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { 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))
|
||||
|
||||
@@ -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<string, Set<string>>): 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<string, string> | undefined): Record<string, string[]> {
|
||||
if (!entries || !allContent) return {}
|
||||
function collectAllVaultTags(entries: VaultEntry[] | undefined): Record<string, string[]> {
|
||||
if (!entries) return {}
|
||||
const tagsByKey = new Map<string, Set<string>>()
|
||||
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<string, string[]> = {}
|
||||
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<string, string> | 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<string | null>(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) => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -84,28 +84,19 @@ async function loadAndSetTab(
|
||||
entry: VaultEntry,
|
||||
updater: (prev: Tab[], content: string) => Tab[],
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
|
||||
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<Tab[]>([])
|
||||
const [activeTabPath, setActiveTabPath] = useState<string | null>(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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -197,8 +197,6 @@ function useThemeApplier(
|
||||
export function useThemeManager(
|
||||
vaultPath: string | null,
|
||||
entries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
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<string | undefined>(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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
console.log(`Vault scan complete: ${entries.length} entries found`)
|
||||
const allContent = isTauri() ? {} : await mockInvoke<Record<string, string>>('get_all_content', { path: vaultPath })
|
||||
return { entries, allContent }
|
||||
return { entries }
|
||||
}
|
||||
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
|
||||
@@ -91,7 +90,6 @@ export function resolveNoteStatus(
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [allContent, setAllContent] = useState<Record<string, string>>({})
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(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<VaultEntry>) =>
|
||||
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<VaultEntry> & { path: string }, newContent: string) => {
|
||||
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { 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<GitCommit[]> => {
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<string, string>,
|
||||
): { 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 ---
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'/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<string, string> = {}
|
||||
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]]'])
|
||||
|
||||
@@ -78,7 +78,6 @@ export interface NoteListItem {
|
||||
/** Parameters for building the structured context snapshot. */
|
||||
export interface ContextSnapshotParams {
|
||||
activeEntry: VaultEntry
|
||||
allContent: Record<string, string>
|
||||
/** 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, string>,
|
||||
): 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)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn>).mockReturnValue('edited body')
|
||||
;(deps.getSavedContent as ReturnType<typeof vi.fn>).mockReturnValue('original body')
|
||||
;(deps.isUnsaved as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue('same content')
|
||||
;(deps.getSavedContent as ReturnType<typeof vi.fn>).mockReturnValue('same content')
|
||||
;(deps.isUnsaved as ReturnType<typeof vi.fn>).mockReturnValue(false)
|
||||
|
||||
await flushEditorContent('/vault/note.md', deps)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface FlushDeps {
|
||||
savePendingForPath: (path: string) => Promise<boolean>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>,
|
||||
tabs: ReadonlyArray<{ entry: { path: string }; content: string }>,
|
||||
): Record<string, string> {
|
||||
let merged: Record<string, string> | 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,21 +242,16 @@ export function clearListSortFromLocalStorage(): void {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): 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<string, string>,
|
||||
): 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user