feat(core): use cloud indexer for search (#12899)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added enhanced error handling and user-friendly error messages in
quick search and document search menus.
  - Introduced loading state indicators for search operations.
  - Quick Search now provides explicit error feedback in the UI.

- **Improvements**
- Search and aggregation operations can now prefer remote or local
indexers based on user or system preference.
- Streamlined indexer logic for more consistent and reliable search
experiences.
- Refined error handling in messaging and synchronization layers for
improved stability.
- Enhanced error object handling in messaging for clearer error
propagation.
- Updated cloud workspace storage to always use IndexedDB locally and
CloudIndexer remotely.
- Shifted indexer operations to use synchronized indexer layer for
better consistency.
  - Simplified indexer client by consolidating storage and sync layers.
- Improved error propagation in messaging handlers by wrapping error
objects.
- Updated document search to prioritize remote indexer results by
default.

- **Bug Fixes**
- Improved robustness of search features by handling errors gracefully
and preventing potential runtime issues.

- **Style**
  - Added new styles for displaying error messages in search interfaces.

- **Chores**
- Removed the obsolete "Enable Cloud Indexer" feature flag; cloud
indexer behavior is now always enabled where applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN 2025-06-25 10:55:27 +08:00 committed by GitHub
parent 6813d84deb
commit aa4874a55c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 366 additions and 178 deletions

View File

@ -43,7 +43,10 @@ describe('op consumer', () => {
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"error": [Error: Handler for operation [add] is not registered.],
"error": {
"message": "Handler for operation [add] is not registered.",
"name": "Error",
},
"id": "add:1",
"type": "return",
},

View File

@ -61,7 +61,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
}
if ('error' in msg) {
pending.reject(msg.error);
pending.reject(Object.assign(new Error(), msg.error));
} else {
pending.resolve(msg.data);
}
@ -86,7 +86,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
return;
}
ob.error(msg.error);
ob.error(Object.assign(new Error(), msg.error));
};
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =

View File

@ -1,4 +1,5 @@
import EventEmitter2 from 'eventemitter2';
import { pick } from 'lodash-es';
import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs';
import { MANUALLY_STOP } from '../utils';
@ -70,7 +71,15 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({
type: 'return',
id: msg.id,
error: error as Error,
error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
} satisfies ReturnMessage);
},
complete: () => {
@ -100,7 +109,15 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({
type: 'error',
id: msg.id,
error: error as Error,
error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
} satisfies SubscriptionErrorMessage);
},
complete: () => {

View File

@ -1,20 +1,13 @@
import { switchMap } from 'rxjs';
import type {
AggregateOptions,
IndexerSchema,
IndexerStorage,
Query,
SearchOptions,
} from '../storage';
import type { IndexerSync } from '../sync/indexer';
import { fromPromise } from '../utils/from-promise';
import type { IndexerPreferOptions, IndexerSync } from '../sync/indexer';
export class IndexerFrontend {
constructor(
public readonly storage: IndexerStorage,
public readonly sync: IndexerSync
) {}
constructor(public readonly sync: IndexerSync) {}
get state$() {
return this.sync.state$;
@ -27,47 +20,47 @@ export class IndexerFrontend {
async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O
options?: O & { prefer?: IndexerPreferOptions }
) {
await this.waitForConnected();
return this.storage.search(table, query, options);
return this.sync.search(table, query, options);
}
async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(table: T, query: Query<T>, field: keyof IndexerSchema[T], options?: O) {
await this.waitForConnected();
return this.storage.aggregate(table, query, field, options);
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
) {
return this.sync.aggregate(table, query, field, options);
}
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O
options?: O & { prefer?: IndexerPreferOptions }
) {
return fromPromise(signal => this.waitForConnected(signal)).pipe(
switchMap(() => this.storage.search$(table, query, options))
);
return this.sync.search$(table, query, options);
}
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(table: T, query: Query<T>, field: keyof IndexerSchema[T], options?: O) {
return fromPromise(signal => this.waitForConnected(signal)).pipe(
switchMap(() => this.storage.aggregate$(table, query, field, options))
);
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
) {
return this.sync.aggregate$(table, query, field, options);
}
addPriority(docId: string, priority: number) {
return this.sync.addPriority(docId, priority);
}
private waitForConnected(signal?: AbortSignal) {
return this.storage.connection.waitForConnected(signal);
}
waitForCompleted(signal?: AbortSignal) {
return this.sync.waitForCompleted(signal);
}

View File

@ -9,7 +9,11 @@ import type { PeerStorageOptions } from './types';
export type { BlobSyncState } from './blob';
export type { DocSyncDocState, DocSyncState } from './doc';
export type { IndexerDocSyncState, IndexerSyncState } from './indexer';
export type {
IndexerDocSyncState,
IndexerPreferOptions,
IndexerSyncState,
} from './indexer';
export interface SyncState {
doc?: DocSyncState;
@ -65,7 +69,19 @@ export class Sync {
])
),
});
this.indexer = new IndexerSyncImpl(doc, indexer, indexerSync);
this.indexer = new IndexerSyncImpl(
doc,
{
local: indexer,
remotes: Object.fromEntries(
Object.entries(storages.remotes).map(([peerId, remote]) => [
peerId,
remote.get('indexer'),
])
),
},
indexerSync
);
this.state$ = this.doc.state$.pipe(map(doc => ({ doc })));
}

View File

@ -1,4 +1,5 @@
import { readAllDocsFromRootDoc } from '@affine/reader';
import { omit } from 'lodash-es';
import {
filter,
first,
@ -7,21 +8,33 @@ import {
ReplaySubject,
share,
Subject,
switchMap,
throttleTime,
} from 'rxjs';
import { applyUpdate, Doc as YDoc } from 'yjs';
import {
type AggregateOptions,
type AggregateResult,
type DocStorage,
IndexerDocument,
type IndexerSchema,
type IndexerStorage,
type Query,
type SearchOptions,
type SearchResult,
} from '../../storage';
import { DummyIndexerStorage } from '../../storage/dummy/indexer';
import type { IndexerSyncStorage } from '../../storage/indexer-sync';
import { AsyncPriorityQueue } from '../../utils/async-priority-queue';
import { fromPromise } from '../../utils/from-promise';
import { takeUntilAbort } from '../../utils/take-until-abort';
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
import type { PeerStorageOptions } from '../types';
import { crawlingDocData } from './crawler';
export type IndexerPreferOptions = 'local' | 'remote';
export interface IndexerSyncState {
/**
* Number of documents currently in the indexing queue
@ -59,6 +72,35 @@ export interface IndexerSync {
addPriority(docId: string, priority: number): () => void;
waitForCompleted(signal?: AbortSignal): Promise<void>;
waitForDocCompleted(docId: string, signal?: AbortSignal): Promise<void>;
search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>>;
aggregate<T extends keyof IndexerSchema, const O extends AggregateOptions<T>>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>>;
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>>;
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>>;
}
export class IndexerSyncImpl implements IndexerSync {
@ -70,6 +112,9 @@ export class IndexerSyncImpl implements IndexerSync {
private readonly rootDocId = this.doc.spaceId;
private readonly status = new IndexerSyncStatus(this.rootDocId);
private readonly indexer: IndexerStorage;
private readonly remote?: IndexerStorage;
state$ = this.status.state$.pipe(
// throttle the state to 1 second to avoid spamming the UI
throttleTime(1000, undefined, {
@ -106,9 +151,13 @@ export class IndexerSyncImpl implements IndexerSync {
constructor(
readonly doc: DocStorage,
readonly indexer: IndexerStorage,
readonly peers: PeerStorageOptions<IndexerStorage>,
readonly indexerSync: IndexerSyncStorage
) {}
) {
// sync feature only works on local indexer
this.indexer = this.peers.local;
this.remote = Object.values(this.peers.remotes).find(remote => !!remote);
}
start() {
if (this.abort) {
@ -439,6 +488,116 @@ export class IndexerSyncImpl implements IndexerSync {
})
);
}
async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
await this.remote.connection.waitForConnected();
return await this.remote.search(table, query, omit(options, 'prefer'));
} else {
await this.indexer.connection.waitForConnected();
return await this.indexer.search(table, query, omit(options, 'prefer'));
}
}
async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
await this.remote.connection.waitForConnected();
return await this.remote.aggregate(
table,
query,
field,
omit(options, 'prefer')
);
} else {
await this.indexer.connection.waitForConnected();
return await this.indexer.aggregate(
table,
query,
field,
omit(options, 'prefer')
);
}
}
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
const remote = this.remote;
return fromPromise(signal =>
remote.connection.waitForConnected(signal)
).pipe(
switchMap(() => remote.search$(table, query, omit(options, 'prefer')))
);
} else {
return fromPromise(signal =>
this.indexer.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
this.indexer.search$(table, query, omit(options, 'prefer'))
)
);
}
}
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
const remote = this.remote;
return fromPromise(signal =>
remote.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
remote.aggregate$(table, query, field, omit(options, 'prefer'))
)
);
} else {
return fromPromise(signal =>
this.indexer.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
this.indexer.aggregate$(table, query, field, omit(options, 'prefer'))
)
);
}
}
}
class IndexerSyncStatus {

View File

@ -18,9 +18,7 @@ import {
type DocRecord,
type DocStorage,
type DocUpdate,
type IndexerDocument,
type IndexerSchema,
type IndexerStorage,
type ListedBlobRecord,
type Query,
type SearchOptions,
@ -29,7 +27,7 @@ import {
import type { AwarenessSync } from '../sync/awareness';
import type { BlobSync } from '../sync/blob';
import type { DocSync } from '../sync/doc';
import type { IndexerSync } from '../sync/indexer';
import type { IndexerPreferOptions, IndexerSync } from '../sync/indexer';
import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops';
export type { StoreInitOptions as WorkerInitOptions } from './ops';
@ -100,12 +98,8 @@ export class StoreClient {
this.docFrontend = new DocFrontend(this.docStorage, this.docSync);
this.blobFrontend = new BlobFrontend(this.blobStorage, this.blobSync);
this.awarenessFrontend = new AwarenessFrontend(this.awarenessSync);
this.indexerStorage = new WorkerIndexerStorage(this.client);
this.indexerSync = new WorkerIndexerSync(this.client);
this.indexerFrontend = new IndexerFrontend(
this.indexerStorage,
this.indexerSync
);
this.indexerFrontend = new IndexerFrontend(this.indexerSync);
}
private readonly docStorage: WorkerDocStorage;
@ -113,7 +107,6 @@ export class StoreClient {
private readonly docSync: WorkerDocSync;
private readonly blobSync: WorkerBlobSync;
private readonly awarenessSync: WorkerAwarenessSync;
private readonly indexerStorage: WorkerIndexerStorage;
private readonly indexerSync: WorkerIndexerSync;
readonly docFrontend: DocFrontend;
@ -348,26 +341,23 @@ class WorkerAwarenessSync implements AwarenessSync {
}
}
class WorkerIndexerStorage implements IndexerStorage {
class WorkerIndexerSync implements IndexerSync {
constructor(private readonly client: OpClient<WorkerOps>) {}
readonly storageType = 'indexer';
readonly isReadonly = true;
connection = new WorkerIndexerConnection(this.client);
search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>> {
return this.client.call('indexerStorage.search', { table, query, options });
return this.client.call('indexerSync.search', { table, query, options });
}
aggregate<T extends keyof IndexerSchema, const O extends AggregateOptions<T>>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>> {
return this.client.call('indexerStorage.aggregate', {
return this.client.call('indexerSync.aggregate', {
table,
query,
field: field as string,
@ -377,9 +367,9 @@ class WorkerIndexerStorage implements IndexerStorage {
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>> {
return this.client.ob$('indexerStorage.subscribeSearch', {
return this.client.ob$('indexerSync.subscribeSearch', {
table,
query,
options,
@ -392,59 +382,16 @@ class WorkerIndexerStorage implements IndexerStorage {
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>> {
return this.client.ob$('indexerStorage.subscribeAggregate', {
return this.client.ob$('indexerSync.subscribeAggregate', {
table,
query,
field: field as string,
options,
});
}
deleteByQuery<T extends keyof IndexerSchema>(
_table: T,
_query: Query<T>
): Promise<void> {
throw new Error('Method not implemented.');
}
insert<T extends keyof IndexerSchema>(
_table: T,
_document: IndexerDocument<T>
): Promise<void> {
throw new Error('Method not implemented.');
}
delete<T extends keyof IndexerSchema>(_table: T, _id: string): Promise<void> {
throw new Error('Method not implemented.');
}
update<T extends keyof IndexerSchema>(
_table: T,
_document: IndexerDocument<T>
): Promise<void> {
throw new Error('Method not implemented.');
}
refresh<T extends keyof IndexerSchema>(_table: T): Promise<void> {
throw new Error('Method not implemented.');
}
}
class WorkerIndexerConnection extends DummyConnection {
constructor(private readonly client: OpClient<WorkerOps>) {
super();
}
promise: Promise<void> | undefined;
override waitForConnected(): Promise<void> {
if (this.promise) {
return this.promise;
}
this.promise = this.client.call('indexerStorage.waitForConnected');
return this.promise;
}
}
class WorkerIndexerSync implements IndexerSync {
constructor(private readonly client: OpClient<WorkerOps>) {}
waitForCompleted(signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const abortListener = () => {

View File

@ -265,16 +265,6 @@ class StoreConsumer {
}),
'awarenessSync.collect': ({ collectId, awareness }) =>
collectJobs.get(collectId)?.(awareness),
'indexerStorage.aggregate': ({ table, query, field, options }) =>
this.indexerStorage.aggregate(table, query, field, options),
'indexerStorage.search': ({ table, query, options }) =>
this.indexerStorage.search(table, query, options),
'indexerStorage.subscribeSearch': ({ table, query, options }) =>
this.indexerStorage.search$(table, query, options),
'indexerStorage.subscribeAggregate': ({ table, query, field, options }) =>
this.indexerStorage.aggregate$(table, query, field, options),
'indexerStorage.waitForConnected': (_, ctx) =>
this.indexerStorage.connection.waitForConnected(ctx.signal),
'indexerSync.state': () => this.indexerSync.state$,
'indexerSync.docState': (docId: string) =>
this.indexerSync.docState$(docId),
@ -287,6 +277,14 @@ class StoreConsumer {
this.indexerSync.waitForCompleted(ctx.signal),
'indexerSync.waitForDocCompleted': (docId: string, ctx) =>
this.indexerSync.waitForDocCompleted(docId, ctx.signal),
'indexerSync.aggregate': ({ table, query, field, options }) =>
this.indexerSync.aggregate(table, query, field, options),
'indexerSync.search': ({ table, query, options }) =>
this.indexerSync.search(table, query, options),
'indexerSync.subscribeSearch': ({ table, query, options }) =>
this.indexerSync.search$(table, query, options),
'indexerSync.subscribeAggregate': ({ table, query, field, options }) =>
this.indexerSync.aggregate$(table, query, field, options),
});
}
}

View File

@ -1,6 +1,5 @@
import type { AvailableStorageImplementations } from '../impls';
import type {
AggregateOptions,
AggregateResult,
BlobRecord,
DocClock,
@ -10,7 +9,6 @@ import type {
DocUpdate,
ListedBlobRecord,
Query,
SearchOptions,
SearchResult,
StorageType,
} from '../storage';
@ -69,36 +67,6 @@ interface GroupedWorkerOps {
waitForConnected: [void, void];
};
indexerStorage: {
search: [
{ table: string; query: Query<any>; options?: SearchOptions<any> },
SearchResult<any, any>,
];
aggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: AggregateOptions<any>;
},
AggregateResult<any, any>,
];
subscribeSearch: [
{ table: string; query: Query<any>; options?: SearchOptions<any> },
SearchResult<any, any>,
];
subscribeAggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: AggregateOptions<any>;
},
AggregateResult<any, any>,
];
waitForConnected: [void, void];
};
docSync: {
state: [void, DocSyncState];
docState: [string, DocSyncDocState];
@ -137,6 +105,40 @@ interface GroupedWorkerOps {
addPriority: [{ docId: string; priority: number }, boolean];
waitForCompleted: [void, void];
waitForDocCompleted: [string, void];
search: [
{
table: string;
query: Query<any>;
options?: any;
},
SearchResult<any, any>,
];
aggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: any;
},
AggregateResult<any, any>,
];
subscribeSearch: [
{
table: string;
query: Query<any>;
options?: any;
},
SearchResult<any, any>,
];
subscribeAggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: any;
},
AggregateResult<any, any>,
];
};
}

View File

@ -112,6 +112,7 @@ export class DocsSearchService extends Service {
},
],
},
prefer: 'remote',
}
)
.pipe(

View File

@ -3,7 +3,6 @@ import type { FlagInfo } from './types';
// const isNotStableBuild = BUILD_CONFIG.appBuildType !== 'stable';
const isDesktopEnvironment = BUILD_CONFIG.isElectron;
const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary';
const isBetaBuild = BUILD_CONFIG.appBuildType === 'beta';
const isMobile = BUILD_CONFIG.isMobileEdition;
export const AFFINE_FLAGS = {
@ -266,13 +265,6 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
enable_cloud_indexer: {
category: 'affine',
displayName: 'Enable Cloud Indexer',
description: 'Use cloud indexer to search docs',
configurable: isBetaBuild || isCanaryBuild,
defaultState: false,
},
enable_adapter_panel: {
category: 'affine',
displayName:

View File

@ -25,6 +25,11 @@ export class QuickSearch extends Entity {
.flat()
.map(items => items.flat());
readonly error$ = this.state$
.map(s => s?.sessions.map(session => session.error$) ?? [])
.flat()
.map(items => items.find(v => !!v) ?? null);
readonly show$ = this.state$.map(s => !!s);
readonly options$ = this.state$.map(s => s?.options);

View File

@ -6,11 +6,12 @@ import {
onStart,
} from '@toeverything/infra';
import { truncate } from 'lodash-es';
import { map, of, switchMap, tap, throttleTime } from 'rxjs';
import { catchError, EMPTY, map, of, switchMap, tap, throttleTime } from 'rxjs';
import type { DocRecord, DocsService } from '../../doc';
import type { DocDisplayMetaService } from '../../doc-display-meta';
import type { DocsSearchService } from '../../docs-search';
import type { WorkspaceService } from '../../workspace';
import type { QuickSearchSession } from '../providers/quick-search-provider';
import type { QuickSearchItem } from '../types/item';
@ -26,6 +27,7 @@ export class DocsQuickSearchSession
implements QuickSearchSession<'docs', DocsPayload>
{
constructor(
private readonly workspaceService: WorkspaceService,
private readonly docsSearchService: DocsSearchService,
private readonly docsService: DocsService,
private readonly docDisplayMetaService: DocDisplayMetaService
@ -41,10 +43,17 @@ export class DocsQuickSearchSession
private readonly isQueryLoading$ = new LiveData(false);
isCloudWorkspace = this.workspaceService.workspace.flavour !== 'local';
isLoading$ = LiveData.computed(get => {
return get(this.isIndexerLoading$) || get(this.isQueryLoading$);
return (
(this.isCloudWorkspace ? false : get(this.isIndexerLoading$)) ||
get(this.isQueryLoading$)
);
});
error$ = new LiveData<any>(null);
query$ = new LiveData('');
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
@ -102,9 +111,16 @@ export class DocsQuickSearchSession
this.isQueryLoading$.next(false);
}),
onStart(() => {
this.error$.next(null);
this.items$.next([]);
this.isQueryLoading$.next(true);
}),
catchError(err => {
this.error$.next(err instanceof Error ? err.message : err);
this.items$.next([]);
this.isQueryLoading$.next(false);
return EMPTY;
}),
onComplete(() => {})
);
})

View File

@ -55,6 +55,7 @@ export function configureQuickSearchModule(framework: Framework) {
.entity(QuickSearch)
.entity(CommandsQuickSearchSession, [GlobalContextService])
.entity(DocsQuickSearchSession, [
WorkspaceService,
DocsSearchService,
DocsService,
DocDisplayMetaService,

View File

@ -8,7 +8,7 @@ export type QuickSearchFunction<S, P> = (
export interface QuickSearchSession<S, P> {
items$: LiveData<QuickSearchItem<S, P>[]>;
isError$?: LiveData<boolean>;
error$?: LiveData<any>;
isLoading$?: LiveData<boolean>;
loadingProgress$?: LiveData<number>;
hasMore$?: LiveData<boolean>;

View File

@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({});
@ -195,3 +196,9 @@ export const itemSubtitle = style({
fontWeight: 400,
textAlign: 'justify',
});
export const errorMessage = style({
padding: '0px 8px 8px',
fontSize: cssVar('fontXs'),
color: cssVarV2('status/error'),
});

View File

@ -24,6 +24,7 @@ export const CMDK = ({
className,
query,
groups: newGroups = [],
error,
inputLabel,
placeholder,
loading: newLoading = false,
@ -33,6 +34,7 @@ export const CMDK = ({
}: React.PropsWithChildren<{
className?: string;
query: string;
error?: ReactNode;
inputLabel?: ReactNode;
placeholder?: string;
loading?: boolean;
@ -200,6 +202,7 @@ export const CMDK = ({
</div>
<Command.List ref={listRef} data-opening={opening ? true : undefined}>
{error && <p className={styles.errorMessage}>{error}</p>}
{groups.map(({ group, items }) => {
return (
<CMDKGroup

View File

@ -1,3 +1,4 @@
import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
@ -18,6 +19,7 @@ export const QuickSearchContainer = () => {
const loading = useLiveData(quickSearch.isLoading$);
const loadingProgress = useLiveData(quickSearch.loadingProgress$);
const items = useLiveData(quickSearch.items$);
const error = useLiveData(quickSearch.error$);
const options = useLiveData(quickSearch.options$);
const i18n = useI18n();
@ -79,6 +81,7 @@ export const QuickSearchContainer = () => {
<CMDK
query={query}
groups={groups}
error={error ? UserFriendlyError.fromAny(error).message : null}
loading={loading}
loadingProgress={loadingProgress}
onQueryChange={handleChangeQuery}

View File

@ -1,4 +1,5 @@
import type { TagMeta } from '@affine/core/components/page-list';
import { UserFriendlyError } from '@affine/error';
import { I18n } from '@affine/i18n';
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
import type { DocMeta } from '@blocksuite/affine/store';
@ -6,14 +7,14 @@ import type {
LinkedMenuGroup,
LinkedMenuItem,
} from '@blocksuite/affine/widgets/linked-doc';
import { CollectionsIcon } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { CollectionsIcon, WarningIcon } from '@blocksuite/icons/lit';
import { computed, signal } from '@preact/signals-core';
import { Service } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import Fuse, { type FuseResultMatch } from 'fuse.js';
import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { map } from 'rxjs';
import { catchError, map, of } from 'rxjs';
import type { CollectionMeta, CollectionService } from '../../collection';
import type { DocDisplayMetaService } from '../../doc-display-meta';
@ -87,6 +88,7 @@ export class SearchMenuService extends Service {
): LinkedMenuGroup {
const currentWorkspace = this.workspaceService.workspace;
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
const loading = signal(true);
const { signal: docsSignal, cleanup: cleanupDocs } =
createSignalFromObservable(
this.searchDocs$(query).pipe(
@ -108,7 +110,26 @@ export class SearchMenuService extends Service {
);
})
.filter(m => !!m);
loading.value = false;
return docs;
}),
catchError(err => {
loading.value = false;
const userFriendlyError = UserFriendlyError.fromAny(err);
return of([
{
name: html`<span style="color: ${cssVarV2('status/error')}"
>${I18n.t(
`error.${userFriendlyError.name}`,
userFriendlyError.data
)}</span
>`,
key: 'error',
icon: WarningIcon(),
disabled: true,
action: () => {},
},
]);
})
),
[]
@ -129,6 +150,7 @@ export class SearchMenuService extends Service {
name: I18n.t('com.affine.editor.at-menu.link-to-doc', {
query,
}),
loading: loading,
items: docsSignal,
maxDisplay: MAX_DOCS,
overflowText,
@ -170,7 +192,7 @@ export class SearchMenuService extends Service {
return {
id,
title,
highlights: node.highlights.title[0],
highlights: node.highlights?.title?.[0],
};
})
)

View File

@ -1,4 +1,3 @@
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { DebugLogger } from '@affine/debug';
import {
createWorkspaceMutation,
@ -7,6 +6,7 @@ import {
getWorkspacesQuery,
Permission,
ServerDeploymentType,
ServerFeature,
} from '@affine/graphql';
import type {
BlobStorage,
@ -86,7 +86,6 @@ const logger = new DebugLogger('affine:cloud-workspace-flavour-provider');
class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
private readonly authService: AuthService;
private readonly graphqlService: GraphQLService;
private readonly featureFlagService: FeatureFlagService;
private readonly unsubscribeAccountChanged: () => void;
constructor(
@ -95,7 +94,6 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
) {
this.authService = server.scope.get(AuthService);
this.graphqlService = server.scope.get(GraphQLService);
this.featureFlagService = server.scope.get(FeatureFlagService);
this.unsubscribeAccountChanged = this.server.scope.eventBus.on(
AccountChanged,
() => {
@ -477,24 +475,14 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
id: `${this.flavour}:${workspaceId}`,
},
},
indexer: this.featureFlagService.flags.enable_cloud_indexer.value
? {
name: 'CloudIndexerStorage',
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
serverBaseUrl: this.server.serverMetadata.baseUrl,
},
}
: {
name: 'IndexedDBIndexerStorage',
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
indexer: {
name: 'IndexedDBIndexerStorage',
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
indexerSync: {
name: 'IndexedDBIndexerSyncStorage',
opts: {
@ -535,6 +523,19 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
ServerDeploymentType.Selfhosted,
},
},
indexer: this.server.config$.value.features.includes(
ServerFeature.Indexer
)
? {
name: 'CloudIndexerStorage',
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
serverBaseUrl: this.server.serverMetadata.baseUrl,
},
}
: undefined,
},
v1: {
doc: this.DocStorageV1Type

View File

@ -308,5 +308,7 @@ export async function enableCloudWorkspaceFromShareButton(page: Page) {
export async function enableShare(page: Page) {
await page.getByTestId('cloud-share-menu-button').click();
await page.getByTestId('share-link-menu-trigger').click();
// wait for the menu to be visible
await page.waitForTimeout(500);
await page.getByTestId('share-link-menu-enable-share').click();
}