Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,35 @@ let response = await request
```

The response is an `entry` collection document: each entry resolves to prerendered HTML (the fast path) or a live serialization, and `fields: ['item']` asks for the full card/file serialization in `included`.

### Result kinds and `scope`

The index holds two row kinds: card instances and files (a card's `.json` is dual-indexed — it appears as both an `instance` row and a `file` row that share the same URL). A search spans both kinds by default.

Select which kinds a query returns with the `scope` member (default `'all'`):

- `scope: 'cards'` — card instances only.
- `scope: 'files'` — file rows only (including a card `.json`'s file row).
- `scope: 'all'` — both kinds. This returns **both** rows of a dual-indexed card `.json`. To keep each card once (drop the duplicate `.json` file row) add the dedup filter `excludeCardInstanceFileRows()` — i.e. `eq: { _isCardInstanceFile: false }`.

`scope` pins the row kind directly, independent of the filter, so it works with any filter shape. Prefer it over discriminating kind through a `{ type: … }` anchor.

```ts
// Card instances only, across realms:
searchEntryWireQueryFromQuery(query, { fields: ['item'], scope: 'cards' });
```

### The mixed-search key contract

Cards and files carry non-overlapping fields, so a filter that names a card field narrows to cards by construction, and a FileDef-typed filter narrows to files. A mixed (`scope: 'all'`) query can filter and sort across both kinds only through the keys both row kinds carry:

- `matches` — full-text search over both kinds' content.
- `_title` — the row's display title (a card's `cardTitle`, a file's name); usable in `contains` and `sort`.
- `_isCardInstanceFile` — stamped `true` only on a card `.json`'s file row; the dedup key (`eq: false` keeps everything else).
- `{ type: <BaseDef> }` — BaseDef terminates both kinds' type chains, so a BaseDef-anchored type filter matches every row and composes with other conditions.

Any other field key narrows the result to a single kind. `_cardType` (the card type's display name) is a card-only synthetic — file rows never carry it — which makes it a kind discriminator in mixed queries (`not: { eq: { _cardType: null } }` is a cards-only filter) but a poor mixed sort key: every file sinks to the NULLS-LAST tail.

When does a BaseDef anchor beat `scope: 'all'`? `scope` is query-global — it pins the row kind for the whole request — while `{ type: <BaseDef> }` is a filter node, composable inside the tree: one branch of an `any:` can span both kinds while a sibling branch is card-typed, or it can intersect with another type condition under `every:`. If the whole query wants both kinds, `scope: 'all'` is the spelling; the BaseDef anchor is for kind-spanning _within_ a filter composition.

`_`-prefixed top-level keys are reserved for these synthetics — the query engine resolves them by name before any field-definition lookup, so a user-defined field with a colliding name would silently get the synthetic's semantics (see `runtime-common/search-doc-keys.ts`).
7 changes: 7 additions & 0 deletions packages/base/cards-grid.gts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ class Isolated extends Component<typeof CardsGrid> {
displayName: 'All Cards',
icon: AllCardsIcon,
query: {
// Cards-only by construction: file rows (both plain files and a
// card's dual-indexed `.json`) never carry `_cardType`, so the
// `not eq` excludes them via NULL semantics even though search is
// mixed by default — while errored instances (which have `_cardType`
// but may lack a `types` array) are kept, since this is a field
// filter with no type-anchor cross-join. The "All Files" group
// (`type: baseFileRef`) owns the file side.
filter: {
not: {
eq: {
Expand Down
6 changes: 6 additions & 0 deletions packages/boxel-cli/src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ interface SearchEntryRequestBody {
sort?: Record<string, unknown>[];
page?: unknown;
cardUrls?: unknown;
// Which row kinds to span: 'cards' | 'files' | 'all' (default 'all' server-
// side). Pass 'cards' to restrict to card instances.
scope?: unknown;
}

/**
Expand Down Expand Up @@ -150,6 +153,9 @@ export function searchEntryRequestBody(
if (query.cardUrls !== undefined) {
body.cardUrls = query.cardUrls;
}
if (query.scope !== undefined) {
body.scope = query.scope;
}
return body;
}

Expand Down
6 changes: 4 additions & 2 deletions packages/host/app/components/card-chooser/mini/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Filter } from '@cardstack/runtime-common';

import SearchPanel from '@cardstack/host/components/card-search/panel';
import {
removeFileExtension,
removeCardJsonExtension,
type NewCardArgs,
} from '@cardstack/host/utils/card-search/types';

Expand Down Expand Up @@ -43,7 +43,7 @@ export default class MiniCardChooser extends Component<Signature> {
if (typeof selection !== 'string') {
return;
}
let normalized = removeFileExtension(selection);
let normalized = removeCardJsonExtension(selection);
if (normalized) {
this.args.onSelect(normalized);
}
Expand All @@ -54,6 +54,7 @@ export default class MiniCardChooser extends Component<Signature> {
<SearchPanel
@searchKey={{this.searchKey}}
@baseFilter={{@baseFilter}}
@cardsOnly={{true}}
as |Bar Content|
>
<header class='mini-card-chooser__header'>
Expand All @@ -66,6 +67,7 @@ export default class MiniCardChooser extends Component<Signature> {
<div class='mini-card-chooser__results'>
<Content
@isCompact={{false}}
@cardsOnly={{true}}
@handleSelect={{this.handleSelect}}
@showHeader={{true}}
@variant='mini'
Expand Down
6 changes: 5 additions & 1 deletion packages/host/app/components/card-chooser/modal.gts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export default class CardChooserModal extends Component<Signature> {
@initialSelectedRealms={{this.initialSelectedRealmsForPanel}}
@initialSelectedTypes={{this.initialSelectedTypesForPanel}}
@lockSelectedRealms={{state.lockConsumingRealm}}
@cardsOnly={{true}}
as |Bar Content|
>
<ModalContainer
Expand Down Expand Up @@ -146,6 +147,7 @@ export default class CardChooserModal extends Component<Signature> {
<:content>
<Content
@isCompact={{false}}
@cardsOnly={{true}}
@handleSelect={{this.selectFromSearch}}
@onSubmit={{this.submitFromSearch}}
@selectedCards={{state.selectedCards}}
Expand Down Expand Up @@ -383,8 +385,10 @@ export default class CardChooserModal extends Component<Signature> {
});
let preselectedCardUrl: string | undefined;
if (opts?.preselectedCardTypeQuery) {
// The result is used as a card instance; store.search pins
// `scope: 'cards'`, so the raw query resolves to card instances only.
let instances: CardDef[] = await this.store.search(
opts.preselectedCardTypeQuery!,
opts.preselectedCardTypeQuery,
this.realmServer.availableRealmIdentifiers,
);
if (instances?.[0]?.id) {
Expand Down
13 changes: 13 additions & 0 deletions packages/host/app/components/card-prerender.gts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import Component from '@glimmer/component';
import { didCancel, enqueueTask } from 'ember-concurrency';

import {
baseRef,
CardError,
internalKeysFor,
SupportedMimeType,
type CardErrorsJSONAPI,
type LooseSingleCardDocument,
Expand Down Expand Up @@ -713,8 +715,19 @@ export default class CardPrerender extends Component {
types: string[],
renderOptions?: RenderRouteOptions,
) => {
// BaseDef is part of the searchable `types` chain (so `{ type: baseRef }`
// spans cards and files) but it is abstract — a card coerced to BaseDef
// has no meaningful fitted/embedded template, so rendering that level is
// pure index bloat. Skip it; the ancestor renderings cover the concrete
// card types through CardDef.
let baseDefKeys = new Set(
internalKeysFor(baseRef, undefined, this.network.virtualNetwork),
);
let ancestors: Record<string, string> = {};
for (let i = 0; i < types.length; i++) {
if (baseDefKeys.has(types[i])) {
continue;
}
let res = await this.renderHTML.perform(url, format, i, renderOptions);
ancestors[types[i]] = res as string;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/host/app/components/card-search/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ export const SORT_OPTIONS: SortOption[] = [
module: baseRRI('card-api'),
name: 'CardDef',
},
by: 'cardTitle',
// The sheet is mixed cards + files, so sort on the synthetic `_title`
// key that both row types carry (a card's title — mirrored from
// `cardTitle` at index time — and a file's name). Sorting on
// `cardTitle` alone would leave file rows NULL and sink them all below
// the cards under NULLS LAST.
by: '_title',
Comment thread
FadhlanR marked this conversation as resolved.
direction: 'asc',
},
],
Expand Down
16 changes: 15 additions & 1 deletion packages/host/app/components/card-search/panel-content.gts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type RecentCards from '@cardstack/host/services/recent-cards-service';
import {
buildRecentsQuery,
buildSearchQuery,
searchScopeForOptions,
shouldSkipSearchQuery,
} from '@cardstack/host/utils/card-search/query-builder';
import { SectionPagination } from '@cardstack/host/utils/card-search/section-pagination';
Expand Down Expand Up @@ -112,6 +113,9 @@ interface Signature {
realmFilter: RealmFilter;
typeFilter: TypeFilter;
baseFilter?: Filter;
// Pin the result set to card instances. Search is mixed (cards + files)
// by default; the card choosers set this so file rows never surface.
cardsOnly?: boolean;
isCompact: boolean;
handleSelect: (selection: string | NewCardArgs) => void;
selectedCards?: (string | NewCardArgs)[];
Expand Down Expand Up @@ -206,7 +210,9 @@ export default class PanelContent extends Component<Signature> {
this.args.activeSort,
this.args.baseFilter,
selectedTypeIds,
{ cardsOnly: this.args.cardsOnly },
),
{ scope: searchScopeForOptions({ cardsOnly: this.args.cardsOnly }) },
),
realms: this.args.realmFilter.selectedURLs,
// Cap each realm's results at the focused-section display limit — the
Expand Down Expand Up @@ -286,7 +292,11 @@ export default class PanelContent extends Component<Signature> {
}
if (this.args.isCompact) {
return this.withMiniHtmlQuery({
...searchEntryWireQueryFromQuery({}),
// A recent's `.json` URL also names the card's dual-indexed file row, so
// an unfiltered `cardUrls` query would surface each recent twice.
// Recents are always cards, so `scope: 'cards'` keeps only the instance
// rows (regardless of the sheet's cardsOnly mode).
...searchEntryWireQueryFromQuery({}, { scope: 'cards' }),
realms: this.realms,
cardUrls: this.recentCardUrls,
});
Expand All @@ -309,7 +319,11 @@ export default class PanelContent extends Component<Signature> {
this.args.activeSort,
this.args.baseFilter,
selectedTypeIds,
// Recents are always cards, so pin the card scope regardless of the
// sheet's cardsOnly mode (this also skips the redundant dedup filter).
{ cardsOnly: true },
),
{ scope: 'cards' },
),
realms: this.recentsSearchRealms,
cardUrls: this.recentCardUrls,
Expand Down
4 changes: 4 additions & 0 deletions packages/host/app/components/card-search/panel.gts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ interface Signature {
* and the realm picker is disabled.
*/
lockSelectedRealms?: boolean;
// A cards-only chooser: the type picker offers card types only (file types
// are hidden so one can't be selected against the card scope).
cardsOnly?: boolean;
onRealmChange?: (selectedRealms: URL[]) => void;
onTypeChange?: (selectedTypes: ResolvedCodeRef[]) => void;
};
Expand Down Expand Up @@ -59,6 +62,7 @@ export default class SearchPanel extends Component<Signature> {
realmURLs: this.selectedRealmURLs,
baseFilter: this.args.baseFilter,
initialSelectedTypes: this.args.initialSelectedTypes,
cardsOnly: this.args.cardsOnly,
}));

private get initialFocusedSectionId(): string | null {
Expand Down
12 changes: 8 additions & 4 deletions packages/host/app/components/card-search/result-section.gts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type {
} from '@cardstack/host/utils/card-search/sections';

import {
removeFileExtension,
removeCardJsonExtension,
type NewCardArgs,
} from '@cardstack/host/utils/card-search/types';

Expand Down Expand Up @@ -400,7 +400,7 @@ export default class ResultSection extends Component<Signature> {
Show
{{this.nextShowMoreCount}}
more
{{pluralize 'card' this.nextShowMoreCount}}
{{pluralize 'result' this.nextShowMoreCount}}
({{this.remainingCount}}
not shown)
</Button>
Expand Down Expand Up @@ -470,7 +470,9 @@ export default class ResultSection extends Component<Signature> {
@adorn={{@adorn}}
@adornStrokeClass={{@adornStrokeClass}}
@adornPositionLabel={{@adornPositionLabel}}
data-test-recent-card-result={{removeFileExtension card.id}}
data-test-recent-card-result={{removeCardJsonExtension
card.id
}}
/>
</:default>
<:after>
Expand Down Expand Up @@ -552,7 +554,9 @@ export default class ResultSection extends Component<Signature> {
>
Show
{{this.nextShowMoreCount}}
more cards ({{this.remainingCount}}
more
{{pluralize 'result' this.nextShowMoreCount}}
({{this.remainingCount}}
not shown)
</Button>
{{/if}}
Expand Down
12 changes: 8 additions & 4 deletions packages/host/app/components/card-search/result-tile.gts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import AdornSelectChip from '@cardstack/host/components/adorn/adorn-select-chip'
import { htmlComponent } from '@cardstack/host/lib/html-component';

import {
removeFileExtension,
removeCardJsonExtension,
type NewCardArgs,
} from '@cardstack/host/utils/card-search/types';

Expand Down Expand Up @@ -207,7 +207,7 @@ export default class SearchResultTile extends Component<Signature> {
{{on 'keydown' this.handleKeydown}}
{{this.registerCardEl}}
data-test-item-button-create-new={{@newCard.realmURL}}
data-test-item-button={{removeFileExtension this.resolvedItemId}}
data-test-item-button={{removeCardJsonExtension this.resolvedItemId}}
data-test-item-button-selected={{if @isSelected 'true'}}
...attributes
>
Expand Down Expand Up @@ -272,15 +272,19 @@ export default class SearchResultTile extends Component<Signature> {
{{else if @entry}}
<@entry.component
class='hide-boundaries'
data-test-search-result={{removeFileExtension this.resolvedItemId}}
data-test-search-result={{removeCardJsonExtension
this.resolvedItemId
}}
/>
{{else if @card}}
<CardRenderer
@card={{@card}}
@format='fitted'
@codeRef={{defaultResultsCardRef}}
@displayContainer={{false}}
data-test-search-result={{removeFileExtension this.resolvedItemId}}
data-test-search-result={{removeCardJsonExtension
this.resolvedItemId
}}
/>
{{/if}}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion packages/host/app/components/card-search/sheet-results.gts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export default class SheetResults extends Component<Signature> {

{{#if this.hasNoResults}}
<div class='empty-state' data-test-search-content-empty>
No cards available
No results found
</div>
{{/if}}
{{/if}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { IconX, IconPlus } from '@cardstack/boxel-ui/icons';
import {
chooseCard,
isResolvedCodeRef,
removeFileExtension,
rri,
type ToolContext,
type ResolvedCodeRef,
Expand All @@ -37,6 +36,7 @@ import type LoaderService from '@cardstack/host/services/loader-service';
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';
import type RealmService from '@cardstack/host/services/realm';
import type ToolService from '@cardstack/host/services/tool-service';
import { removeCardJsonExtension } from '@cardstack/host/utils/card-search/types';

interface Signature {
Args: {};
Expand Down Expand Up @@ -113,13 +113,19 @@ export default class CreateListingModal extends Component<Signature> {
// chosen card URLs (matched by index file URL, hence the `.json`-suffixed
// `selectedExampleCardUrls`) and their realms, with the `atom` rendering
// bound through the filter's `htmlQuery` (the way to select a prerendered
// format). The eq carries only that binding, which the engine lifts out,
// leaving the result set scoped purely by `cardUrls`.
// format). The engine lifts the htmlQuery binding out of the eq (which then
// dissolves). `scope: 'cards'` pins the instance row, dropping each example's
// dual-indexed `.json` file row that shares its `cardUrls` URL.
private get exampleSearchQuery(): SearchEntryWireQuery {
return {
cardUrls: this.selectedExampleCardUrls,
realms: this.selectedExampleRealms,
filter: { eq: { htmlQuery: { eq: { format: 'atom' } } } },
scope: 'cards',
filter: {
eq: {
htmlQuery: { eq: { format: 'atom' } },
},
},
};
}

Expand All @@ -146,9 +152,9 @@ export default class CreateListingModal extends Component<Signature> {
});

@action private removeSelectedExample(urlToRemove: string) {
let normalizedUrlToRemove = removeFileExtension(urlToRemove);
let normalizedUrlToRemove = removeCardJsonExtension(urlToRemove);
this._selectedExampleURLs = this.selectedExampleURLs.filter(
(url) => removeFileExtension(url) !== normalizedUrlToRemove,
(url) => removeCardJsonExtension(url) !== normalizedUrlToRemove,
);
}

Expand Down
Loading
Loading