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

View File

@@ -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: <SearchResLabel item={item} />,
})),
docList
.filter(item => item.id !== 'search-locally')
.map(item => ({
id: item.payload.docId,
icon: item.icon,
title: <SearchResLabel item={item} />,
})),
[docList]
);
@@ -121,6 +126,7 @@ const WithQueryList = () => {
docs={docs}
collections={collectionList}
tags={tagList}
error={error ? UserFriendlyError.fromAny(error).message : null}
/>
);
};

View File

@@ -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 (
<>
<div className={styles.resTitle}>{title}</div>
{error && <p className={styles.errorMessage}>{error}</p>}
{!docs?.length && !collections?.length && !tags?.length ? (
<Empty />
) : null}

View File

@@ -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'),
});

View File

@@ -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);

View File

@@ -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<any>(null);
query$ = new LiveData('');
lastQuery = '';
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
searchLocally = false;
query = effect(
throttleTime<string>(1000, undefined, {
tap(query => {
this.lastQuery = query;
}),
throttleTime<string>(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();
}

View File

@@ -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,

View File

@@ -16,6 +16,7 @@ export interface QuickSearchSession<S, P> {
query?: (query: string) => void;
loadMore?: () => void;
dispose?: () => void;
beforeSubmit?: (item: QuickSearchItem<S, P>) => boolean;
}
export type QuickSearchSource<S, P> =

View File

@@ -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];
}

View File

@@ -18,4 +18,5 @@ export type QuickSearchItem<S = any, P = any> = {
keyBinding?: string;
timestamp?: number;
payload?: P;
beforeSubmit?: () => boolean;
} & (P extends NonNullable<unknown> ? { payload: P } : unknown);

View File

@@ -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`
*/

View File

@@ -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",