diff --git a/packages/host/app/components/operator-mode/submode-layout.gts b/packages/host/app/components/operator-mode/submode-layout.gts index 59e102b70cc..060818f5c8e 100644 --- a/packages/host/app/components/operator-mode/submode-layout.gts +++ b/packages/host/app/components/operator-mode/submode-layout.gts @@ -51,6 +51,7 @@ import type AiAssistantPanelService from '../../services/ai-assistant-panel-serv import type MatrixService from '../../services/matrix-service'; import type OperatorModeStateService from '../../services/operator-mode-state-service'; import type RecentCardsService from '../../services/recent-cards-service'; +import type SearchSheetStateService from '../../services/search-sheet-state'; import type StoreService from '../../services/store'; import type { SearchSheetMode } from '../search-sheet'; import type { Submode } from '../submode-switcher'; @@ -137,6 +138,8 @@ export default class SubmodeLayout extends Component { @service declare private store: StoreService; @service declare private aiAssistantPanelService: AiAssistantPanelService; @service declare private recentCardsService: RecentCardsService; + @service('search-sheet-state') + declare private searchSheetState: SearchSheetStateService; private searchElement: HTMLElement | null = null; private suppressSearchClose = false; @@ -346,7 +349,11 @@ export default class SubmodeLayout extends Component { @action private openSearchSheetToPrompt() { if (this.searchSheetMode === SearchSheetModes.Closed) { - this.searchSheetMode = SearchSheetModes.SearchPrompt; + // Reopen straight to the results view when a search is persisted, so the + // restored results are shown immediately rather than the compact prompt. + this.searchSheetMode = this.searchSheetState.searchKey.trim() + ? SearchSheetModes.SearchResults + : SearchSheetModes.SearchPrompt; } this.searchElement?.focus(); diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index 6a4342ff841..937501b8537 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -4,7 +4,6 @@ import type Owner from '@ember/owner'; import { debounce } from '@ember/runloop'; import { service } from '@ember/service'; import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; import onClickOutside from 'ember-click-outside/modifiers/on-click-outside'; import { modifier } from 'ember-modifier'; @@ -27,6 +26,7 @@ import { } from '@cardstack/runtime-common'; import type RealmServerService from '@cardstack/host/services/realm-server'; +import type SearchSheetStateService from '@cardstack/host/services/search-sheet-state'; import { isURLSearchKey, resolveSearchKeyAsURL, @@ -35,6 +35,7 @@ import { import SearchPanel from '../search/panel'; import type StoreService from '../../services/store'; +import type { SortOption } from '../search/constants'; export const SearchSheetModes = { Closed: 'closed', @@ -87,11 +88,31 @@ const repositionDropdownsOnTransitionEnd = modifier((element: Element) => { const BASE_FILTER: Filter = { type: baseRef }; export default class SearchSheet extends Component { - @tracked private searchKey = ''; - @tracked private initialSelectedTypes: ResolvedCodeRef[] | undefined; - @service declare private realmServer: RealmServerService; @service declare private store: StoreService; + @service('search-sheet-state') + declare private searchSheetState: SearchSheetStateService; + + // The sheet's search state is held in the session-scoped service so it + // survives the close/reopen that unmounts this component's subtree. + private get searchKey() { + return this.searchSheetState.searchKey; + } + private set searchKey(value: string) { + this.searchSheetState.searchKey = value; + } + + private get initialSelectedTypes(): ResolvedCodeRef[] | undefined { + return this.searchSheetState.selectedTypes; + } + + private get initialSelectedRealms(): URL[] { + return this.searchSheetState.selectedRealms; + } + + private get initialActiveSort(): SortOption | undefined { + return this.searchSheetState.activeSort; + } constructor(owner: Owner, args: any) { super(owner, args); @@ -141,29 +162,27 @@ export default class SearchSheet extends Component { @action private onBlur() { + // A plain close/blur keeps the search so reopening restores it; only an + // explicit Cancel (or Escape) resets. this.args.onBlur(); - if (this.args.mode === SearchSheetModes.Closed) { - this.resetState(); - } } @action private handleCardSelect(selection: string | { realmURL: string }) { if (typeof selection !== 'string') { return; } - this.resetState(); + // Selecting a result keeps the search, so reopening returns to it. this.args.onCardSelect(selection); } @action private doExternallyTriggeredSearch(term: string, typeRef?: ResolvedCodeRef) { this.searchKey = term; - this.initialSelectedTypes = typeRef ? [typeRef] : undefined; + this.searchSheetState.selectedTypes = typeRef ? [typeRef] : undefined; } private resetState() { - this.searchKey = ''; - this.initialSelectedTypes = undefined; + this.searchSheetState.resetState(); } @action private debouncedSetSearchKey(searchKey: string) { @@ -176,14 +195,23 @@ export default class SearchSheet extends Component { this.args.onSearch?.(searchKey); } - @action private handleRealmChange(_selectedRealms: URL[]) { + @action private handleRealmChange(selectedRealms: URL[]) { + this.searchSheetState.selectedRealms = selectedRealms; this.args.onFilterChange?.(); } - @action private handleTypeChange(_selectedTypes: ResolvedCodeRef[]) { + @action private handleTypeChange(selectedTypes: ResolvedCodeRef[]) { + this.searchSheetState.selectedTypes = selectedTypes; this.args.onFilterChange?.(); } + @action private handleSortChange(option: SortOption) { + // Unlike realm/type changes, no `onFilterChange` here: the sort control only + // exists in the results view, so there's never a prompt→results expansion to + // trigger — just record the choice for persistence. + this.searchSheetState.activeSort = option; + } + @action private onSearchInputKeyDown(e: Event) { let kbEvent = e as KeyboardEvent; if (kbEvent.key === 'Escape') { @@ -268,8 +296,12 @@ export default class SearchSheet extends Component { @searchKey={{this.searchKey}} @baseFilter={{BASE_FILTER}} @initialSelectedTypes={{this.initialSelectedTypes}} + @initialSelectedRealms={{this.initialSelectedRealms}} + @initialActiveSort={{this.initialActiveSort}} + @persist={{true}} @onRealmChange={{this.handleRealmChange}} @onTypeChange={{this.handleTypeChange}} + @onSortChange={{this.handleSortChange}} as |Bar Content| > void; initialFocusedSection?: string | null; + // When true, view + pagination back onto the session-scoped + // search-sheet-state service, and the main results are snapshotted there so + // a reopen redisplays them immediately (the operator-mode search sheet opts + // in; the card choosers keep their own ephemeral state). + persist?: boolean; // When true, search-result tiles render the Adorn visual treatment // (teal hover type-label tab + teal selection chip). adorn?: boolean; @@ -157,9 +164,32 @@ export default class PanelContent extends Component { @service declare realmServer: RealmServerService; @service('recent-cards-service') declare private recentCardsService: RecentCards; + @service('search-sheet-state') + declare private searchSheetState: SearchSheetStateService; + + @tracked private _localActiveViewId = 'grid'; + #localPagination = new SectionPagination(this.args.initialFocusedSection); + + // View + pagination live in the persistence service when persisting so they + // survive the sheet's close/reopen; otherwise they are component-local. + get activeViewId() { + return this.args.persist + ? this.searchSheetState.activeViewId + : this._localActiveViewId; + } + set activeViewId(id: string) { + if (this.args.persist) { + this.searchSheetState.activeViewId = id; + } else { + this._localActiveViewId = id; + } + } - @tracked activeViewId = 'grid'; - pagination = new SectionPagination(this.args.initialFocusedSection); + get pagination(): SectionPagination { + return this.args.persist + ? this.searchSheetState.pagination + : this.#localPagination; + } @consume(GetCardContextName) declare private getCard: getCard; @@ -225,6 +255,15 @@ export default class PanelContent extends Component { }); } + // A stable identity for the current main search — the serialized wire query. + // Undefined when the search is idle (empty key or a URL paste, both skipped). + // The snapshot is keyed by this so a reopen only redisplays results for the + // same search, and an idle sheet never captures (or clobbers) a snapshot. + private get mainQueryKey(): string | undefined { + let query = this.mainSearchQuery; + return query ? JSON.stringify(query) : undefined; + } + // In the mini card chooser every result row renders as the uniform CardDef // fitted tile instead of each card's own fitted template. Bind that // rendering through the wire filter's top-level `eq` htmlQuery — fitted @@ -376,12 +415,104 @@ export default class PanelContent extends Component { this.args.onSortChange(option); } + // The snapshot to seed the fresh main-search resource with on reopen, so it + // adopts the prior rows and skips its fetch — no re-run, no "Searching…" + // flash. Only offered while persisting, and only when a captured snapshot + // belongs to the current query and actually has rows; the resource re-checks + // the query match before adopting it. A changed term/filter/sort has a + // different `mainQueryKey`, so the seed no longer matches and the search runs + // normally. + get mainSearchSeed(): MainResultsSnapshot | undefined { + if (!this.args.persist) { + return undefined; + } + let snapshot = this.searchSheetState.mainSnapshot; + if ( + snapshot === undefined || + snapshot.entries.length === 0 || + snapshot.queryKey !== this.mainQueryKey + ) { + return undefined; + } + return snapshot; + } + + // Persist a freshly-settled main-search snapshot for a later reopen. Skips a + // write that matches the stored one (same query, same rows) so a live re-run + // that changed nothing doesn't churn the seed identity. + persistMainSnapshot = (snapshot: MainResultsSnapshot) => { + let current = this.searchSheetState.mainSnapshot; + let unchanged = + current !== undefined && + current.queryKey === snapshot.queryKey && + current.entries.length === snapshot.entries.length && + current.meta.page?.total === snapshot.meta.page?.total && + current.entries.every((entry, i) => entry.id === snapshot.entries[i].id); + if (unchanged) { + return; + } + this.searchSheetState.mainSnapshot = snapshot; + }; + + // Restore and track the results-list scroll offset across a sheet + // close/reopen (only while persisting; the choosers keep their own scroll). + // + // Capture records the live offset on every scroll event, so the service + // always holds the current position. We deliberately don't capture on + // teardown: by the time the modifier's destructor runs the element is + // detached and reports `scrollTop === 0`, which would clobber the good value. + // + // Restore is a per-frame retry rather than a single rAF: the seeded rows land + // a microtask + render after mount, so on the first frame the list is usually + // not yet tall enough to accept the offset (setting it would clamp to 0). + // Re-apply each frame until it sticks (or the content settles shorter than + // the offset), then stop. The saved offset is read inside the frame, not in + // the modifier body, so recording new positions never re-runs this modifier. + // Runs AFTER `ScrollToFocusedSection` (placed earlier on the element), so a + // restored focused section can't leave the list scrolled to the top. + private persistScroll = modifier((element: HTMLElement) => { + if (!this.args.persist) { + return undefined; + } + let rafId: number | undefined; + let target: number | undefined; + let attempts = 0; + let restore = () => { + if (target === undefined) { + target = this.searchSheetState.resultsScrollTop; + } + if (target <= 0) { + return; + } + element.scrollTop = target; + attempts += 1; + if (Math.abs(element.scrollTop - target) > 1 && attempts < 30) { + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state -- awaiting post-seed layout + rafId = requestAnimationFrame(restore); + } + }; + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state -- restore needs post-layout scroll height + rafId = requestAnimationFrame(restore); + + let onScroll = () => { + this.searchSheetState.resultsScrollTop = element.scrollTop; + }; + element.addEventListener('scroll', onScroll, { passive: true }); + return () => { + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + element.removeEventListener('scroll', onScroll); + }; + }); + diff --git a/packages/host/app/components/search/search-results.gts b/packages/host/app/components/search/search-results.gts index 6193c50d907..99b87c91f9f 100644 --- a/packages/host/app/components/search/search-results.gts +++ b/packages/host/app/components/search/search-results.gts @@ -1,3 +1,4 @@ +import { isDestroyed, isDestroying } from '@ember/destroyable'; import { service } from '@ember/service'; import Component from '@glimmer/component'; @@ -16,8 +17,27 @@ import { import type { HydrationMode } from './hydratable-card'; +import type { MainResultsSnapshot } from '../../services/search-sheet-state'; import type StoreService from '../../services/store'; +// The card-facing signature plus two host-only, optional extras used by the +// search sheet to persist and rehydrate its results across a close/reopen. +// They stay off the public `SearchResultsComponentSignature` (the `@context` +// contract cards see) and are absent for every other consumer — the card +// choosers, playground, and card authors — so their behavior is unchanged. +interface HostSearchResultsSignature { + Element: SearchResultsComponentSignature['Element']; + Args: SearchResultsComponentSignature['Args'] & { + // A prior run's snapshot to adopt on mount instead of fetching, when it + // belongs to the current query (forwarded to the search resource). + seed?: MainResultsSnapshot; + // Invoked with a fresh snapshot whenever the results settle, so the caller + // can persist it for a later reopen. + onSnapshot?: (snapshot: MainResultsSnapshot) => void; + }; + Blocks: SearchResultsComponentSignature['Blocks']; +} + // The one search component family. Consumes the heterogeneous `entry` // stream from `getSearchEntriesResource` (through the shared render-stable // view-model layer) and renders it transparently — prerendered HTML inert (the @@ -26,7 +46,7 @@ import type StoreService from '../../services/store'; // `errors`); used without one it renders the default stream of // `entry.component`s itself. Additive: it supersedes the `prerendered-card-search` // component and the live `SearchContent` tree as call sites migrate. -export default class SearchResults extends Component { +export default class SearchResults extends Component { @service declare private store: StoreService; #log = runtimeLogger('search-results'); @@ -47,6 +67,7 @@ export default class SearchResults extends Component this.args.query, () => this.mode, () => this.overlays, + () => this.args.seed, ); private get results(): SearchResultsYield { @@ -82,10 +103,55 @@ export default class SearchResults extends Component { + if (!this.args.onSnapshot || this.renderables.isLoading) { + return; + } + if (this.args.query === undefined) { + return; + } + if (this.#snapshotCaptureScheduled) { + return; + } + this.#snapshotCaptureScheduled = true; + void Promise.resolve().then(() => { + this.#snapshotCaptureScheduled = false; + if (isDestroyed(this) || isDestroying(this)) { + return; + } + let query = this.args.query; + if (!this.args.onSnapshot || this.renderables.isLoading || !query) { + return; + } + this.args.onSnapshot({ + queryKey: JSON.stringify(query), + entries: [...this.renderables.rawEntries], + meta: this.renderables.meta, + }); + }); + }, + ); +