feat(core): quick search support search locally (#12987)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-07-03 11:11:55 +08:00
committed by GitHub
parent aa7edb7255
commit 4641b080f2
11 changed files with 101 additions and 16 deletions
@@ -10,6 +10,7 @@ import {
QuickSearchTagIcon, QuickSearchTagIcon,
} from '@affine/core/modules/quicksearch'; } from '@affine/core/modules/quicksearch';
import { TagService } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag';
import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { sleep } from '@blocksuite/affine/global/utils'; import { sleep } from '@blocksuite/affine/global/utils';
import { ViewLayersIcon } from '@blocksuite/icons/rc'; import { ViewLayersIcon } from '@blocksuite/icons/rc';
@@ -105,13 +106,17 @@ const WithQueryList = () => {
const docList = useLiveData(searchService.docs.items$); const docList = useLiveData(searchService.docs.items$);
const tagList = useLiveData(searchService.tags.items$); const tagList = useLiveData(searchService.tags.items$);
const error = useLiveData(searchService.docs.error$);
const docs = useMemo( const docs = useMemo(
() => () =>
docList.map(item => ({ docList
id: item.payload.docId, .filter(item => item.id !== 'search-locally')
icon: item.icon, .map(item => ({
title: <SearchResLabel item={item} />, id: item.payload.docId,
})), icon: item.icon,
title: <SearchResLabel item={item} />,
})),
[docList] [docList]
); );
@@ -121,6 +126,7 @@ const WithQueryList = () => {
docs={docs} docs={docs}
collections={collectionList} collections={collectionList}
tags={tagList} tags={tagList}
error={error ? UserFriendlyError.fromAny(error).message : null}
/> />
); );
}; };
@@ -12,6 +12,7 @@ export interface SearchResultsProps {
docs?: DocCardProps['meta'][]; docs?: DocCardProps['meta'][];
collections?: UniversalSearchResultItemProps['item'][]; collections?: UniversalSearchResultItemProps['item'][];
tags?: UniversalSearchResultItemProps['item'][]; tags?: UniversalSearchResultItemProps['item'][];
error?: any;
} }
const Empty = () => { const Empty = () => {
@@ -26,11 +27,14 @@ export const SearchResults = ({
docs, docs,
collections, collections,
tags, tags,
error,
}: SearchResultsProps) => { }: SearchResultsProps) => {
return ( return (
<> <>
<div className={styles.resTitle}>{title}</div> <div className={styles.resTitle}>{title}</div>
{error && <p className={styles.errorMessage}>{error}</p>}
{!docs?.length && !collections?.length && !tags?.length ? ( {!docs?.length && !collections?.length && !tags?.length ? (
<Empty /> <Empty />
) : null} ) : null}
@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { import {
bodyEmphasized, bodyEmphasized,
footnoteRegular, footnoteRegular,
@@ -84,3 +85,9 @@ export const empty = style([
color: cssVarV2('text/primary'), color: cssVarV2('text/primary'),
}, },
]); ]);
export const errorMessage = style({
padding: '0px 16px 16px',
fontSize: cssVar('fontSm'),
color: cssVarV2('status/error'),
});
@@ -110,6 +110,9 @@ export class QuickSearch extends Entity {
} }
submit(result: QuickSearchItem | null) { submit(result: QuickSearchItem | null) {
if (result && result.beforeSubmit && !result.beforeSubmit?.()) {
return;
}
if (this.state$.value?.callback) { if (this.state$.value?.callback) {
this.state$.value.sessions.forEach(session => session.dispose?.()); this.state$.value.sessions.forEach(session => session.dispose?.());
this.state$.value.callback(result); this.state$.value.callback(result);
@@ -1,3 +1,5 @@
import { ServerFeature } from '@affine/graphql';
import { SearchIcon } from '@blocksuite/icons/rc';
import { import {
effect, effect,
Entity, Entity,
@@ -8,6 +10,7 @@ import {
import { truncate } from 'lodash-es'; import { truncate } from 'lodash-es';
import { catchError, EMPTY, map, of, switchMap, tap, throttleTime } from 'rxjs'; import { catchError, EMPTY, map, of, switchMap, tap, throttleTime } from 'rxjs';
import type { WorkspaceServerService } from '../../cloud';
import type { DocRecord, DocsService } from '../../doc'; import type { DocRecord, DocsService } from '../../doc';
import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocDisplayMetaService } from '../../doc-display-meta';
import type { DocsSearchService } from '../../docs-search'; import type { DocsSearchService } from '../../docs-search';
@@ -28,6 +31,7 @@ export class DocsQuickSearchSession
{ {
constructor( constructor(
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService,
private readonly docsSearchService: DocsSearchService, private readonly docsSearchService: DocsSearchService,
private readonly docsService: DocsService, private readonly docsService: DocsService,
private readonly docDisplayMetaService: DocDisplayMetaService private readonly docDisplayMetaService: DocDisplayMetaService
@@ -35,6 +39,11 @@ export class DocsQuickSearchSession
super(); super();
} }
private readonly isSupportServerIndexer = () =>
this.workspaceServerService.server?.config$.value.features.includes(
ServerFeature.Indexer
) ?? false;
private readonly isIndexerLoading$ = this.docsSearchService.indexerState$.map( private readonly isIndexerLoading$ = this.docsSearchService.indexerState$.map(
({ completed }) => { ({ completed }) => {
return !completed; return !completed;
@@ -45,6 +54,26 @@ export class DocsQuickSearchSession
isCloudWorkspace = this.workspaceService.workspace.flavour !== 'local'; 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 => { isLoading$ = LiveData.computed(get => {
return ( return (
(this.isCloudWorkspace ? false : get(this.isIndexerLoading$)) || (this.isCloudWorkspace ? false : get(this.isIndexerLoading$)) ||
@@ -54,12 +83,17 @@ export class DocsQuickSearchSession
error$ = new LiveData<any>(null); error$ = new LiveData<any>(null);
query$ = new LiveData(''); lastQuery = '';
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]); items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
searchLocally = false;
query = effect( query = effect(
throttleTime<string>(1000, undefined, { tap(query => {
this.lastQuery = query;
}),
throttleTime<string>(500, undefined, {
leading: false, leading: false,
trailing: true, trailing: true,
}), }),
@@ -87,7 +121,9 @@ export class DocsQuickSearchSession
group: { group: {
id: 'docs', id: 'docs',
label: { 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) }, options: { query: truncate(query) },
}, },
score: 5, score: 5,
@@ -107,17 +143,29 @@ export class DocsQuickSearchSession
} }
return out.pipe( return out.pipe(
tap((items: QuickSearchItem<'docs', DocsPayload>[]) => { tap((items: QuickSearchItem<'docs', DocsPayload>[]) => {
this.items$.next(items); this.items$.next(
this.isSupportServerIndexer() && !this.searchLocally
? [...items, this.searchLocallyItem]
: items
);
this.isQueryLoading$.next(false); this.isQueryLoading$.next(false);
}), }),
onStart(() => { onStart(() => {
this.error$.next(null); this.error$.next(null);
this.items$.next([]); this.items$.next(
this.isSupportServerIndexer() && !this.searchLocally
? [this.searchLocallyItem]
: []
);
this.isQueryLoading$.next(true); this.isQueryLoading$.next(true);
}), }),
catchError(err => { catchError(err => {
this.error$.next(err instanceof Error ? err.message : 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); this.isQueryLoading$.next(false);
return EMPTY; return EMPTY;
}), }),
@@ -128,10 +176,6 @@ export class DocsQuickSearchSession
// TODO(@EYHN): load more // TODO(@EYHN): load more
setQuery(query: string) {
this.query$.next(query);
}
override dispose(): void { override dispose(): void {
this.query.unsubscribe(); this.query.unsubscribe();
} }
@@ -1,5 +1,6 @@
import { type Framework } from '@toeverything/infra'; import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { CollectionService } from '../collection'; import { CollectionService } from '../collection';
import { WorkspaceDialogService } from '../dialogs'; import { WorkspaceDialogService } from '../dialogs';
import { DocsService } from '../doc'; import { DocsService } from '../doc';
@@ -56,6 +57,7 @@ export function configureQuickSearchModule(framework: Framework) {
.entity(CommandsQuickSearchSession, [GlobalContextService]) .entity(CommandsQuickSearchSession, [GlobalContextService])
.entity(DocsQuickSearchSession, [ .entity(DocsQuickSearchSession, [
WorkspaceService, WorkspaceService,
WorkspaceServerService,
DocsSearchService, DocsSearchService,
DocsService, DocsService,
DocDisplayMetaService, DocDisplayMetaService,
@@ -16,6 +16,7 @@ export interface QuickSearchSession<S, P> {
query?: (query: string) => void; query?: (query: string) => void;
loadMore?: () => void; loadMore?: () => void;
dispose?: () => void; dispose?: () => void;
beforeSubmit?: (item: QuickSearchItem<S, P>) => boolean;
} }
export type QuickSearchSource<S, P> = export type QuickSearchSource<S, P> =
@@ -60,10 +60,14 @@ export class CMDKQuickSearchService extends Service {
if (result.source === 'recent-doc' || result.source === 'docs') { if (result.source === 'recent-doc' || result.source === 'docs') {
const doc: { const doc: {
docId: string; docId?: string;
blockId?: string; blockId?: string;
} = result.payload; } = result.payload;
if (!doc.docId) {
return;
}
result.source === 'recent-doc' && track.$.cmdk.recent.recentDocs(); result.source === 'recent-doc' && track.$.cmdk.recent.recentDocs();
result.source === 'docs' && result.source === 'docs' &&
track.$.cmdk.results.searchResultsDocs(); track.$.cmdk.results.searchResultsDocs();
@@ -71,6 +75,7 @@ export class CMDKQuickSearchService extends Service {
const options: { docId: string; blockIds?: string[] } = { const options: { docId: string; blockIds?: string[] } = {
docId: doc.docId, docId: doc.docId,
}; };
if (doc.blockId) { if (doc.blockId) {
options.blockIds = [doc.blockId]; options.blockIds = [doc.blockId];
} }
@@ -18,4 +18,5 @@ export type QuickSearchItem<S = any, P = any> = {
keyBinding?: string; keyBinding?: string;
timestamp?: number; timestamp?: number;
payload?: P; payload?: P;
beforeSubmit?: () => boolean;
} & (P extends NonNullable<unknown> ? { payload: P } : unknown); } & (P extends NonNullable<unknown> ? { payload: P } : unknown);
+10
View File
@@ -4519,12 +4519,22 @@ export function useAFFiNEI18N(): {
* `New` * `New`
*/ */
["com.affine.quicksearch.group.creation"](): string; ["com.affine.quicksearch.group.creation"](): string;
/**
* `Search locally`
*/
["com.affine.quicksearch.search-locally"](): string;
/** /**
* `Search for "{{query}}"` * `Search for "{{query}}"`
*/ */
["com.affine.quicksearch.group.searchfor"](options: { ["com.affine.quicksearch.group.searchfor"](options: {
readonly query: string; readonly query: string;
}): string; }): string;
/**
* `Search for "{{query}}" (locally)`
*/
["com.affine.quicksearch.group.searchfor-locally"](options: {
readonly query: string;
}): string;
/** /**
* `Reset sync` * `Reset sync`
*/ */
@@ -1121,7 +1121,9 @@
"com.affine.split-view-folder-warning.description": "Split view does not support folders.", "com.affine.split-view-folder-warning.description": "Split view does not support folders.",
"do-not-show-this-again": "Do not show this again", "do-not-show-this-again": "Do not show this again",
"com.affine.quicksearch.group.creation": "New", "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": "Search for \"{{query}}\"",
"com.affine.quicksearch.group.searchfor-locally": "Search for \"{{query}}\" (locally)",
"com.affine.resetSyncStatus.button": "Reset sync", "com.affine.resetSyncStatus.button": "Reset sync",
"com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.", "com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.",
"com.affine.rootAppSidebar.collections": "Collections", "com.affine.rootAppSidebar.collections": "Collections",