From a1c9e1d28e0fc6276a36abbfaa9cb5316bae3879 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 8 Apr 2026 15:24:53 +0700 Subject: [PATCH 1/6] Simplify SearchPanel args to use domain types instead of internal concerns Replace cluttered args (consumingRealm, preselectConsumingRealm, availableRealmUrls, onFilterChange) with cleaner domain-typed alternatives (initialSelectedRealms, initialSelectedTypes, onRealmChange, onTypeChange) so callers deal with URL[] and ResolvedCodeRef[] instead of PickerOption[] and booleans. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/card-catalog/modal.gts | 11 ++- .../host/app/components/card-search/panel.gts | 98 ++++++++++--------- .../app/components/search-sheet/index.gts | 19 +++- 3 files changed, 75 insertions(+), 53 deletions(-) diff --git a/packages/host/app/components/card-catalog/modal.gts b/packages/host/app/components/card-catalog/modal.gts index 092289a7e60..65fac225a19 100644 --- a/packages/host/app/components/card-catalog/modal.gts +++ b/packages/host/app/components/card-catalog/modal.gts @@ -111,9 +111,7 @@ export default class CardCatalogModal extends Component { { return this.stateStack[this.stateStack.length - 1]; } + private get initialSelectedRealmsForPanel(): URL[] | undefined { + if (!this.state?.preselectConsumingRealm || !this.state?.consumingRealm) { + return undefined; + } + return [this.state.consumingRealm]; + } + private get offerToCreateArg() { if (!this.state) { return undefined; diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index c8b56b183da..837556ef3b1 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -22,6 +22,7 @@ import { baseFieldRef, baseRef, CardContextName, + codeRefFromInternalKey, GetCardCollectionContextName, internalKeyFor, isResolvedCodeRef, @@ -59,11 +60,10 @@ interface Signature { Args: { searchKey: string; baseFilter?: Filter; - initialSelectedType?: ResolvedCodeRef; - availableRealmUrls?: string[]; - consumingRealm?: URL; - preselectConsumingRealm?: boolean; - onFilterChange?: () => void; + initialSelectedTypes?: ResolvedCodeRef[]; + initialSelectedRealms?: URL[]; + onRealmChange?: (selectedRealms: URL[]) => void; + onTypeChange?: (selectedTypes: ResolvedCodeRef[]) => void; }; Blocks: { default: [ @@ -110,30 +110,29 @@ export default class SearchPanel extends Component { @consume(GetCardCollectionContextName) declare private getCardCollection: getCardCollection; - @tracked private selectedRealms: PickerOption[] = this.initialSelectedRealms; + @tracked private selectedRealms: PickerOption[] = + this.computeInitialSelectedRealms(); - private get shouldPreselectConsumingRealm(): boolean { - return Boolean(this.args.preselectConsumingRealm); - } - - private get initialSelectedRealms(): PickerOption[] { - let consumingRealm = this.args.consumingRealm; - if (!this.shouldPreselectConsumingRealm || !consumingRealm) { + private computeInitialSelectedRealms(): PickerOption[] { + let realmURLs = this.args.initialSelectedRealms; + if (!realmURLs || realmURLs.length === 0) { return []; } - let realmURL = consumingRealm.href; - let info = this.realm.info(realmURL); - let label = info?.name ?? realmURL; - let icon = info?.iconURL ?? undefined; - return [{ id: realmURL, icon, label, type: 'option' }]; + return realmURLs.map((url) => { + let realmURL = url.href; + let info = this.realm.info(realmURL); + let label = info?.name ?? realmURL; + let icon = info?.iconURL ?? undefined; + return { id: realmURL, icon, label, type: 'option' as const }; + }); } private get initialFocusedSectionId(): string | null { - let consumingRealm = this.args.consumingRealm; - if (!this.shouldPreselectConsumingRealm || !consumingRealm) { + let realmURLs = this.args.initialSelectedRealms; + if (!realmURLs || realmURLs.length === 0) { return null; } - return `realm:${consumingRealm.href}`; + return `realm:${realmURLs[0].href}`; } @tracked private activeSort: SortOption = SORT_OPTIONS[0]; @@ -163,9 +162,7 @@ export default class SearchPanel extends Component { (opt) => opt.type === 'select-all', ); if (hasSelectAll || this.selectedRealms.length === 0) { - return ( - this.args.availableRealmUrls ?? this.realmServer.availableRealmURLs - ); + return this.realmServer.availableRealmURLs; } return this.selectedRealms.map((opt) => opt.id).filter(Boolean); } @@ -229,16 +226,18 @@ export default class SearchPanel extends Component { this._typesTotalCount = result.meta.page.total; this._hasMoreTypes = result.data.length < result.meta.page.total; - // If there are selected types (or an initialSelectedType) not yet in + // If there are selected types (or initialSelectedTypes) not yet in // the fetched results, keep fetching more pages until they're found. const selectedIds = new Set( this._previousSelectedTypes .filter((opt) => opt.type !== 'select-all') .map((opt) => opt.id), ); - const initialType = this.args.initialSelectedType; - if (initialType) { - selectedIds.add(internalKeyFor(initialType, undefined)); + const initialTypes = this.args.initialSelectedTypes; + if (initialTypes) { + for (const ref of initialTypes) { + selectedIds.add(internalKeyFor(ref, undefined)); + } } if (selectedIds.size > 0 && this._hasMoreTypes) { @@ -377,28 +376,30 @@ export default class SearchPanel extends Component { // An empty array lets the Picker's ensureDefaultSelection() handle // selecting the built-in select-all option automatically. const prev = this._previousSelectedTypes; - const initialType = this.args.initialSelectedType; + const initialTypes = this.args.initialSelectedTypes; const hadSelectAll = prev.length === 0 || prev.some((opt) => opt.type === 'select-all'); - if (initialType && prev.length === 0) { - // First launch with initialSelectedType (e.g., from "Find Instances"). + if (initialTypes && initialTypes.length > 0 && prev.length === 0) { + // First launch with initialSelectedTypes (e.g., from "Find Instances"). // Only applied when prev is empty (first computation). After that, // _previousSelectedTypes is always non-empty, preserving user's choice. - const typeKey = internalKeyFor(initialType, undefined); - const matchingOption = optionsById.get(typeKey); - if (matchingOption) { - value.selected = [matchingOption]; - } else { - // Type summaries not yet loaded; create a synthetic option so the - // search query is type-constrained immediately. - value.selected = [ - { + const matched: PickerOption[] = []; + for (const ref of initialTypes) { + const typeKey = internalKeyFor(ref, undefined); + const matchingOption = optionsById.get(typeKey); + if (matchingOption) { + matched.push(matchingOption); + } else { + // Type summaries not yet loaded; create a synthetic option so the + // search query is type-constrained immediately. + matched.push({ id: typeKey, - label: initialType.name, + label: ref.name, type: 'option', - } as PickerOption, - ]; + } as PickerOption); + } } + value.selected = matched; } else if (hadSelectAll) { // If baseFilter constrains to specific types and they exist in options, // auto-select them instead of defaulting to "Any Type" @@ -456,14 +457,21 @@ export default class SearchPanel extends Component { @action private onRealmChange(selected: PickerOption[]) { this.selectedRealms = selected; - this.args.onFilterChange?.(); + const realmURLs = selected + .filter((opt) => opt.type !== 'select-all') + .map((opt) => new URL(opt.id)); + this.args.onRealmChange?.(realmURLs); } @action private onTypeChange(selected: PickerOption[]) { this._previousSelectedTypes = selected; this.typeFilter.selected = selected; - this.args.onFilterChange?.(); + const types = selected + .filter((opt) => opt.type !== 'select-all') + .map((opt) => codeRefFromInternalKey(opt.id)) + .filter((ref): ref is ResolvedCodeRef => ref !== undefined); + this.args.onTypeChange?.(types); } @action diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index 56309777056..fd6b7246dea 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -82,7 +82,7 @@ const BASE_FILTER: Filter = { export default class SearchSheet extends Component { @tracked private searchKey = ''; - @tracked private initialSelectedType: ResolvedCodeRef | undefined; + @tracked private initialSelectedTypes: ResolvedCodeRef[] | undefined; @service declare private realmServer: RealmServerService; @service declare private store: StoreService; @@ -157,12 +157,12 @@ export default class SearchSheet extends Component { @action private doExternallyTriggeredSearch(term: string, typeRef?: ResolvedCodeRef) { this.searchKey = term; - this.initialSelectedType = typeRef; + this.initialSelectedTypes = typeRef ? [typeRef] : undefined; } private resetState() { this.searchKey = ''; - this.initialSelectedType = undefined; + this.initialSelectedTypes = undefined; } @action private debouncedSetSearchKey(searchKey: string) { @@ -175,6 +175,14 @@ export default class SearchSheet extends Component { this.args.onSearch?.(searchKey); } + @action private handleRealmChange(_selectedRealms: URL[]) { + this.args.onFilterChange?.(); + } + + @action private handleTypeChange(_selectedTypes: ResolvedCodeRef[]) { + this.args.onFilterChange?.(); + } + @action private onSearchInputKeyDown(e: Event) { let kbEvent = e as KeyboardEvent; if (kbEvent.key === 'Escape') { @@ -263,8 +271,9 @@ export default class SearchSheet extends Component { Date: Thu, 16 Apr 2026 10:12:52 +0700 Subject: [PATCH 2/6] Introduce type summaries resource --- .../app/components/card-catalog/modal.gts | 1 - .../host/app/components/card-search/panel.gts | 523 +++--------------- .../app/components/card-search/search-bar.gts | 37 +- .../components/card-search/search-content.gts | 131 ++++- .../app/components/realm-picker/index.gts | 37 +- .../app/components/search-sheet/index.gts | 14 +- .../host/app/components/type-picker/index.gts | 103 ++-- packages/host/app/resources/type-summaries.ts | 400 ++++++++++++++ packages/host/app/services/realm-server.ts | 6 +- 9 files changed, 690 insertions(+), 562 deletions(-) create mode 100644 packages/host/app/resources/type-summaries.ts diff --git a/packages/host/app/components/card-catalog/modal.gts b/packages/host/app/components/card-catalog/modal.gts index 65fac225a19..9a754b51b09 100644 --- a/packages/host/app/components/card-catalog/modal.gts +++ b/packages/host/app/components/card-catalog/modal.gts @@ -132,7 +132,6 @@ export default class CardCatalogModal extends Component { <:header> , WithBoundArgs< typeof SearchContent, | 'searchKey' - | 'selectedRealmURLs' - | 'selectedCardTypes' + | 'realmFilter' + | 'typeFilter' | 'baseFilter' - | 'skipTypeFiltering' - | 'searchResource' | 'activeSort' | 'onSortChange' - | 'filteredRecentCards' | 'initialFocusedSection' >, - string, ]; }; } export default class SearchPanel extends Component { - @service declare private realm: RealmService; @service declare private realmServer: RealmServerService; - @service declare private recentCardsService: RecentCards; - @consume(CardContextName) declare private cardContext: - | CardContext - | undefined; - @consume(GetCardCollectionContextName) - declare private getCardCollection: getCardCollection; - @tracked private selectedRealms: PickerOption[] = - this.computeInitialSelectedRealms(); + @tracked private selectedRealms: URL[] = + this.args.initialSelectedRealms ?? []; + @tracked private activeSort: SortOption = SORT_OPTIONS[0]; - private computeInitialSelectedRealms(): PickerOption[] { - let realmURLs = this.args.initialSelectedRealms; - if (!realmURLs || realmURLs.length === 0) { - return []; - } - return realmURLs.map((url) => { - let realmURL = url.href; - let info = this.realm.info(realmURL); - let label = info?.name ?? realmURL; - let icon = info?.iconURL ?? undefined; - return { id: realmURL, icon, label, type: 'option' as const }; - }); + // Re-export for tests that override PAGE_SIZE + static get PAGE_SIZE() { + return TypeSummariesResource.PAGE_SIZE; + } + static set PAGE_SIZE(v: number) { + TypeSummariesResource.PAGE_SIZE = v; } + private typeSummaries = getTypeSummaries( + this, + getOwner(this)!, + () => ({ + realmURLs: this.selectedRealmURLs, + baseFilter: this.args.baseFilter, + initialSelectedTypes: this.args.initialSelectedTypes, + }), + ); + private get initialFocusedSectionId(): string | null { let realmURLs = this.args.initialSelectedRealms; if (!realmURLs || realmURLs.length === 0) { @@ -135,343 +85,53 @@ export default class SearchPanel extends Component { return `realm:${realmURLs[0].href}`; } - @tracked private activeSort: SortOption = SORT_OPTIONS[0]; - - // Type summaries state - @tracked private _typeSearchKey = ''; - @tracked private _typeSummariesData: TypeSummaryItem[] = []; - @tracked private _isLoadingTypes = false; - @tracked private _isLoadingMoreTypes = false; - @tracked private _hasMoreTypes = false; - @tracked private _typesTotalCount = 0; - - @cached - private get recentCardCollection(): ReturnType { - return this.getCardCollection( - this, - () => this.recentCardsService.recentCardIds, - ); - } - - // Non-tracked: persists across resource re-runs without creating - // tracking dependencies. Updated by onTypeChange and the resource itself. - private _previousSelectedTypes: PickerOption[] = []; - private get selectedRealmURLs(): string[] { - const hasSelectAll = this.selectedRealms.some( - (opt) => opt.type === 'select-all', - ); - if (hasSelectAll || this.selectedRealms.length === 0) { + if (this.selectedRealms.length === 0) { return this.realmServer.availableRealmURLs; } - return this.selectedRealms.map((opt) => opt.id).filter(Boolean); - } - - private get joinedSelectedRealmURLs(): string { - return this.selectedRealmURLs.join(','); - } - - private get baseFilteredRecentCards(): CardDef[] { - const cards = - (this.recentCardCollection?.cards?.filter(Boolean) as - | CardDef[] - | undefined) ?? []; - const realmURLs = this.selectedRealmURLs; - const realmFiltered = cards.filter( - (c) => c.id && realmURLs.some((url) => c.id.startsWith(url)), - ); - const typeRefs = getFilterTypeRefs(this.args.baseFilter); - return filterCardsByTypeRefs(realmFiltered, typeRefs); - } - - private get cardComponentModifier() { - if (isDestroying(this) || isDestroyed(this)) { - return undefined; - } - try { - return this.cardContext?.cardComponentModifier; - } catch (error) { - if ( - error instanceof Error && - error.message.includes(OWNER_DESTROYED_ERROR) - ) { - return undefined; - } - throw error; - } + return this.selectedRealms.map((url) => url.href); } - @tracked private _currentPage = 0; - static PAGE_SIZE = 25; - - private fetchTypeSummariesTask = restartableTask( - async (realmURLs: string[], searchKey: string) => { - if (searchKey) { - await timeout(300); // debounce search - } else { - await Promise.resolve(); // yield to avoid autotracking assertion - } - if (isDestroyed(this) || isDestroying(this)) return; - this._isLoadingTypes = true; - this._currentPage = 0; - - try { - let result = await this.realmServer.fetchCardTypeSummaries(realmURLs, { - searchKey: searchKey || undefined, - page: { number: 0, size: SearchPanel.PAGE_SIZE }, - }); - if (isDestroyed(this) || isDestroying(this)) return; - - this._typeSummariesData = result.data; - this._typesTotalCount = result.meta.page.total; - this._hasMoreTypes = result.data.length < result.meta.page.total; - - // If there are selected types (or initialSelectedTypes) not yet in - // the fetched results, keep fetching more pages until they're found. - const selectedIds = new Set( - this._previousSelectedTypes - .filter((opt) => opt.type !== 'select-all') - .map((opt) => opt.id), - ); - const initialTypes = this.args.initialSelectedTypes; - if (initialTypes) { - for (const ref of initialTypes) { - selectedIds.add(internalKeyFor(ref, undefined)); - } - } - - if (selectedIds.size > 0 && this._hasMoreTypes) { - while (this._hasMoreTypes) { - const fetchedIds = new Set( - this._typeSummariesData.map((d) => d.id), - ); - if ([...selectedIds].every((id) => fetchedIds.has(id))) break; - - const nextPage = this._currentPage + 1; - let moreResult = await this.realmServer.fetchCardTypeSummaries( - realmURLs, - { - searchKey: searchKey || undefined, - page: { number: nextPage, size: SearchPanel.PAGE_SIZE }, - }, - ); - if (isDestroyed(this) || isDestroying(this)) return; + // -- Filter objects -- - this._currentPage = nextPage; - this._typeSummariesData = [ - ...this._typeSummariesData, - ...moreResult.data, - ]; - this._hasMoreTypes = - this._typeSummariesData.length < moreResult.meta.page.total; - } - } - - this._isLoadingTypes = false; - } catch (e) { - console.error('Failed to fetch card type summaries', e); - if (!isDestroyed(this) && !isDestroying(this)) { - this._typeSummariesData = []; - this._typesTotalCount = 0; - this._hasMoreTypes = false; - this._isLoadingTypes = false; - } - } - }, - ); - - // Resource that watches selectedRealmURLs and typeSearchKey to trigger fetches - @use private _typeFetchTrigger = resource(() => { - let realmURLs = this.selectedRealmURLs; - let searchKey = this._typeSearchKey; - - this.fetchTypeSummariesTask.perform(realmURLs, searchKey); - - return { realmURLs, searchKey }; - }); - - private get baseFilterCodeRefs(): Set | undefined { - const typeRefs = getFilterTypeRefs(this.args.baseFilter); - if (!typeRefs || typeRefs.length === 0) { - return undefined; - } - const refs = new Set(); - for (const { ref, negated } of typeRefs) { - if (!negated && isResolvedCodeRef(ref)) { - refs.add(internalKeyFor(ref, undefined)); - } - } - if (refs.size === 0) return undefined; - - // CardDef/FieldDef are root types — all card types inherit from them, - // so filtering by them would incorrectly show zero results. Skip. - const baseKeys = new Set([ - internalKeyFor(baseCardRef, undefined), - internalKeyFor(baseFieldRef, undefined), - internalKeyFor(baseRef, undefined), - ]); - if ([...refs].every((r) => baseKeys.has(r))) { - return undefined; - } - - return refs; - } - - private get hasNonRootBaseFilter(): boolean { - return this.baseFilterCodeRefs !== undefined; + private get realmFilter(): RealmFilter { + return { + selected: this.selectedRealms, + onChange: this.onRealmChange, + selectedURLs: this.selectedRealmURLs, + }; } - @use private typeFilter = resource(() => { - // Access _typeFetchTrigger to ensure we re-run when fetch completes - this._typeFetchTrigger; - - let value: { selected: PickerOption[]; options: PickerOption[] } = - new TrackedObject({ - selected: [], - options: [], - }); - - const allowedCodeRefs = this.baseFilterCodeRefs; - const optionsById = new Map(); - - const rootTypeKeys = new Set([ - internalKeyFor(baseCardRef, undefined), - internalKeyFor(baseFieldRef, undefined), - internalKeyFor(baseRef, undefined), - ]); - - for (const item of this._typeSummariesData) { - const name = item.attributes.displayName; - const codeRef = item.id; - if (!name) { - continue; - } - - // Never show root types — they are internal meta types - if (rootTypeKeys.has(codeRef)) { - continue; - } - - // When baseFilter constrains to specific types, only show matching types - if (allowedCodeRefs && !allowedCodeRefs.has(codeRef)) { - continue; - } - - optionsById.set(codeRef, { - id: codeRef, - label: name, - tooltip: codeRef, - icon: item.attributes.iconHTML ?? undefined, - type: 'option', - }); - } - - value.options = [ - ...[...optionsById.values()].sort((a, b) => - a.label.localeCompare(b.label), - ), - ]; - - // Recalculate selected based on previous user selection. - // An empty array lets the Picker's ensureDefaultSelection() handle - // selecting the built-in select-all option automatically. - const prev = this._previousSelectedTypes; - const initialTypes = this.args.initialSelectedTypes; - const hadSelectAll = - prev.length === 0 || prev.some((opt) => opt.type === 'select-all'); - if (initialTypes && initialTypes.length > 0 && prev.length === 0) { - // First launch with initialSelectedTypes (e.g., from "Find Instances"). - // Only applied when prev is empty (first computation). After that, - // _previousSelectedTypes is always non-empty, preserving user's choice. - const matched: PickerOption[] = []; - for (const ref of initialTypes) { - const typeKey = internalKeyFor(ref, undefined); - const matchingOption = optionsById.get(typeKey); - if (matchingOption) { - matched.push(matchingOption); - } else { - // Type summaries not yet loaded; create a synthetic option so the - // search query is type-constrained immediately. - matched.push({ - id: typeKey, - label: ref.name, - type: 'option', - } as PickerOption); - } - } - value.selected = matched; - } else if (hadSelectAll) { - // If baseFilter constrains to specific types and they exist in options, - // auto-select them instead of defaulting to "Any Type" - const baseTypeRefs = getFilterTypeRefs(this.args.baseFilter); - const baseRefs = - baseTypeRefs - ?.filter((r) => !r.negated && isResolvedCodeRef(r.ref)) - .map((r) => internalKeyFor(r.ref, undefined)) ?? []; - if (baseRefs.length > 0) { - const autoSelected = baseRefs - .filter((ref) => optionsById.has(ref)) - .map((ref) => optionsById.get(ref)!); - value.selected = autoSelected.length > 0 ? autoSelected : []; - } else { - value.selected = []; - } - } else if (this._isLoadingTypes || this._isLoadingMoreTypes) { - // Type summaries still loading — keep previous selections - // to avoid jarring UI changes. - value.selected = prev; - } else { - // Keep previous selections that still exist in the new options, - // mapping to new object references so Picker's isSelected (which - // uses reference equality via lodash includes) works correctly. - const kept = prev - .filter((opt) => opt.type !== 'select-all' && optionsById.has(opt.id)) - .map((opt) => optionsById.get(opt.id)!); - value.selected = kept.length > 0 ? kept : []; - } - - this._previousSelectedTypes = value.selected; - return value; - }); - - private searchResource = getPrerenderedSearch(this, getOwner(this)!, () => { - // Consume typeFilter.selected outside the ternary so the tracking - // dependency is always established, even when the query is skipped. - const selectedTypeIds = this.typeFilter.selected.map((opt) => opt.id); + private get typeFilter(): TypeFilter { + let ts = this.typeSummaries; return { - query: shouldSkipSearchQuery(this.args.searchKey, this.args.baseFilter) - ? undefined - : buildSearchQuery( - this.args.searchKey, - this.activeSort, - this.args.baseFilter, - selectedTypeIds, - ), - format: 'fitted' as const, - realms: this.selectedRealmURLs, - isLive: true, - cardComponentModifier: this.cardComponentModifier, + options: ts.options, + selected: ts.selected, + onChange: this.onTypeChange, + onSearchChange: this.onTypeSearchChange, + onLoadMore: this.onLoadMoreTypes, + hasMore: ts.hasMore, + isLoading: ts.isLoading, + isLoadingMore: ts.isLoadingMore, + totalCount: ts.totalCount, + disableSelectAll: ts.hasNonRootBaseFilter, + skipTypeFiltering: ts.hasNonRootBaseFilter, + selectedTypeIds: ts.selectedTypeIds, }; - }); + } + + // -- Actions -- @action - private onRealmChange(selected: PickerOption[]) { + private onRealmChange(selected: URL[]) { this.selectedRealms = selected; - const realmURLs = selected - .filter((opt) => opt.type !== 'select-all') - .map((opt) => new URL(opt.id)); - this.args.onRealmChange?.(realmURLs); + this.args.onRealmChange?.(selected); } @action - private onTypeChange(selected: PickerOption[]) { - this._previousSelectedTypes = selected; - this.typeFilter.selected = selected; - const types = selected - .filter((opt) => opt.type !== 'select-all') - .map((opt) => codeRefFromInternalKey(opt.id)) - .filter((ref): ref is ResolvedCodeRef => ref !== undefined); - this.args.onTypeChange?.(types); + private onTypeChange(selected: ResolvedCodeRef[]) { + this.typeSummaries.updateSelected(selected); + this.args.onTypeChange?.(selected); } @action @@ -481,81 +141,32 @@ export default class SearchPanel extends Component { @action private onTypeSearchChange(term: string) { - this._typeSearchKey = term; - this.fetchTypeSummariesTask.perform(this.selectedRealmURLs, term); + this.typeSummaries.onSearchChange(term); } - private loadMoreTypesTask = restartableTask(async () => { - if (isDestroyed(this) || isDestroying(this)) return; - this._isLoadingMoreTypes = true; - const nextPage = this._currentPage + 1; - - try { - let result = await this.realmServer.fetchCardTypeSummaries( - this.selectedRealmURLs, - { - searchKey: this._typeSearchKey || undefined, - page: { number: nextPage, size: SearchPanel.PAGE_SIZE }, - }, - ); - if (isDestroyed(this) || isDestroying(this)) return; - - this._currentPage = nextPage; - this._typeSummariesData = [...this._typeSummariesData, ...result.data]; - const totalFetched = this._typeSummariesData.length; - this._hasMoreTypes = totalFetched < result.meta.page.total; - this._isLoadingMoreTypes = false; - } catch (e) { - console.error('Failed to load more card type summaries', e); - if (!isDestroyed(this) && !isDestroying(this)) { - this._isLoadingMoreTypes = false; - } - } - }); - @action private onLoadMoreTypes() { - if ( - this._isLoadingTypes || - this._isLoadingMoreTypes || - !this._hasMoreTypes - ) { - return; - } - this.loadMoreTypesTask.perform(); + this.typeSummaries.onLoadMore(); } } diff --git a/packages/host/app/components/card-search/search-bar.gts b/packages/host/app/components/card-search/search-bar.gts index 6bedd97ac82..1233409175b 100644 --- a/packages/host/app/components/card-search/search-bar.gts +++ b/packages/host/app/components/card-search/search-bar.gts @@ -8,12 +8,15 @@ import { BoxelInput, type BoxelInputBottomTreatments, } from '@cardstack/boxel-ui/components'; -import type { PickerOption } from '@cardstack/boxel-ui/components'; import { IconSearch } from '@cardstack/boxel-ui/icons'; import { autoFocus } from '@cardstack/boxel-ui/modifiers'; -import RealmPicker from '@cardstack/host/components/realm-picker'; -import TypePicker from '@cardstack/host/components/type-picker'; +import RealmPicker, { + type RealmFilter, +} from '@cardstack/host/components/realm-picker'; +import TypePicker, { + type TypeFilter, +} from '@cardstack/host/components/type-picker'; let elementCallback = modifier( (element, [callback]: [((element: HTMLElement) => void) | undefined]) => { @@ -27,6 +30,8 @@ interface Signature { Element: HTMLElement; Args: { value: string; + realmFilter: RealmFilter; + typeFilter: TypeFilter; placeholder?: string; autocomplete?: string; onInput?: (value: string) => void; @@ -34,18 +39,6 @@ interface Signature { onBlur?: (ev: Event) => void; onKeyDown?: (ev: Event) => void; onInputInsertion?: (element: HTMLElement) => void; - selectedRealms: PickerOption[]; - onRealmChange: (selected: PickerOption[]) => void; - typeOptions: PickerOption[]; - selectedTypes: PickerOption[]; - onTypeChange: (selected: PickerOption[]) => void; - onTypeSearchChange?: (term: string) => void; - onLoadMoreTypes?: () => void; - hasMoreTypes?: boolean; - isLoadingTypes?: boolean; - isLoadingMoreTypes?: boolean; - typesTotalCount?: number; - disableSelectAll?: boolean; pickerDestination?: string; bottomTreatment?: BoxelInputBottomTreatments; state?: 'none' | 'valid' | 'invalid' | 'loading' | 'initial'; @@ -75,23 +68,13 @@ export default class SearchBar extends Component {
diff --git a/packages/host/app/components/card-search/search-content.gts b/packages/host/app/components/card-search/search-content.gts index b521952d6c3..353667f9f2f 100644 --- a/packages/host/app/components/card-search/search-content.gts +++ b/packages/host/app/components/card-search/search-content.gts @@ -1,4 +1,6 @@ +import { isDestroyed, isDestroying } from '@ember/destroyable'; import { action } from '@ember/object'; +import { getOwner } from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; import { cached, tracked } from '@glimmer/tracking'; @@ -8,27 +10,27 @@ import { consume } from 'ember-provide-consume-context'; import pluralize from 'pluralize'; -import type { PickerOption } from '@cardstack/boxel-ui/components'; - import { eq } from '@cardstack/boxel-ui/helpers'; import { type CodeRef, type Filter, type getCard, + type getCardCollection, + CardContextName, GetCardContextName, + GetCardCollectionContextName, } from '@cardstack/runtime-common'; -import { cardTypeDisplayName } from '@cardstack/runtime-common/helpers/card-type-display-name'; - import { urlForRealmLookup } from '@cardstack/host/lib/utils'; -import type { PrerenderedSearchResource } from '@cardstack/host/resources/prerendered-search'; +import { getPrerenderedSearch } from '@cardstack/host/resources/prerendered-search'; import type LoaderService from '@cardstack/host/services/loader-service'; import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; +import type RecentCards from '@cardstack/host/services/recent-cards-service'; import type StoreService from '@cardstack/host/services/store'; -import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type { CardContext, CardDef } from 'https://cardstack.com/base/card-api'; import { SECTION_DISPLAY_LIMIT_FOCUSED, @@ -43,7 +45,16 @@ import SearchResultSection from './search-result-section'; import type { PrerenderedCard } from '../prerendered-card-search'; -import type { NewCardArgs } from './utils'; +import type { RealmFilter } from '@cardstack/host/components/realm-picker'; +import type { TypeFilter } from '@cardstack/host/components/type-picker'; +import { + buildSearchQuery, + cardMatchesTypeRef, + filterCardsByTypeRefs, + getFilterTypeRefs, + shouldSkipSearchQuery, + type NewCardArgs, +} from './utils'; import type { NamedArgs } from 'ember-modifier'; interface ScrollToFocusedSectionSignature { @@ -136,24 +147,21 @@ interface Signature { Element: HTMLElement; Args: { searchKey: string; - selectedRealmURLs: string[]; + realmFilter: RealmFilter; + typeFilter: TypeFilter; + baseFilter?: Filter; isCompact: boolean; handleSelect: (selection: string | NewCardArgs) => void; selectedCards?: (string | NewCardArgs)[]; multiSelect?: boolean; onSelectAll?: (cards: string[]) => void; onDeselectAll?: () => void; - baseFilter?: Filter; - skipTypeFiltering?: boolean; offerToCreate?: { ref: CodeRef; relativeTo: URL | undefined; }; onSubmit?: (selection: string | NewCardArgs) => void; showHeader?: boolean; - selectedCardTypes?: PickerOption[]; - filteredRecentCards?: CardDef[]; - searchResource: PrerenderedSearchResource; activeSort: SortOption; onSortChange: (sort: SortOption) => void; initialFocusedSection?: string | null; @@ -161,11 +169,15 @@ interface Signature { Blocks: {}; } +const OWNER_DESTROYED_ERROR = 'OWNER_DESTROYED_ERROR'; + export default class SearchContent extends Component { @service declare loaderService: LoaderService; @service declare realm: RealmService; @service declare realmServer: RealmServerService; @service declare store: StoreService; + @service('recent-cards-service') + declare private recentCardsService: RecentCards; @tracked activeViewId = 'grid'; /** Section id when focused: 'realm:' or 'recents'. Null = no focus */ @@ -174,6 +186,71 @@ export default class SearchContent extends Component { @tracked displayedCountBySection: Record = {}; @consume(GetCardContextName) declare private getCard: getCard; + @consume(CardContextName) declare private cardContext: + | CardContext + | undefined; + @consume(GetCardCollectionContextName) + declare private getCardCollection: getCardCollection; + + // -- Internal resources (moved from SearchPanel) -- + + private get cardComponentModifier() { + if (isDestroying(this) || isDestroyed(this)) { + return undefined; + } + try { + return this.cardContext?.cardComponentModifier; + } catch (error) { + if ( + error instanceof Error && + error.message.includes(OWNER_DESTROYED_ERROR) + ) { + return undefined; + } + throw error; + } + } + + private searchResource = getPrerenderedSearch(this, getOwner(this)!, () => { + // Consume selectedTypeIds outside the ternary so the tracking + // dependency is always established, even when the query is skipped. + const selectedTypeIds = this.args.typeFilter.selectedTypeIds; + return { + query: shouldSkipSearchQuery(this.args.searchKey, this.args.baseFilter) + ? undefined + : buildSearchQuery( + this.args.searchKey, + this.args.activeSort, + this.args.baseFilter, + selectedTypeIds, + ), + format: 'fitted' as const, + realms: this.args.realmFilter.selectedURLs, + isLive: true, + cardComponentModifier: this.cardComponentModifier, + }; + }); + + @cached + private get recentCardCollection(): ReturnType { + return this.getCardCollection( + this, + () => this.recentCardsService.recentCardIds, + ); + } + + private get filteredRecentCards(): CardDef[] { + const cards = + (this.recentCardCollection?.cards?.filter(Boolean) as + | CardDef[] + | undefined) ?? []; + const realmURLs = this.args.realmFilter.selectedURLs; + const realmFiltered = cards.filter( + (c) => c.id && realmURLs.some((url) => c.id.startsWith(url)), + ); + const typeRefs = getFilterTypeRefs(this.args.baseFilter); + return filterCardsByTypeRefs(realmFiltered, typeRefs); + } @cached private get cardResource(): ReturnType { @@ -235,8 +312,8 @@ export default class SearchContent extends Component { private get realms() { const urls = - this.args.selectedRealmURLs.length > 0 - ? this.args.selectedRealmURLs + this.args.realmFilter.selectedURLs.length > 0 + ? this.args.realmFilter.selectedURLs : this.realmServer.availableRealmURLs; return urls ?? []; } @@ -246,7 +323,7 @@ export default class SearchContent extends Component { return ''; } - if (this.args.searchResource.isLoading) { + if (this.searchResource.isLoading) { return 'Searching…'; } @@ -259,7 +336,7 @@ export default class SearchContent extends Component { } // Query search results - const total = this.args.searchResource.meta.page?.total ?? 0; + const total = this.searchResource.meta.page?.total ?? 0; const realms = this.realms; // Default: all results across all realms @@ -267,21 +344,17 @@ export default class SearchContent extends Component { } private get sortedRecentCards(): CardDef[] { - let cards = [...(this.args.filteredRecentCards ?? [])]; + let cards = [...(this.filteredRecentCards ?? [])]; // Apply type picker filter (from TypePicker selection). // Skip when baseFilter has a non-root type — the server already // constrains results via the adoption chain, and recent cards are // pre-filtered by filterCardsByTypeRefs which handles subtypes. - if (!this.args.skipTypeFiltering) { - const pickerSelectedTypeNames = new Set( - (this.args.selectedCardTypes ?? []) - .filter((opt) => opt.type !== 'select-all') - .map((opt) => opt.label), - ); - if (pickerSelectedTypeNames.size > 0) { + if (!this.args.typeFilter.skipTypeFiltering) { + const selectedTypes = this.args.typeFilter.selected; + if (selectedTypes.length > 0) { cards = cards.filter((card) => - pickerSelectedTypeNames.has(cardTypeDisplayName(card)), + selectedTypes.some((ref) => cardMatchesTypeRef(card, ref)), ); } } @@ -426,7 +499,7 @@ export default class SearchContent extends Component { return null; } - const cards = this.args.searchResource.instances; + const cards = this.searchResource.instances; const byRealm = new Map(); for (const card of cards) { @@ -530,7 +603,7 @@ export default class SearchContent extends Component { private get allCards(): string[] { const urls: string[] = []; // Cards from search results (realm sections) - respects type filter - for (const card of this.args.searchResource.instances) { + for (const card of this.searchResource.instances) { if (card.url) { urls.push(card.url.replace(/\.json$/, '')); } @@ -551,7 +624,7 @@ export default class SearchContent extends Component { private get hasNoResults(): boolean { return ( this.sections.length === 0 && - !this.args.searchResource.isLoading && + !this.searchResource.isLoading && !this.shouldSkipQuery ); } diff --git a/packages/host/app/components/realm-picker/index.gts b/packages/host/app/components/realm-picker/index.gts index 0176f1b2fc7..c1c564bc494 100644 --- a/packages/host/app/components/realm-picker/index.gts +++ b/packages/host/app/components/realm-picker/index.gts @@ -9,10 +9,16 @@ import type RealmServerService from '@cardstack/host/services/realm-server'; import WithKnownRealmsLoaded from '../with-known-realms-loaded'; +export interface RealmFilter { + selected: URL[]; + onChange: (selected: URL[]) => void; + /** Resolved realm URL strings (handles select-all → all available realms) */ + selectedURLs: string[]; +} + interface Signature { Args: { - selected: PickerOption[]; - onChange: (selected: PickerOption[]) => void; + filter: RealmFilter; label?: string; placeholder?: string; destination?: string; @@ -39,10 +45,18 @@ export default class RealmPicker extends Component { }; } - get selected(): PickerOption[] { - return this.args.selected.length > 0 - ? this.args.selected - : [this.selectAllOption]; + private get pickerSelected(): PickerOption[] { + const selected = this.args.filter.selected; + if (selected.length === 0) { + return [this.selectAllOption]; + } + return selected.map((url) => { + let realmURL = url.href; + let info = this.realm.info(realmURL); + let label = info?.name ?? this.realmDisplayNameFromURL(realmURL); + let icon = info?.iconURL ?? undefined; + return { id: realmURL, icon, label, type: 'option' as const }; + }); } get realmOptions(): PickerOption[] { @@ -62,6 +76,13 @@ export default class RealmPicker extends Component { return options; } + private onChange = (selected: PickerOption[]) => { + const urls = selected + .filter((opt) => opt.type !== 'select-all') + .map((opt) => new URL(opt.id)); + this.args.filter.onChange(urls); + }; + private realmDisplayNameFromURL(realmURL: string): string { try { const pathname = new URL(realmURL).pathname; @@ -81,8 +102,8 @@ export default class RealmPicker extends Component { { this.args.onSearch?.(searchKey); } - @action private handleRealmChange(_selectedRealms: URL[]) { + @tracked private selectedRealmURLs: URL[] = []; + + @action private handleRealmChange(selectedRealms: URL[]) { + this.selectedRealmURLs = selectedRealms; this.args.onFilterChange?.(); } + private get joinedSelectedRealmURLs(): string { + return this.selectedRealmURLs.map((u) => u.href).join(','); + } + @action private handleTypeChange(_selectedTypes: ResolvedCodeRef[]) { this.args.onFilterChange?.(); } @@ -274,11 +281,10 @@ export default class SearchSheet extends Component { @initialSelectedTypes={{this.initialSelectedTypes}} @onRealmChange={{this.handleRealmChange}} @onTypeChange={{this.handleTypeChange}} - as |Bar Content joinedRealmURLs| + as |Bar Content| > { @onKeyDown={{this.onSearchInputKeyDown}} @onInputInsertion={{@onInputInsertion}} @autocomplete='off' - data-test-search-realms={{joinedRealmURLs}} + data-test-search-realms={{this.joinedSelectedRealmURLs}} /> void; + onSearchChange: (term: string) => void; + onLoadMore: () => void; + hasMore: boolean; + isLoading: boolean; + isLoadingMore: boolean; + totalCount: number; + disableSelectAll: boolean; + /** Whether to skip type-based filtering on recent cards */ + skipTypeFiltering: boolean; + /** Internal key IDs of selected types, for building search queries */ + selectedTypeIds: string[]; +} + interface Signature { Args: { - disableSelectAll?: boolean; - options: PickerOption[]; - selected: PickerOption[]; - onChange: (selected: PickerOption[]) => void; + filter: TypeFilter; label?: string; - onSearchChange?: (term: string) => void; - onLoadMore?: () => void; - hasMore?: boolean; - isLoading?: boolean; - isLoadingMore?: boolean; - totalCount?: number; destination?: string; }; Blocks: {}; } export default class TypePicker extends Component { - // Provide a default selection so Picker's ensureDefaultSelection() never - // fires onChange to the parent. Without this, Picker sees an empty @selected - // on first render and calls onChange([select-all]), which the parent - // interprets as a user-initiated filter change (expanding the search sheet). @cached - get selectAllOption() { + get selectAllOption(): PickerOption { let count = - this.args.totalCount !== undefined - ? this.args.totalCount - : this.args.options.length; + this.args.filter.totalCount !== undefined + ? this.args.filter.totalCount + : this.args.filter.options.length; return { id: 'select-all', label: `Any Type (${count})`, shortLabel: `Any`, type: 'select-all', - ...(this.args.disableSelectAll ? { disabled: true } : {}), + ...(this.args.filter.disableSelectAll ? { disabled: true } : {}), }; } - get allOptions(): PickerOption[] { - return [this.selectAllOption, ...this.args.options]; + private get pickerOptions(): PickerOption[] { + const options: PickerOption[] = this.args.filter.options.map((opt) => ({ + id: opt.id, + label: opt.displayName, + tooltip: opt.id, + icon: opt.icon, + type: 'option' as const, + })); + return [this.selectAllOption, ...options]; } - get selected(): PickerOption[] { - return this.args.selected.length > 0 - ? this.args.selected - : [this.selectAllOption]; + private get pickerSelected(): PickerOption[] { + const selectedKeys = new Set( + this.args.filter.selected.map((ref) => internalKeyFor(ref, undefined)), + ); + if (selectedKeys.size === 0) { + return [this.selectAllOption]; + } + return this.pickerOptions.filter( + (opt) => opt.type !== 'select-all' && selectedKeys.has(opt.id), + ); } - get hasServerSearch(): boolean { - return !!this.args.onSearchChange; + private get hasServerSearch(): boolean { + return !!this.args.filter.onSearchChange; } + private onChange = (selected: PickerOption[]) => { + const refs = selected + .filter((opt) => opt.type !== 'select-all') + .map((opt) => codeRefFromInternalKey(opt.id)) + .filter((ref): ref is ResolvedCodeRef => ref !== undefined); + this.args.filter.onChange(refs); + }; + diff --git a/packages/host/app/resources/type-summaries.ts b/packages/host/app/resources/type-summaries.ts new file mode 100644 index 00000000000..a8efa20881a --- /dev/null +++ b/packages/host/app/resources/type-summaries.ts @@ -0,0 +1,400 @@ +import { isDestroyed, isDestroying } from '@ember/destroyable'; +import { registerDestructor } from '@ember/destroyable'; +import type Owner from '@ember/owner'; +import { setOwner } from '@ember/owner'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +import { restartableTask, timeout } from 'ember-concurrency'; +import { Resource } from 'ember-modify-based-class-resource'; + +import { + type Filter, + type ResolvedCodeRef, + baseCardRef, + baseFieldRef, + baseRef, + codeRefFromInternalKey, + internalKeyFor, + isResolvedCodeRef, +} from '@cardstack/runtime-common'; + +import { + getFilterTypeRefs, +} from '../components/card-search/utils'; + +import type RealmServerService from '../services/realm-server'; + +/** Domain-level representation of a type option (no PickerOption dependency). */ +export interface TypeOption { + id: string; // internal key + displayName: string; + icon?: string; +} + +type TypeSummaryItem = { + id: string; + type: string; + attributes: { displayName: string; total: number; iconHTML: string }; + meta?: { realmURL: string }; +}; + +export interface TypeSummariesArgs { + named: { + realmURLs: string[]; + baseFilter: Filter | undefined; + initialSelectedTypes: ResolvedCodeRef[] | undefined; + owner: Owner; + }; +} + +const ROOT_TYPE_KEYS = new Set([ + internalKeyFor(baseCardRef, undefined), + internalKeyFor(baseFieldRef, undefined), + internalKeyFor(baseRef, undefined), +]); + +export class TypeSummariesResource extends Resource { + @service declare private realmServer: RealmServerService; + + static PAGE_SIZE = 25; + + @tracked private _typeSummariesData: TypeSummaryItem[] = []; + @tracked private _isLoading = false; + @tracked private _isLoadingMore = false; + @tracked private _hasMore = false; + @tracked private _totalCount = 0; + @tracked private _options: TypeOption[] = []; + @tracked private _selected: ResolvedCodeRef[] = []; + + private _currentPage = 0; + private _typeSearchKey = ''; + + // Non-tracked: persists across resource re-runs without creating + // tracking dependencies. Updated by updateSelected and recomputation. + private _previousSelectedKeys: Set = new Set(); + + #previousRealmURLs: string[] | undefined; + #baseFilter: Filter | undefined; + #initialSelectedTypes: ResolvedCodeRef[] | undefined; + + constructor(owner: object) { + super(owner); + registerDestructor(this, () => { + this.fetchTypeSummariesTask.cancelAll(); + this.loadMoreTypesTask.cancelAll(); + }); + } + + modify(_positional: never[], named: TypeSummariesArgs['named']) { + let { realmURLs, baseFilter, initialSelectedTypes, owner } = named; + setOwner(this, owner); + + this.#baseFilter = baseFilter; + this.#initialSelectedTypes = initialSelectedTypes; + + // Only re-fetch when realmURLs change (search key changes are handled by onSearchChange) + let realmURLsKey = realmURLs.join(','); + let prevKey = this.#previousRealmURLs?.join(','); + + if (realmURLsKey !== prevKey) { + this.#previousRealmURLs = realmURLs; + this._typeSearchKey = ''; + this.fetchTypeSummariesTask.perform(realmURLs, ''); + } + } + + // -- Public getters -- + + get options(): TypeOption[] { + return this._options; + } + + get selected(): ResolvedCodeRef[] { + return this._selected; + } + + get isLoading(): boolean { + return this._isLoading; + } + + get isLoadingMore(): boolean { + return this._isLoadingMore; + } + + get hasMore(): boolean { + return this._hasMore; + } + + get totalCount(): number { + return this._totalCount; + } + + get hasNonRootBaseFilter(): boolean { + return this.baseFilterCodeRefs !== undefined; + } + + get selectedTypeIds(): string[] { + return this._selected.map((ref) => internalKeyFor(ref, undefined)); + } + + // -- Public methods -- + + onSearchChange(term: string): void { + this._typeSearchKey = term; + this.fetchTypeSummariesTask.perform( + this.#previousRealmURLs ?? [], + term, + ); + } + + onLoadMore(): void { + if (this._isLoading || this._isLoadingMore || !this._hasMore) { + return; + } + this.loadMoreTypesTask.perform(); + } + + updateSelected(selected: ResolvedCodeRef[]): void { + this._previousSelectedKeys = new Set( + selected.map((ref) => internalKeyFor(ref, undefined)), + ); + this._selected = selected; + } + + // -- Private computed -- + + private get baseFilterCodeRefs(): Set | undefined { + const typeRefs = getFilterTypeRefs(this.#baseFilter); + if (!typeRefs || typeRefs.length === 0) { + return undefined; + } + const refs = new Set(); + for (const { ref, negated } of typeRefs) { + if (!negated && isResolvedCodeRef(ref)) { + refs.add(internalKeyFor(ref, undefined)); + } + } + if (refs.size === 0) return undefined; + + // CardDef/FieldDef are root types — all card types inherit from them, + // so filtering by them would incorrectly show zero results. Skip. + if ([...refs].every((r) => ROOT_TYPE_KEYS.has(r))) { + return undefined; + } + + return refs; + } + + // -- Private tasks -- + + private fetchTypeSummariesTask = restartableTask( + async (realmURLs: string[], searchKey: string) => { + if (searchKey) { + await timeout(300); // debounce search + } else { + await Promise.resolve(); // yield to avoid autotracking assertion + } + if (isDestroyed(this) || isDestroying(this)) return; + this._isLoading = true; + this._currentPage = 0; + + try { + let result = await this.realmServer.fetchCardTypeSummaries(realmURLs, { + searchKey: searchKey || undefined, + page: { + number: 0, + size: TypeSummariesResource.PAGE_SIZE, + }, + }); + if (isDestroyed(this) || isDestroying(this)) return; + + this._typeSummariesData = result.data; + this._totalCount = result.meta.page.total; + this._hasMore = result.data.length < result.meta.page.total; + + // If there are selected types (or initialSelectedTypes) not yet in + // the fetched results, keep fetching more pages until they're found. + const selectedIds = new Set(this._previousSelectedKeys); + const initialTypes = this.#initialSelectedTypes; + if (initialTypes) { + for (const ref of initialTypes) { + selectedIds.add(internalKeyFor(ref, undefined)); + } + } + + if (selectedIds.size > 0 && this._hasMore) { + while (this._hasMore) { + const fetchedIds = new Set( + this._typeSummariesData.map((d) => d.id), + ); + if ([...selectedIds].every((id) => fetchedIds.has(id))) break; + + const nextPage = this._currentPage + 1; + let moreResult = await this.realmServer.fetchCardTypeSummaries( + realmURLs, + { + searchKey: searchKey || undefined, + page: { + number: nextPage, + size: TypeSummariesResource.PAGE_SIZE, + }, + }, + ); + if (isDestroyed(this) || isDestroying(this)) return; + + this._currentPage = nextPage; + this._typeSummariesData = [ + ...this._typeSummariesData, + ...moreResult.data, + ]; + this._hasMore = + this._typeSummariesData.length < moreResult.meta.page.total; + } + } + + this._isLoading = false; + this.recomputeTypeFilter(); + } catch (e) { + console.error('Failed to fetch card type summaries', e); + if (!isDestroyed(this) && !isDestroying(this)) { + this._typeSummariesData = []; + this._totalCount = 0; + this._hasMore = false; + this._isLoading = false; + this.recomputeTypeFilter(); + } + } + }, + ); + + private loadMoreTypesTask = restartableTask(async () => { + if (isDestroyed(this) || isDestroying(this)) return; + this._isLoadingMore = true; + const nextPage = this._currentPage + 1; + + try { + let result = await this.realmServer.fetchCardTypeSummaries( + this.#previousRealmURLs ?? [], + { + searchKey: this._typeSearchKey || undefined, + page: { + number: nextPage, + size: TypeSummariesResource.PAGE_SIZE, + }, + }, + ); + if (isDestroyed(this) || isDestroying(this)) return; + + this._currentPage = nextPage; + this._typeSummariesData = [...this._typeSummariesData, ...result.data]; + const totalFetched = this._typeSummariesData.length; + this._hasMore = totalFetched < result.meta.page.total; + this._isLoadingMore = false; + this.recomputeTypeFilter(); + } catch (e) { + console.error('Failed to load more card type summaries', e); + if (!isDestroyed(this) && !isDestroying(this)) { + this._isLoadingMore = false; + } + } + }); + + // -- Type filter computation -- + + private recomputeTypeFilter(): void { + const allowedCodeRefs = this.baseFilterCodeRefs; + const optionsById = new Map(); + + for (const item of this._typeSummariesData) { + const name = item.attributes.displayName; + const codeRef = item.id; + if (!name) { + continue; + } + + // Never show root types — they are internal meta types + if (ROOT_TYPE_KEYS.has(codeRef)) { + continue; + } + + // When baseFilter constrains to specific types, only show matching types + if (allowedCodeRefs && !allowedCodeRefs.has(codeRef)) { + continue; + } + + optionsById.set(codeRef, { + id: codeRef, + displayName: name, + icon: item.attributes.iconHTML ?? undefined, + }); + } + + this._options = [ + ...[...optionsById.values()].sort((a, b) => + a.displayName.localeCompare(b.displayName), + ), + ]; + + // Recalculate selected based on previous user selection. + const prevKeys = this._previousSelectedKeys; + const initialTypes = this.#initialSelectedTypes; + const hadSelectAll = prevKeys.size === 0; + + if (initialTypes && initialTypes.length > 0 && prevKeys.size === 0) { + // First launch with initialSelectedTypes (e.g., from "Find Instances"). + this._selected = [...initialTypes]; + } else if (hadSelectAll) { + // If baseFilter constrains to specific types and they exist in options, + // auto-select them instead of defaulting to "Any Type" + const baseTypeRefs = getFilterTypeRefs(this.#baseFilter); + const baseRefs = + baseTypeRefs + ?.filter((r) => !r.negated && isResolvedCodeRef(r.ref)) + .map((r) => internalKeyFor(r.ref, undefined)) ?? []; + if (baseRefs.length > 0) { + const autoSelected = baseRefs + .filter((ref) => optionsById.has(ref)) + .map((ref) => codeRefFromInternalKey(ref)) + .filter((ref): ref is ResolvedCodeRef => ref !== undefined); + this._selected = autoSelected.length > 0 ? autoSelected : []; + } else { + this._selected = []; + } + } else if (this._isLoading || this._isLoadingMore) { + // Type summaries still loading — keep previous selections + // to avoid jarring UI changes. No change to _selected. + } else { + // Keep previous selections that still exist in the new options. + const kept = [...prevKeys] + .filter((key) => optionsById.has(key)) + .map((key) => codeRefFromInternalKey(key)) + .filter((ref): ref is ResolvedCodeRef => ref !== undefined); + this._selected = kept.length > 0 ? kept : []; + } + + this._previousSelectedKeys = new Set( + this._selected.map((ref) => internalKeyFor(ref, undefined)), + ); + } +} + +export function getTypeSummaries( + parent: object, + owner: Owner, + getArgs: () => { + realmURLs: string[]; + baseFilter: Filter | undefined; + initialSelectedTypes: ResolvedCodeRef[] | undefined; + }, +) { + let resource = TypeSummariesResource.from(parent, () => ({ + named: { + realmURLs: getArgs().realmURLs, + baseFilter: getArgs().baseFilter, + initialSelectedTypes: getArgs().initialSelectedTypes, + owner, + }, + })); + return resource as TypeSummariesResource; +} diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 7d79cef9bb9..cd3fcd3e935 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -367,7 +367,8 @@ export default class RealmServerService extends Service { [realmUrl: string]: string; }) { if (!this.client) { - throw new Error(`Cannot check joined rooms without matrix client`); + console.warn(`Cannot check joined rooms without matrix client`); + return; } let { joined_rooms } = await this.client.getJoinedRooms(); let joinedRoomSet = new Set(joined_rooms ?? []); @@ -686,7 +687,8 @@ export default class RealmServerService extends Service { private loginTask = task(async () => { if (!this.client) { - throw new Error(`Cannot login to realm server without matrix client`); + console.warn(`Cannot login to realm server without matrix client`); + return; } try { let realmAuthClient = new RealmAuthClient( From a0ad325bce40a6acb438f84fa533d477567359cf Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 16 Apr 2026 15:29:36 +0700 Subject: [PATCH 3/6] CS-10802: Extract card-search utilities and simplify SearchPanel args - Replace SearchPanel's internal-leaking args with domain types (URL[], ResolvedCodeRef[] instead of PickerOption[], booleans) - Extract TypeSummariesResource from SearchPanel - Introduce RealmFilter/TypeFilter interfaces to bundle picker args - Move utilities to app/utils/card-search/ (7 focused files) - Deduplicate URL parsing into shared isURLSearchKey/resolveSearchKeyAsURL - Extract section building, pagination, type filtering, recent cards filtering into pure helper functions - Slim SearchContent from 736 to ~500 lines Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/card-catalog/modal.gts | 2 +- .../app/components/card-search/constants.ts | 7 + .../components/card-search/item-button.gts | 7 +- .../host/app/components/card-search/panel.gts | 38 +- .../components/card-search/search-content.gts | 406 ++++-------------- .../card-search/search-result-header.gts | 2 +- .../card-search/search-result-section.gts | 4 +- .../components/card-search/section-header.gts | 2 +- .../operator-mode/operator-mode-overlays.gts | 2 +- .../app/components/search-sheet/index.gts | 22 +- .../host/app/components/type-picker/index.gts | 2 - packages/host/app/resources/type-summaries.ts | 48 +-- .../services/operator-mode-state-service.ts | 2 +- .../card-search/query-builder.ts} | 84 +--- .../app/utils/card-search/recent-cards.ts | 104 +++++ .../utils/card-search/section-pagination.ts | 57 +++ .../host/app/utils/card-search/sections.ts | 212 +++++++++ .../host/app/utils/card-search/type-filter.ts | 129 ++++++ packages/host/app/utils/card-search/types.ts | 11 + packages/host/app/utils/card-search/url.ts | 25 ++ .../components/operator-mode-ui-test.gts | 14 +- 21 files changed, 676 insertions(+), 504 deletions(-) rename packages/host/app/{components/card-search/utils.ts => utils/card-search/query-builder.ts} (65%) create mode 100644 packages/host/app/utils/card-search/recent-cards.ts create mode 100644 packages/host/app/utils/card-search/section-pagination.ts create mode 100644 packages/host/app/utils/card-search/sections.ts create mode 100644 packages/host/app/utils/card-search/type-filter.ts create mode 100644 packages/host/app/utils/card-search/types.ts create mode 100644 packages/host/app/utils/card-search/url.ts diff --git a/packages/host/app/components/card-catalog/modal.gts b/packages/host/app/components/card-catalog/modal.gts index 9a754b51b09..44d207d637c 100644 --- a/packages/host/app/components/card-catalog/modal.gts +++ b/packages/host/app/components/card-catalog/modal.gts @@ -46,7 +46,7 @@ import type OperatorModeStateService from '../../services/operator-mode-state-se import type RealmService from '../../services/realm'; import type RealmServerService from '../../services/realm-server'; import type StoreService from '../../services/store'; -import type { NewCardArgs } from '../card-search/utils'; +import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; interface Signature { Args: {}; diff --git a/packages/host/app/components/card-search/constants.ts b/packages/host/app/components/card-search/constants.ts index 97340d2960e..96e7dfe5ce9 100644 --- a/packages/host/app/components/card-search/constants.ts +++ b/packages/host/app/components/card-search/constants.ts @@ -36,6 +36,13 @@ export const SECTION_SHOW_MORE_INCREMENT = 5; /** * Host-local SORT_OPTIONS compatible with realm server query expectations. * Aligned with SORT_OPTIONS in packages/base/components/cards-grid-layout.gts. + * + * Sort is dual-mode: + * - Server-side: the `sort` field is embedded in the search Query sent to + * the realm server, affecting prerendered search results (realm sections). + * - Client-side: `sortAndFilterRecentCards()` in utils/card-search/recent-cards.ts + * applies the same sort locally to recent cards, matching by `displayName`. + * - URL card lookup: no sort (single card). */ export const SORT_OPTIONS: SortOption[] = [ { diff --git a/packages/host/app/components/card-search/item-button.gts b/packages/host/app/components/card-search/item-button.gts index 30732b96e51..d6ceee6515d 100644 --- a/packages/host/app/components/card-search/item-button.gts +++ b/packages/host/app/components/card-search/item-button.gts @@ -15,9 +15,10 @@ import type { CardDef } from 'https://cardstack.com/base/card-api'; import CardRenderer from '../card-renderer'; -import { removeFileExtension } from './utils'; - -import type { NewCardArgs } from './utils'; +import { + removeFileExtension, + type NewCardArgs, +} from '@cardstack/host/utils/card-search/types'; import type { ComponentLike } from '@glint/template'; type ItemType = ComponentLike<{ Element: Element }> | CardDef | NewCardArgs; diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index 2166eadf335..f0f5b954140 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -4,17 +4,11 @@ import { service } from '@ember/service'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { - type Filter, - type ResolvedCodeRef, -} from '@cardstack/runtime-common'; +import { type Filter, type ResolvedCodeRef } from '@cardstack/runtime-common'; import type { RealmFilter } from '@cardstack/host/components/realm-picker'; import type { TypeFilter } from '@cardstack/host/components/type-picker'; -import { - getTypeSummaries, - TypeSummariesResource, -} from '@cardstack/host/resources/type-summaries'; +import { getTypeSummaries } from '@cardstack/host/resources/type-summaries'; import type RealmServerService from '@cardstack/host/services/realm-server'; import { SORT_OPTIONS, type SortOption } from './constants'; @@ -34,10 +28,7 @@ interface Signature { }; Blocks: { default: [ - WithBoundArgs< - typeof SearchBar, - 'value' | 'realmFilter' | 'typeFilter' - >, + WithBoundArgs, WithBoundArgs< typeof SearchContent, | 'searchKey' @@ -59,23 +50,11 @@ export default class SearchPanel extends Component { this.args.initialSelectedRealms ?? []; @tracked private activeSort: SortOption = SORT_OPTIONS[0]; - // Re-export for tests that override PAGE_SIZE - static get PAGE_SIZE() { - return TypeSummariesResource.PAGE_SIZE; - } - static set PAGE_SIZE(v: number) { - TypeSummariesResource.PAGE_SIZE = v; - } - - private typeSummaries = getTypeSummaries( - this, - getOwner(this)!, - () => ({ - realmURLs: this.selectedRealmURLs, - baseFilter: this.args.baseFilter, - initialSelectedTypes: this.args.initialSelectedTypes, - }), - ); + private typeSummaries = getTypeSummaries(this, getOwner(this)!, () => ({ + realmURLs: this.selectedRealmURLs, + baseFilter: this.args.baseFilter, + initialSelectedTypes: this.args.initialSelectedTypes, + })); private get initialFocusedSectionId(): string | null { let realmURLs = this.args.initialSelectedRealms; @@ -116,7 +95,6 @@ export default class SearchPanel extends Component { totalCount: ts.totalCount, disableSelectAll: ts.hasNonRootBaseFilter, skipTypeFiltering: ts.hasNonRootBaseFilter, - selectedTypeIds: ts.selectedTypeIds, }; } diff --git a/packages/host/app/components/card-search/search-content.gts b/packages/host/app/components/card-search/search-content.gts index 353667f9f2f..5e823c31505 100644 --- a/packages/host/app/components/card-search/search-content.gts +++ b/packages/host/app/components/card-search/search-content.gts @@ -20,41 +20,43 @@ import { CardContextName, GetCardContextName, GetCardCollectionContextName, + internalKeyFor, } from '@cardstack/runtime-common'; -import { urlForRealmLookup } from '@cardstack/host/lib/utils'; import { getPrerenderedSearch } from '@cardstack/host/resources/prerendered-search'; -import type LoaderService from '@cardstack/host/services/loader-service'; import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; import type RecentCards from '@cardstack/host/services/recent-cards-service'; -import type StoreService from '@cardstack/host/services/store'; import type { CardContext, CardDef } from 'https://cardstack.com/base/card-api'; -import { - SECTION_DISPLAY_LIMIT_FOCUSED, - SECTION_DISPLAY_LIMIT_UNFOCUSED, - SECTION_SHOW_MORE_INCREMENT, - SORT_OPTIONS, - VIEW_OPTIONS, - type SortOption, -} from './constants'; +import { SORT_OPTIONS, VIEW_OPTIONS, type SortOption } from './constants'; import SearchResultHeader from './search-result-header'; import SearchResultSection from './search-result-section'; -import type { PrerenderedCard } from '../prerendered-card-search'; - import type { RealmFilter } from '@cardstack/host/components/realm-picker'; import type { TypeFilter } from '@cardstack/host/components/type-picker'; import { buildSearchQuery, - cardMatchesTypeRef, - filterCardsByTypeRefs, - getFilterTypeRefs, shouldSkipSearchQuery, - type NewCardArgs, -} from './utils'; +} from '@cardstack/host/utils/card-search/query-builder'; +import { + filterRecentCards, + sortAndFilterRecentCards, +} from '@cardstack/host/utils/card-search/recent-cards'; +import { SectionPagination } from '@cardstack/host/utils/card-search/section-pagination'; +import { + assembleSections, + buildQuerySections, + buildRecentsSection, + buildUrlSection, + type SearchSheetSection, +} from '@cardstack/host/utils/card-search/sections'; +import { type NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import { + isURLSearchKey, + resolveSearchKeyAsURL, +} from '@cardstack/host/utils/card-search/url'; import type { NamedArgs } from 'ember-modifier'; interface ScrollToFocusedSectionSignature { @@ -112,37 +114,6 @@ class ScrollToFocusedSection extends Modifier { } } -export interface RealmSectionInfo { - name: string; - iconURL: string | null; - publishable: boolean | null; -} - -export interface RealmSection { - sid: string; - type: 'realm'; - realmUrl: string; - realmInfo: RealmSectionInfo; - cards: PrerenderedCard[]; - totalCount: number; -} - -export interface RecentsSection { - sid: string; - type: 'recents'; - cards: CardDef[]; - totalCount: number; -} - -export interface UrlSection { - sid: string; - type: 'url'; - card: CardDef; - realmInfo: RealmSectionInfo; -} - -export type SearchSheetSection = RealmSection | RecentsSection | UrlSection; - interface Signature { Element: HTMLElement; Args: { @@ -172,18 +143,13 @@ interface Signature { const OWNER_DESTROYED_ERROR = 'OWNER_DESTROYED_ERROR'; export default class SearchContent extends Component { - @service declare loaderService: LoaderService; @service declare realm: RealmService; @service declare realmServer: RealmServerService; - @service declare store: StoreService; @service('recent-cards-service') declare private recentCardsService: RecentCards; @tracked activeViewId = 'grid'; - /** Section id when focused: 'realm:' or 'recents'. Null = no focus */ - @tracked focusedSection: string | null = - this.args.initialFocusedSection ?? null; - @tracked displayedCountBySection: Record = {}; + private pagination = new SectionPagination(this.args.initialFocusedSection); @consume(GetCardContextName) declare private getCard: getCard; @consume(CardContextName) declare private cardContext: @@ -192,7 +158,31 @@ export default class SearchContent extends Component { @consume(GetCardCollectionContextName) declare private getCardCollection: getCardCollection; - // -- Internal resources (moved from SearchPanel) -- + private get searchKeyIsURL() { + return isURLSearchKey(this.args.searchKey); + } + + private get searchKeyAsURL() { + return resolveSearchKeyAsURL( + this.args.searchKey, + this.realmServer.availableRealmURLs, + ); + } + + @cached + private get cardResource(): ReturnType { + return this.getCard(this, () => this.searchKeyAsURL); + } + + private get resolvedCard(): CardDef | undefined { + return this.cardResource?.card; + } + + private get isCardResourceLoaded(): boolean { + return this.cardResource?.isLoaded ?? false; + } + + // -- Card component modifier -- private get cardComponentModifier() { if (isDestroying(this) || isDestroyed(this)) { @@ -214,7 +204,9 @@ export default class SearchContent extends Component { private searchResource = getPrerenderedSearch(this, getOwner(this)!, () => { // Consume selectedTypeIds outside the ternary so the tracking // dependency is always established, even when the query is skipped. - const selectedTypeIds = this.args.typeFilter.selectedTypeIds; + const selectedTypeIds = this.args.typeFilter.selected.map((ref) => + internalKeyFor(ref, undefined), + ); return { query: shouldSkipSearchQuery(this.args.searchKey, this.args.baseFilter) ? undefined @@ -244,17 +236,11 @@ export default class SearchContent extends Component { (this.recentCardCollection?.cards?.filter(Boolean) as | CardDef[] | undefined) ?? []; - const realmURLs = this.args.realmFilter.selectedURLs; - const realmFiltered = cards.filter( - (c) => c.id && realmURLs.some((url) => c.id.startsWith(url)), + return filterRecentCards( + cards, + this.args.realmFilter.selectedURLs, + this.args.baseFilter, ); - const typeRefs = getFilterTypeRefs(this.args.baseFilter); - return filterCardsByTypeRefs(realmFiltered, typeRefs); - } - - @cached - private get cardResource(): ReturnType { - return this.getCard(this, () => this.searchKeyAsURL); } private get shouldSkipQuery() { @@ -274,15 +260,6 @@ export default class SearchContent extends Component { return (this.args.searchKey?.trim() ?? '') === ''; } - private get searchKeyIsURL() { - try { - new URL(this.args.searchKey); - return true; - } catch (_e) { - return false; - } - } - private get searchTerm(): string | undefined { if (this.isSearchKeyEmpty || this.searchKeyIsURL) { return undefined; @@ -290,26 +267,6 @@ export default class SearchContent extends Component { return this.args.searchKey?.trim(); } - private get searchKeyAsURL() { - if (!this.searchKeyIsURL) { - return undefined; - } - let cardURL = this.args.searchKey; - - let maybeIndexCardURL = this.realmServer.availableRealmURLs.find( - (u) => u === cardURL + '/', - ); - return maybeIndexCardURL ?? cardURL; - } - - private get resolvedCard() { - return this.cardResource?.card; - } - - private get isCardResourceLoaded() { - return this.cardResource?.isLoaded ?? false; - } - private get realms() { const urls = this.args.realmFilter.selectedURLs.length > 0 @@ -344,77 +301,15 @@ export default class SearchContent extends Component { } private get sortedRecentCards(): CardDef[] { - let cards = [...(this.filteredRecentCards ?? [])]; - - // Apply type picker filter (from TypePicker selection). - // Skip when baseFilter has a non-root type — the server already - // constrains results via the adoption chain, and recent cards are - // pre-filtered by filterCardsByTypeRefs which handles subtypes. - if (!this.args.typeFilter.skipTypeFiltering) { - const selectedTypes = this.args.typeFilter.selected; - if (selectedTypes.length > 0) { - cards = cards.filter((card) => - selectedTypes.some((ref) => cardMatchesTypeRef(card, ref)), - ); - } - } - - if (this.args.isCompact) { - return cards; - } - let filtered = cards; - const term = this.searchTerm; - if (term) { - const lowerTerm = term.toLowerCase(); - filtered = cards.filter((c) => - (c.cardTitle ?? '').toLowerCase().includes(lowerTerm), - ); - } - const sortOption = this.args.activeSort; - const displayName = sortOption.displayName; - return [...filtered].sort((a, b) => { - if (displayName === 'A-Z') { - return (a.cardTitle ?? '').localeCompare(b.cardTitle ?? ''); - } - if (displayName === 'Last Updated') { - const aVal = - 'lastModified' in a - ? ((a as Record).lastModified as number) - : 0; - const bVal = - 'lastModified' in b - ? ((b as Record).lastModified as number) - : 0; - return bVal - aVal; - } - if (displayName === 'Date Created') { - const aVal = - 'createdAt' in a - ? ((a as Record).createdAt as number) - : 0; - const bVal = - 'createdAt' in b - ? ((b as Record).createdAt as number) - : 0; - return bVal - aVal; - } - return 0; + return sortAndFilterRecentCards(this.filteredRecentCards, { + selectedTypes: this.args.typeFilter.selected, + skipTypeFiltering: this.args.typeFilter.skipTypeFiltering, + searchTerm: this.searchTerm, + activeSort: this.args.activeSort, + isCompact: this.args.isCompact, }); } - private get recentCardsSection(): RecentsSection | undefined { - const cards = this.sortedRecentCards; - if (cards.length === 0) { - return undefined; - } - return { - sid: 'recents', - type: 'recents', - cards, - totalCount: cards.length, - }; - } - @action onChangeView(id: string) { this.activeViewId = id; @@ -427,177 +322,54 @@ export default class SearchContent extends Component { @action onFocusSection(sectionId: string | null) { - this.focusedSection = sectionId; - if (sectionId) { - const current = this.displayedCountBySection[sectionId] ?? 0; - const limit = SECTION_DISPLAY_LIMIT_FOCUSED; - if (current < limit) { - this.displayedCountBySection = { - ...this.displayedCountBySection, - [sectionId]: limit, - }; - } - } + this.pagination.focus(sectionId); } getDisplayedCount = (sectionId: string, totalCount: number): number => { - const isFocused = this.focusedSection === sectionId; - const initialLimit = isFocused - ? SECTION_DISPLAY_LIMIT_FOCUSED - : SECTION_DISPLAY_LIMIT_UNFOCUSED; - const current = this.displayedCountBySection[sectionId] ?? initialLimit; - return Math.min(current, totalCount); + return this.pagination.getDisplayedCount(sectionId, totalCount); }; @action onShowMore(sectionId: string, totalCount: number) { - const current = this.getDisplayedCount(sectionId, totalCount); - const next = Math.min(current + SECTION_SHOW_MORE_INCREMENT, totalCount); - this.displayedCountBySection = { - ...this.displayedCountBySection, - [sectionId]: next, - }; + this.pagination.showMore(sectionId, totalCount); } - private realmNameFromUrl(realmUrl: string): string { - try { - const pathname = new URL(realmUrl).pathname; - const segments = pathname.split('/').filter(Boolean); - return segments[segments.length - 1] ?? 'Workspace'; - } catch { - return 'Workspace'; - } + private get recentCardsSection() { + return buildRecentsSection(this.sortedRecentCards); } - private get cardByUrlSection(): SearchSheetSection | undefined { - if (!this.searchKeyIsURL || !this.resolvedCard) { - return undefined; - } - const card = this.resolvedCard; - const urlForRealm = urlForRealmLookup(card); - const realmUrl = this.realmUrlForCard(urlForRealm); - const realmInfo = realmUrl ? this.realm.info(realmUrl) : null; - return { - sid: `url:${card.id}`, - type: 'url', - card, - realmInfo: { - name: realmInfo?.name ?? this.realmNameFromUrl(realmUrl), - iconURL: realmInfo?.iconURL ?? null, - publishable: realmInfo?.publishable ?? null, - }, - } as UrlSection; + private get cardByUrlSection() { + return buildUrlSection( + this.resolvedCard, + this.searchKeyIsURL, + this.realms, + this.realm, + ); } - private get cardsByQuerySection(): SearchSheetSection[] | null { - if (this.searchKeyIsURL) { - return null; - } - - // In search-sheet mode (no baseFilter), skip when search key is empty - if (!this.args.baseFilter && this.isSearchKeyEmpty) { - return null; - } - - const cards = this.searchResource.instances; - const byRealm = new Map(); - - for (const card of cards) { - const list: PrerenderedCard[] = byRealm.get(card.realmUrl) ?? []; - list.push(card); - byRealm.set(card.realmUrl, list); - } - - const sections: RealmSection[] = []; - for (const [realmUrl, realmCards] of byRealm) { - const realmInfo = this.realm.info(realmUrl); - sections.push({ - sid: `realm:${realmUrl}`, - type: 'realm', - realmUrl, - realmInfo: { - name: realmInfo?.name ?? this.realmNameFromUrl(realmUrl), - iconURL: realmInfo?.iconURL ?? null, - publishable: realmInfo?.publishable ?? null, - }, - cards: realmCards, - totalCount: realmCards.length, - }); - } - - // When offerToCreate is provided, include empty sections for all - // available/selected realms that have no results, so users can - // create new cards in those realms. - if (this.args.offerToCreate) { - for (const realmUrl of this.realms) { - if (!byRealm.has(realmUrl)) { - const realmInfo = this.realm.info(realmUrl); - sections.push({ - sid: `realm:${realmUrl}`, - type: 'realm', - realmUrl, - realmInfo: { - name: realmInfo?.name ?? this.realmNameFromUrl(realmUrl), - iconURL: realmInfo?.iconURL ?? null, - publishable: realmInfo?.publishable ?? null, - }, - cards: [], - totalCount: 0, - }); - } - } - } - - return sections; + private get cardsByQuerySection() { + return buildQuerySections(this.searchResource.instances, { + isURL: this.searchKeyIsURL, + isSearchKeyEmpty: this.isSearchKeyEmpty, + hasBaseFilter: !!this.args.baseFilter, + realmURLs: this.realms, + offerToCreate: this.args.offerToCreate, + realm: this.realm, + }); } private get sections(): SearchSheetSection[] { - const sections: SearchSheetSection[] = []; - - // Add recents section if present - if (this.recentCardsSection) { - sections.push(this.recentCardsSection); - } - - // Add URL section if present - if (this.cardByUrlSection) { - sections.push(this.cardByUrlSection); - } - - // Add query sections if present - if (this.cardsByQuerySection) { - sections.push(...this.cardsByQuerySection); - } - - // Move focused section to front so it appears at the top - if (this.focusedSection) { - const idx = sections.findIndex((s) => s.sid === this.focusedSection); - if (idx > 0) { - const [focused] = sections.splice(idx, 1); - sections.unshift(focused); - } - } - - return sections; - } - - private realmUrlForCard(cardIdOrUrl: string): string { - for (const realm of this.realms) { - if (cardIdOrUrl.startsWith(realm)) { - return realm; - } - } - try { - const url = new URL(cardIdOrUrl); - return `${url.origin}${url.pathname.split('/').slice(0, -1)?.join('/') ?? ''}/`; - } catch { - return ''; - } + return assembleSections( + this.recentCardsSection, + this.cardByUrlSection, + this.cardsByQuerySection, + this.pagination.focusedSection, + ); } @action isSectionCollapsed(sectionId: string): boolean { - return !!this.focusedSection && this.focusedSection !== sectionId; + return this.pagination.isCollapsed(sectionId); } private get allCards(): string[] { @@ -632,7 +404,7 @@ export default class SearchContent extends Component {