From 4641b080f275d16a67ee1064f6e240adc1bfdfef Mon Sep 17 00:00:00 2001 From: EYHN Date: Thu, 3 Jul 2025 11:11:55 +0800 Subject: [PATCH] feat(core): quick search support search locally (#12987) ## Summary by CodeRabbit * **New Features** * Introduced a "search locally" option in the docs quick search, allowing users to perform searches on their local device when supported. * Added new quick search group labels and options for local search, with dynamic UI updates based on search mode. * **Improvements** * Enhanced search responsiveness by reducing input throttling delay. * Added a pre-submission check to improve search item handling. * Improved stability by handling cases where document IDs may be missing during search result processing. * **Localization** * Added English language support for new local search options and labels. --- .../src/mobile/pages/workspace/search.tsx | 16 +++-- .../mobile/views/search/search-results.tsx | 4 ++ .../core/src/mobile/views/search/style.css.ts | 7 ++ .../quicksearch/entities/quick-search.ts | 3 + .../src/modules/quicksearch/impls/docs.ts | 64 ++++++++++++++++--- .../core/src/modules/quicksearch/index.ts | 2 + .../providers/quick-search-provider.ts | 1 + .../src/modules/quicksearch/services/cmdk.ts | 7 +- .../src/modules/quicksearch/types/item.ts | 1 + packages/frontend/i18n/src/i18n.gen.ts | 10 +++ packages/frontend/i18n/src/resources/en.json | 2 + 11 files changed, 101 insertions(+), 16 deletions(-) diff --git a/packages/frontend/core/src/mobile/pages/workspace/search.tsx b/packages/frontend/core/src/mobile/pages/workspace/search.tsx index 6911440c2..5ddc07907 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/search.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/search.tsx @@ -10,6 +10,7 @@ import { QuickSearchTagIcon, } from '@affine/core/modules/quicksearch'; import { TagService } from '@affine/core/modules/tag'; +import { UserFriendlyError } from '@affine/error'; import { useI18n } from '@affine/i18n'; import { sleep } from '@blocksuite/affine/global/utils'; import { ViewLayersIcon } from '@blocksuite/icons/rc'; @@ -105,13 +106,17 @@ const WithQueryList = () => { const docList = useLiveData(searchService.docs.items$); const tagList = useLiveData(searchService.tags.items$); + const error = useLiveData(searchService.docs.error$); + const docs = useMemo( () => - docList.map(item => ({ - id: item.payload.docId, - icon: item.icon, - title: , - })), + docList + .filter(item => item.id !== 'search-locally') + .map(item => ({ + id: item.payload.docId, + icon: item.icon, + title: , + })), [docList] ); @@ -121,6 +126,7 @@ const WithQueryList = () => { docs={docs} collections={collectionList} tags={tagList} + error={error ? UserFriendlyError.fromAny(error).message : null} /> ); }; diff --git a/packages/frontend/core/src/mobile/views/search/search-results.tsx b/packages/frontend/core/src/mobile/views/search/search-results.tsx index 498688686..d1215a92a 100644 --- a/packages/frontend/core/src/mobile/views/search/search-results.tsx +++ b/packages/frontend/core/src/mobile/views/search/search-results.tsx @@ -12,6 +12,7 @@ export interface SearchResultsProps { docs?: DocCardProps['meta'][]; collections?: UniversalSearchResultItemProps['item'][]; tags?: UniversalSearchResultItemProps['item'][]; + error?: any; } const Empty = () => { @@ -26,11 +27,14 @@ export const SearchResults = ({ docs, collections, tags, + error, }: SearchResultsProps) => { return ( <>
{title}
+ {error &&

{error}

} + {!docs?.length && !collections?.length && !tags?.length ? ( ) : null} diff --git a/packages/frontend/core/src/mobile/views/search/style.css.ts b/packages/frontend/core/src/mobile/views/search/style.css.ts index 60537be18..521bbaa44 100644 --- a/packages/frontend/core/src/mobile/views/search/style.css.ts +++ b/packages/frontend/core/src/mobile/views/search/style.css.ts @@ -1,3 +1,4 @@ +import { cssVar } from '@toeverything/theme'; import { bodyEmphasized, footnoteRegular, @@ -84,3 +85,9 @@ export const empty = style([ color: cssVarV2('text/primary'), }, ]); + +export const errorMessage = style({ + padding: '0px 16px 16px', + fontSize: cssVar('fontSm'), + color: cssVarV2('status/error'), +}); diff --git a/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts b/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts index ea6b23fb7..f4959c0fc 100644 --- a/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts +++ b/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts @@ -110,6 +110,9 @@ export class QuickSearch extends Entity { } submit(result: QuickSearchItem | null) { + if (result && result.beforeSubmit && !result.beforeSubmit?.()) { + return; + } if (this.state$.value?.callback) { this.state$.value.sessions.forEach(session => session.dispose?.()); this.state$.value.callback(result); diff --git a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts index d240f8fd4..a54ce2999 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts @@ -1,3 +1,5 @@ +import { ServerFeature } from '@affine/graphql'; +import { SearchIcon } from '@blocksuite/icons/rc'; import { effect, Entity, @@ -8,6 +10,7 @@ import { import { truncate } from 'lodash-es'; import { catchError, EMPTY, map, of, switchMap, tap, throttleTime } from 'rxjs'; +import type { WorkspaceServerService } from '../../cloud'; import type { DocRecord, DocsService } from '../../doc'; import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocsSearchService } from '../../docs-search'; @@ -28,6 +31,7 @@ export class DocsQuickSearchSession { constructor( private readonly workspaceService: WorkspaceService, + private readonly workspaceServerService: WorkspaceServerService, private readonly docsSearchService: DocsSearchService, private readonly docsService: DocsService, private readonly docDisplayMetaService: DocDisplayMetaService @@ -35,6 +39,11 @@ export class DocsQuickSearchSession super(); } + private readonly isSupportServerIndexer = () => + this.workspaceServerService.server?.config$.value.features.includes( + ServerFeature.Indexer + ) ?? false; + private readonly isIndexerLoading$ = this.docsSearchService.indexerState$.map( ({ completed }) => { return !completed; @@ -45,6 +54,26 @@ export class DocsQuickSearchSession isCloudWorkspace = this.workspaceService.workspace.flavour !== 'local'; + searchLocallyItem = { + id: 'search-locally', + source: 'docs', + label: { + title: { + i18nKey: 'com.affine.quicksearch.search-locally', + }, + }, + score: 1000, + icon: SearchIcon, + payload: { + docId: '', + }, + beforeSubmit: () => { + this.searchLocally = true; + this.query(this.lastQuery); + return false; + }, + } as QuickSearchItem<'docs', DocsPayload>; + isLoading$ = LiveData.computed(get => { return ( (this.isCloudWorkspace ? false : get(this.isIndexerLoading$)) || @@ -54,12 +83,17 @@ export class DocsQuickSearchSession error$ = new LiveData(null); - query$ = new LiveData(''); + lastQuery = ''; items$ = new LiveData[]>([]); + searchLocally = false; + query = effect( - throttleTime(1000, undefined, { + tap(query => { + this.lastQuery = query; + }), + throttleTime(500, undefined, { leading: false, trailing: true, }), @@ -87,7 +121,9 @@ export class DocsQuickSearchSession group: { id: 'docs', label: { - i18nKey: 'com.affine.quicksearch.group.searchfor', + i18nKey: this.searchLocally + ? 'com.affine.quicksearch.group.searchfor-locally' + : 'com.affine.quicksearch.group.searchfor', options: { query: truncate(query) }, }, score: 5, @@ -107,17 +143,29 @@ export class DocsQuickSearchSession } return out.pipe( tap((items: QuickSearchItem<'docs', DocsPayload>[]) => { - this.items$.next(items); + this.items$.next( + this.isSupportServerIndexer() && !this.searchLocally + ? [...items, this.searchLocallyItem] + : items + ); this.isQueryLoading$.next(false); }), onStart(() => { this.error$.next(null); - this.items$.next([]); + this.items$.next( + this.isSupportServerIndexer() && !this.searchLocally + ? [this.searchLocallyItem] + : [] + ); this.isQueryLoading$.next(true); }), catchError(err => { this.error$.next(err instanceof Error ? err.message : err); - this.items$.next([]); + this.items$.next( + this.isSupportServerIndexer() && !this.searchLocally + ? [this.searchLocallyItem] + : [] + ); this.isQueryLoading$.next(false); return EMPTY; }), @@ -128,10 +176,6 @@ export class DocsQuickSearchSession // TODO(@EYHN): load more - setQuery(query: string) { - this.query$.next(query); - } - override dispose(): void { this.query.unsubscribe(); } diff --git a/packages/frontend/core/src/modules/quicksearch/index.ts b/packages/frontend/core/src/modules/quicksearch/index.ts index be3f4f48a..4af1a6f5d 100644 --- a/packages/frontend/core/src/modules/quicksearch/index.ts +++ b/packages/frontend/core/src/modules/quicksearch/index.ts @@ -1,5 +1,6 @@ import { type Framework } from '@toeverything/infra'; +import { WorkspaceServerService } from '../cloud'; import { CollectionService } from '../collection'; import { WorkspaceDialogService } from '../dialogs'; import { DocsService } from '../doc'; @@ -56,6 +57,7 @@ export function configureQuickSearchModule(framework: Framework) { .entity(CommandsQuickSearchSession, [GlobalContextService]) .entity(DocsQuickSearchSession, [ WorkspaceService, + WorkspaceServerService, DocsSearchService, DocsService, DocDisplayMetaService, diff --git a/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts b/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts index 8d83df112..746aa2b9d 100644 --- a/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts +++ b/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts @@ -16,6 +16,7 @@ export interface QuickSearchSession { query?: (query: string) => void; loadMore?: () => void; dispose?: () => void; + beforeSubmit?: (item: QuickSearchItem) => boolean; } export type QuickSearchSource = diff --git a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts index c4439d7bc..e5b8d3a52 100644 --- a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts +++ b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts @@ -60,10 +60,14 @@ export class CMDKQuickSearchService extends Service { if (result.source === 'recent-doc' || result.source === 'docs') { const doc: { - docId: string; + docId?: string; blockId?: string; } = result.payload; + if (!doc.docId) { + return; + } + result.source === 'recent-doc' && track.$.cmdk.recent.recentDocs(); result.source === 'docs' && track.$.cmdk.results.searchResultsDocs(); @@ -71,6 +75,7 @@ export class CMDKQuickSearchService extends Service { const options: { docId: string; blockIds?: string[] } = { docId: doc.docId, }; + if (doc.blockId) { options.blockIds = [doc.blockId]; } diff --git a/packages/frontend/core/src/modules/quicksearch/types/item.ts b/packages/frontend/core/src/modules/quicksearch/types/item.ts index c697da3b8..3555aee52 100644 --- a/packages/frontend/core/src/modules/quicksearch/types/item.ts +++ b/packages/frontend/core/src/modules/quicksearch/types/item.ts @@ -18,4 +18,5 @@ export type QuickSearchItem = { keyBinding?: string; timestamp?: number; payload?: P; + beforeSubmit?: () => boolean; } & (P extends NonNullable ? { payload: P } : unknown); diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index c83b603c2..5043fe378 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -4519,12 +4519,22 @@ export function useAFFiNEI18N(): { * `New` */ ["com.affine.quicksearch.group.creation"](): string; + /** + * `Search locally` + */ + ["com.affine.quicksearch.search-locally"](): string; /** * `Search for "{{query}}"` */ ["com.affine.quicksearch.group.searchfor"](options: { readonly query: string; }): string; + /** + * `Search for "{{query}}" (locally)` + */ + ["com.affine.quicksearch.group.searchfor-locally"](options: { + readonly query: string; + }): string; /** * `Reset sync` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index be3ba7ea1..42c640aed 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1121,7 +1121,9 @@ "com.affine.split-view-folder-warning.description": "Split view does not support folders.", "do-not-show-this-again": "Do not show this again", "com.affine.quicksearch.group.creation": "New", + "com.affine.quicksearch.search-locally": "Search locally", "com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"", + "com.affine.quicksearch.group.searchfor-locally": "Search for \"{{query}}\" (locally)", "com.affine.resetSyncStatus.button": "Reset sync", "com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.", "com.affine.rootAppSidebar.collections": "Collections",