Compare commits
3 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068d434344 | ||
|
|
6b2a1b0659 | ||
|
|
bf5f5521af |
@@ -464,7 +464,9 @@ where
|
||||
|
||||
ensure_collection(vault_path)?;
|
||||
|
||||
// Phase 1: update (scan files)
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
|
||||
// Phase 1: update (scan files) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "scanning".to_string(),
|
||||
current: 0,
|
||||
@@ -475,7 +477,7 @@ where
|
||||
|
||||
let update_output = qmd
|
||||
.command()
|
||||
.args(["update"])
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd update failed: {e}"))?;
|
||||
|
||||
@@ -504,7 +506,7 @@ where
|
||||
error: None,
|
||||
});
|
||||
|
||||
// Phase 2: embed (generate vectors)
|
||||
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "embedding".to_string(),
|
||||
current: 0,
|
||||
@@ -515,7 +517,7 @@ where
|
||||
|
||||
let embed_output = qmd
|
||||
.command()
|
||||
.args(["embed"])
|
||||
.args(["embed", "-c", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd embed failed: {e}"))?;
|
||||
|
||||
@@ -579,7 +581,7 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
|
||||
|
||||
let output = qmd
|
||||
.command()
|
||||
.args(["update"])
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { findEntryByTarget } from '../utils/wikilinkColors'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
@@ -169,10 +168,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
if (!entries) return
|
||||
const entry = findEntryByTarget(entries, target)
|
||||
if (entry) onOpenNote?.(entry.path)
|
||||
}, [entries, onOpenNote])
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'green')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found and applies customization', async () => {
|
||||
@@ -174,50 +175,52 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
describe('handleUpdateTypeTemplate', () => {
|
||||
it('updates template on the type entry', () => {
|
||||
it('updates template on the type entry', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets template to null when empty string', () => {
|
||||
it('sets template to null when empty string', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '')
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateTypeTemplate('Project', '')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'template', '## Template')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { template: '## Template' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderSections', () => {
|
||||
it('updates order on multiple type entries', () => {
|
||||
it('updates order on multiple type entries', async () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
const typeB = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeA, typeB])
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderSections([
|
||||
await act(async () => {
|
||||
await result.current.handleReorderSections([
|
||||
{ typeName: 'Note', order: 0 },
|
||||
{ typeName: 'Project', order: 1 },
|
||||
])
|
||||
@@ -227,23 +230,24 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'order', 1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/note.md', { order: 0 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { order: 1 })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips types that are not found', () => {
|
||||
it('auto-creates type entries when not found', async () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
const { result } = setup([typeA])
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderSections([
|
||||
await act(async () => {
|
||||
await result.current.handleReorderSections([
|
||||
{ typeName: 'Note', order: 0 },
|
||||
{ typeName: 'Missing', order: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
// Only Note's order was set; Missing was skipped
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(1)
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('Missing')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(2)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/missing.md', 'order', 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -258,6 +262,7 @@ describe('useEntryActions', () => {
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('trims whitespace before saving', async () => {
|
||||
@@ -285,15 +290,16 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', async () => {
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('NonExistent', 'Label')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'sidebar label', 'Label')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { sidebarLabel: 'Label' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -308,6 +314,7 @@ describe('useEntryActions', () => {
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets visible to true (deletes property) when currently hidden', async () => {
|
||||
@@ -320,6 +327,7 @@ describe('useEntryActions', () => {
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
|
||||
@@ -55,27 +55,30 @@ export function useEntryActions({
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
|
||||
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
|
||||
for (const { typeName, order } of orderedTypes) {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) continue
|
||||
handleUpdateFrontmatter(typeEntry.path, 'order', order)
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
|
||||
updateEntry(typeEntry.path, { order })
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
const trimmed = label.trim()
|
||||
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
|
||||
if (trimmed) {
|
||||
@@ -83,7 +86,8 @@ export function useEntryActions({
|
||||
} else {
|
||||
await handleDeleteProperty(typeEntry.path, 'sidebar label')
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
@@ -95,7 +99,8 @@ export function useEntryActions({
|
||||
updateEntry(typeEntry.path, { visible: false })
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('AI chat wikilink rendering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block vault API so mock entries are used (ensures "Build Laputa App" exists)
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
|
||||
// Select a note first so the AI panel has context
|
||||
// Select a note so the AI panel has context
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
@@ -17,14 +18,17 @@ test.describe('AI chat wikilink rendering', () => {
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Send a message to get a mock response containing wikilinks
|
||||
// Send a message to trigger mock response with [[Build Laputa App]] and [[Matteo Cellini]]
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
await input.fill('Tell me about this note')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
// Wait for mock response (contains [[Build Laputa App]] and [[Matteo Cellini]])
|
||||
// Wait for wikilinks to render
|
||||
await expect(page.locator('.chat-wikilink').first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
|
||||
const wikilink = page.locator('.chat-wikilink').first()
|
||||
await expect(wikilink).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify wikilink text and attributes
|
||||
await expect(wikilink).toHaveText('Build Laputa App')
|
||||
@@ -36,11 +40,23 @@ test.describe('AI chat wikilink rendering', () => {
|
||||
await expect(secondWikilink).toHaveText('Matteo Cellini')
|
||||
|
||||
// Verify multiple wikilinks rendered
|
||||
const allWikilinks = page.locator('.chat-wikilink')
|
||||
await expect(allWikilinks).toHaveCount(2)
|
||||
await expect(page.locator('.chat-wikilink')).toHaveCount(2)
|
||||
|
||||
// Verify wikilink has pointer cursor (is styled as clickable)
|
||||
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
|
||||
expect(cursor).toBe('pointer')
|
||||
})
|
||||
|
||||
test('clicking a wikilink opens the note in a tab', async ({ page }) => {
|
||||
const wikilink = page.locator('.chat-wikilink').first()
|
||||
await expect(wikilink).toHaveText('Build Laputa App')
|
||||
|
||||
// Click the wikilink
|
||||
await wikilink.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify the note opened in a tab — the editor breadcrumb shows the active note title
|
||||
const breadcrumb = page.locator('.app__editor span.truncate.font-medium', { hasText: 'Build Laputa App' })
|
||||
await expect(breadcrumb).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user