From 981dd4d1cf24e71ee0f62858114b8c0fccc52772 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 14 Aug 2026 01:17:07 +0300 Subject: [PATCH 1/2] feat(content): connect the search that was already built, and stop rendering a thousand rows at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio shipped a FlexSearch index, a worker handler, a `searchContent` function and an `@modelId query` parser — and nothing in `app/` ever called any of it. The palette's own help promised "search models, entries, vocabulary" and "@modelId to search within a model"; neither searched an entry. This wires the engine up rather than writing one. ## Two bugs in the engine, found on the way - **The model filter was applied after the limit.** The worker asked the index for `limit` hits and then dropped the ones from other models, so a search scoped to `articles` competed for those slots against every other model — and could come back empty while matching. - **Locale was stored and never filtered.** A Turkish list got English hits. Both are now decided before the cut, in `app/utils/search-results.ts`, where the order of operations can be stated and tested. Duplicate ids are dropped too: FlexSearch returns one set per indexed field, and the same entry was eating several slots. ## The list A search box that queries the index, so a match on page twenty is found without paging there. Results intersect with what this locale actually holds — the index is rebuilt per sync and can briefly name an entry the payload no longer has. Rendering is paged at 50. Every row is a `
` plus a stateful Radix dropdown plus three buttons; at 1000 articles that is the cost of opening a model, not the data. Virtualisation was the other option and was not taken: a new dependency, and rows change height when they expand. Paging leaves the row component untouched. "No matches" and "the index is not ready" are now different states. `searchContent` resolves to `[]` when the worker is absent, and reporting that as no results is a lie. ## The palette Entry hits are filled into their own ref and appended, because `buildResults` is synchronous and search is not. They go last, and the selection resets on the synchronous results rather than the final list, so an async hit landing does not yank the cursor from under someone already arrowing down. `SearchResult` carries no title, so each hit is read back and titled through the same resolver the list uses — without it the palette would list `f3a81c09d24e`. Selecting one navigates with `?entry=`, and the list pulls that entry to the front and opens it. Opening the model alone would have handed someone a thousand rows to find it in, which is what they searched to avoid. Also: the dictionary filter's placeholder said "Filter keys..." while the filter has always searched keys AND values. --- .../content/system/ui-strings/en.json | 10 +- app/components/organisms/CommandPalette.vue | 94 +++++++++- .../organisms/ContentCollectionView.vue | 169 +++++++++++++++++- app/composables/useContentBrain.ts | 31 +++- app/utils/search-results.ts | 78 ++++++++ app/workers/content-brain.worker.ts | 29 ++- .../content-collection-view.nuxt.test.ts | 133 ++++++++++++++ tests/unit/search-results.test.ts | 80 +++++++++ 8 files changed, 599 insertions(+), 25 deletions(-) create mode 100644 app/utils/search-results.ts create mode 100644 tests/nuxt/components/content-collection-view.nuxt.test.ts create mode 100644 tests/unit/search-results.test.ts 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/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: