mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 05:25:53 +08:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user