diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json
index 3d496232..ea6e2555 100644
--- a/.contentrain/content/system/ui-strings/en.json
+++ b/.contentrain/content/system/ui-strings/en.json
@@ -273,8 +273,10 @@
"content.edit_content": "Edit content",
"content.edit_entry": "Edit entry",
"content.entry_count": "{count} entry(s)",
+ "content.entry_count_filtered": "Showing {shown} of {matched} matches in {total} entries",
+ "content.entry_count_partial": "Showing {shown} of {total}",
"content.field_required": "This field is required",
- "content.filter_keys": "Filter keys...",
+ "content.filter_keys": "Filter keys or values...",
"content.keep_editing": "Keep editing",
"content.key_column": "Key",
"content.keys_count": "keys",
@@ -302,7 +304,13 @@
"content.resize_panel": "Resize content panel",
"content.save_all": "Save changes",
"content.save_error": "Failed to save content. Please try again.",
+ "content.search_entries": "Search entries...",
+ "content.search_entries_group": "Entries",
+ "content.search_unavailable_description": "The content index is still loading. Try again in a moment.",
+ "content.search_unavailable_title": "Search is not ready yet",
+ "content.searching_description": "Looking through every entry in this model.",
"content.select_entry": "Select an entry",
+ "content.show_more": "Show more",
"content.slug_hint": "Lowercase letters, numbers, and hyphens only",
"content.stat_entries": "Entries",
"content.stat_locales": "Locales",
diff --git a/app/app.vue b/app/app.vue
index 6a6043e9..1b395f8a 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -30,8 +30,12 @@ useSeoMeta({
-
-
-
+
+
+
+
+
+
diff --git a/app/components/atoms/Tooltip.vue b/app/components/atoms/Tooltip.vue
index 497b51a3..af344f74 100644
--- a/app/components/atoms/Tooltip.vue
+++ b/app/components/atoms/Tooltip.vue
@@ -1,17 +1,11 @@
-
+ ('open', { default: undefined })
-
+
diff --git a/app/components/atoms/TooltipScope.vue b/app/components/atoms/TooltipScope.vue
new file mode 100644
index 00000000..0c6ff52d
--- /dev/null
+++ b/app/components/atoms/TooltipScope.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/app/components/organisms/CommandPalette.vue b/app/components/organisms/CommandPalette.vue
index 4d61390d..4945fe01 100644
--- a/app/components/organisms/CommandPalette.vue
+++ b/app/components/organisms/CommandPalette.vue
@@ -69,8 +69,80 @@ const modeBadge = computed((): { label: string, color: 'primary' | 'secondary' |
const isInProject = computed(() => !!route.params.projectId)
+// ── Entry search ───────────────────────────────────────────
+// The palette's own help promised "search models, entries, vocabulary" and
+// "@modelId to search within a model", and neither searched an entry: nothing
+// in the app called `searchContent`. It does now.
+//
+// `buildResults` is synchronous and `searchContent` is not, so entry hits are
+// filled into their own ref and appended, rather than forced into the computed.
+const brain = useContentBrain()
+
+interface EntryHit { modelId: string, entryId: string, locale: string, title: string }
+const entryHits = ref([])
+
+let entryToken = 0
+let entryTimer: ReturnType | null = null
+
+async function resolveEntryHits(query: string, modelId?: string) {
+ const token = ++entryToken
+ const results = await brain.searchContent(query, { modelId, limit: 8 })
+ if (token !== entryToken) return
+
+ const hits: EntryHit[] = []
+ for (const r of results) {
+ // The index stores no title — `SearchResult` is ids and a score — so the
+ // entry is read back and titled the same way the list titles it. Without
+ // this the palette would list `f3a81c09d24e`.
+ const model = brain.models.value.find(m => m.id === r.modelId) ?? null
+ const content = await brain.queryContent(r.modelId, r.locale)
+ const data = content?.data as Record> | Array> | null
+ let entry: Record | undefined
+ if (Array.isArray(data)) entry = data.find(d => d.slug === r.entryId)
+ else if (data) entry = data[r.entryId]
+
+ hits.push({
+ modelId: r.modelId,
+ entryId: r.entryId,
+ locale: r.locale,
+ title: resolveEntryTitle(entry, model, r.entryId),
+ })
+ }
+
+ if (token !== entryToken) return
+ entryHits.value = hits
+}
+
+watch(parsed, ({ mode, query, modelId }) => {
+ if (entryTimer) clearTimeout(entryTimer)
+
+ const trimmed = query.trim()
+ const wantsEntries = isInProject.value && trimmed.length > 1 && (mode === 'global' || mode === 'model')
+ if (!wantsEntries) {
+ entryToken++
+ entryHits.value = []
+ return
+ }
+
+ entryTimer = setTimeout(() => resolveEntryHits(trimmed, mode === 'model' ? modelId : undefined), 150)
+})
+
+onBeforeUnmount(() => {
+ if (entryTimer) clearTimeout(entryTimer)
+})
+
+const entryResults = computed(() => entryHits.value.map(hit => ({
+ id: `entry:${hit.modelId}:${hit.entryId}`,
+ label: hit.title,
+ sublabel: hit.modelId,
+ icon: 'icon-[annon--file-text]',
+ group: t('content.search_entries_group'),
+ type: 'entry',
+ action: () => navigateToEntry(hit),
+})))
+
// Build results via composable
-const results = computed(() => {
+const baseResults = computed(() => {
const { mode, query, modelId } = parsed.value
return buildResults({
mode,
@@ -96,10 +168,17 @@ const results = computed(() => {
})
})
+// Entries go after the synchronous items so keyboard navigation starts where it
+// always did — the top result does not jump when an async search lands.
+const results = computed(() => [...baseResults.value, ...entryResults.value])
+
const groupedResults = computed(() => groupResults(results.value))
const hasResults = computed(() => results.value.length > 0)
-watch(results, () => {
+// Reset the cursor when the QUERY changes, not when results do: entry hits
+// arrive a beat later, and resetting on those would yank the selection out from
+// under someone already arrowing through the list.
+watch(baseResults, () => {
selectedIndex.value = 0
})
@@ -198,6 +277,17 @@ function navigateToModel(modelId: string, modelName: string) {
open.value = false
}
+/**
+ * Open the model AND point at the entry. Opening the model alone would hand
+ * someone a thousand rows and let them find it themselves, which is what they
+ * used the search for.
+ */
+function navigateToEntry(hit: EntryHit) {
+ addRecent({ id: `entry:${hit.modelId}:${hit.entryId}`, label: hit.title, sublabel: hit.modelId, icon: 'icon-[annon--file-text]', type: 'entry' })
+ router.replace({ query: { model: hit.modelId, entry: hit.entryId } })
+ open.value = false
+}
+
function navigateToBranch(branchName: string) {
addRecent({ id: `branch:${branchName}`, label: branchName.replace('contentrain/', ''), icon: 'icon-[annon--arrow-swap]', type: 'branch' })
router.replace({ query: { branch: encodeURIComponent(branchName) } })
diff --git a/app/components/organisms/ContentCollectionView.vue b/app/components/organisms/ContentCollectionView.vue
index 4ace0c6c..ab865ce5 100644
--- a/app/components/organisms/ContentCollectionView.vue
+++ b/app/components/organisms/ContentCollectionView.vue
@@ -41,6 +41,112 @@ function getEntryUpdatedAt(entryId: string, metaData: Record |
return d.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
+// ── Search + progressive rendering ─────────────────────────
+// The list used to render every entry: one ``, one stateful Radix
+// dropdown and three action buttons each. At 1000 articles that is the whole
+// cost of opening a model — not the data, the components. Rendering a page at a
+// time fixes it without touching the row, and without a virtualiser (a new
+// dependency, and rows change height when they expand).
+const PAGE_SIZE = 50
+
+const brain = useContentBrain()
+
+const searchQuery = ref('')
+const searchIds = ref(null)
+const searching = ref(false)
+const visibleCount = ref(PAGE_SIZE)
+
+const allIds = computed(() => Object.keys(props.content))
+
+/**
+ * Search runs against the brain's index, not the rendered rows — otherwise it
+ * would only ever find what is already on screen, which is the opposite of what
+ * it is for. The index holds every entry, so a match on page twenty is found
+ * without paging there.
+ */
+const matchedIds = computed(() => {
+ if (!searchQuery.value.trim()) return allIds.value
+ if (!searchIds.value) return []
+ // Intersect rather than trust the index: it is rebuilt per sync, so it can
+ // briefly name an entry this locale's payload no longer has.
+ const present = new Set(allIds.value)
+ return searchIds.value.filter(id => present.has(id))
+})
+
+/**
+ * An entry named by `?entry=` — the palette's search result — is pulled to the
+ * front and opened. Otherwise "go to this entry" would drop someone at the top
+ * of a thousand rows to find it themselves, which is what they searched to
+ * avoid. Paging cannot hide it either: it is prepended, not paged to.
+ */
+const route = useRoute()
+const targetEntryId = computed(() => {
+ const id = route.query.entry
+ return typeof id === 'string' && id in props.content ? id : null
+})
+
+const visibleEntries = computed(() => {
+ const target = targetEntryId.value
+ const ordered = target
+ ? [target, ...matchedIds.value.filter(id => id !== target)]
+ : matchedIds.value
+
+ return ordered
+ .slice(0, visibleCount.value)
+ .map(id => ({ id, entry: props.content[id] as Record }))
+})
+const hasMore = computed(() => matchedIds.value.length > visibleCount.value)
+
+// Distinguishable from "nothing matched": the worker resolves an empty array
+// when it is not there, and reporting that as no results is a lie.
+const searchUnavailable = computed(() => !!searchQuery.value.trim() && !brain.searchReady.value)
+
+// Hand-rolled rather than pulling in a utility library for one debounce. The
+// token guards against a slow early query landing after a faster later one and
+// overwriting it — the classic way a search box shows the wrong results.
+let searchToken = 0
+let searchTimer: ReturnType | null = null
+
+watch(searchQuery, (query) => {
+ if (searchTimer) clearTimeout(searchTimer)
+ visibleCount.value = PAGE_SIZE
+
+ const trimmed = query.trim()
+ if (!trimmed) {
+ searchToken++
+ searchIds.value = null
+ searching.value = false
+ return
+ }
+
+ searching.value = true
+ searchTimer = setTimeout(async () => {
+ const token = ++searchToken
+ // A high limit on purpose: the palette wants a shortlist, a filtered list
+ // wants every match. `hasMore` still keeps the render bounded.
+ const results = await brain.searchContent(trimmed, {
+ modelId: props.modelId,
+ locale: props.locale,
+ limit: 1000,
+ })
+ if (token !== searchToken) return
+ searchIds.value = results.map(r => r.entryId)
+ searching.value = false
+ }, 200)
+})
+
+onBeforeUnmount(() => {
+ if (searchTimer) clearTimeout(searchTimer)
+})
+
+// A different model or locale is a different list; the old query and page
+// position mean nothing there.
+watch(() => [props.modelId, props.locale], () => {
+ searchQuery.value = ''
+ searchIds.value = null
+ visibleCount.value = PAGE_SIZE
+})
+
const getFieldType = inject(getFieldTypeKey, () => 'string')
const getEntryTitle = inject(getEntryTitleKey, (_e: Record, f: string) => f)
const getUserFieldIds = inject(getUserFieldIdsKey, () => [])
@@ -146,10 +252,24 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value:
diff --git a/app/composables/useContentBrain.ts b/app/composables/useContentBrain.ts
index 93de41cf..cad9d80a 100644
--- a/app/composables/useContentBrain.ts
+++ b/app/composables/useContentBrain.ts
@@ -72,6 +72,9 @@ export function useContentBrain() {
const treeSha = useState('brain-tree-sha', () => null)
const syncing = useState('brain-syncing', () => false)
const ready = useState('brain-ready', () => false)
+ // `sharedWorker` is a module-scope `let`, so a component cannot react to it.
+ // Search needs to know, hence the mirror.
+ const workerAvailable = useState('brain-worker-available', () => false)
const syncError = useState('brain-sync-error', () => null)
const config = useState('brain-config', () => null)
const models = useState('brain-models', () => [])
@@ -103,11 +106,13 @@ export function useContentBrain() {
// eslint-disable-next-line no-console
console.log('[brain] Worker created successfully, sending init for project:', projectId)
sharedWorker.postMessage({ type: 'init', projectId })
+ workerAvailable.value = true
}
catch (e) {
// eslint-disable-next-line no-console
console.warn('[brain] Worker creation failed, using in-memory only mode:', e)
sharedWorker = null
+ workerAvailable.value = false
}
}
@@ -117,6 +122,7 @@ export function useContentBrain() {
sharedWorker.terminate()
sharedWorker = null
}
+ workerAvailable.value = false
sharedProjectId = null
ready.value = false
treeSha.value = null
@@ -288,16 +294,27 @@ export function useContentBrain() {
return { data: null, kind: 'collection', meta: null }
}
- async function searchContent(query: string, modelId?: string, limit?: number): Promise {
+ /**
+ * Full-text search over the brain's index.
+ *
+ * `locale` matters: the index is keyed per locale, so a search that omits it
+ * returns English hits to someone reading the Turkish list.
+ */
+ async function searchContent(
+ query: string,
+ options: { modelId?: string, locale?: string, limit?: number } = {},
+ ): Promise {
if (!sharedWorker) return []
+ const { modelId, locale, limit } = options
+
return new Promise((resolve) => {
const id = `search-${++requestCounter}`
pendingRequests.set(id, {
resolve: data => resolve((data ?? []) as SearchResult[]),
reject: () => resolve([]),
})
- sharedWorker!.postMessage({ type: 'search', id, query, modelId, limit: limit ?? 10 })
+ sharedWorker!.postMessage({ type: 'search', id, query, modelId, locale, limit: limit ?? 10 })
setTimeout(() => {
if (pendingRequests.has(id)) {
@@ -312,6 +329,15 @@ export function useContentBrain() {
const modelList = computed(() => models.value)
const hasContentrain = computed(() => config.value !== null)
+
+ /**
+ * Whether a search can currently answer.
+ *
+ * `searchContent` resolves to `[]` when there is no worker, which is
+ * indistinguishable from "nothing matched" — and telling someone their query
+ * found nothing when nothing was searched is worse than saying so.
+ */
+ const searchReady = computed(() => workerAvailable.value && ready.value)
const projectStats = computed(() => {
const ctx = contentContext.value as { stats?: { models?: number, entries?: number, locales?: string[] } } | null
if (!config.value) return null
@@ -328,6 +354,7 @@ export function useContentBrain() {
treeSha: readonly(treeSha),
syncing: readonly(syncing),
ready: readonly(ready),
+ searchReady,
syncError: readonly(syncError),
config: readonly(config),
models: readonly(models),
diff --git a/app/utils/injection-keys.ts b/app/utils/injection-keys.ts
index 5180d681..d40976ab 100644
--- a/app/utils/injection-keys.ts
+++ b/app/utils/injection-keys.ts
@@ -11,3 +11,12 @@ export const getUserFieldIdsKey: InjectionKey<() => string[]> = Symbol('getUserF
export const activeModelMetaKey: InjectionKey> = Symbol('activeModelMeta')
export const getModelFieldsKey: InjectionKey<() => Record> = Symbol('getModelFields')
export const sendChatPromptKey: InjectionKey<(text: string) => void> = Symbol('sendChatPrompt')
+
+/**
+ * Set by the app root to say a Radix `TooltipProvider` is already in the tree.
+ *
+ * Radix does not export its own provider-context inject, so `AtomsTooltip`
+ * cannot ask it directly — and it must know, because `TooltipRoot` throws
+ * without a provider while a nested one would defeat the point of hoisting.
+ */
+export const tooltipProviderKey: InjectionKey = Symbol('tooltipProvider')
diff --git a/app/utils/search-results.ts b/app/utils/search-results.ts
new file mode 100644
index 00000000..fd9b02b4
--- /dev/null
+++ b/app/utils/search-results.ts
@@ -0,0 +1,78 @@
+/**
+ * Turning raw index hits into the results a caller asked for.
+ *
+ * Split out of the brain worker because the interesting part is not FlexSearch,
+ * it is the order of operations: the filters have to be applied *before* the
+ * limit, and the worker used to do it the other way round.
+ */
+
+export interface IndexedDoc {
+ modelId: string
+ entryId: string
+ locale: string
+}
+
+export interface SearchHit extends IndexedDoc {
+ score: number
+}
+
+export interface SearchFilters {
+ modelId?: string
+ locale?: string
+ limit: number
+}
+
+/**
+ * How deep to read the index when a search is scoped.
+ *
+ * Sized for the case that motivated search at all — a model with ~1000 entries
+ * — so a scoped search sees its own matches rather than only the ones that
+ * placed globally.
+ */
+export const SEARCH_FILTER_FETCH_CAP = 1000
+
+/**
+ * How many hits to ask the index for.
+ *
+ * Asking for exactly what the caller wants and then filtering drops matches: a
+ * search scoped to one model competes for those slots against every other
+ * model, and a search in `tr` against every other locale. So over-fetch when a
+ * filter is in play. The index is in memory; the extra reads are cheap next to
+ * being wrong.
+ */
+export function indexFetchLimit(filters: SearchFilters): number {
+ const scoped = Boolean(filters.modelId || filters.locale)
+ return scoped ? Math.max(filters.limit, SEARCH_FILTER_FETCH_CAP) : filters.limit
+}
+
+/**
+ * Apply the filters, drop duplicates, then cut to size.
+ *
+ * FlexSearch returns one result set per indexed field, so the same document can
+ * appear more than once; without dedup a single entry eats several slots of the
+ * caller's limit.
+ */
+export function collectSearchHits(
+ docIds: Iterable,
+ lookup: (id: string) => IndexedDoc | null | undefined,
+ filters: SearchFilters,
+): SearchHit[] {
+ const hits: SearchHit[] = []
+ const seen = new Set()
+
+ for (const rawId of docIds) {
+ const id = String(rawId)
+ if (seen.has(id)) continue
+ seen.add(id)
+
+ const doc = lookup(id)
+ if (!doc) continue
+ if (filters.modelId && doc.modelId !== filters.modelId) continue
+ if (filters.locale && doc.locale !== filters.locale) continue
+
+ hits.push({ modelId: doc.modelId, entryId: doc.entryId, locale: doc.locale, score: 1 })
+ if (hits.length >= filters.limit) break
+ }
+
+ return hits
+}
diff --git a/app/utils/tooltip-timing.ts b/app/utils/tooltip-timing.ts
new file mode 100644
index 00000000..b07f1497
--- /dev/null
+++ b/app/utils/tooltip-timing.ts
@@ -0,0 +1,20 @@
+/**
+ * How long a tooltip waits.
+ *
+ * One definition, because there used to be three: `InfoTooltip` waited 200ms,
+ * `ContentStatsBar` waited 300ms, and Radix's own default is 700ms. Which delay
+ * you got depended on which component you happened to hover.
+ */
+
+/** First hover. Long enough not to fire while the pointer is passing through. */
+export const TOOLTIP_DELAY_MS = 200
+
+/**
+ * How long after closing one tooltip the next opens with no delay at all.
+ *
+ * This is the reason the provider is hoisted to the app root: Radix only skips
+ * the delay within a single provider, so with one provider per tooltip it never
+ * applied. Scanning the three action icons on a row — the thing tooltips were
+ * added for — used to cost the full delay three times.
+ */
+export const TOOLTIP_SKIP_DELAY_MS = 400
diff --git a/app/workers/content-brain.worker.ts b/app/workers/content-brain.worker.ts
index 42912f6d..8f171a47 100644
--- a/app/workers/content-brain.worker.ts
+++ b/app/workers/content-brain.worker.ts
@@ -8,6 +8,8 @@
import { createStore, del, get, keys, set } from 'idb-keyval'
import FlexSearch from 'flexsearch'
+// A worker has no Nuxt auto-imports, so this is explicit.
+import { collectSearchHits, indexFetchLimit } from '../utils/search-results'
// Custom IDB stores in 'cr-brain' database
const metaStore = createStore('cr-brain', 'brain-meta')
@@ -127,24 +129,19 @@ self.onmessage = async (event: MessageEvent) => {
}
case 'search': {
- const { id, query, modelId: searchModelId, limit } = msg
- const results: Array<{ modelId: string, entryId: string, locale: string, score: number }> = []
+ const { id, query, modelId: searchModelId, locale: searchLocale, limit } = msg
+ const filters = { modelId: searchModelId, locale: searchLocale, limit: limit ?? 10 }
+ let results: Array<{ modelId: string, entryId: string, locale: string, score: number }> = []
if (searchIndex) {
- const flexResults = searchIndex.search(query, { limit: limit ?? 10 })
- for (const field of flexResults) {
- for (const resultId of field.result) {
- const doc = searchIndex.get(resultId as unknown as string)
- if (doc && (!searchModelId || doc.modelId === searchModelId)) {
- results.push({
- modelId: doc.modelId,
- entryId: doc.entryId,
- locale: doc.locale,
- score: 1,
- })
- }
- }
- }
+ const flexResults = searchIndex.search(query, { limit: indexFetchLimit(filters) })
+ // One result set per indexed field, flattened in rank order.
+ const docIds = flexResults.flatMap((field: { result: unknown[] }) => field.result.map(String))
+ results = collectSearchHits(
+ docIds,
+ (docId: string) => searchIndex.get(docId),
+ filters,
+ )
}
self.postMessage({ type: 'searchResult', id, results })
diff --git a/tests/nuxt/components/content-collection-view.nuxt.test.ts b/tests/nuxt/components/content-collection-view.nuxt.test.ts
new file mode 100644
index 00000000..d5bd86af
--- /dev/null
+++ b/tests/nuxt/components/content-collection-view.nuxt.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
+import ContentCollectionView from '../../../app/components/organisms/ContentCollectionView.vue'
+
+const searchContent = vi.hoisted(() => vi.fn())
+const routeQuery = vi.hoisted(() => ({ value: {} as Record }))
+const searchReady = vi.hoisted(() => ({ value: true }))
+
+mockNuxtImport('useContentBrain', () => () => ({
+ searchContent,
+ searchReady: computed(() => searchReady.value),
+ models: computed(() => []),
+ queryContent: vi.fn(),
+}))
+
+mockNuxtImport('useRoute', () => () => ({ query: routeQuery.value, params: {} }))
+
+function makeContent(count: number) {
+ const out: Record> = {}
+ for (let i = 0; i < count; i++) out[`entry-${i}`] = { title: `Entry ${i}` }
+ return out
+}
+
+async function mount(count: number) {
+ return mountSuspended(ContentCollectionView, {
+ props: { content: makeContent(count), modelId: 'articles', locale: 'en', editable: true },
+ })
+}
+
+describe('ContentCollectionView progressive rendering', () => {
+ it('renders one page instead of every entry', async () => {
+ // Each row is a `` plus a stateful Radix dropdown and three
+ // buttons; at 1000 entries that is the whole cost of opening a model.
+ const wrapper = await mount(1000)
+
+ expect(wrapper.findAll('details')).toHaveLength(50)
+ })
+
+ it('reveals another page on demand, and says where you are', async () => {
+ const wrapper = await mount(120)
+ expect(wrapper.text()).toContain('Showing 50 of 120')
+
+ await wrapper.findAll('button').find(b => b.text().includes('Show more'))!.trigger('click')
+
+ expect(wrapper.findAll('details')).toHaveLength(100)
+ expect(wrapper.text()).toContain('Showing 100 of 120')
+ })
+
+ it('drops the counter qualifier once everything is on screen', async () => {
+ const wrapper = await mount(10)
+
+ expect(wrapper.findAll('details')).toHaveLength(10)
+ expect(wrapper.text()).toContain('10 entry(s)')
+ expect(wrapper.findAll('button').find(b => b.text().includes('Show more'))).toBeUndefined()
+ })
+})
+
+describe('ContentCollectionView search', () => {
+ it('narrows to what the index returned, not to what was rendered', async () => {
+ // The point of searching the index: a match on page twenty is found without
+ // paging there.
+ searchContent.mockResolvedValue([{ modelId: 'articles', entryId: 'entry-900', locale: 'en', score: 1 }])
+ const wrapper = await mount(1000)
+
+ await wrapper.find('input').setValue('needle')
+ await new Promise(r => setTimeout(r, 250))
+ await nextTick()
+
+ // The title resolver is injected by ContentPanel; standalone the row falls
+ // back to the entry id, which is enough to say WHICH row survived.
+ expect(wrapper.findAll('details')).toHaveLength(1)
+ expect(wrapper.text()).toContain('entry-900')
+ })
+
+ it('scopes the search to this model and locale', async () => {
+ searchContent.mockResolvedValue([])
+ const wrapper = await mount(10)
+
+ await wrapper.find('input').setValue('needle')
+ await new Promise(r => setTimeout(r, 250))
+
+ expect(searchContent).toHaveBeenCalledWith('needle', expect.objectContaining({
+ modelId: 'articles',
+ locale: 'en',
+ }))
+ })
+
+ it('ignores an index hit this locale no longer holds', async () => {
+ // The index is rebuilt per sync, so it can briefly name a stale entry.
+ searchContent.mockResolvedValue([
+ { modelId: 'articles', entryId: 'entry-1', locale: 'en', score: 1 },
+ { modelId: 'articles', entryId: 'deleted-one', locale: 'en', score: 1 },
+ ])
+ const wrapper = await mount(10)
+
+ await wrapper.find('input').setValue('needle')
+ await new Promise(r => setTimeout(r, 250))
+ await nextTick()
+
+ expect(wrapper.findAll('details')).toHaveLength(1)
+ })
+
+ it('says the index is not ready rather than claiming no matches', async () => {
+ // `searchContent` resolves to `[]` when the worker is absent, which is
+ // indistinguishable from "nothing matched" unless it is asked.
+ searchReady.value = false
+ searchContent.mockResolvedValue([])
+ const wrapper = await mount(10)
+
+ await wrapper.find('input').setValue('needle')
+ await new Promise(r => setTimeout(r, 250))
+ await nextTick()
+
+ expect(wrapper.text()).toContain('Search is not ready yet')
+ expect(wrapper.text()).not.toContain('No matches')
+ searchReady.value = true
+ })
+})
+
+describe('ContentCollectionView deep link', () => {
+ it('pulls the entry named by ?entry= to the front and opens it', async () => {
+ // Otherwise "go to this entry" drops you at the top of a thousand rows to
+ // find it yourself — which is what the search was for.
+ routeQuery.value = { entry: 'entry-800' }
+ const wrapper = await mount(1000)
+
+ const first = wrapper.findAll('details')[0]!
+ expect(first.text()).toContain('entry-800')
+ expect(first.attributes('open')).toBeDefined()
+
+ routeQuery.value = {}
+ })
+})
diff --git a/tests/nuxt/components/tooltip.nuxt.test.ts b/tests/nuxt/components/tooltip.nuxt.test.ts
index 36dff58e..1f3466d2 100644
--- a/tests/nuxt/components/tooltip.nuxt.test.ts
+++ b/tests/nuxt/components/tooltip.nuxt.test.ts
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { TooltipProvider } from 'radix-vue'
import Tooltip from '../../../app/components/atoms/Tooltip.vue'
+import TooltipScope from '../../../app/components/atoms/TooltipScope.vue'
// The content is portalled to `document.body`, which outlives the wrapper —
// without this, one test reads the tooltip a previous one left behind.
@@ -24,15 +26,35 @@ describe('Tooltip atom', () => {
expect(trigger.attributes('data-state')).toBeDefined()
})
- it('carries its own provider, so it works with no app-level one', async () => {
- // `TooltipRoot` throws without a provider — a component test mounting a
- // tooltip-bearing component on its own would fail if this were hoisted.
+ it('carries its own provider when there is no app-level one', async () => {
+ // `TooltipRoot` throws without a provider — a component mounted on its own,
+ // in a test or outside the layout, has to keep working.
await expect(mountSuspended(Tooltip, {
props: { text: 'Standalone' },
slots: { default: '' },
})).resolves.toBeTruthy()
})
+ it('adds no provider of its own inside the app scope', async () => {
+ // The whole point of hoisting: one provider, so Radix's skipDelayDuration
+ // applies across a row's icons instead of never applying at all. A nested
+ // provider would silently defeat it.
+ const host = defineComponent({
+ components: { TooltipScope, Tooltip },
+ template: `
+
+
+ `,
+ })
+
+ const wrapper = await mountSuspended(host)
+
+ // Two tooltips, one provider — the scope's.
+ expect(wrapper.findAllComponents(TooltipProvider)).toHaveLength(1)
+ expect(wrapper.findAllComponents(Tooltip)).toHaveLength(2)
+ expect(wrapper.find('button').attributes('data-state')).toBeDefined()
+ })
+
it('shows its text once opened', async () => {
await mountSuspended(Tooltip, {
props: { text: 'Attach entry to chat context', open: true },
diff --git a/tests/unit/search-results.test.ts b/tests/unit/search-results.test.ts
new file mode 100644
index 00000000..469db3c1
--- /dev/null
+++ b/tests/unit/search-results.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+import {
+ SEARCH_FILTER_FETCH_CAP,
+ collectSearchHits,
+ indexFetchLimit,
+} from '../../app/utils/search-results'
+
+const INDEX: Record = {
+ 'articles:en:a1': { modelId: 'articles', entryId: 'a1', locale: 'en' },
+ 'articles:tr:a1': { modelId: 'articles', entryId: 'a1', locale: 'tr' },
+ 'articles:en:a2': { modelId: 'articles', entryId: 'a2', locale: 'en' },
+ 'authors:en:u1': { modelId: 'authors', entryId: 'u1', locale: 'en' },
+ 'authors:en:u2': { modelId: 'authors', entryId: 'u2', locale: 'en' },
+}
+
+const lookup = (id: string) => INDEX[id] ?? null
+
+describe('indexFetchLimit', () => {
+ it('reads deeper when the search is scoped', () => {
+ // Asking the index for exactly the caller's limit and filtering afterwards
+ // is what let another model's hits eat every slot.
+ expect(indexFetchLimit({ limit: 10 })).toBe(10)
+ expect(indexFetchLimit({ limit: 10, modelId: 'articles' })).toBe(SEARCH_FILTER_FETCH_CAP)
+ expect(indexFetchLimit({ limit: 10, locale: 'tr' })).toBe(SEARCH_FILTER_FETCH_CAP)
+ })
+
+ it('never reads less than the caller asked for', () => {
+ expect(indexFetchLimit({ limit: 5000, modelId: 'articles' })).toBe(5000)
+ })
+})
+
+describe('collectSearchHits', () => {
+ it('filters by model before applying the limit, not after', () => {
+ // The reported shape of the bug: two `authors` hits rank above the article,
+ // so a 2-result search scoped to `articles` used to return nothing.
+ const ranked = ['authors:en:u1', 'authors:en:u2', 'articles:en:a1']
+
+ expect(collectSearchHits(ranked, lookup, { limit: 2, modelId: 'articles' }))
+ .toEqual([{ modelId: 'articles', entryId: 'a1', locale: 'en', score: 1 }])
+ })
+
+ it('filters by locale, so a Turkish list does not list English hits', () => {
+ const ranked = ['articles:en:a1', 'articles:tr:a1']
+
+ expect(collectSearchHits(ranked, lookup, { limit: 10, locale: 'tr' }))
+ .toEqual([{ modelId: 'articles', entryId: 'a1', locale: 'tr', score: 1 }])
+ })
+
+ it('applies both filters together', () => {
+ const ranked = ['authors:en:u1', 'articles:en:a1', 'articles:tr:a1']
+
+ expect(collectSearchHits(ranked, lookup, { limit: 10, modelId: 'articles', locale: 'tr' }))
+ .toEqual([{ modelId: 'articles', entryId: 'a1', locale: 'tr', score: 1 }])
+ })
+
+ it('does not let one document occupy two slots', () => {
+ // FlexSearch returns a set per indexed field, so the same id can repeat.
+ const ranked = ['articles:en:a1', 'articles:en:a1', 'articles:en:a2']
+
+ expect(collectSearchHits(ranked, lookup, { limit: 2 })).toEqual([
+ { modelId: 'articles', entryId: 'a1', locale: 'en', score: 1 },
+ { modelId: 'articles', entryId: 'a2', locale: 'en', score: 1 },
+ ])
+ })
+
+ it('stops at the limit', () => {
+ const ranked = Object.keys(INDEX)
+ expect(collectSearchHits(ranked, lookup, { limit: 3 })).toHaveLength(3)
+ })
+
+ it('skips ids the index no longer knows', () => {
+ // The index is rebuilt per sync; a stale id must not become a null row.
+ expect(collectSearchHits(['gone:en:x', 'articles:en:a1'], lookup, { limit: 10 }))
+ .toEqual([{ modelId: 'articles', entryId: 'a1', locale: 'en', score: 1 }])
+ })
+
+ it('returns nothing rather than everything when there are no hits', () => {
+ expect(collectSearchHits([], lookup, { limit: 10 })).toEqual([])
+ })
+})