Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -137,6 +138,8 @@ export default class SubmodeLayout extends Component<Signature> {
@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;
Expand Down Expand Up @@ -346,7 +349,11 @@ export default class SubmodeLayout extends Component<Signature> {

@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();
Expand Down
55 changes: 42 additions & 13 deletions packages/host/app/components/search-sheet/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -87,11 +88,31 @@ const repositionDropdownsOnTransitionEnd = modifier((element: Element) => {
const BASE_FILTER: Filter = { type: baseRef };

export default class SearchSheet extends Component<Signature> {
@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);
Expand Down Expand Up @@ -141,29 +162,27 @@ export default class SearchSheet extends Component<Signature> {

@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) {
Expand All @@ -176,14 +195,20 @@ export default class SearchSheet extends Component<Signature> {
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) {
this.searchSheetState.activeSort = option;
}

@action private onSearchInputKeyDown(e: Event) {
let kbEvent = e as KeyboardEvent;
if (kbEvent.key === 'Escape') {
Expand Down Expand Up @@ -268,8 +293,12 @@ export default class SearchSheet extends Component<Signature> {
@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|
>
<Bar
Expand Down
92 changes: 88 additions & 4 deletions packages/host/app/components/search/panel-content.gts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { service } from '@ember/service';
import Component from '@glimmer/component';
import { cached, tracked } from '@glimmer/tracking';

import Modifier from 'ember-modifier';
import Modifier, { modifier } from 'ember-modifier';
import { consume } from 'ember-provide-consume-context';

import { cn, eq } from '@cardstack/boxel-ui/helpers';
Expand All @@ -14,6 +14,7 @@ import {
type getCard,
type SearchEntryWireFilter,
type SearchEntryWireQuery,
type SearchResultsYield,
baseCardRef,
GetCardContextName,
internalKeyFor,
Expand All @@ -26,6 +27,7 @@ import type { TypeFilter } from '@cardstack/host/components/type-picker';
import type NetworkService from '@cardstack/host/services/network';
import type RealmServerService from '@cardstack/host/services/realm-server';
import type RecentCards from '@cardstack/host/services/recent-cards-service';
import type SearchSheetStateService from '@cardstack/host/services/search-sheet-state';

import {
buildRecentsQuery,
Expand Down Expand Up @@ -131,6 +133,11 @@ interface Signature {
activeSort: SortOption;
onSortChange: (sort: SortOption) => 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;
Expand All @@ -157,9 +164,32 @@ export default class PanelContent extends Component<Signature> {
@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;

Expand Down Expand Up @@ -376,6 +406,55 @@ export default class PanelContent extends Component<Signature> {
this.args.onSortChange(option);
}

// The results to render for the main search. While persisting, if a snapshot
// from a previous open exists and the recreated live resource hasn't produced
// rows yet, show the snapshot (flagged loading, so the "refreshing" indicator
// shows) — a single clean handoff to the live results once they land.
mainResultsFor = (live: SearchResultsYield): SearchResultsYield => {
if (!this.args.persist) {
return live;
}
let snapshot = this.searchSheetState.mainSnapshot;
if (snapshot && live.entries.length === 0 && live.isLoading) {
return {
entries: snapshot.entries,
meta: snapshot.meta,
isLoading: true,
errors: undefined,
};
}
return live;
};

// Capture the live main results into the service whenever they settle, so a
// later reopen can redisplay them. Guarded on `!isLoading` so a transient
// empty loading state (e.g. the re-run on reopen) never clobbers the cache.
// `<SearchResults>` yields a fresh `results` object every render, so this
// modifier re-runs on every render; the content check keeps it from writing
// an equal-but-new snapshot each time, which — since `mainResultsFor` reads
// `mainSnapshot` during render — would otherwise feed back into a re-render
// loop.
private captureMainSnapshot = modifier(
(_element: Element, [live]: [SearchResultsYield]) => {
if (live.isLoading) {
return;
}
let current = this.searchSheetState.mainSnapshot;
let unchanged =
current !== undefined &&
current.entries.length === live.entries.length &&
current.meta.page?.total === live.meta.page?.total &&
current.entries.every((entry, i) => entry.id === live.entries[i].id);
if (unchanged) {
return;
}
this.searchSheetState.mainSnapshot = {
entries: [...live.entries],
meta: live.meta,
};
},
);

<template>
<div
{{ScrollToFocusedSection
Expand Down Expand Up @@ -406,6 +485,11 @@ export default class PanelContent extends Component<Signature> {
@mode='none'
as |mainResults|
>
{{#if @persist}}
{{! Capture the settled main results so a reopen can redisplay them
instantly. }}
<span hidden {{this.captureMainSnapshot mainResults}}></span>
{{/if}}
<SearchResults
@query={{this.recentsSearchQuery}}
@mode='none'
Expand All @@ -417,7 +501,7 @@ export default class PanelContent extends Component<Signature> {
as |liveRecentCards|
>
<SheetResults
@mainResults={{mainResults}}
@mainResults={{this.mainResultsFor mainResults}}
@recentsResults={{recentsResults}}
@liveRecentCards={{liveRecentCards}}
@isCompact={{@isCompact}}
Expand Down
14 changes: 13 additions & 1 deletion packages/host/app/components/search/panel.gts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,16 @@ interface Signature {
// 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;
// Seed the sort from a restored value (the search sheet passes its
// persisted sort); defaults to the first option otherwise.
initialActiveSort?: SortOption;
// When true, view + pagination + last-results state persist to the
// session-scoped search-sheet-state service (the operator-mode search sheet
// opts in; the card choosers do not). Forwarded to PanelContent.
persist?: boolean;
onRealmChange?: (selectedRealms: URL[]) => void;
onTypeChange?: (selectedTypes: ResolvedCodeRef[]) => void;
onSortChange?: (option: SortOption) => void;
};
Blocks: {
default: [
Expand All @@ -46,6 +54,7 @@ interface Signature {
| 'activeSort'
| 'onSortChange'
| 'initialFocusedSection'
| 'persist'
>,
];
};
Expand All @@ -56,7 +65,8 @@ export default class SearchPanel extends Component<Signature> {

@tracked private selectedRealms: URL[] =
this.args.initialSelectedRealms ?? [];
@tracked private activeSort: SortOption = SORT_OPTIONS[0];
@tracked private activeSort: SortOption =
this.args.initialActiveSort ?? SORT_OPTIONS[0];

private typeSummaries = getTypeSummaries(this, getOwner(this)!, () => ({
realmURLs: this.selectedRealmURLs,
Expand Down Expand Up @@ -128,6 +138,7 @@ export default class SearchPanel extends Component<Signature> {
@action
private onSortChange(option: SortOption) {
this.activeSort = option;
this.args.onSortChange?.(option);
}

@action
Expand Down Expand Up @@ -157,6 +168,7 @@ export default class SearchPanel extends Component<Signature> {
activeSort=this.activeSort
onSortChange=this.onSortChange
initialFocusedSection=this.initialFocusedSectionId
persist=@persist
)
}}
</template>
Expand Down
Loading
Loading