diff --git a/.eslintrc.js b/.eslintrc.js index aeefc341d1..0584f1209c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,7 +64,7 @@ const allPackages = [ 'packages/frontend/i18n', 'packages/frontend/native', 'packages/frontend/templates', - 'packages/frontend/workspace', + 'packages/frontend/workspace-impl', 'packages/common/debug', 'packages/common/env', 'packages/common/infra', diff --git a/.github/labeler.yml b/.github/labeler.yml index 79c9e0c192..ee96cc8099 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -29,11 +29,6 @@ mod:plugin-cli: - any-glob-to-any-file: - 'tools/plugin-cli/**/*' -mod:workspace: - - changed-files: - - any-glob-to-any-file: - - 'packages/common/workspace/**/*' - mod:workspace-impl: - changed-files: - any-glob-to-any-file: diff --git a/packages/common/infra/src/di/core/collection.ts b/packages/common/infra/src/di/core/collection.ts index 5fbd5b6840..ffab4af3ae 100644 --- a/packages/common/infra/src/di/core/collection.ts +++ b/packages/common/infra/src/di/core/collection.ts @@ -203,6 +203,25 @@ export class ServiceCollection { this.services.set(normalizedScope, services); } + remove(identifier: ServiceIdentifierValue, scope: ServiceScope = ROOT_SCOPE) { + const normalizedScope = stringifyScope(scope); + const normalizedIdentifier = parseIdentifier(identifier); + const normalizedVariant = + normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT; + + const services = this.services.get(normalizedScope); + if (!services) { + return; + } + + const variants = services.get(normalizedIdentifier.identifierName); + if (!variants) { + return; + } + + variants.delete(normalizedVariant); + } + /** * Create a service provider from the collection. * @@ -365,7 +384,7 @@ class ServiceCollectionEditor { */ override = < Arg1 extends ServiceIdentifier, - Arg2 extends Type | ServiceFactory | Trait, + Arg2 extends Type | ServiceFactory | Trait | null, Trait = ServiceIdentifierType, Deps extends Arg2 extends Type ? TypesToDeps> @@ -378,7 +397,10 @@ class ServiceCollectionEditor { arg2: Arg2, ...[arg3]: Arg3 extends [] ? [] : [Arg3] ): this => { - if (arg2 instanceof Function) { + if (arg2 === null) { + this.collection.remove(identifier, this.currentScope); + return this; + } else if (arg2 instanceof Function) { this.collection.addFactory( identifier, dependenciesToFactory(arg2, arg3 as any[]), diff --git a/packages/common/infra/src/index.ts b/packages/common/infra/src/index.ts index 75538c5621..a72bdc4422 100644 --- a/packages/common/infra/src/index.ts +++ b/packages/common/infra/src/index.ts @@ -26,6 +26,6 @@ export function configureInfraServices(services: ServiceCollection) { export function configureTestingInfraServices(services: ServiceCollection) { configureTestingWorkspaceServices(services); - services.addImpl(GlobalCache, MemoryMemento); - services.addImpl(GlobalState, MemoryMemento); + services.override(GlobalCache, MemoryMemento); + services.override(GlobalState, MemoryMemento); } diff --git a/packages/common/infra/src/page/index.ts b/packages/common/infra/src/page/index.ts index f290f43b99..f366d269df 100644 --- a/packages/common/infra/src/page/index.ts +++ b/packages/common/infra/src/page/index.ts @@ -17,7 +17,7 @@ export function configurePageServices(services: ServiceCollection) { services .scope(WorkspaceScope) .add(PageListService, [Workspace]) - .add(PageManager, [Workspace, ServiceProvider]); + .add(PageManager, [Workspace, PageListService, ServiceProvider]); services .scope(PageScope) .add(CleanupService) diff --git a/packages/common/infra/src/page/list.ts b/packages/common/infra/src/page/list.ts index 834391da9f..831df6e19d 100644 --- a/packages/common/infra/src/page/list.ts +++ b/packages/common/infra/src/page/list.ts @@ -2,7 +2,7 @@ import type { PageMeta } from '@blocksuite/store'; import { Observable } from 'rxjs'; import { LiveData } from '../livedata'; -import type { Workspace } from '../workspace'; +import { SyncEngineStep, type Workspace } from '../workspace'; export class PageListService { constructor(private readonly workspace: Workspace) {} @@ -25,4 +25,26 @@ export class PageListService { }), [] ); + + public readonly isReady = LiveData.from( + new Observable(subscriber => { + subscriber.next( + this.workspace.engine.status.sync.step === SyncEngineStep.Synced + ); + + const dispose = this.workspace.engine.onStatusChange.on(() => { + subscriber.next( + this.workspace.engine.status.sync.step === SyncEngineStep.Synced + ); + }).dispose; + return () => { + dispose(); + }; + }), + false + ); + + public getPageMetaById(id: string) { + return this.pages.value.find(page => page.id === id); + } } diff --git a/packages/common/infra/src/page/manager.ts b/packages/common/infra/src/page/manager.ts index c3e56ddfb6..974a76fb9e 100644 --- a/packages/common/infra/src/page/manager.ts +++ b/packages/common/infra/src/page/manager.ts @@ -1,9 +1,10 @@ import type { PageMeta } from '@blocksuite/store'; import type { ServiceProvider } from '../di'; -import { ObjectPool, type RcRef } from '../utils/object-pool'; +import { ObjectPool } from '../utils/object-pool'; import type { Workspace } from '../workspace'; import { configurePageContext } from './context'; +import type { PageListService } from './list'; import { Page } from './page'; import { PageScope } from './service-scope'; @@ -12,10 +13,20 @@ export class PageManager { constructor( private readonly workspace: Workspace, + private readonly pageList: PageListService, private readonly serviceProvider: ServiceProvider ) {} - open(pageMeta: PageMeta): RcRef { + openByPageId(pageId: string) { + const pageMeta = this.pageList.getPageMetaById(pageId); + if (!pageMeta) { + throw new Error('Page not found'); + } + + return this.open(pageMeta); + } + + open(pageMeta: PageMeta) { const blockSuitePage = this.workspace.blockSuiteWorkspace.getPage( pageMeta.id ); @@ -25,7 +36,7 @@ export class PageManager { const exists = this.pool.get(pageMeta.id); if (exists) { - return exists; + return { page: exists.obj, release: exists.release }; } const serviceCollection = this.serviceProvider.collection @@ -41,6 +52,8 @@ export class PageManager { const page = provider.get(Page); - return this.pool.put(pageMeta.id, page); + const { obj, release } = this.pool.put(pageMeta.id, page); + + return { page: obj, release }; } } diff --git a/packages/common/infra/src/workspace/engine/awareness.ts b/packages/common/infra/src/workspace/engine/awareness.ts index 4964b264f3..fc9b1b41a3 100644 --- a/packages/common/infra/src/workspace/engine/awareness.ts +++ b/packages/common/infra/src/workspace/engine/awareness.ts @@ -11,10 +11,6 @@ export const AwarenessProvider = export class AwarenessEngine { constructor(public readonly providers: AwarenessProvider[]) {} - static get EMPTY() { - return new AwarenessEngine([]); - } - connect() { this.providers.forEach(provider => provider.connect()); } diff --git a/packages/common/infra/src/workspace/engine/blob.ts b/packages/common/infra/src/workspace/engine/blob.ts index 4bdb888480..f4edb8457d 100644 --- a/packages/common/infra/src/workspace/engine/blob.ts +++ b/packages/common/infra/src/workspace/engine/blob.ts @@ -54,10 +54,6 @@ export class BlobEngine { private readonly remotes: BlobStorage[] ) {} - static get EMPTY() { - return new BlobEngine(createEmptyBlobStorage(), []); - } - start() { if (this.abort || this._status.isStorageOverCapacity) { return; @@ -222,21 +218,19 @@ export class BlobEngine { } } -export function createEmptyBlobStorage() { - return { - name: 'empty', - readonly: true, - async get(_key: string) { - return null; - }, - async set(_key: string, _value: Blob) { - throw new Error('not supported'); - }, - async delete(_key: string) { - throw new Error('not supported'); - }, - async list() { - return []; - }, - } satisfies BlobStorage; -} +export const EmptyBlobStorage: BlobStorage = { + name: 'empty', + readonly: true, + async get(_key: string) { + return null; + }, + async set(_key: string, _value: Blob) { + throw new Error('not supported'); + }, + async delete(_key: string) { + throw new Error('not supported'); + }, + async list() { + return []; + }, +}; diff --git a/packages/common/infra/src/workspace/engine/sync/storage.ts b/packages/common/infra/src/workspace/engine/sync/storage.ts index 34784f1d40..0e1011c5d4 100644 --- a/packages/common/infra/src/workspace/engine/sync/storage.ts +++ b/packages/common/infra/src/workspace/engine/sync/storage.ts @@ -23,3 +23,22 @@ export interface SyncStorage { disconnect: (reason: string) => void ): Promise<() => void>; } + +export const EmptySyncStorage: SyncStorage = { + name: 'empty', + pull: async () => null, + push: async () => {}, + subscribe: async () => () => {}, +}; + +export const ReadonlyMappingSyncStorage = (map: { + [key: string]: Uint8Array; +}): SyncStorage => ({ + name: 'map', + pull: async (id: string) => { + const data = map[id]; + return data ? { data } : null; + }, + push: async () => {}, + subscribe: async () => () => {}, +}); diff --git a/packages/common/infra/src/workspace/index.ts b/packages/common/infra/src/workspace/index.ts index 74629684f7..d4997ea1f0 100644 --- a/packages/common/infra/src/workspace/index.ts +++ b/packages/common/infra/src/workspace/index.ts @@ -74,8 +74,14 @@ export function configureWorkspaceServices(services: ServiceCollection) { export function configureTestingWorkspaceServices(services: ServiceCollection) { services - .addImpl(WorkspaceListProvider, TestingLocalWorkspaceListProvider, [ + .override(WorkspaceListProvider('affine-cloud'), null) + .override(WorkspaceFactory('affine-cloud'), null) + .override( + WorkspaceListProvider('local'), + TestingLocalWorkspaceListProvider, + [GlobalState] + ) + .override(WorkspaceFactory('local'), TestingLocalWorkspaceFactory, [ GlobalState, - ]) - .addImpl(WorkspaceFactory, TestingLocalWorkspaceFactory, [GlobalState]); + ]); } diff --git a/packages/common/infra/src/workspace/manager.ts b/packages/common/infra/src/workspace/manager.ts index 29570ef5b0..e1ba7f7167 100644 --- a/packages/common/infra/src/workspace/manager.ts +++ b/packages/common/infra/src/workspace/manager.ts @@ -5,7 +5,7 @@ import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { fixWorkspaceVersion } from '../blocksuite'; -import type { ServiceProvider } from '../di'; +import type { ServiceCollection, ServiceProvider } from '../di'; import { ObjectPool } from '../utils/object-pool'; import { configureWorkspaceContext } from './context'; import type { BlobStorage } from './engine'; @@ -90,6 +90,9 @@ export class WorkspaceManager { } const workspace = this.instantiate(metadata); + // sync information with workspace list, when workspace's avatar and name changed, information will be updated + this.list.getInformation(metadata).syncWithWorkspace(workspace); + const ref = this.pool.put(workspace.meta.id, workspace); return { @@ -164,14 +167,21 @@ export class WorkspaceManager { return factory.getWorkspaceBlob(metadata.id, blobKey); } - private instantiate(metadata: WorkspaceMetadata) { + instantiate( + metadata: WorkspaceMetadata, + configureWorkspace?: (serviceCollection: ServiceCollection) => void + ) { logger.info(`open workspace [${metadata.flavour}] ${metadata.id} `); - const factory = this.factories.find(x => x.name === metadata.flavour); - if (!factory) { - throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); - } const serviceCollection = this.serviceProvider.collection.clone(); - factory.configureWorkspace(serviceCollection); + if (configureWorkspace) { + configureWorkspace(serviceCollection); + } else { + const factory = this.factories.find(x => x.name === metadata.flavour); + if (!factory) { + throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); + } + factory.configureWorkspace(serviceCollection); + } configureWorkspaceContext(serviceCollection, metadata); const provider = serviceCollection.provider( WorkspaceScope, @@ -179,9 +189,6 @@ export class WorkspaceManager { ); const workspace = provider.get(Workspace); - // sync information with workspace list, when workspace's avatar and name changed, information will be updated - this.list.getInformation(metadata).syncWithWorkspace(workspace); - // apply compatibility fix fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc); diff --git a/packages/common/workspace/.gitignore b/packages/common/workspace/.gitignore deleted file mode 100644 index a65b41774a..0000000000 --- a/packages/common/workspace/.gitignore +++ /dev/null @@ -1 +0,0 @@ -lib diff --git a/packages/common/workspace/package.json b/packages/common/workspace/package.json deleted file mode 100644 index 9652d6b1be..0000000000 --- a/packages/common/workspace/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@affine/workspace", - "private": true, - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "peerDependencies": { - "@blocksuite/blocks": "*", - "@blocksuite/global": "*", - "@blocksuite/store": "*" - }, - "dependencies": { - "@affine/debug": "workspace:*", - "@affine/env": "workspace:*", - "@toeverything/infra": "workspace:*", - "lodash-es": "^4.17.21", - "yjs": "^13.6.10" - }, - "devDependencies": { - "vitest": "1.1.3" - }, - "version": "0.12.0" -} diff --git a/packages/common/workspace/src/engine/awareness.ts b/packages/common/workspace/src/engine/awareness.ts deleted file mode 100644 index 2d365f72ed..0000000000 --- a/packages/common/workspace/src/engine/awareness.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface AwarenessProvider { - connect(): void; - disconnect(): void; -} diff --git a/packages/common/workspace/src/engine/blob.ts b/packages/common/workspace/src/engine/blob.ts deleted file mode 100644 index 1c5b6541e1..0000000000 --- a/packages/common/workspace/src/engine/blob.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; -import { difference } from 'lodash-es'; - -import { BlobStorageOverCapacity } from './error'; - -const logger = new DebugLogger('affine:blob-engine'); - -export interface BlobStatus { - isStorageOverCapacity: boolean; -} - -/** - * # BlobEngine - * - * sync blobs between storages in background. - * - * all operations priority use local, then use remote. - */ -export class BlobEngine { - private abort: AbortController | null = null; - private _status: BlobStatus = { isStorageOverCapacity: false }; - onStatusChange = new Slot(); - singleBlobSizeLimit: number = 100 * 1024 * 1024; - onAbortLargeBlob = new Slot(); - - private set status(s: BlobStatus) { - logger.debug('status change', s); - this._status = s; - this.onStatusChange.emit(s); - } - get status() { - return this._status; - } - - constructor( - private readonly local: BlobStorage, - private readonly remotes: BlobStorage[] - ) {} - - start() { - if (this.abort) { - return; - } - this.abort = new AbortController(); - const abortSignal = this.abort.signal; - - const sync = () => { - if (abortSignal.aborted) { - return; - } - - this.sync() - .catch(error => { - logger.error('sync blob error', error); - }) - .finally(() => { - // sync every 1 minute - setTimeout(sync, 60000); - }); - }; - - sync(); - } - - stop() { - this.abort?.abort(); - this.abort = null; - } - - get storages() { - return [this.local, ...this.remotes]; - } - - async sync() { - if (this.local.readonly || this._status.isStorageOverCapacity) { - return; - } - logger.debug('start syncing blob...'); - for (const remote of this.remotes) { - let localList: string[] = []; - let remoteList: string[] = []; - - if (!remote.readonly) { - try { - localList = await this.local.list(); - remoteList = await remote.list(); - } catch (err) { - logger.error(`error when sync`, err); - continue; - } - - const needUpload = difference(localList, remoteList); - for (const key of needUpload) { - try { - const data = await this.local.get(key); - if (data) { - await remote.set(key, data); - } - } catch (err) { - if (err instanceof BlobStorageOverCapacity) { - this.status = { - isStorageOverCapacity: true, - }; - } - logger.error( - `error when sync ${key} from [${this.local.name}] to [${remote.name}]`, - err - ); - } - } - } - - const needDownload = difference(remoteList, localList); - - for (const key of needDownload) { - try { - const data = await remote.get(key); - if (data) { - await this.local.set(key, data); - } - } catch (err) { - logger.error( - `error when sync ${key} from [${remote.name}] to [${this.local.name}]`, - err - ); - } - } - } - - logger.debug('finish syncing blob'); - } - - async get(key: string) { - logger.debug('get blob', key); - for (const storage of this.storages) { - const data = await storage.get(key); - if (data) { - return data; - } - } - return null; - } - - async set(key: string, value: Blob) { - if (this.local.readonly) { - throw new Error('local peer is readonly'); - } - - if (value.size > this.singleBlobSizeLimit) { - this.onAbortLargeBlob.emit(value); - logger.error('blob over limit, abort set'); - return key; - } - - // await upload to the local peer - await this.local.set(key, value); - - // uploads to other peers in the background - Promise.allSettled( - this.remotes - .filter(r => !r.readonly) - .map(peer => - peer.set(key, value).catch(err => { - logger.error('Error when uploading to peer', err); - }) - ) - ) - .then(result => { - if (result.some(({ status }) => status === 'rejected')) { - logger.error( - `blob ${key} update finish, but some peers failed to update` - ); - } else { - logger.debug(`blob ${key} update finish`); - } - }) - .catch(() => { - // Promise.allSettled never reject - }); - - return key; - } - - async delete(_key: string) { - // not supported - } - - async list() { - const blobList = new Set(); - - for (const peer of this.storages) { - const list = await peer.list(); - if (list) { - for (const blob of list) { - blobList.add(blob); - } - } - } - - return Array.from(blobList); - } -} - -export interface BlobStorage { - name: string; - readonly: boolean; - get: (key: string) => Promise; - set: (key: string, value: Blob) => Promise; - delete: (key: string) => Promise; - list: () => Promise; -} - -export function createMemoryBlobStorage() { - const map = new Map(); - return { - name: 'memory', - readonly: false, - async get(key: string) { - return map.get(key) ?? null; - }, - async set(key: string, value: Blob) { - map.set(key, value); - return key; - }, - async delete(key: string) { - map.delete(key); - }, - async list() { - return Array.from(map.keys()); - }, - } satisfies BlobStorage; -} diff --git a/packages/common/workspace/src/engine/error.ts b/packages/common/workspace/src/engine/error.ts deleted file mode 100644 index db74dc86f6..0000000000 --- a/packages/common/workspace/src/engine/error.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class BlobStorageOverCapacity extends Error { - constructor(public originError?: any) { - super('Blob storage over capacity.'); - } -} diff --git a/packages/common/workspace/src/engine/index.ts b/packages/common/workspace/src/engine/index.ts deleted file mode 100644 index 25ff836b67..0000000000 --- a/packages/common/workspace/src/engine/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Slot } from '@blocksuite/global/utils'; - -import { throwIfAborted } from '../utils/throw-if-aborted'; -import type { AwarenessProvider } from './awareness'; -import type { BlobEngine, BlobStatus } from './blob'; -import type { SyncEngine, SyncEngineStatus } from './sync'; - -export interface WorkspaceEngineStatus { - sync: SyncEngineStatus; - blob: BlobStatus; -} - -/** - * # WorkspaceEngine - * - * sync ydoc, blob, awareness together - */ -export class WorkspaceEngine { - _status: WorkspaceEngineStatus; - onStatusChange = new Slot(); - - get status() { - return this._status; - } - - set status(status: WorkspaceEngineStatus) { - this._status = status; - this.onStatusChange.emit(status); - } - - constructor( - public blob: BlobEngine, - public sync: SyncEngine, - public awareness: AwarenessProvider[] - ) { - this._status = { - sync: sync.status, - blob: blob.status, - }; - sync.onStatusChange.on(status => { - this.status = { - sync: status, - blob: blob.status, - }; - }); - blob.onStatusChange.on(status => { - this.status = { - sync: sync.status, - blob: status, - }; - }); - } - - start() { - this.sync.start(); - for (const awareness of this.awareness) { - awareness.connect(); - } - this.blob.start(); - } - - canGracefulStop() { - return this.sync.canGracefulStop(); - } - - async waitForGracefulStop(abort?: AbortSignal) { - await this.sync.waitForGracefulStop(abort); - throwIfAborted(abort); - this.forceStop(); - } - - forceStop() { - this.sync.forceStop(); - for (const awareness of this.awareness) { - awareness.disconnect(); - } - this.blob.stop(); - } -} - -export * from './awareness'; -export * from './blob'; -export * from './error'; -export * from './sync'; diff --git a/packages/common/workspace/src/engine/sync/consts.ts b/packages/common/workspace/src/engine/sync/consts.ts deleted file mode 100644 index e5fd2e8718..0000000000 --- a/packages/common/workspace/src/engine/sync/consts.ts +++ /dev/null @@ -1,15 +0,0 @@ -export enum SyncEngineStep { - Stopped = 0, - Syncing = 1, - Synced = 2, -} - -export enum SyncPeerStep { - Stopped = 0, - Retrying = 1, - LoadingRootDoc = 2, - LoadingSubDoc = 3, - Loaded = 4.5, - Syncing = 5, - Synced = 6, -} diff --git a/packages/common/workspace/src/engine/sync/engine.ts b/packages/common/workspace/src/engine/sync/engine.ts deleted file mode 100644 index ba1a734e9f..0000000000 --- a/packages/common/workspace/src/engine/sync/engine.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; -import type { Doc } from 'yjs'; - -import { SharedPriorityTarget } from '../../utils/async-queue'; -import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted'; -import { SyncEngineStep, SyncPeerStep } from './consts'; -import { SyncPeer, type SyncPeerStatus } from './peer'; -import type { SyncStorage } from './storage'; - -export interface SyncEngineStatus { - step: SyncEngineStep; - local: SyncPeerStatus | null; - remotes: (SyncPeerStatus | null)[]; - retrying: boolean; -} - -/** - * # SyncEngine - * - * ``` - * ┌────────────┐ - * │ SyncEngine │ - * └─────┬──────┘ - * │ - * ▼ - * ┌────────────┐ - * │ SyncPeer │ - * ┌─────────┤ local ├─────────┐ - * │ └─────┬──────┘ │ - * │ │ │ - * ▼ ▼ ▼ - * ┌────────────┐ ┌────────────┐ ┌────────────┐ - * │ SyncPeer │ │ SyncPeer │ │ SyncPeer │ - * │ Remote │ │ Remote │ │ Remote │ - * └────────────┘ └────────────┘ └────────────┘ - * ``` - * - * Sync engine manage sync peers - * - * Sync steps: - * 1. start local sync - * 2. wait for local sync complete - * 3. start remote sync - * 4. continuously sync local and remote - */ -export class SyncEngine { - get rootDocId() { - return this.rootDoc.guid; - } - - logger = new DebugLogger('affine:sync-engine:' + this.rootDocId); - private _status: SyncEngineStatus; - onStatusChange = new Slot(); - private set status(s: SyncEngineStatus) { - this.logger.debug('status change', s); - this._status = s; - this.onStatusChange.emit(s); - } - - priorityTarget = new SharedPriorityTarget(); - - get status() { - return this._status; - } - - private abort = new AbortController(); - - constructor( - private readonly rootDoc: Doc, - private readonly local: SyncStorage, - private readonly remotes: SyncStorage[] - ) { - this._status = { - step: SyncEngineStep.Stopped, - local: null, - remotes: remotes.map(() => null), - retrying: false, - }; - } - - start() { - if (this.status.step !== SyncEngineStep.Stopped) { - this.forceStop(); - } - this.abort = new AbortController(); - - this.sync(this.abort.signal).catch(err => { - // should never reach here - this.logger.error(err); - }); - } - - canGracefulStop() { - return !!this.status.local && this.status.local.pendingPushUpdates === 0; - } - - async waitForGracefulStop(abort?: AbortSignal) { - await Promise.race([ - new Promise((_, reject) => { - if (abort?.aborted) { - reject(abort?.reason); - } - abort?.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - new Promise(resolve => { - this.onStatusChange.on(() => { - if (this.canGracefulStop()) { - resolve(); - } - }); - }), - ]); - throwIfAborted(abort); - this.forceStop(); - } - - forceStop() { - this.abort.abort(MANUALLY_STOP); - this._status = { - step: SyncEngineStep.Stopped, - local: null, - remotes: this.remotes.map(() => null), - retrying: false, - }; - } - - // main sync process, should never return until abort - async sync(signal: AbortSignal) { - const state: { - localPeer: SyncPeer | null; - remotePeers: (SyncPeer | null)[]; - } = { - localPeer: null, - remotePeers: this.remotes.map(() => null), - }; - - const cleanUp: (() => void)[] = []; - try { - // Step 1: start local sync peer - state.localPeer = new SyncPeer( - this.rootDoc, - this.local, - this.priorityTarget - ); - - cleanUp.push( - state.localPeer.onStatusChange.on(() => { - if (!signal.aborted) - this.updateSyncingState(state.localPeer, state.remotePeers); - }).dispose - ); - - this.updateSyncingState(state.localPeer, state.remotePeers); - - // Step 2: wait for local sync complete - await state.localPeer.waitForLoaded(signal); - - // Step 3: start remote sync peer - state.remotePeers = this.remotes.map(remote => { - const peer = new SyncPeer(this.rootDoc, remote, this.priorityTarget); - cleanUp.push( - peer.onStatusChange.on(() => { - if (!signal.aborted) - this.updateSyncingState(state.localPeer, state.remotePeers); - }).dispose - ); - return peer; - }); - - this.updateSyncingState(state.localPeer, state.remotePeers); - - // Step 4: continuously sync local and remote - - // wait for abort - await new Promise((_, reject) => { - if (signal.aborted) { - reject(signal.reason); - } - signal.addEventListener('abort', () => { - reject(signal.reason); - }); - }); - } catch (error) { - if (error === MANUALLY_STOP || signal.aborted) { - return; - } - throw error; - } finally { - // stop peers - state.localPeer?.stop(); - for (const remotePeer of state.remotePeers) { - remotePeer?.stop(); - } - for (const clean of cleanUp) { - clean(); - } - } - } - - updateSyncingState(local: SyncPeer | null, remotes: (SyncPeer | null)[]) { - let step = SyncEngineStep.Synced; - const allPeer = [local, ...remotes]; - for (const peer of allPeer) { - if (!peer || peer.status.step !== SyncPeerStep.Synced) { - step = SyncEngineStep.Syncing; - break; - } - } - this.status = { - step, - local: local?.status ?? null, - remotes: remotes.map(peer => peer?.status ?? null), - retrying: allPeer.some( - peer => peer?.status.step === SyncPeerStep.Retrying - ), - }; - } - - async waitForSynced(abort?: AbortSignal) { - if (this.status.step === SyncEngineStep.Synced) { - return; - } else { - return Promise.race([ - new Promise(resolve => { - this.onStatusChange.on(status => { - if (status.step === SyncEngineStep.Synced) { - resolve(); - } - }); - }), - new Promise((_, reject) => { - if (abort?.aborted) { - reject(abort?.reason); - } - abort?.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - ]); - } - } - - async waitForLoadedRootDoc(abort?: AbortSignal) { - function isLoadedRootDoc(status: SyncEngineStatus) { - return ![status.local, ...status.remotes].some( - peer => !peer || peer.step <= SyncPeerStep.LoadingRootDoc - ); - } - if (isLoadedRootDoc(this.status)) { - return; - } else { - return Promise.race([ - new Promise(resolve => { - this.onStatusChange.on(status => { - if (isLoadedRootDoc(status)) { - resolve(); - } - }); - }), - new Promise((_, reject) => { - if (abort?.aborted) { - reject(abort?.reason); - } - abort?.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - ]); - } - } - - setPriorityRule(target: ((id: string) => boolean) | null) { - this.priorityTarget.priorityRule = target; - } -} diff --git a/packages/common/workspace/src/engine/sync/index.ts b/packages/common/workspace/src/engine/sync/index.ts deleted file mode 100644 index 0e3d766d79..0000000000 --- a/packages/common/workspace/src/engine/sync/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * - * **SyncEngine** - * - * Manages one local storage and multiple remote storages. - * - * Responsible for creating SyncPeers for synchronization, following the local-first strategy. - * - * **SyncPeer** - * - * Responsible for synchronizing a single storage with Y.Doc. - * - * Carries the main synchronization logic. - * - */ - -export * from './consts'; -export * from './engine'; -export * from './peer'; -export * from './storage'; diff --git a/packages/common/workspace/src/engine/sync/peer.ts b/packages/common/workspace/src/engine/sync/peer.ts deleted file mode 100644 index 3c58a3f218..0000000000 --- a/packages/common/workspace/src/engine/sync/peer.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; -import { isEqual } from '@blocksuite/global/utils'; -import type { Doc } from 'yjs'; -import { applyUpdate, encodeStateAsUpdate, encodeStateVector } from 'yjs'; - -import { - PriorityAsyncQueue, - SharedPriorityTarget, -} from '../../utils/async-queue'; -import { mergeUpdates } from '../../utils/merge-updates'; -import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted'; -import { SyncPeerStep } from './consts'; -import type { SyncStorage } from './storage'; - -export interface SyncPeerStatus { - step: SyncPeerStep; - totalDocs: number; - loadedDocs: number; - pendingPullUpdates: number; - pendingPushUpdates: number; -} - -/** - * # SyncPeer - * A SyncPeer is responsible for syncing one Storage with one Y.Doc and its subdocs. - * - * ``` - * ┌─────┐ - * │Start│ - * └──┬──┘ - * │ - * ┌──────┐ ┌─────▼──────┐ ┌────┐ - * │listen◄─────┤pull rootdoc│ │peer│ - * └──┬───┘ └─────┬──────┘ └──┬─┘ - * │ │ onLoad() │ - * ┌──▼───┐ ┌─────▼──────┐ ┌────▼────┐ - * │listen◄─────┤pull subdocs│ │subscribe│ - * └──┬───┘ └─────┬──────┘ └────┬────┘ - * │ │ onReady() │ - * ┌──▼──┐ ┌─────▼───────┐ ┌──▼──┐ - * │queue├──────►apply updates◄───────┤queue│ - * └─────┘ └─────────────┘ └─────┘ - * ``` - * - * listen: listen for updates from ydoc, typically from user modifications. - * subscribe: listen for updates from storage, typically from other users. - * - */ -export class SyncPeer { - private _status: SyncPeerStatus = { - step: SyncPeerStep.LoadingRootDoc, - totalDocs: 1, - loadedDocs: 0, - pendingPullUpdates: 0, - pendingPushUpdates: 0, - }; - onStatusChange = new Slot(); - readonly abort = new AbortController(); - get name() { - return this.storage.name; - } - logger = new DebugLogger('affine:sync-peer:' + this.name); - - constructor( - private readonly rootDoc: Doc, - private readonly storage: SyncStorage, - private readonly priorityTarget = new SharedPriorityTarget() - ) { - this.logger.debug('peer start'); - - this.syncRetryLoop(this.abort.signal).catch(err => { - // should not reach here - console.error(err); - }); - } - - private set status(s: SyncPeerStatus) { - if (!isEqual(s, this._status)) { - this.logger.debug('status change', s); - this._status = s; - this.onStatusChange.emit(s); - } - } - - get status() { - return this._status; - } - - /** - * stop sync - * - * SyncPeer is one-time use, this peer should be discarded after call stop(). - */ - stop() { - this.logger.debug('peer stop'); - this.abort.abort(MANUALLY_STOP); - } - - /** - * auto retry after 5 seconds if sync failed - */ - async syncRetryLoop(abort: AbortSignal) { - while (abort.aborted === false) { - try { - await this.sync(abort); - } catch (err) { - if (err === MANUALLY_STOP || abort.aborted) { - return; - } - - this.logger.error('sync error', err); - } - try { - this.logger.error('retry after 5 seconds'); - this.status = { - step: SyncPeerStep.Retrying, - totalDocs: 1, - loadedDocs: 0, - pendingPullUpdates: 0, - pendingPushUpdates: 0, - }; - await Promise.race([ - new Promise(resolve => { - setTimeout(resolve, 5 * 1000); - }), - new Promise((_, reject) => { - // exit if manually stopped - if (abort.aborted) { - reject(abort.reason); - } - abort.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - ]); - } catch (err) { - if (err === MANUALLY_STOP || abort.aborted) { - return; - } - - // should never reach here - throw err; - } - } - } - - private readonly state: { - connectedDocs: Map; - pushUpdatesQueue: PriorityAsyncQueue<{ - id: string; - data: Uint8Array[]; - }>; - pushingUpdate: boolean; - pullUpdatesQueue: PriorityAsyncQueue<{ - id: string; - data: Uint8Array; - }>; - subdocLoading: boolean; - subdocsLoadQueue: PriorityAsyncQueue<{ id: string; doc: Doc }>; - } = { - connectedDocs: new Map(), - pushUpdatesQueue: new PriorityAsyncQueue([], this.priorityTarget), - pushingUpdate: false, - pullUpdatesQueue: new PriorityAsyncQueue([], this.priorityTarget), - subdocLoading: false, - subdocsLoadQueue: new PriorityAsyncQueue([], this.priorityTarget), - }; - - initState() { - this.state.connectedDocs.clear(); - this.state.pushUpdatesQueue.clear(); - this.state.pullUpdatesQueue.clear(); - this.state.subdocsLoadQueue.clear(); - this.state.pushingUpdate = false; - this.state.subdocLoading = false; - } - - /** - * main synchronization logic - */ - async sync(abortOuter: AbortSignal) { - this.initState(); - const abortInner = new AbortController(); - - abortOuter.addEventListener('abort', reason => { - abortInner.abort(reason); - }); - - let dispose: (() => void) | null = null; - try { - this.reportSyncStatus(); - - // start listen storage updates - dispose = await this.storage.subscribe( - this.handleStorageUpdates, - reason => { - // abort if storage disconnect, should trigger retry loop - abortInner.abort('subscribe disconnect:' + reason); - } - ); - throwIfAborted(abortInner.signal); - - // Step 1: load root doc - await this.connectDoc(this.rootDoc, abortInner.signal); - - // Step 2: load subdocs - this.state.subdocsLoadQueue.push( - ...Array.from(this.rootDoc.getSubdocs()).map(doc => ({ - id: doc.guid, - doc, - })) - ); - this.reportSyncStatus(); - - this.rootDoc.on('subdocs', this.handleSubdocsUpdate); - - // Finally: start sync - await Promise.all([ - // load subdocs - (async () => { - while (throwIfAborted(abortInner.signal)) { - const subdoc = await this.state.subdocsLoadQueue.next( - abortInner.signal - ); - this.state.subdocLoading = true; - this.reportSyncStatus(); - await this.connectDoc(subdoc.doc, abortInner.signal); - this.state.subdocLoading = false; - this.reportSyncStatus(); - } - })(), - // pull updates - (async () => { - while (throwIfAborted(abortInner.signal)) { - const { id, data } = await this.state.pullUpdatesQueue.next( - abortInner.signal - ); - // don't apply empty data or Uint8Array([0, 0]) - if ( - !( - data.byteLength === 0 || - (data.byteLength === 2 && data[0] === 0 && data[1] === 0) - ) - ) { - const subdoc = this.state.connectedDocs.get(id); - if (subdoc) { - applyUpdate(subdoc, data, this.name); - } - } - this.reportSyncStatus(); - } - })(), - // push updates - (async () => { - while (throwIfAborted(abortInner.signal)) { - const { id, data } = await this.state.pushUpdatesQueue.next( - abortInner.signal - ); - this.state.pushingUpdate = true; - this.reportSyncStatus(); - - const merged = mergeUpdates(data); - - // don't push empty data or Uint8Array([0, 0]) - if ( - !( - merged.byteLength === 0 || - (merged.byteLength === 2 && merged[0] === 0 && merged[1] === 0) - ) - ) { - await this.storage.push(id, merged); - } - - this.state.pushingUpdate = false; - this.reportSyncStatus(); - } - })(), - ]); - } finally { - dispose?.(); - for (const docs of this.state.connectedDocs.values()) { - this.disconnectDoc(docs); - } - this.rootDoc.off('subdocs', this.handleSubdocsUpdate); - } - } - - async connectDoc(doc: Doc, abort: AbortSignal) { - const { data: docData, state: inStorageState } = - (await this.storage.pull(doc.guid, encodeStateVector(doc))) ?? {}; - throwIfAborted(abort); - - if (docData) { - applyUpdate(doc, docData, 'load'); - } - - // diff root doc and in-storage, save updates to pendingUpdates - this.state.pushUpdatesQueue.push({ - id: doc.guid, - data: [encodeStateAsUpdate(doc, inStorageState)], - }); - - this.state.connectedDocs.set(doc.guid, doc); - - // start listen root doc changes - doc.on('update', this.handleYDocUpdates); - - // mark rootDoc as loaded - doc.emit('sync', [true]); - - this.reportSyncStatus(); - } - - disconnectDoc(doc: Doc) { - doc.off('update', this.handleYDocUpdates); - this.state.connectedDocs.delete(doc.guid); - this.reportSyncStatus(); - } - - // handle updates from ydoc - handleYDocUpdates = (update: Uint8Array, origin: string, doc: Doc) => { - // don't push updates from storage - if (origin === this.name) { - return; - } - - const exist = this.state.pushUpdatesQueue.find(({ id }) => id === doc.guid); - if (exist) { - exist.data.push(update); - } else { - this.state.pushUpdatesQueue.push({ - id: doc.guid, - data: [update], - }); - } - - this.reportSyncStatus(); - }; - - // handle subdocs changes, append new subdocs to queue, remove subdocs from queue - handleSubdocsUpdate = ({ - added, - removed, - }: { - added: Set; - removed: Set; - }) => { - for (const subdoc of added) { - this.state.subdocsLoadQueue.push({ id: subdoc.guid, doc: subdoc }); - } - - for (const subdoc of removed) { - this.disconnectDoc(subdoc); - this.state.subdocsLoadQueue.remove(doc => doc.doc === subdoc); - } - this.reportSyncStatus(); - }; - - // handle updates from storage - handleStorageUpdates = (id: string, data: Uint8Array) => { - this.state.pullUpdatesQueue.push({ - id, - data, - }); - this.reportSyncStatus(); - }; - - reportSyncStatus() { - let step; - if (this.state.connectedDocs.size === 0) { - step = SyncPeerStep.LoadingRootDoc; - } else if (this.state.subdocsLoadQueue.length || this.state.subdocLoading) { - step = SyncPeerStep.LoadingSubDoc; - } else if ( - this.state.pullUpdatesQueue.length || - this.state.pushUpdatesQueue.length || - this.state.pushingUpdate - ) { - step = SyncPeerStep.Syncing; - } else { - step = SyncPeerStep.Synced; - } - - this.status = { - step: step, - totalDocs: - this.state.connectedDocs.size + this.state.subdocsLoadQueue.length, - loadedDocs: this.state.connectedDocs.size, - pendingPullUpdates: - this.state.pullUpdatesQueue.length + (this.state.subdocLoading ? 1 : 0), - pendingPushUpdates: - this.state.pushUpdatesQueue.length + (this.state.pushingUpdate ? 1 : 0), - }; - } - - async waitForSynced(abort?: AbortSignal) { - if (this.status.step >= SyncPeerStep.Synced) { - return; - } else { - return Promise.race([ - new Promise(resolve => { - this.onStatusChange.on(status => { - if (status.step >= SyncPeerStep.Synced) { - resolve(); - } - }); - }), - new Promise((_, reject) => { - if (abort?.aborted) { - reject(abort?.reason); - } - abort?.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - ]); - } - } - - async waitForLoaded(abort?: AbortSignal) { - if (this.status.step > SyncPeerStep.Loaded) { - return; - } else { - return Promise.race([ - new Promise(resolve => { - this.onStatusChange.on(status => { - if (status.step > SyncPeerStep.Loaded) { - resolve(); - } - }); - }), - new Promise((_, reject) => { - if (abort?.aborted) { - reject(abort?.reason); - } - abort?.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - ]); - } - } -} diff --git a/packages/common/workspace/src/engine/sync/storage.ts b/packages/common/workspace/src/engine/sync/storage.ts deleted file mode 100644 index 34784f1d40..0000000000 --- a/packages/common/workspace/src/engine/sync/storage.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface SyncStorage { - /** - * for debug - */ - name: string; - - pull( - docId: string, - state: Uint8Array - ): Promise<{ data: Uint8Array; state?: Uint8Array } | null>; - push(docId: string, data: Uint8Array): Promise; - - /** - * Subscribe to updates from peer - * - * @param cb callback to handle updates - * @param disconnect callback to handle disconnect, reason can be something like 'network-error' - * - * @returns unsubscribe function - */ - subscribe( - cb: (docId: string, data: Uint8Array) => void, - disconnect: (reason: string) => void - ): Promise<() => void>; -} diff --git a/packages/common/workspace/src/factory.ts b/packages/common/workspace/src/factory.ts deleted file mode 100644 index da72aa62b5..0000000000 --- a/packages/common/workspace/src/factory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { WorkspaceMetadata } from './metadata'; -import type { Workspace } from './workspace'; - -export interface WorkspaceFactory { - name: string; - - openWorkspace(metadata: WorkspaceMetadata): Workspace; - - /** - * get blob without open workspace - */ - getWorkspaceBlob(id: string, blobKey: string): Promise; -} diff --git a/packages/common/workspace/src/global-schema.ts b/packages/common/workspace/src/global-schema.ts deleted file mode 100644 index e03dc9a7c2..0000000000 --- a/packages/common/workspace/src/global-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; -import { Schema } from '@blocksuite/store'; - -export const globalBlockSuiteSchema = new Schema(); - -globalBlockSuiteSchema.register(AffineSchemas).register(__unstableSchemas); diff --git a/packages/common/workspace/src/index.ts b/packages/common/workspace/src/index.ts deleted file mode 100644 index bd084ab359..0000000000 --- a/packages/common/workspace/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './engine'; -export * from './factory'; -export * from './global-schema'; -export * from './list'; -export * from './manager'; -export * from './metadata'; -export * from './workspace'; diff --git a/packages/common/workspace/src/list/cache.ts b/packages/common/workspace/src/list/cache.ts deleted file mode 100644 index 0b154e7d75..0000000000 --- a/packages/common/workspace/src/list/cache.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type WorkspaceMetadata } from '../metadata'; - -const CACHE_STORAGE_KEY = 'jotai-workspaces'; - -export function readWorkspaceListCache() { - const metadata = localStorage.getItem(CACHE_STORAGE_KEY); - if (metadata) { - try { - const items = JSON.parse(metadata) as WorkspaceMetadata[]; - return [...items]; - } catch (e) { - console.error('cannot parse worksapce', e); - } - return []; - } - return []; -} - -export function writeWorkspaceListCache(metadata: WorkspaceMetadata[]) { - localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(metadata)); -} diff --git a/packages/common/workspace/src/list/index.ts b/packages/common/workspace/src/list/index.ts deleted file mode 100644 index 002f569a35..0000000000 --- a/packages/common/workspace/src/list/index.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { WorkspaceFlavour } from '@affine/env/workspace'; -import { Slot } from '@blocksuite/global/utils'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { differenceWith } from 'lodash-es'; - -import type { BlobStorage } from '../engine'; -import type { WorkspaceMetadata } from '../metadata'; -import { readWorkspaceListCache, writeWorkspaceListCache } from './cache'; -import { type WorkspaceInfo, WorkspaceInformation } from './information'; - -export * from './information'; - -const logger = new DebugLogger('affine:workspace:list'); - -export interface WorkspaceListProvider { - name: WorkspaceFlavour; - - /** - * get workspaces list - */ - getList(): Promise; - - /** - * delete workspace by id - */ - delete(workspaceId: string): Promise; - - /** - * create workspace - * @param initial callback to put initial data to workspace - */ - create( - initial: ( - workspace: BlockSuiteWorkspace, - blobStorage: BlobStorage - ) => Promise - ): Promise; - - /** - * Start subscribe workspaces list - * - * @returns unsubscribe function - */ - subscribe( - callback: (changed: { - added?: WorkspaceMetadata[]; - deleted?: WorkspaceMetadata[]; - }) => void - ): () => void; - - /** - * get workspace avatar and name by id - * - * @param id workspace id - */ - getInformation(id: string): Promise; -} - -export interface WorkspaceListStatus { - /** - * is workspace list doing first loading. - * if false, UI can display workspace not found page. - */ - loading: boolean; - workspaceList: WorkspaceMetadata[]; -} - -/** - * # WorkspaceList - * - * manage multiple workspace metadata list providers. - * provide a __cache-first__ and __offline useable__ workspace list. - */ -export class WorkspaceList { - private readonly abortController = new AbortController(); - - private readonly workspaceInformationList = new Map< - string, - WorkspaceInformation - >(); - - onStatusChanged = new Slot(); - private _status: Readonly = { - loading: true, - workspaceList: [], - }; - - get status() { - return this._status; - } - - set status(status) { - this._status = status; - // update cache - writeWorkspaceListCache(status.workspaceList); - this.onStatusChanged.emit(this._status); - } - - get workspaceList() { - return this.status.workspaceList; - } - - constructor(private readonly providers: WorkspaceListProvider[]) { - // initialize workspace list from cache - const cache = readWorkspaceListCache(); - const workspaceList = cache; - this.status = { - ...this.status, - workspaceList, - }; - - // start first load - this.startLoad(); - } - - /** - * create workspace - * @param flavour workspace flavour - * @param initial callback to put initial data to workspace - * @returns workspace id - */ - async create( - flavour: WorkspaceFlavour, - initial: ( - workspace: BlockSuiteWorkspace, - blobStorage: BlobStorage - ) => Promise - ) { - const provider = this.providers.find(x => x.name === flavour); - if (!provider) { - throw new Error(`Unknown workspace flavour: ${flavour}`); - } - const id = await provider.create(initial); - const metadata = { - id, - flavour, - }; - // update workspace list - this.status = this.addWorkspace(this.status, metadata); - return id; - } - - /** - * delete workspace - * @param workspaceMetadata - */ - async delete(workspaceMetadata: WorkspaceMetadata) { - logger.info( - `delete workspace [${workspaceMetadata.flavour}] ${workspaceMetadata.id}` - ); - const provider = this.providers.find( - x => x.name === workspaceMetadata.flavour - ); - if (!provider) { - throw new Error( - `Unknown workspace flavour: ${workspaceMetadata.flavour}` - ); - } - await provider.delete(workspaceMetadata.id); - - // delete workspace from list - this.status = this.deleteWorkspace(this.status, workspaceMetadata); - } - - /** - * add workspace to list - */ - private addWorkspace( - status: WorkspaceListStatus, - workspaceMetadata: WorkspaceMetadata - ) { - if (status.workspaceList.some(x => x.id === workspaceMetadata.id)) { - return status; - } - return { - ...status, - workspaceList: status.workspaceList.concat(workspaceMetadata), - }; - } - - /** - * delete workspace from list - */ - private deleteWorkspace( - status: WorkspaceListStatus, - workspaceMetadata: WorkspaceMetadata - ) { - if (!status.workspaceList.some(x => x.id === workspaceMetadata.id)) { - return status; - } - return { - ...status, - workspaceList: status.workspaceList.filter( - x => x.id !== workspaceMetadata.id - ), - }; - } - - /** - * callback for subscribe workspaces list - */ - private handleWorkspaceChange(changed: { - added?: WorkspaceMetadata[]; - deleted?: WorkspaceMetadata[]; - }) { - let status = this.status; - - for (const added of changed.added ?? []) { - status = this.addWorkspace(status, added); - } - for (const deleted of changed.deleted ?? []) { - status = this.deleteWorkspace(status, deleted); - } - - this.status = status; - } - - /** - * start first load workspace list - */ - private startLoad() { - for (const provider of this.providers) { - // subscribe workspace list change - const unsubscribe = provider.subscribe(changed => { - this.handleWorkspaceChange(changed); - }); - - // unsubscribe when abort - if (this.abortController.signal.aborted) { - unsubscribe(); - return; - } - this.abortController.signal.addEventListener('abort', () => { - unsubscribe(); - }); - } - - this.revalidate() - .catch(error => { - logger.error('load workspace list error: ' + error); - }) - .finally(() => { - this.status = { - ...this.status, - loading: false, - }; - }); - } - - async revalidate() { - await Promise.allSettled( - this.providers.map(async provider => { - try { - const list = await provider.getList(); - const oldList = this.workspaceList.filter( - w => w.flavour === provider.name - ); - this.handleWorkspaceChange({ - added: differenceWith(list, oldList, (a, b) => a.id === b.id), - deleted: differenceWith(oldList, list, (a, b) => a.id === b.id), - }); - } catch (error) { - logger.error('load workspace list error: ' + error); - } - }) - ); - } - - /** - * get workspace information, if not exists, create it. - */ - getInformation(meta: WorkspaceMetadata) { - const exists = this.workspaceInformationList.get(meta.id); - if (exists) { - return exists; - } - - return this.createInformation(meta); - } - - private createInformation(workspaceMetadata: WorkspaceMetadata) { - const provider = this.providers.find( - x => x.name === workspaceMetadata.flavour - ); - if (!provider) { - throw new Error( - `Unknown workspace flavour: ${workspaceMetadata.flavour}` - ); - } - const information = new WorkspaceInformation(workspaceMetadata, provider); - information.fetch(); - this.workspaceInformationList.set(workspaceMetadata.id, information); - return information; - } - - dispose() { - this.abortController.abort(); - } -} diff --git a/packages/common/workspace/src/list/information.ts b/packages/common/workspace/src/list/information.ts deleted file mode 100644 index 79b3c4bc76..0000000000 --- a/packages/common/workspace/src/list/information.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; - -import type { WorkspaceMetadata } from '../metadata'; -import type { Workspace } from '../workspace'; -import type { WorkspaceListProvider } from './index'; - -const logger = new DebugLogger('affine:workspace:list:information'); - -const WORKSPACE_INFORMATION_CACHE_KEY = 'workspace-information:'; - -export interface WorkspaceInfo { - avatar?: string; - name?: string; -} - -/** - * # WorkspaceInformation - * - * This class take care of workspace avatar and name - * - * The class will try to get from 3 places: - * - local cache - * - fetch from `WorkspaceListProvider`, which will fetch from database or server - * - sync with active workspace - */ -export class WorkspaceInformation { - private _info: WorkspaceInfo = {}; - - public set info(info: WorkspaceInfo) { - if (info.avatar !== this._info.avatar || info.name !== this._info.name) { - localStorage.setItem( - WORKSPACE_INFORMATION_CACHE_KEY + this.meta.id, - JSON.stringify(info) - ); - this._info = info; - this.onUpdated.emit(info); - } - } - - public get info() { - return this._info; - } - - public onUpdated = new Slot(); - - constructor( - public meta: WorkspaceMetadata, - public provider: WorkspaceListProvider - ) { - const cached = this.getCachedInformation(); - // init with cached information - this.info = { ...cached }; - } - - /** - * sync information with workspace - */ - syncWithWorkspace(workspace: Workspace) { - this.info = { - avatar: workspace.blockSuiteWorkspace.meta.avatar ?? this.info.avatar, - name: workspace.blockSuiteWorkspace.meta.name ?? this.info.name, - }; - workspace.blockSuiteWorkspace.meta.commonFieldsUpdated.on(() => { - this.info = { - avatar: workspace.blockSuiteWorkspace.meta.avatar ?? this.info.avatar, - name: workspace.blockSuiteWorkspace.meta.name ?? this.info.name, - }; - }); - } - - getCachedInformation() { - const cache = localStorage.getItem( - WORKSPACE_INFORMATION_CACHE_KEY + this.meta.id - ); - if (cache) { - return JSON.parse(cache) as WorkspaceInfo; - } - return null; - } - - /** - * fetch information from provider - */ - fetch() { - this.provider - .getInformation(this.meta.id) - .then(info => { - if (info) { - this.info = info; - } - }) - .catch(err => { - logger.warn('get workspace information error: ' + err); - }); - } -} diff --git a/packages/common/workspace/src/manager.ts b/packages/common/workspace/src/manager.ts deleted file mode 100644 index 2aa9f3759e..0000000000 --- a/packages/common/workspace/src/manager.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { assertEquals } from '@blocksuite/global/utils'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { fixWorkspaceVersion } from '@toeverything/infra/blocksuite'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -import type { WorkspaceFactory } from './factory'; -import type { BlobStorage } from './index'; -import type { WorkspaceList } from './list'; -import type { WorkspaceMetadata } from './metadata'; -import { WorkspacePool } from './pool'; -import type { Workspace } from './workspace'; - -const logger = new DebugLogger('affine:workspace-manager'); - -/** - * # `WorkspaceManager` - * - * This class acts as the central hub for managing various aspects of workspaces. - * It is structured as follows: - * - * ``` - * ┌───────────┐ - * │ Workspace │ - * │ Manager │ - * └─────┬─────┘ - * ┌─────────────┼─────────────┐ - * ┌───┴───┐ ┌───┴───┐ ┌─────┴─────┐ - * │ List │ │ Pool │ │ Factories │ - * └───────┘ └───────┘ └───────────┘ - * ``` - * - * Manage every about workspace - * - * # List - * - * The `WorkspaceList` component stores metadata for all workspaces, also include workspace avatar and custom name. - * - * # Factories - * - * This class contains a collection of `WorkspaceFactory`, - * We utilize `metadata.flavour` to identify the appropriate factory for opening a workspace. - * Once opened, workspaces are stored in the `WorkspacePool`. - * - * # Pool - * - * The `WorkspacePool` use reference counting to manage active workspaces. - * Calling `use()` to create a reference to the workspace. Calling `release()` to release the reference. - * When the reference count is 0, it will close the workspace. - * - */ -export class WorkspaceManager { - pool: WorkspacePool = new WorkspacePool(); - - constructor( - public list: WorkspaceList, - public factories: WorkspaceFactory[] - ) {} - - /** - * get workspace reference by metadata. - * - * You basically don't need to call this function directly, use the react hook `useWorkspace(metadata)` instead. - * - * @returns the workspace reference and a release function, don't forget to call release function when you don't - * need the workspace anymore. - */ - use(metadata: WorkspaceMetadata): { - workspace: Workspace; - release: () => void; - } { - const exist = this.pool.get(metadata.id); - if (exist) { - return exist; - } - - const workspace = this.open(metadata); - const ref = this.pool.put(workspace); - - return ref; - } - - createWorkspace( - flavour: WorkspaceFlavour, - initial: ( - workspace: BlockSuiteWorkspace, - blobStorage: BlobStorage - ) => Promise - ): Promise { - logger.info(`create workspace [${flavour}]`); - return this.list.create(flavour, initial); - } - - /** - * delete workspace by metadata, same as `WorkspaceList.deleteWorkspace` - */ - async deleteWorkspace(metadata: WorkspaceMetadata) { - await this.list.delete(metadata); - } - - /** - * helper function to transform local workspace to cloud workspace - */ - async transformLocalToCloud(local: Workspace): Promise { - assertEquals(local.flavour, WorkspaceFlavour.LOCAL); - - await local.engine.sync.waitForSynced(); - - const newId = await this.list.create( - WorkspaceFlavour.AFFINE_CLOUD, - async (ws, bs) => { - applyUpdate(ws.doc, encodeStateAsUpdate(local.blockSuiteWorkspace.doc)); - - for (const subdoc of local.blockSuiteWorkspace.doc.getSubdocs()) { - for (const newSubdoc of ws.doc.getSubdocs()) { - if (newSubdoc.guid === subdoc.guid) { - applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc)); - } - } - } - - const blobList = await local.engine.blob.list(); - - for (const blobKey of blobList) { - const blob = await local.engine.blob.get(blobKey); - if (blob) { - await bs.set(blobKey, blob); - } - } - } - ); - - await this.list.delete(local.meta); - - return { - id: newId, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - }; - } - - /** - * helper function to get blob without open workspace, its be used for download workspace avatars. - */ - getWorkspaceBlob(metadata: WorkspaceMetadata, blobKey: string) { - const factory = this.factories.find(x => x.name === metadata.flavour); - if (!factory) { - throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); - } - return factory.getWorkspaceBlob(metadata.id, blobKey); - } - - private open(metadata: WorkspaceMetadata) { - logger.info(`open workspace [${metadata.flavour}] ${metadata.id} `); - const factory = this.factories.find(x => x.name === metadata.flavour); - if (!factory) { - throw new Error(`Unknown workspace flavour: ${metadata.flavour}`); - } - const workspace = factory.openWorkspace(metadata); - - // sync information with workspace list, when workspace's avatar and name changed, information will be updated - this.list.getInformation(metadata).syncWithWorkspace(workspace); - - // apply compatibility fix - fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc); - - return workspace; - } -} diff --git a/packages/common/workspace/src/metadata.ts b/packages/common/workspace/src/metadata.ts deleted file mode 100644 index d73b79f8a6..0000000000 --- a/packages/common/workspace/src/metadata.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { WorkspaceFlavour } from '@affine/env/workspace'; - -export type WorkspaceMetadata = { id: string; flavour: WorkspaceFlavour }; diff --git a/packages/common/workspace/src/pool.ts b/packages/common/workspace/src/pool.ts deleted file mode 100644 index e271c5cdc9..0000000000 --- a/packages/common/workspace/src/pool.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Unreachable } from '@affine/env/constant'; - -import type { Workspace } from './workspace'; - -const logger = new DebugLogger('affine:workspace-manager:pool'); - -/** - * Collection of opened workspaces. use reference counting to manage active workspaces. - */ -export class WorkspacePool { - openedWorkspaces = new Map(); - timeoutToGc: NodeJS.Timeout | null = null; - - get(workspaceId: string): { - workspace: Workspace; - release: () => void; - } | null { - const exist = this.openedWorkspaces.get(workspaceId); - if (exist) { - exist.rc++; - let released = false; - return { - workspace: exist.workspace, - release: () => { - // avoid double release - if (released) { - return; - } - released = true; - exist.rc--; - this.requestGc(); - }, - }; - } - return null; - } - - put(workspace: Workspace) { - const ref = { workspace, rc: 0 }; - this.openedWorkspaces.set(workspace.meta.id, ref); - - const r = this.get(workspace.meta.id); - if (!r) { - throw new Unreachable(); - } - - return r; - } - - private requestGc() { - if (this.timeoutToGc) { - clearInterval(this.timeoutToGc); - } - - // do gc every 1s - this.timeoutToGc = setInterval(() => { - this.gc(); - }, 1000); - } - - private gc() { - for (const [id, { workspace, rc }] of new Map( - this.openedWorkspaces /* clone the map, because the origin will be modified during iteration */ - )) { - if (rc === 0 && workspace.canGracefulStop()) { - // we can safely close the workspace - logger.info(`close workspace [${workspace.flavour}] ${workspace.id}`); - workspace.forceStop(); - - this.openedWorkspaces.delete(id); - } - } - - for (const [_, { rc }] of this.openedWorkspaces) { - if (rc === 0) { - return; - } - } - - // if all workspaces has referrer, stop gc - if (this.timeoutToGc) { - clearInterval(this.timeoutToGc); - } - } -} diff --git a/packages/common/workspace/src/upgrade/index.ts b/packages/common/workspace/src/upgrade/index.ts deleted file mode 100644 index 48c7a14bc3..0000000000 --- a/packages/common/workspace/src/upgrade/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Unreachable } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Slot } from '@blocksuite/global/utils'; -import { - checkWorkspaceCompatibility, - MigrationPoint, -} from '@toeverything/infra/blocksuite'; -import { - forceUpgradePages, - upgradeV1ToV2, -} from '@toeverything/infra/blocksuite'; -import { migrateGuidCompatibility } from '@toeverything/infra/blocksuite'; -import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; - -import type { WorkspaceManager } from '..'; -import type { Workspace } from '../workspace'; - -export interface WorkspaceUpgradeStatus { - needUpgrade: boolean; - upgrading: boolean; -} - -export class WorkspaceUpgradeController { - _status: Readonly = { - needUpgrade: false, - upgrading: false, - }; - readonly onStatusChange = new Slot(); - - get status() { - return this._status; - } - - set status(value) { - if ( - value.needUpgrade !== this._status.needUpgrade || - value.upgrading !== this._status.upgrading - ) { - this._status = value; - this.onStatusChange.emit(value); - } - } - - constructor(private readonly workspace: Workspace) { - workspace.blockSuiteWorkspace.doc.on('update', () => { - this.checkIfNeedUpgrade(); - }); - } - - checkIfNeedUpgrade() { - const needUpgrade = !!checkWorkspaceCompatibility( - this.workspace.blockSuiteWorkspace, - this.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD - ); - this.status = { - ...this.status, - needUpgrade, - }; - return needUpgrade; - } - - async upgrade(workspaceManager: WorkspaceManager): Promise { - if (this.status.upgrading) { - return null; - } - - this.status = { ...this.status, upgrading: true }; - - try { - await this.workspace.engine.sync.waitForSynced(); - - const step = checkWorkspaceCompatibility( - this.workspace.blockSuiteWorkspace, - this.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD - ); - - if (!step) { - return null; - } - - // Clone a new doc to prevent change events. - const clonedDoc = new YDoc({ - guid: this.workspace.blockSuiteWorkspace.doc.guid, - }); - applyDoc(clonedDoc, this.workspace.blockSuiteWorkspace.doc); - - if (step === MigrationPoint.SubDoc) { - const newWorkspace = await workspaceManager.createWorkspace( - WorkspaceFlavour.LOCAL, - async (workspace, blobStorage) => { - await upgradeV1ToV2(clonedDoc, workspace.doc); - migrateGuidCompatibility(clonedDoc); - await forceUpgradePages( - workspace.doc, - this.workspace.blockSuiteWorkspace.schema - ); - const blobList = - await this.workspace.blockSuiteWorkspace.blob.list(); - - for (const blobKey of blobList) { - const blob = - await this.workspace.blockSuiteWorkspace.blob.get(blobKey); - if (blob) { - await blobStorage.set(blobKey, blob); - } - } - } - ); - await workspaceManager.deleteWorkspace(this.workspace.meta); - return newWorkspace; - } else if (step === MigrationPoint.GuidFix) { - migrateGuidCompatibility(clonedDoc); - await forceUpgradePages( - clonedDoc, - this.workspace.blockSuiteWorkspace.schema - ); - applyDoc(this.workspace.blockSuiteWorkspace.doc, clonedDoc); - await this.workspace.engine.sync.waitForSynced(); - return null; - } else if (step === MigrationPoint.BlockVersion) { - await forceUpgradePages( - clonedDoc, - this.workspace.blockSuiteWorkspace.schema - ); - applyDoc(this.workspace.blockSuiteWorkspace.doc, clonedDoc); - await this.workspace.engine.sync.waitForSynced(); - return null; - } else { - throw new Unreachable(); - } - } finally { - this.status = { ...this.status, upgrading: false }; - } - } -} - -function applyDoc(target: YDoc, result: YDoc) { - applyUpdate(target, encodeStateAsUpdate(result)); - for (const targetSubDoc of target.subdocs.values()) { - const resultSubDocs = Array.from(result.subdocs.values()); - const resultSubDoc = resultSubDocs.find( - item => item.guid === targetSubDoc.guid - ); - if (resultSubDoc) { - applyDoc(targetSubDoc, resultSubDoc); - } - } -} diff --git a/packages/common/workspace/src/utils/__tests__/async-queue.spec.ts b/packages/common/workspace/src/utils/__tests__/async-queue.spec.ts deleted file mode 100644 index 017401ec84..0000000000 --- a/packages/common/workspace/src/utils/__tests__/async-queue.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; - -import { AsyncQueue } from '../async-queue'; - -describe('async-queue', () => { - test('push & pop', async () => { - const queue = new AsyncQueue(); - queue.push(1, 2, 3); - expect(queue.length).toBe(3); - expect(await queue.next()).toBe(1); - expect(await queue.next()).toBe(2); - expect(await queue.next()).toBe(3); - expect(queue.length).toBe(0); - }); - - test('await', async () => { - const queue = new AsyncQueue(); - queue.push(1, 2); - expect(await queue.next()).toBe(1); - expect(await queue.next()).toBe(2); - - let v = -1; - - // setup 2 pop tasks - queue.next().then(next => { - v = next; - }); - queue.next().then(next => { - v = next; - }); - - // Wait for 100ms - await new Promise(resolve => setTimeout(resolve, 100)); - // v should not be changed - expect(v).toBe(-1); - - // push 3, should trigger the first pop task - queue.push(3); - await vi.waitFor(() => v === 3); - - // push 4, should trigger the second pop task - queue.push(4); - await vi.waitFor(() => v === 4); - }); -}); diff --git a/packages/common/workspace/src/utils/__tests__/throw-if-aborted.spec.ts b/packages/common/workspace/src/utils/__tests__/throw-if-aborted.spec.ts deleted file mode 100644 index 137f748a6b..0000000000 --- a/packages/common/workspace/src/utils/__tests__/throw-if-aborted.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { throwIfAborted } from '../throw-if-aborted'; - -describe('throw-if-aborted', () => { - test('basic', async () => { - const abortController = new AbortController(); - const abortSignal = abortController.signal; - expect(throwIfAborted(abortSignal)).toBe(true); - abortController.abort('TEST_ABORT'); - expect(() => throwIfAborted(abortSignal)).toThrowError('TEST_ABORT'); - }); -}); diff --git a/packages/common/workspace/src/utils/async-queue.ts b/packages/common/workspace/src/utils/async-queue.ts deleted file mode 100644 index e7f994a39b..0000000000 --- a/packages/common/workspace/src/utils/async-queue.ts +++ /dev/null @@ -1,101 +0,0 @@ -export class AsyncQueue { - private _queue: T[]; - - private _resolveUpdate: (() => void) | null = null; - private _waitForUpdate: Promise | null = null; - - constructor(init: T[] = []) { - this._queue = init; - } - - get length() { - return this._queue.length; - } - - async next( - abort?: AbortSignal, - dequeue: (arr: T[]) => T | undefined = a => a.shift() - ): Promise { - const update = dequeue(this._queue); - if (update) { - return update; - } else { - if (!this._waitForUpdate) { - this._waitForUpdate = new Promise(resolve => { - this._resolveUpdate = resolve; - }); - } - - await Promise.race([ - this._waitForUpdate, - new Promise((_, reject) => { - if (abort?.aborted) { - reject(abort?.reason); - } - abort?.addEventListener('abort', () => { - reject(abort.reason); - }); - }), - ]); - - return this.next(abort, dequeue); - } - } - - push(...updates: T[]) { - this._queue.push(...updates); - if (this._resolveUpdate) { - const resolve = this._resolveUpdate; - this._resolveUpdate = null; - this._waitForUpdate = null; - resolve(); - } - } - - remove(predicate: (update: T) => boolean) { - const index = this._queue.findIndex(predicate); - if (index !== -1) { - this._queue.splice(index, 1); - } - } - - find(predicate: (update: T) => boolean) { - return this._queue.find(predicate); - } - - clear() { - this._queue = []; - } -} - -export class PriorityAsyncQueue< - T extends { id: string }, -> extends AsyncQueue { - constructor( - init: T[] = [], - public readonly priorityTarget: SharedPriorityTarget = new SharedPriorityTarget() - ) { - super(init); - } - - override next(abort?: AbortSignal | undefined): Promise { - return super.next(abort, arr => { - if (this.priorityTarget.priorityRule !== null) { - const index = arr.findIndex( - update => this.priorityTarget.priorityRule?.(update.id) - ); - if (index !== -1) { - return arr.splice(index, 1)[0]; - } - } - return arr.shift(); - }); - } -} - -/** - * Shared priority target can be shared by multiple queues. - */ -export class SharedPriorityTarget { - public priorityRule: ((id: string) => boolean) | null = null; -} diff --git a/packages/common/workspace/src/utils/merge-updates.ts b/packages/common/workspace/src/utils/merge-updates.ts deleted file mode 100644 index e3c8a4a06a..0000000000 --- a/packages/common/workspace/src/utils/merge-updates.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'; - -export function mergeUpdates(updates: Uint8Array[]) { - if (updates.length === 0) { - return new Uint8Array(); - } - if (updates.length === 1) { - return updates[0]; - } - const doc = new Doc(); - doc.transact(() => { - updates.forEach(update => { - applyUpdate(doc, update); - }); - }); - return encodeStateAsUpdate(doc); -} diff --git a/packages/common/workspace/src/utils/throw-if-aborted.ts b/packages/common/workspace/src/utils/throw-if-aborted.ts deleted file mode 100644 index 54e2c81ac9..0000000000 --- a/packages/common/workspace/src/utils/throw-if-aborted.ts +++ /dev/null @@ -1,9 +0,0 @@ -// because AbortSignal.throwIfAborted is not available in abortcontroller-polyfill -export function throwIfAborted(abort?: AbortSignal) { - if (abort?.aborted) { - throw new Error(abort.reason); - } - return true; -} - -export const MANUALLY_STOP = 'manually-stop'; diff --git a/packages/common/workspace/src/workspace.ts b/packages/common/workspace/src/workspace.ts deleted file mode 100644 index c0ecaf1ec5..0000000000 --- a/packages/common/workspace/src/workspace.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { Slot } from '@blocksuite/global/utils'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; - -import type { WorkspaceEngine, WorkspaceEngineStatus } from './engine'; -import type { WorkspaceMetadata } from './metadata'; -import { - WorkspaceUpgradeController, - type WorkspaceUpgradeStatus, -} from './upgrade'; - -const logger = new DebugLogger('affine:workspace'); - -export type WorkspaceStatus = { - mode: 'ready' | 'closed'; - engine: WorkspaceEngineStatus; - upgrade: WorkspaceUpgradeStatus; -}; - -/** - * # Workspace - * - * ``` - * ┌───────────┐ - * │ Workspace │ - * └─────┬─────┘ - * │ - * │ - * ┌──────────────┼─────────────┐ - * │ │ │ - * ┌───┴─────┐ ┌──────┴─────┐ ┌───┴────┐ - * │ Upgrade │ │ blocksuite │ │ Engine │ - * └─────────┘ └────────────┘ └───┬────┘ - * │ - * ┌──────┼─────────┐ - * │ │ │ - * ┌──┴─┐ ┌──┴─┐ ┌─────┴───┐ - * │sync│ │blob│ │awareness│ - * └────┘ └────┘ └─────────┘ - * ``` - * - * This class contains all the components needed to run a workspace. - */ -export class Workspace { - get id() { - return this.meta.id; - } - get flavour() { - return this.meta.flavour; - } - - private _status: WorkspaceStatus; - - upgrade: WorkspaceUpgradeController; - - /** - * event on workspace stop, workspace is one-time use, so it will be triggered only once - */ - onStop = new Slot(); - - onStatusChange = new Slot(); - get status() { - return this._status; - } - - set status(status: WorkspaceStatus) { - this._status = status; - this.onStatusChange.emit(status); - } - - constructor( - public meta: WorkspaceMetadata, - public engine: WorkspaceEngine, - public blockSuiteWorkspace: BlockSuiteWorkspace - ) { - this.upgrade = new WorkspaceUpgradeController(this); - - this._status = { - mode: 'closed', - engine: engine.status, - upgrade: this.upgrade.status, - }; - this.engine.onStatusChange.on(status => { - this.status = { - ...this.status, - engine: status, - }; - }); - this.upgrade.onStatusChange.on(status => { - this.status = { - ...this.status, - upgrade: status, - }; - }); - - this.start(); - } - - /** - * workspace start when create and workspace is one-time use - */ - private start() { - if (this.status.mode === 'ready') { - return; - } - logger.info('start workspace', this.id); - this.engine.start(); - this.status = { - ...this.status, - mode: 'ready', - engine: this.engine.status, - }; - } - - canGracefulStop() { - return this.engine.canGracefulStop() && !this.status.upgrade.upgrading; - } - - forceStop() { - if (this.status.mode === 'closed') { - return; - } - logger.info('stop workspace', this.id); - this.engine.forceStop(); - this.status = { - ...this.status, - mode: 'closed', - engine: this.engine.status, - }; - this.onStop.emit(); - } - - // same as `WorkspaceEngine.sync.setPriorityRule` - setPriorityRule(target: ((id: string) => boolean) | null) { - this.engine.sync.setPriorityRule(target); - } -} diff --git a/packages/common/workspace/tsconfig.json b/packages/common/workspace/tsconfig.json deleted file mode 100644 index 15af0a0980..0000000000 --- a/packages/common/workspace/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "include": ["./src"], - "compilerOptions": { - "noEmit": false, - "outDir": "lib" - }, - "references": [ - { "path": "../../../tests/fixtures" }, - { "path": "../../common/env" }, - { "path": "../../common/debug" }, - { "path": "../../common/infra" }, - { "path": "../../frontend/graphql" }, - { "path": "../../frontend/electron-api" } - ] -} diff --git a/packages/common/workspace/typedoc.json b/packages/common/workspace/typedoc.json deleted file mode 100644 index 101e923dba..0000000000 --- a/packages/common/workspace/typedoc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": ["../../../typedoc.base.json"], - "entryPoints": ["src/index.ts"] -} diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index f57bdac65a..2aa493a8db 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -24,7 +24,6 @@ "@affine/electron-api": "workspace:*", "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", - "@affine/workspace": "workspace:*", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", diff --git a/packages/frontend/component/src/components/card/workspace-card/index.tsx b/packages/frontend/component/src/components/card/workspace-card/index.tsx index fc93cf0515..bd1c11216e 100644 --- a/packages/frontend/component/src/components/card/workspace-card/index.tsx +++ b/packages/frontend/component/src/components/card/workspace-card/index.tsx @@ -1,8 +1,8 @@ import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace'; import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { useCallback } from 'react'; import { Avatar } from '../../../ui/avatar'; diff --git a/packages/frontend/component/src/components/workspace-list/index.tsx b/packages/frontend/component/src/components/workspace-list/index.tsx index 6ab0b6b084..ecd4204e6c 100644 --- a/packages/frontend/component/src/components/workspace-list/index.tsx +++ b/packages/frontend/component/src/components/workspace-list/index.tsx @@ -1,4 +1,3 @@ -import type { WorkspaceMetadata } from '@affine/workspace'; import type { DragEndEvent } from '@dnd-kit/core'; import { DndContext, @@ -11,6 +10,7 @@ import { restrictToVerticalAxis, } from '@dnd-kit/modifiers'; import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import type { CSSProperties } from 'react'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; diff --git a/packages/frontend/component/tsconfig.json b/packages/frontend/component/tsconfig.json index 387ccdb91f..dc589b1568 100644 --- a/packages/frontend/component/tsconfig.json +++ b/packages/frontend/component/tsconfig.json @@ -14,7 +14,9 @@ { "path": "../../frontend/electron-api" }, - { "path": "../../common/workspace" }, + { + "path": "../../frontend/graphql" + }, { "path": "../../common/debug" }, diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index bb916f65bf..560cd3a175 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -24,7 +24,6 @@ "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", "@affine/templates": "workspace:*", - "@affine/workspace": "workspace:*", "@affine/workspace-impl": "workspace:*", "@blocksuite/block-std": "0.12.0-nightly-202401290223-b6302df", "@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df", diff --git a/packages/frontend/core/src/app.tsx b/packages/frontend/core/src/app.tsx index dfd19931f1..1cd7018504 100644 --- a/packages/frontend/core/src/app.tsx +++ b/packages/frontend/core/src/app.tsx @@ -8,14 +8,17 @@ import { WorkspaceFallback } from '@affine/component/workspace'; import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; import { getCurrentStore } from '@toeverything/infra/atom'; +import { ServiceCollection } from '@toeverything/infra/di'; import type { PropsWithChildren, ReactElement } from 'react'; import { lazy, memo, Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; +import { GlobalScopeProvider } from './modules/infra-web/global-scope'; import { CloudSessionProvider } from './providers/session-provider'; import { router } from './router'; import { performanceLogger, performanceRenderLogger } from './shared'; import createEmotionCache from './utils/create-emotion-cache'; +import { configureWebServices } from './web'; const performanceI18nLogger = performanceLogger.namespace('i18n'); const cache = createEmotionCache(); @@ -52,6 +55,10 @@ async function loadLanguage() { let languageLoadingPromise: Promise | null = null; +const services = new ServiceCollection(); +configureWebServices(services); +const serviceProvider = services.provider(); + export const App = memo(function App() { performanceRenderLogger.info('App'); @@ -60,20 +67,26 @@ export const App = memo(function App() { } return ( - - - - - - {runtimeConfig.enableNotificationCenter && } - } - router={router} - future={future} - /> - - - - + + + + + + + + {runtimeConfig.enableNotificationCenter && ( + + )} + } + router={router} + future={future} + /> + + + + + + ); }); diff --git a/packages/frontend/core/src/atoms/collections.ts b/packages/frontend/core/src/atoms/collections.ts deleted file mode 100644 index 098bddc658..0000000000 --- a/packages/frontend/core/src/atoms/collections.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { - currentWorkspaceAtom, - waitForCurrentWorkspaceAtom, -} from '@affine/core/modules/workspace'; -import type { Collection, DeprecatedCollection } from '@affine/env/filter'; -import { DisposableGroup } from '@blocksuite/global/utils'; -import type { Workspace } from '@blocksuite/store'; -import { type DBSchema, openDB } from 'idb'; -import { atom } from 'jotai'; -import { atomWithObservable } from 'jotai/utils'; -import { Observable, of } from 'rxjs'; - -import type { - CollectionsCRUD, - CollectionsCRUDAtom, -} from '../components/page-list'; -import { getUserSetting } from '../utils/user-setting'; -import { getWorkspaceSetting } from '../utils/workspace-setting'; -import { sessionAtom } from './cloud-user'; - -/** - * @deprecated - */ -export interface PageCollectionDBV1 extends DBSchema { - view: { - key: DeprecatedCollection['id']; - value: DeprecatedCollection; - }; -} - -/** - * @deprecated - */ -export interface StorageCRUD { - get: (key: string) => Promise; - set: (key: string, value: Value) => Promise; - delete: (key: string) => Promise; - list: () => Promise; -} - -/** - * @deprecated - */ -const collectionDBAtom = atom( - openDB('page-view', 1, { - upgrade(database) { - database.createObjectStore('view', { - keyPath: 'id', - }); - }, - }) -); -/** - * @deprecated - */ -const localCollectionCRUDAtom = atom(get => ({ - get: async (key: string) => { - const db = await get(collectionDBAtom); - const t = db.transaction('view').objectStore('view'); - return (await t.get(key)) ?? null; - }, - set: async (key: string, value: DeprecatedCollection) => { - const db = await get(collectionDBAtom); - const t = db.transaction('view', 'readwrite').objectStore('view'); - await t.put(value); - return key; - }, - delete: async (key: string) => { - const db = await get(collectionDBAtom); - const t = db.transaction('view', 'readwrite').objectStore('view'); - await t.delete(key); - }, - list: async () => { - const db = await get(collectionDBAtom); - const t = db.transaction('view').objectStore('view'); - return t.getAllKeys(); - }, -})); -/** - * @deprecated - */ -const getCollections = async ( - storage: StorageCRUD -): Promise => { - return storage - .list() - .then(async keys => { - return await Promise.all(keys.map(key => storage.get(key))).then(v => - v.filter((v): v is DeprecatedCollection => v !== null) - ); - }) - .catch(error => { - console.error('Failed to load collections', error); - return []; - }); -}; -type BaseCollectionsDataType = { - loading: boolean; - collections: Collection[]; -}; -export const pageCollectionBaseAtom = - atomWithObservable( - get => { - const currentWorkspace = get(currentWorkspaceAtom); - if (!currentWorkspace) { - return of({ loading: true, collections: [] }); - } - - const session = get(sessionAtom); - const userId = session?.data?.user.id ?? null; - const migrateCollectionsFromIdbData = async ( - workspace: Workspace - ): Promise => { - const localCRUD = get(localCollectionCRUDAtom); - const collections = await getCollections(localCRUD); - const result = collections.filter(v => v.workspaceId === workspace.id); - Promise.all( - result.map(collection => { - return localCRUD.delete(collection.id); - }) - ).catch(error => { - console.error('Failed to delete collections from indexeddb', error); - }); - return result.map(v => { - return { - id: v.id, - name: v.name, - filterList: v.filterList, - allowList: v.allowList ?? [], - }; - }); - }; - const migrateCollectionsFromUserData = async ( - workspace: Workspace - ): Promise => { - if (userId == null) { - return []; - } - const userSetting = getUserSetting(workspace, userId); - await userSetting.loaded; - const view = userSetting.view; - if (view) { - const collections: Omit[] = [ - ...view.values(), - ]; - //delete collections - view.clear(); - return collections.map(v => { - return { - id: v.id, - name: v.name, - filterList: v.filterList, - allowList: v.allowList ?? [], - }; - }); - } - return []; - }; - - return new Observable(subscriber => { - const group = new DisposableGroup(); - const workspaceSetting = getWorkspaceSetting( - currentWorkspace.blockSuiteWorkspace - ); - migrateCollectionsFromIdbData(currentWorkspace.blockSuiteWorkspace) - .then(collections => { - if (collections.length) { - workspaceSetting.addCollection(...collections); - } - }) - .catch(error => { - console.error(error); - }); - migrateCollectionsFromUserData(currentWorkspace.blockSuiteWorkspace) - .then(collections => { - if (collections.length) { - workspaceSetting.addCollection(...collections); - } - }) - .catch(error => { - console.error(error); - }); - subscriber.next({ - loading: false, - collections: workspaceSetting.collections, - }); - if (group.disposed) { - return; - } - const fn = () => { - subscriber.next({ - loading: false, - collections: workspaceSetting.collections, - }); - }; - workspaceSetting.setting.observeDeep(fn); - group.add(() => { - workspaceSetting.setting.unobserveDeep(fn); - }); - - return () => { - group.dispose(); - }; - }); - }, - { initialValue: { loading: true, collections: [] } } - ); - -export const collectionsCRUDAtom: CollectionsCRUDAtom = atom(async get => { - const workspace = await get(waitForCurrentWorkspaceAtom); - return { - addCollection: (...collections) => { - getWorkspaceSetting(workspace.blockSuiteWorkspace).addCollection( - ...collections - ); - }, - collections: get(pageCollectionBaseAtom).collections, - updateCollection: (id, updater) => { - getWorkspaceSetting(workspace.blockSuiteWorkspace).updateCollection( - id, - updater - ); - }, - deleteCollection: (info, ...ids) => { - getWorkspaceSetting(workspace.blockSuiteWorkspace).deleteCollection( - info, - ...ids - ); - }, - } satisfies CollectionsCRUD; -}); diff --git a/packages/frontend/core/src/bootstrap/first-app-data.ts b/packages/frontend/core/src/bootstrap/first-app-data.ts index d040b73b40..251f44feec 100644 --- a/packages/frontend/core/src/bootstrap/first-app-data.ts +++ b/packages/frontend/core/src/bootstrap/first-app-data.ts @@ -1,7 +1,7 @@ import { DebugLogger } from '@affine/debug'; import { DEFAULT_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { workspaceManager } from '@affine/workspace-impl'; +import type { WorkspaceManager } from '@toeverything/infra'; import { getCurrentStore } from '@toeverything/infra/atom'; import { buildShowcaseWorkspace, @@ -12,12 +12,12 @@ import { setPageModeAtom } from '../atoms'; const logger = new DebugLogger('affine:first-app-data'); -export async function createFirstAppData() { +export async function createFirstAppData(workspaceManager: WorkspaceManager) { if (localStorage.getItem('is-first-open') !== null) { return; } localStorage.setItem('is-first-open', 'false'); - const workspaceId = await workspaceManager.createWorkspace( + const workspaceMetadata = await workspaceManager.createWorkspace( WorkspaceFlavour.LOCAL, async workspace => { workspace.meta.setName(DEFAULT_WORKSPACE_NAME); @@ -38,6 +38,6 @@ export async function createFirstAppData() { logger.debug('create first workspace'); } ); - console.info('create first workspace', workspaceId); - return workspaceId; + console.info('create first workspace', workspaceMetadata); + return workspaceMetadata; } diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx index d19bb773ff..1377accc78 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx @@ -1,12 +1,12 @@ -import { - currentWorkspaceAtom, - workspaceListAtom, -} from '@affine/core/modules/workspace'; +import { WorkspaceListService } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; import { useAtomValue } from 'jotai/react'; import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { currentPageIdAtom } from '../../../../atoms/mode'; +import { CurrentWorkspaceService } from '../../../../modules/workspace/current-workspace'; export interface DumpInfoProps { error: any; @@ -14,8 +14,10 @@ export interface DumpInfoProps { export const DumpInfo = (_props: DumpInfoProps) => { const location = useLocation(); - const workspaceList = useAtomValue(workspaceListAtom); - const currentWorkspace = useAtomValue(currentWorkspaceAtom); + const workspaceList = useService(WorkspaceListService); + const currentWorkspace = useLiveData( + useService(CurrentWorkspaceService).currentWorkspace + ); const currentPageId = useAtomValue(currentPageIdAtom); const path = location.pathname; const query = useParams(); diff --git a/packages/frontend/core/src/components/affine/awareness/index.tsx b/packages/frontend/core/src/components/affine/awareness/index.tsx index 7d2a02e7ab..f736203343 100644 --- a/packages/frontend/core/src/components/affine/awareness/index.tsx +++ b/packages/frontend/core/src/components/affine/awareness/index.tsx @@ -1,13 +1,16 @@ -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; import { Suspense, useEffect } from 'react'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace'; const SyncAwarenessInnerLoggedIn = () => { const currentUser = useCurrentUser(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useLiveData( + useService(CurrentWorkspaceService).currentWorkspace + ); useEffect(() => { if (currentUser && currentWorkspace) { diff --git a/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx b/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx index df58adbfaa..0e7d7f9747 100644 --- a/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/create-workspace-modal/index.tsx @@ -5,18 +5,18 @@ import { Modal, } from '@affine/component/ui/modal'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; import { DebugLogger } from '@affine/debug'; import { apis } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { _addLocalWorkspace } from '@affine/workspace-impl'; +import { WorkspaceManager } from '@toeverything/infra'; import { getCurrentStore } from '@toeverything/infra/atom'; import { buildShowcaseWorkspace, initEmptyPage, } from '@toeverything/infra/blocksuite'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra/di'; import type { KeyboardEvent } from 'react'; import { useLayoutEffect } from 'react'; import { useCallback, useState } from 'react'; @@ -101,7 +101,7 @@ export const CreateWorkspaceModal = ({ }: ModalProps) => { const [step, setStep] = useState(); const t = useAFFiNEI18N(); - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); // todo: maybe refactor using xstate? useLayoutEffect(() => { @@ -148,7 +148,7 @@ export const CreateWorkspaceModal = ({ async (name: string) => { // this will be the last step for web for now // fix me later - const id = await workspaceManager.createWorkspace( + const { id } = await workspaceManager.createWorkspace( WorkspaceFlavour.LOCAL, async workspace => { workspace.meta.setName(name); diff --git a/packages/frontend/core/src/components/affine/page-history-modal/data.ts b/packages/frontend/core/src/components/affine/page-history-modal/data.ts index 334731e127..f40d9e0e50 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/data.ts +++ b/packages/frontend/core/src/components/affine/page-history-modal/data.ts @@ -8,10 +8,10 @@ import { listHistoryQuery, recoverDocMutation, } from '@affine/graphql'; -import { globalBlockSuiteSchema } from '@affine/workspace'; -import { createAffineCloudBlobStorage } from '@affine/workspace-impl'; +import { AffineCloudBlobStorage } from '@affine/workspace-impl'; import { assertEquals } from '@blocksuite/global/utils'; import { Workspace } from '@blocksuite/store'; +import { globalBlockSuiteSchema } from '@toeverything/infra'; import { revertUpdate } from '@toeverything/y-indexeddb'; import { useEffect, useMemo } from 'react'; import useSWRImmutable from 'swr/immutable'; @@ -108,7 +108,7 @@ const workspaceMap = new Map(); const getOrCreateShellWorkspace = (workspaceId: string) => { let workspace = workspaceMap.get(workspaceId); if (!workspace) { - const blobStorage = createAffineCloudBlobStorage(workspaceId); + const blobStorage = new AffineCloudBlobStorage(workspaceId); workspace = new Workspace({ id: workspaceId, providerCreators: [], diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index 61c8adce4e..3372fd8f81 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -7,14 +7,17 @@ import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace- import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { timestampToLocalTime } from '@affine/core/utils'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons'; -import type { Page, Workspace } from '@blocksuite/store'; +import { + type Page, + type Workspace as BlockSuiteWorkspace, +} from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; import type { DialogContentProps } from '@radix-ui/react-dialog'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Fragment, @@ -29,6 +32,7 @@ import { encodeStateAsUpdate } from 'yjs'; import { currentModeAtom } from '../../../atoms/mode'; import { pageHistoryModalAtom } from '../../../atoms/page-history'; +import { timestampToLocalTime } from '../../../utils'; import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor'; import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style'; import { @@ -48,7 +52,7 @@ import * as styles from './styles.css'; export interface PageHistoryModalProps { open: boolean; onOpenChange: (open: boolean) => void; - workspace: Workspace; + workspace: BlockSuiteWorkspace; pageId: string; } @@ -153,13 +157,12 @@ const HistoryEditorPreview = ({ const planPromptClosedAtom = atom(false); const PlanPrompt = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - - const isOwner = useIsWorkspaceOwner(currentWorkspace.meta); - const workspaceQuota = useWorkspaceQuota(currentWorkspace.id); + const workspace = useService(Workspace); + const workspaceQuota = useWorkspaceQuota(workspace.id); const isProWorkspace = useMemo(() => { return workspaceQuota?.humanReadable.name.toLowerCase() !== 'free'; }, [workspaceQuota]); + const isOwner = useIsWorkspaceOwner(workspace.meta); const setSettingModalAtom = useSetAtom(openSettingModalAtom); const [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom); @@ -412,7 +415,7 @@ const PageHistoryManager = ({ pageId, onClose, }: { - workspace: Workspace; + workspace: BlockSuiteWorkspace; pageId: string; onClose: () => void; }) => { @@ -536,8 +539,7 @@ export const PageHistoryModal = ({ export const GlobalPageHistoryModal = () => { const [{ open, pageId }, setState] = useAtom(pageHistoryModalAtom); - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); - + const workspace = useService(Workspace); const handleOpenChange = useCallback( (open: boolean) => { setState(prev => ({ diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx index eb9e85aa13..e05e0d7f4f 100644 --- a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx @@ -3,15 +3,15 @@ import { openQuotaModalAtom, openSettingModalAtom } from '@affine/core/atoms'; import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useUserQuota } from '@affine/core/hooks/use-quota'; import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useService, Workspace } from '@toeverything/infra'; import bytes from 'bytes'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useSetAtom } from 'jotai'; import { useCallback, useEffect, useMemo } from 'react'; export const CloudQuotaModal = () => { const t = useAFFiNEI18N(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const [open, setOpen] = useAtom(openQuotaModalAtom); const workspaceQuota = useWorkspaceQuota(currentWorkspace.id); const isOwner = useIsWorkspaceOwner(currentWorkspace.meta); diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx index 0ec8bc8473..93eb50961f 100644 --- a/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx @@ -1,13 +1,13 @@ import { ConfirmModal } from '@affine/component/ui/modal'; import { openQuotaModalAtom } from '@affine/core/atoms'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useAtom, useAtomValue } from 'jotai'; +import { useService, Workspace } from '@toeverything/infra'; +import { useAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; export const LocalQuotaModal = () => { const t = useAFFiNEI18N(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const [open, setOpen] = useAtom(openQuotaModalAtom); const onConfirm = useCallback(() => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx index 3a68c47dba..52e3929892 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -1,8 +1,8 @@ import { WorkspaceDetailSkeleton } from '@affine/component/setting-components'; import { Modal, type ModalProps } from '@affine/component/ui/modal'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; import { ContactWithUsIcon } from '@blocksuite/icons'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { debounce } from 'lodash-es'; import { Suspense, useCallback, useLayoutEffect, useRef } from 'react'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx index 0ad9d09b64..4369c937e8 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx @@ -8,17 +8,19 @@ import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace- import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; import { useWorkspaceAvailableFeatures } from '@affine/core/hooks/use-workspace-features'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; -import { - waitForCurrentWorkspaceAtom, - workspaceListAtom, -} from '@affine/core/modules/workspace'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace'; import { Logo1Icon } from '@blocksuite/icons'; +import { + Workspace, + WorkspaceManager, + type WorkspaceMetadata, +} from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; import clsx from 'clsx'; -import { useAtom, useAtomValue } from 'jotai/react'; +import { useAtom } from 'jotai/react'; import { type ReactElement, Suspense, useCallback, useMemo } from 'react'; import { authAtom } from '../../../../atoms'; @@ -188,7 +190,9 @@ export const WorkspaceList = ({ selectedWorkspaceId: string | null; activeSubTab: WorkspaceSubTab; }) => { - const workspaces = useAtomValue(workspaceListAtom); + const workspaces = useLiveData( + useService(WorkspaceManager).list.workspaceList + ); return ( <> {workspaces.map(workspace => { @@ -236,7 +240,7 @@ const WorkspaceListItem = ({ const information = useWorkspaceInfo(meta); const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar); const name = information?.name ?? UNTITLED_WORKSPACE_NAME; - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const isCurrent = currentWorkspace.id === meta.id; const t = useAFFiNEI18N(); const isOwner = useIsWorkspaceOwner(meta); diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx index a3aab5f820..07579c18f9 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx @@ -8,7 +8,7 @@ import { } from '@affine/core/hooks/use-workspace-features'; import { FeatureType } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { useAtom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { Suspense, useCallback, useState } from 'react'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/index.tsx index 03fc5da7b9..b59e406944 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/index.tsx @@ -1,4 +1,4 @@ -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { useIsWorkspaceOwner } from '../../../../hooks/affine/use-is-workspace-owner'; import { ExperimentalFeatures } from './experimental-features'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx index a07ee40be9..8ad9455356 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx @@ -8,7 +8,7 @@ import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { useCallback, useState } from 'react'; import * as styles from './style.css'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/index.tsx index fefc82518a..59251420d2 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/index.tsx @@ -2,15 +2,12 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; import { SettingRow } from '@affine/component/setting-components'; import { ConfirmModal } from '@affine/component/ui/modal'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { - currentWorkspaceAtom, - workspaceListAtom, - workspaceManagerAtom, -} from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowRightSmallIcon } from '@blocksuite/icons'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; +import { useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; import { openSettingModalAtom } from '../../../../../../atoms'; @@ -18,6 +15,7 @@ import { RouteLogic, useNavigateHelper, } from '../../../../../../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../../../../../../shared'; import type { WorkspaceSettingDetailProps } from '../types'; import { WorkspaceDeleteModal } from './delete'; @@ -35,9 +33,9 @@ export const DeleteLeaveWorkspace = ({ const [showLeave, setShowLeave] = useState(false); const setSettingModal = useSetAtom(openSettingModalAtom); - const workspaceManager = useAtomValue(workspaceManagerAtom); - const workspaceList = useAtomValue(workspaceListAtom); - const currentWorkspace = useAtomValue(currentWorkspaceAtom); + const workspaceManager = useService(WorkspaceManager); + const workspaceList = useLiveData(workspaceManager.list.workspaceList); + const currentWorkspace = useService(Workspace); const pushNotification = useSetAtom(pushNotificationAtom); const onLeaveOrDelete = useCallback(() => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/enable-cloud.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/enable-cloud.tsx index 644f6248f4..419acdcdc5 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/enable-cloud.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/enable-cloud.tsx @@ -2,17 +2,17 @@ import { SettingRow } from '@affine/component/setting-components'; import { Button } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@affine/workspace'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { type Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useSetAtom } from 'jotai'; import { useState } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; import { useNavigateHelper } from '../../../../../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../../../../../shared'; import { EnableAffineCloudModal } from '../../../enable-affine-cloud-modal'; import { TmpDisableAffineCloudModal } from '../../../tmp-disable-affine-cloud-modal'; import type { WorkspaceSettingDetailProps } from './types'; @@ -29,7 +29,7 @@ export const EnableCloudPanel = ({ const { openPage } = useNavigateHelper(); - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); const workspaceInfo = useWorkspaceInfo(workspaceMetadata); const setSettingModal = useSetAtom(openSettingModalAtom); diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/export.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/export.tsx index 691ed8244c..3eb88c9e06 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/export.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/export.tsx @@ -4,7 +4,7 @@ import { Button } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { apis } from '@affine/electron-api'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace, WorkspaceMetadata } from '@affine/workspace'; +import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { useState } from 'react'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx index 78aa8c3e82..02a217ef90 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx @@ -9,9 +9,9 @@ import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status'; import { validateAndReduceImage } from '@affine/core/utils/reduce-image'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@affine/workspace'; -import { SyncPeerStep } from '@affine/workspace'; import { CameraIcon } from '@blocksuite/icons'; +import type { Workspace } from '@toeverything/infra'; +import { SyncPeerStep } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { type KeyboardEvent, diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/storage.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/storage.tsx index 3548a9e855..8fb09bc8ab 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/storage.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/storage.tsx @@ -4,7 +4,7 @@ import { Button } from '@affine/component/ui/button'; import { Tooltip } from '@affine/component/ui/tooltip'; import { apis, events } from '@affine/electron-api'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { useMemo } from 'react'; import { useCallback, useEffect, useState } from 'react'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/types.ts b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/types.ts index b07a28e27b..59e457016a 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/types.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/types.ts @@ -1,4 +1,4 @@ -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import type { WorkspaceMetadata } from '@toeverything/infra'; export interface WorkspaceSettingDetailProps { isOwner: boolean; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx index f9b2eb1512..2f67cb3678 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx @@ -1,9 +1,8 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { Workspace } from '@affine/workspace'; import type { Page } from '@blocksuite/store'; -import { useAtomValue } from 'jotai'; +import { type Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra'; import { useState } from 'react'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; @@ -25,7 +24,7 @@ export const SharePageButton = ({ const { openPage } = useNavigateHelper(); - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); const handleConfirm = useAsyncCallback(async () => { if (workspace.flavour !== WorkspaceFlavour.LOCAL) { diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx index 5e385c2aa7..26ea51be2e 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx @@ -3,9 +3,9 @@ import { Divider } from '@affine/component/ui/divider'; import { Menu } from '@affine/component/ui/menu'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace'; import { WebIcon } from '@blocksuite/icons'; import type { Page } from '@blocksuite/store'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import clsx from 'clsx'; import { useIsSharedPage } from '../../../../hooks/affine/use-is-shared-page'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx index 27abc7eff2..b0c3f38b91 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx @@ -1,11 +1,11 @@ import { FavoriteTag } from '@affine/core/components/page-list'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { toast } from '@affine/core/utils'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; -import { useAtomValue } from 'jotai'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useCallback } from 'react'; export interface FavoriteButtonProps { @@ -14,7 +14,7 @@ export interface FavoriteButtonProps { export const useFavorite = (pageId: string) => { const t = useAFFiNEI18N(); - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const currentPage = blockSuiteWorkspace.getPage(pageId); assertExists(currentPage); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx index dfe7f6bea2..f82aaf6f65 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx @@ -1,3 +1,4 @@ +import { toast } from '@affine/component'; import { Menu, MenuIcon, @@ -11,8 +12,6 @@ import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-sui import { useExportPage } from '@affine/core/hooks/affine/use-export-page'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { toast } from '@affine/core/utils'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; @@ -26,6 +25,7 @@ import { ImportIcon, PageIcon, } from '@blocksuite/icons'; +import { useService, Workspace } from '@toeverything/infra'; import { useAtomValue } from 'jotai'; import { useCallback, useState } from 'react'; @@ -46,8 +46,7 @@ export const PageHeaderMenuButton = ({ }: PageMenuProps) => { const t = useAFFiNEI18N(); - // fixme(himself65): remove these hooks ASAP - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const currentPage = blockSuiteWorkspace.getPage(pageId); assertExists(currentPage); diff --git a/packages/frontend/core/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/frontend/core/src/components/page-list/__tests__/use-all-page-setting.spec.ts index fe0c5d44b8..809df8a590 100644 --- a/packages/frontend/core/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/frontend/core/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -3,57 +3,46 @@ */ import 'fake-indexeddb/auto'; +import type { CollectionService } from '@affine/core/modules/collection'; import type { Collection } from '@affine/env/filter'; import { renderHook } from '@testing-library/react'; -import { atom } from 'jotai'; -import { atomWithObservable } from 'jotai/utils'; +import { LiveData } from '@toeverything/infra'; import { BehaviorSubject } from 'rxjs'; import { expect, test } from 'vitest'; import { createDefaultFilter, vars } from '../filter/vars'; -import { - type CollectionsCRUD, - useCollectionManager, -} from '../use-collection-manager'; +import { useCollectionManager } from '../use-collection-manager'; const defaultMeta = { tags: { options: [] } }; const collectionsSubject = new BehaviorSubject([]); -const baseAtom = atomWithObservable( - () => { - return collectionsSubject; - }, - { - initialValue: [], - } -); -const mockAtom = atom(get => { - return { - collections: get(baseAtom), - addCollection: (...collections) => { - const prev = collectionsSubject.value; - collectionsSubject.next([...collections, ...prev]); - }, - deleteCollection: (...ids) => { - const prev = collectionsSubject.value; - collectionsSubject.next(prev.filter(v => !ids.includes(v.id))); - }, - updateCollection: (id, updater) => { - const prev = collectionsSubject.value; - collectionsSubject.next( - prev.map(v => { - if (v.id === id) { - return updater(v); - } - return v; - }) - ); - }, - } satisfies CollectionsCRUD; -}); +const mockWorkspaceCollectionService = { + collections: LiveData.from(collectionsSubject, []), + addCollection: (...collections) => { + const prev = collectionsSubject.value; + collectionsSubject.next([...collections, ...prev]); + }, + deleteCollection: (...ids) => { + const prev = collectionsSubject.value; + collectionsSubject.next(prev.filter(v => !ids.includes(v.id))); + }, + updateCollection: (id, updater) => { + const prev = collectionsSubject.value; + collectionsSubject.next( + prev.map(v => { + if (v.id === id) { + return updater(v); + } + return v; + }) + ); + }, +} as CollectionService; test('useAllPageSetting', async () => { - const settingHook = renderHook(() => useCollectionManager(mockAtom)); + const settingHook = renderHook(() => + useCollectionManager(mockWorkspaceCollectionService) + ); const prevCollection = settingHook.result.current.currentCollection; expect(settingHook.result.current.savedCollections).toEqual([]); settingHook.result.current.updateCollection({ diff --git a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx index 7ef7d5024f..fede1ea01c 100644 --- a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx +++ b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx @@ -1,9 +1,8 @@ -import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra'; +import { Workspace } from '@toeverything/infra'; import { type ReactElement, useCallback, @@ -12,6 +11,7 @@ import { useState, } from 'react'; +import { CollectionService } from '../../../modules/collection'; import { ListFloatingToolbar } from '../components/list-floating-toolbar'; import { collectionHeaderColsDef } from '../header-col-def'; import { CollectionOperationCell } from '../operation-cell'; @@ -69,8 +69,8 @@ export const VirtualizedCollectionList = ({ const [selectedCollectionIds, setSelectedCollectionIds] = useState( [] ); - const setting = useCollectionManager(collectionsCRUDAtom); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const setting = useCollectionManager(useService(CollectionService)); + const currentWorkspace = useService(Workspace); const info = useDeleteCollectionInfo(); const collectionOperations = useCollectionOperationsRenderer({ diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx index e5ef4836e3..dabcace4ac 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx @@ -1,13 +1,14 @@ import { Button } from '@affine/component'; -import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import type { Collection, Tag } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ViewLayersIcon } from '@blocksuite/icons'; +import { useService } from '@toeverything/infra/di'; import { nanoid } from 'nanoid'; import { useCallback, useMemo } from 'react'; +import { CollectionService } from '../../../modules/collection'; import { createTagFilter } from '../filter/utils'; import { createEmptyCollection, @@ -24,7 +25,7 @@ import { PageListNewPageButton } from './page-list-new-page-button'; export const PageListHeader = ({ workspaceId }: { workspaceId: string }) => { const t = useAFFiNEI18N(); - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const { jumpToCollections } = useNavigateHelper(); const handleJumpToCollections = useCallback(() => { @@ -74,14 +75,16 @@ export const CollectionPageListHeader = ({ workspaceId: string; }) => { const t = useAFFiNEI18N(); - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const { jumpToCollections } = useNavigateHelper(); const handleJumpToCollections = useCallback(() => { jumpToCollections(workspaceId); }, [jumpToCollections, workspaceId]); - const { updateCollection } = useCollectionManager(collectionsCRUDAtom); + const { updateCollection } = useCollectionManager( + useService(CollectionService) + ); const { node, open } = useEditCollection(config); const handleAddPage = useAsyncCallback(async () => { @@ -121,7 +124,7 @@ export const TagPageListHeader = ({ }) => { const t = useAFFiNEI18N(); const { jumpToTags, jumpToCollection } = useNavigateHelper(); - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const { open, node } = useEditCollectionName({ title: t['com.affine.editCollection.saveCollection'](), showTips: true, diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx index 17e9601ca4..d867622e83 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-new-page-button.tsx @@ -1,5 +1,5 @@ -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { useAtomValue } from 'jotai'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import type { PropsWithChildren } from 'react'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; @@ -16,7 +16,7 @@ export const PageListNewPageButton = ({ size?: 'small' | 'default'; testId?: string; }>) => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const { importFile, createEdgeless, createPage } = usePageHelper( currentWorkspace.blockSuiteWorkspace ); diff --git a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx index 32776b8aac..54e328af97 100644 --- a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx +++ b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx @@ -2,12 +2,12 @@ import { toast } from '@affine/component'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import type { Collection } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { PageMeta, Tag } from '@blocksuite/store'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra'; +import { Workspace } from '@toeverything/infra'; import { useCallback, useMemo, useRef, useState } from 'react'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; @@ -27,7 +27,7 @@ import { } from './page-list-header'; const usePageOperationsRenderer = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const { setTrashModal } = useTrashModalHelper( currentWorkspace.blockSuiteWorkspace ); @@ -89,7 +89,7 @@ export const VirtualizedPageList = ({ const listRef = useRef(null); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [selectedPageIds, setSelectedPageIds] = useState([]); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); const pageOperations = usePageOperationsRenderer(); const { isPreferredEdgeless } = usePageHelper( diff --git a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx index f50a63170d..f665798216 100644 --- a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx +++ b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx @@ -1,7 +1,7 @@ -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { Trans } from '@affine/i18n'; import type { Tag } from '@blocksuite/store'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra'; +import { Workspace } from '@toeverything/infra'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ListFloatingToolbar } from '../components/list-floating-toolbar'; @@ -26,7 +26,7 @@ export const VirtualizedTagList = ({ const listRef = useRef(null); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const filteredSelectedTagIds = useMemo(() => { const ids = tags.map(tag => tag.id); diff --git a/packages/frontend/core/src/components/page-list/use-collection-manager.ts b/packages/frontend/core/src/components/page-list/use-collection-manager.ts index 9d501d6b82..866a919611 100644 --- a/packages/frontend/core/src/components/page-list/use-collection-manager.ts +++ b/packages/frontend/core/src/components/page-list/use-collection-manager.ts @@ -1,11 +1,8 @@ -import type { - Collection, - DeleteCollectionInfo, - Filter, - VariableMap, -} from '@affine/env/filter'; +import type { CollectionService } from '@affine/core/modules/collection'; +import type { Collection, Filter, VariableMap } from '@affine/env/filter'; import type { PageMeta } from '@blocksuite/store'; -import { type Atom, useAtom, useAtomValue } from 'jotai'; +import { useLiveData } from '@toeverything/infra/livedata'; +import { useAtom, useAtomValue } from 'jotai'; import { atomWithReset } from 'jotai/utils'; import { useCallback } from 'react'; import { NIL } from 'uuid'; @@ -32,47 +29,28 @@ export const currentCollectionAtom = atomWithReset(NIL); export type Updater = (value: T) => T; export type CollectionUpdater = Updater; -export type CollectionsCRUD = { - addCollection: (...collections: Collection[]) => void; - collections: Collection[]; - updateCollection: (id: string, updater: CollectionUpdater) => void; - deleteCollection: (info: DeleteCollectionInfo, ...ids: string[]) => void; -}; -export type CollectionsCRUDAtom = Atom< - Promise | CollectionsCRUD ->; -export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => { - const [{ collections, addCollection, deleteCollection, updateCollection }] = - useAtom(collectionAtom); +export const useSavedCollections = (collectionService: CollectionService) => { const addPage = useCallback( (collectionId: string, pageId: string) => { - updateCollection(collectionId, old => { + collectionService.updateCollection(collectionId, old => { return { ...old, allowList: [pageId, ...(old.allowList ?? [])], }; }); }, - [updateCollection] + [collectionService] ); return { - collections, - addCollection, - updateCollection, - deleteCollection, + collectionService, addPage, }; }; -export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { - const { - collections, - updateCollection, - addCollection, - deleteCollection, - addPage, - } = useSavedCollections(collectionsAtom); +export const useCollectionManager = (collectionService: CollectionService) => { + const collections = useLiveData(collectionService.collections); + const { addPage } = useSavedCollections(collectionService); const currentCollectionId = useAtomValue(currentCollectionAtom); const [defaultCollection, updateDefaultCollection] = useAtom( defaultCollectionAtom @@ -82,10 +60,10 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { if (collection.id === NIL) { updateDefaultCollection(collection); } else { - updateCollection(collection.id, () => collection); + collectionService.updateCollection(collection.id, () => collection); } }, - [updateDefaultCollection, updateCollection] + [updateDefaultCollection, collectionService] ); const setTemporaryFilter = useCallback( (filterList: Filter[]) => { @@ -108,9 +86,10 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { isDefault: currentCollectionId === NIL, // actions - createCollection: addCollection, + createCollection: collectionService.addCollection.bind(collectionService), updateCollection: update, - deleteCollection, + deleteCollection: + collectionService.deleteCollection.bind(collectionService), addPage, setTemporaryFilter, }; diff --git a/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx index 0a17b50ff5..81969434dc 100644 --- a/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx +++ b/packages/frontend/core/src/components/page-list/use-filtered-page-metas.tsx @@ -1,8 +1,9 @@ import { allPageModeSelectAtom } from '@affine/core/atoms'; -import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import { CollectionService } from '@affine/core/modules/collection'; import type { BlockSuiteWorkspace } from '@affine/core/shared'; import type { PageMeta } from '@blocksuite/store'; +import { useService } from '@toeverything/infra/di'; import { useAtomValue } from 'jotai'; import { useMemo } from 'react'; @@ -19,8 +20,9 @@ export const useFilteredPageMetas = ( ) => { const { isPreferredEdgeless } = usePageHelper(workspace); const pageMode = useAtomValue(allPageModeSelectAtom); - const { currentCollection, isDefault } = - useCollectionManager(collectionsCRUDAtom); + const { currentCollection, isDefault } = useCollectionManager( + useService(CollectionService) + ); const filteredPageMetas = useMemo( () => diff --git a/packages/frontend/core/src/components/page-list/view/collection-bar.tsx b/packages/frontend/core/src/components/page-list/view/collection-bar.tsx index ec8f1daf05..183de6f01c 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-bar.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-bar.tsx @@ -1,4 +1,5 @@ import { Button, Tooltip } from '@affine/component'; +import type { CollectionService } from '@affine/core/modules/collection'; import type { DeleteCollectionInfo, PropertiesMeta } from '@affine/env/filter'; import type { GetPageInfoById } from '@affine/env/page-info'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -6,10 +7,7 @@ import { ViewLayersIcon } from '@blocksuite/icons'; import clsx from 'clsx'; import { useState } from 'react'; -import { - type CollectionsCRUDAtom, - useCollectionManager, -} from '../use-collection-manager'; +import { useCollectionManager } from '../use-collection-manager'; import * as styles from './collection-bar.css'; import { type AllPageListConfig, @@ -20,16 +18,16 @@ import { useActions } from './use-action'; interface CollectionBarProps { getPageInfo: GetPageInfoById; propertiesMeta: PropertiesMeta; - collectionsAtom: CollectionsCRUDAtom; + collectionService: CollectionService; backToAll: () => void; allPageListConfig: AllPageListConfig; info: DeleteCollectionInfo; } export const CollectionBar = (props: CollectionBarProps) => { - const { collectionsAtom } = props; + const { collectionService } = props; const t = useAFFiNEI18N(); - const setting = useCollectionManager(collectionsAtom); + const setting = useCollectionManager(collectionService); const collection = setting.currentCollection; const [open, setOpen] = useState(false); const actions = useActions({ diff --git a/packages/frontend/core/src/components/pure/cmdk/data.tsx b/packages/frontend/core/src/components/pure/cmdk/data.tsx index 469a463526..298dcd3b0b 100644 --- a/packages/frontend/core/src/components/pure/cmdk/data.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/data.tsx @@ -3,15 +3,11 @@ import { useBlockSuitePageMeta, usePageMetaHelper, } from '@affine/core/hooks/use-block-suite-page-meta'; -import { - currentWorkspaceAtom, - waitForCurrentWorkspaceAtom, -} from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon, ViewLayersIcon } from '@blocksuite/icons'; -import type { Page, PageMeta } from '@blocksuite/store'; +import type { PageMeta } from '@blocksuite/store'; +import { Workspace } from '@toeverything/infra'; import { getCurrentStore } from '@toeverything/infra/atom'; import { type AffineCommand, @@ -19,19 +15,17 @@ import { type CommandCategory, PreconditionStrategy, } from '@toeverything/infra/command'; +import { useService } from '@toeverything/infra/di'; import { commandScore } from 'cmdk'; import { atom, useAtomValue } from 'jotai'; import { groupBy } from 'lodash-es'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - openQuickSearchModalAtom, - pageSettingsAtom, - recentPageIdsBaseAtom, -} from '../../../atoms'; -import { collectionsCRUDAtom } from '../../../atoms/collections'; +import { pageSettingsAtom, recentPageIdsBaseAtom } from '../../../atoms'; import { currentPageIdAtom } from '../../../atoms/mode'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; +import { CollectionService } from '../../../modules/collection'; +import { WorkspaceSubPath } from '../../../shared'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; import type { CMDKCommand, CommandContext } from './types'; @@ -47,41 +41,6 @@ export function removeDoubleQuotes(str?: string): string | undefined { export const cmdkQueryAtom = atom(''); export const cmdkValueAtom = atom(''); -// like currentWorkspaceAtom, but not throw error -const safeCurrentPageAtom = atom>(async get => { - const currentWorkspace = get(currentWorkspaceAtom); - if (!currentWorkspace) { - return; - } - - const currentPageId = get(currentPageIdAtom); - - if (!currentPageId) { - return; - } - - const page = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId); - - if (!page) { - return; - } - - if (!page.loaded) { - await page.waitForLoaded(); - } - return page; -}); - -export const commandContextAtom = atom>(async get => { - const currentPage = await get(safeCurrentPageAtom); - const pageSettings = get(pageSettingsAtom); - - return { - currentPage, - pageMode: currentPage ? pageSettings[currentPage.id]?.mode : undefined, - }; -}); - function filterCommandByContext( command: AffineCommand, context: CommandContext @@ -96,7 +55,7 @@ function filterCommandByContext( return context.pageMode === 'page'; } if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) { - return !!context.currentPage; + return !!context.pageMode; } if (command.preconditionStrategy === PreconditionStrategy.Never) { return false; @@ -107,27 +66,16 @@ function filterCommandByContext( return true; } -let quickSearchOpenCounter = 0; -const openCountAtom = atom(get => { - if (get(openQuickSearchModalAtom)) { - quickSearchOpenCounter++; - } - return quickSearchOpenCounter; -}); - -export const filteredAffineCommands = atom(async get => { - const context = await get(commandContextAtom); - // reset when modal open - get(openCountAtom); +function getAllCommand(context: CommandContext) { const commands = AffineCommandRegistry.getAll(); return commands.filter(command => { return filterCommandByContext(command, context); }); -}); +} const useWorkspacePages = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - const pages = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); + const workspace = useService(Workspace); + const pages = useBlockSuitePageMeta(workspace.blockSuiteWorkspace); return pages; }; @@ -153,6 +101,7 @@ export const pageToCommand = ( store: ReturnType, navigationHelper: ReturnType, t: ReturnType, + workspace: Workspace, label?: { title: string; subTitle?: string; @@ -160,7 +109,6 @@ export const pageToCommand = ( blockId?: string ): CMDKCommand => { const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode; - const currentWorkspace = store.get(currentWorkspaceAtom); const title = page.title || t['Untitled'](); const commandLabel = label || { @@ -186,18 +134,14 @@ export const pageToCommand = ( originalValue: title, category: category, run: () => { - if (!currentWorkspace) { + if (!workspace) { console.error('current workspace not found'); return; } if (blockId) { - return navigationHelper.jumpToPageBlock( - currentWorkspace.id, - page.id, - blockId - ); + return navigationHelper.jumpToPageBlock(workspace.id, page.id, blockId); } - return navigationHelper.jumpToPage(currentWorkspace.id, page.id); + return navigationHelper.jumpToPage(workspace.id, page.id); }, icon: pageMode === 'edgeless' ? : , timestamp: page.updatedDate, @@ -212,7 +156,7 @@ export const usePageCommands = () => { const recentPages = useRecentPages(); const pages = useWorkspacePages(); const store = getCurrentStore(); - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const pageHelper = usePageHelper(workspace.blockSuiteWorkspace); const pageMetaHelper = usePageMetaHelper(workspace.blockSuiteWorkspace); const query = useAtomValue(cmdkQueryAtom); @@ -241,7 +185,14 @@ export const usePageCommands = () => { let results: CMDKCommand[] = []; if (query.trim() === '') { results = recentPages.map(page => { - return pageToCommand('affine:recent', page, store, navigationHelper, t); + return pageToCommand( + 'affine:recent', + page, + store, + navigationHelper, + t, + workspace + ); }); } else { // queried pages that has matched contents @@ -283,6 +234,7 @@ export const usePageCommands = () => { store, navigationHelper, t, + workspace, label, blockId ); @@ -334,27 +286,26 @@ export const usePageCommands = () => { } return results; }, [ - pageHelper, - pageMetaHelper, - navigationHelper, - pages, + searchTime, query, recentPages, store, + navigationHelper, t, - workspace.blockSuiteWorkspace, - searchTime, + workspace, + pages, + pageHelper, + pageMetaHelper, ]); }; export const collectionToCommand = ( collection: Collection, - store: ReturnType, navigationHelper: ReturnType, selectCollection: (id: string) => void, - t: ReturnType + t: ReturnType, + workspace: Workspace ): CMDKCommand => { - const currentWorkspace = store.get(currentWorkspaceAtom); const label = collection.name || t['Untitled'](); const category = 'affine:collections'; return { @@ -372,11 +323,7 @@ export const collectionToCommand = ( originalValue: label, category: category, run: () => { - if (!currentWorkspace) { - console.error('current workspace not found'); - return; - } - navigationHelper.jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL); + navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); selectCollection(collection.id); }, icon: , @@ -385,12 +332,13 @@ export const collectionToCommand = ( export const useCollectionsCommands = () => { // todo: considering collections for searching pages - const { savedCollections } = useCollectionManager(collectionsCRUDAtom); - const store = getCurrentStore(); + const { savedCollections } = useCollectionManager( + useService(CollectionService) + ); const query = useAtomValue(cmdkQueryAtom); const navigationHelper = useNavigateHelper(); const t = useAFFiNEI18N(); - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const selectCollection = useCallback( (id: string) => { navigationHelper.jumpToCollection(workspace.id, id); @@ -405,22 +353,39 @@ export const useCollectionsCommands = () => { results = savedCollections.map(collection => { const command = collectionToCommand( collection, - store, navigationHelper, selectCollection, - t + t, + workspace ); return command; }); return results; } - }, [query, savedCollections, store, navigationHelper, selectCollection, t]); + }, [ + query, + savedCollections, + navigationHelper, + selectCollection, + t, + workspace, + ]); }; export const useCMDKCommandGroups = () => { const pageCommands = usePageCommands(); const collectionCommands = useCollectionsCommands(); - const affineCommands = useAtomValue(filteredAffineCommands); + + const currentPageId = useAtomValue(currentPageIdAtom); + const pageSettings = useAtomValue(pageSettingsAtom); + const currentPageMode = currentPageId + ? pageSettings[currentPageId]?.mode + : undefined; + const affineCommands = useMemo(() => { + return getAllCommand({ + pageMode: currentPageMode, + }); + }, [currentPageMode]); return useMemo(() => { const commands = [ diff --git a/packages/frontend/core/src/components/pure/cmdk/types.ts b/packages/frontend/core/src/components/pure/cmdk/types.ts index 5fddfab0c7..dddc96abf9 100644 --- a/packages/frontend/core/src/components/pure/cmdk/types.ts +++ b/packages/frontend/core/src/components/pure/cmdk/types.ts @@ -1,8 +1,6 @@ -import type { Page } from '@blocksuite/store'; import type { CommandCategory } from '@toeverything/infra/command'; export interface CommandContext { - currentPage: Page | undefined; pageMode: 'page' | 'edgeless' | undefined; } diff --git a/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx b/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx index 6896cb2f30..c02bd05ffd 100644 --- a/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx +++ b/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx @@ -2,22 +2,25 @@ import { Button } from '@affine/component/ui/button'; import { ConfirmModal } from '@affine/component/ui/modal'; import { Tooltip } from '@affine/component/ui/tooltip'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon, ResetIcon } from '@blocksuite/icons'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; import { useCallback, useState } from 'react'; import { useAppSettingHelper } from '../../../hooks/affine/use-app-setting-helper'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; +import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace'; +import { WorkspaceSubPath } from '../../../shared'; import { toast } from '../../../utils'; import * as styles from './styles.css'; export const TrashPageFooter = ({ pageId }: { pageId: string }) => { - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useLiveData( + useService(CurrentWorkspaceService).currentWorkspace + ); assertExists(workspace); const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index af8bc3bbbb..ffe696ce6b 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -7,21 +7,22 @@ import { filterPage, stopPropagation, useCollectionManager, - useSavedCollections, } from '@affine/core/components/page-list'; -import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import { CollectionService } from '@affine/core/modules/collection'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons'; import type { PageMeta, Workspace } from '@blocksuite/store'; import { useDroppable } from '@dnd-kit/core'; import * as Collapsible from '@radix-ui/react-collapsible'; +import { useService } from '@toeverything/infra'; +import { useLiveData } from '@toeverything/infra/livedata'; import { useCallback, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { collectionsCRUDAtom } from '../../../../atoms/collections'; import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config'; import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; +import { useBlockSuitePageMeta } from '../../../../hooks/use-block-suite-page-meta'; import type { CollectionsListProps } from '../index'; import { Page } from './page'; import * as styles from './styles.css'; @@ -39,7 +40,7 @@ const CollectionRenderer = ({ }) => { const [collapsed, setCollapsed] = useState(true); const [open, setOpen] = useState(false); - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const t = useAFFiNEI18N(); const dragItemId = getDropItemId('collections', collection.id); @@ -168,7 +169,7 @@ export const CollectionsList = ({ onCreate, }: CollectionsListProps) => { const metas = useBlockSuitePageMeta(workspace); - const { collections } = useSavedCollections(collectionsCRUDAtom); + const collections = useLiveData(useService(CollectionService).collections); const t = useAFFiNEI18N(); if (collections.length === 0) { return ( diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx index e76bf53f15..8357556588 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx @@ -1,13 +1,12 @@ import { Divider } from '@affine/component/ui/divider'; import { MenuItem } from '@affine/component/ui/menu'; -import { - workspaceListAtom, - workspaceManagerAtom, -} from '@affine/core/modules/workspace'; import { Unreachable } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Logo1Icon } from '@blocksuite/icons'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; +import { useSetAtom } from 'jotai'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useSession } from 'next-auth/react'; import { useCallback, useEffect, useMemo } from 'react'; @@ -85,9 +84,8 @@ export const UserWithWorkspaceList = ({ onEventEnd?.(); }, [onEventEnd, setOpenCreateWorkspaceModal]); - const workspaces = useAtomValue(workspaceListAtom); - - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); + const workspaces = useLiveData(workspaceManager.list.workspaceList); // revalidate workspace list when mounted useEffect(() => { diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx index 91164deb48..88b721533b 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx @@ -5,16 +5,13 @@ import { useWorkspaceAvatar, useWorkspaceName, } from '@affine/core/hooks/use-workspace-info'; -import { - currentWorkspaceAtom, - workspaceListAtom, -} from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { WorkspaceMetadata } from '@affine/workspace'; import type { DragEndEvent } from '@dnd-kit/core'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { WorkspaceManager, type WorkspaceMetadata } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; +import { useSetAtom } from 'jotai'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useSession } from 'next-auth/react'; import { useCallback, useMemo } from 'react'; @@ -23,6 +20,8 @@ import { openCreateWorkspaceModalAtom, openSettingModalAtom, } from '../../../../../atoms'; +import { CurrentWorkspaceService } from '../../../../../modules/workspace/current-workspace'; +import { WorkspaceSubPath } from '../../../../../shared'; import { useIsWorkspaceOwner } from '../.././../../../hooks/affine/use-is-workspace-owner'; import { useNavigateHelper } from '../.././../../../hooks/use-navigate-helper'; import * as styles from './index.css'; @@ -106,13 +105,17 @@ export const AFFiNEWorkspaceList = ({ }: { onEventEnd?: () => void; }) => { - const workspaces = useAtomValue(workspaceListAtom); + const workspaces = useLiveData( + useService(WorkspaceManager).list.workspaceList + ); const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const { jumpToSubPath } = useNavigateHelper(); - const currentWorkspace = useAtomValue(currentWorkspaceAtom); + const currentWorkspace = useLiveData( + useService(CurrentWorkspaceService).currentWorkspace + ); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx index 289270db44..33853d80ac 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx @@ -6,11 +6,9 @@ import { openSettingModalAtom } from '@affine/core/atoms'; import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace'; import { CloudWorkspaceIcon, InformationFillDuotoneIcon, @@ -18,7 +16,13 @@ import { NoNetworkIcon, UnsyncIcon, } from '@blocksuite/icons'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { + type SyncEngineStatus, + SyncEngineStep, + Workspace, +} from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useSetAtom } from 'jotai'; import { debounce, mean } from 'lodash-es'; import { forwardRef, @@ -97,7 +101,7 @@ const useSyncEngineSyncProgress = () => { useState(null); const [isOverCapacity, setIsOverCapacity] = useState(false); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const isOwner = useIsWorkspaceOwner(currentWorkspace.meta); const setSettingModalAtom = useSetAtom(openSettingModalAtom); @@ -250,7 +254,7 @@ export const WorkspaceCard = forwardRef< HTMLDivElement, HTMLAttributes >(({ ...props }, ref) => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const information = useWorkspaceInfo(currentWorkspace.meta); diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index ffc86afb3c..d28a0867b3 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -12,15 +12,14 @@ import { SidebarScrollableContainer, } from '@affine/component/app-sidebar'; import { Menu } from '@affine/component/ui/menu'; -import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { WorkspaceSubPath } from '@affine/core/shared'; +import { CollectionService } from '@affine/core/modules/collection'; import { apis, events } from '@affine/electron-api'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@affine/workspace'; import { FolderIcon, SettingsIcon } from '@blocksuite/icons'; import { type Page } from '@blocksuite/store'; import { useDroppable } from '@dnd-kit/core'; +import { useService, type Workspace } from '@toeverything/infra'; import { useAtom, useAtomValue } from 'jotai'; import { nanoid } from 'nanoid'; import type { HTMLAttributes, ReactElement } from 'react'; @@ -35,6 +34,7 @@ import { getDropItemId } from '../../hooks/affine/use-sidebar-drag'; import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper'; import { useRegisterBrowserHistoryCommands } from '../../hooks/use-browser-history-commands'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../../shared'; import { createEmptyCollection, MoveToTrash, @@ -177,7 +177,7 @@ export const RootAppSidebar = ({ useRegisterBrowserHistoryCommands(router.back, router.forward); const userInfo = useDeleteCollectionInfo(); - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const { node, open } = useEditCollectionName({ title: t['com.affine.editCollection.createCollection'](), showTips: true, diff --git a/packages/frontend/core/src/components/top-tip.tsx b/packages/frontend/core/src/components/top-tip.tsx index 7c8ce14de4..6efe092c58 100644 --- a/packages/frontend/core/src/components/top-tip.tsx +++ b/packages/frontend/core/src/components/top-tip.tsx @@ -1,18 +1,18 @@ import { BrowserWarning } from '@affine/component/affine-banner'; import { LocalDemoTips } from '@affine/component/affine-banner'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@affine/workspace'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { type Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra'; +import { useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; import { authAtom } from '../atoms'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../shared'; import { EnableAffineCloudModal } from './affine/enable-affine-cloud-modal'; const minimumChromeVersion = 106; @@ -77,7 +77,7 @@ export const TopTip = ({ }, [setAuthModal]); const { openPage } = useNavigateHelper(); - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); const handleConfirm = useAsyncCallback(async () => { if (workspace.flavour !== WorkspaceFlavour.LOCAL) { return; diff --git a/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx b/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx index 1d7c8e59aa..6a02be89d4 100644 --- a/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx +++ b/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx @@ -3,15 +3,12 @@ import { AffineShapeIcon } from '@affine/core/components/page-list'; // TODO: im import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status'; -import { - waitForCurrentWorkspaceAtom, - workspaceManagerAtom, -} from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useAtomValue } from 'jotai'; +import { Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useState } from 'react'; +import { WorkspaceSubPath } from '../../shared'; import * as styles from './upgrade.css'; import { ArrowCircleIcon, HeartBreakIcon } from './upgrade-icon'; @@ -20,8 +17,8 @@ import { ArrowCircleIcon, HeartBreakIcon } from './upgrade-icon'; */ export const WorkspaceUpgrade = function WorkspaceUpgrade() { const [error, setError] = useState(null); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - const workspaceManager = useAtomValue(workspaceManagerAtom); + const currentWorkspace = useService(Workspace); + const workspaceManager = useService(WorkspaceManager); const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade); const { openPage } = useNavigateHelper(); const t = useAFFiNEI18N(); @@ -32,10 +29,10 @@ export const WorkspaceUpgrade = function WorkspaceUpgrade() { } try { - const newWorkspaceId = + const newWorkspace = await currentWorkspace.upgrade.upgrade(workspaceManager); - if (newWorkspaceId) { - openPage(newWorkspaceId, WorkspaceSubPath.ALL); + if (newWorkspace) { + openPage(newWorkspace.id, WorkspaceSubPath.ALL); } else { // blocksuite may enter an incorrect state, reload to reset it. location.reload(); diff --git a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx index 8f5eaeb860..86a42b3a7e 100644 --- a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx +++ b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx @@ -3,101 +3,71 @@ */ import 'fake-indexeddb/auto'; -import { - currentWorkspaceAtom, - WorkspacePropertiesAdapter, -} from '@affine/core/modules/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { Workspace } from '@affine/workspace/workspace'; -import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; -import { assertExists } from '@blocksuite/global/utils'; -import { type Page, Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { Schema } from '@blocksuite/store'; +import { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace'; import { render } from '@testing-library/react'; +import { Workspace } from '@toeverything/infra'; +import { ServiceProviderContext, useService } from '@toeverything/infra/di'; import { createStore, Provider } from 'jotai'; import { Suspense } from 'react'; import { describe, expect, test, vi } from 'vitest'; import { beforeEach } from 'vitest'; +import { configureTestingEnvironment } from '../../testing'; import { useBlockSuiteWorkspacePageTitle } from '../use-block-suite-workspace-page-title'; -let blockSuiteWorkspace: BlockSuiteWorkspace; const store = createStore(); -const schema = new Schema(); -schema.register(AffineSchemas).register(__unstableSchemas); - const Component = () => { - const title = useBlockSuiteWorkspacePageTitle(blockSuiteWorkspace, 'page0'); + const workspace = useService(Workspace); + const title = useBlockSuiteWorkspacePageTitle( + workspace.blockSuiteWorkspace, + 'page0' + ); return
title: {title}
; }; -// todo: this module has some side-effects that will break the tests -vi.mock('@affine/workspace-impl', () => ({ - default: {}, -})); - beforeEach(async () => { vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); - - blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema }); - - const workspace = { - blockSuiteWorkspace, - flavour: WorkspaceFlavour.LOCAL, - } as Workspace; - - store.set(currentWorkspaceAtom, workspace); - - blockSuiteWorkspace = workspace.blockSuiteWorkspace; - - blockSuiteWorkspace.doc.emit('sync', []); - - const initPage = async (page: Page) => { - await page.waitForLoaded(); - expect(page).not.toBeNull(); - assertExists(page); - const pageBlockId = page.addBlock('affine:page', { - title: new page.Text(''), - }); - const frameId = page.addBlock('affine:note', {}, pageBlockId); - page.addBlock('affine:paragraph', {}, frameId); - }; - await initPage(blockSuiteWorkspace.createPage({ id: 'page0' })); - await initPage(blockSuiteWorkspace.createPage({ id: 'page1' })); - await initPage(blockSuiteWorkspace.createPage({ id: 'page2' })); }); describe('useBlockSuiteWorkspacePageTitle', () => { test('basic', async () => { + const { workspace, page } = await configureTestingEnvironment(); const { findByText, rerender } = render( - - - - - + + + + + + + ); expect(await findByText('title: Untitled')).toBeDefined(); - blockSuiteWorkspace.setPageMeta('page0', { title: '1' }); + workspace.blockSuiteWorkspace.setPageMeta(page.id, { title: '1' }); rerender( - - - - - + + + + + + + ); expect(await findByText('title: 1')).toBeDefined(); }); test('journal', async () => { - const adapter = new WorkspacePropertiesAdapter(blockSuiteWorkspace); - adapter.setJournalPageDateString('page0', '2021-01-01'); + const { workspace, page } = await configureTestingEnvironment(); + const adapter = workspace.services.get(WorkspacePropertiesAdapter); + adapter.setJournalPageDateString(page.id, '2021-01-01'); const { findByText } = render( - - - - - + + + + + + + ); expect(await findByText('title: Jan 1, 2021')).toBeDefined(); }); diff --git a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx index b263b64f57..b556192d9d 100644 --- a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx +++ b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx @@ -4,17 +4,17 @@ import { FavoriteTag, } from '@affine/core/components/page-list'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { PageMeta } from '@blocksuite/store'; -import { useAtomValue } from 'jotai'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useCallback, useMemo } from 'react'; import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; export const useAllPageListConfig = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const workspace = currentWorkspace.blockSuiteWorkspace; const pageMetas = useBlockSuitePageMeta(workspace); const { isPreferredEdgeless } = usePageHelper(workspace); diff --git a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts index 9b6a4de9bb..c5cfbcb4ed 100644 --- a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts @@ -4,6 +4,8 @@ import { usePageMetaHelper, } from '@affine/core/hooks/use-block-suite-page-meta'; import { useBlockSuiteWorkspaceHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; +import { CollectionService } from '@affine/core/modules/collection'; +import { useService } from '@toeverything/infra'; import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback } from 'react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; @@ -11,7 +13,6 @@ import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { setPageModeAtom } from '../../atoms'; import { currentModeAtom } from '../../atoms/mode'; import type { BlockSuiteWorkspace } from '../../shared'; -import { getWorkspaceSetting } from '../../utils/workspace-setting'; import { useNavigateHelper } from '../use-navigate-helper'; import { useReferenceLinkHelper } from './use-reference-link-helper'; @@ -26,6 +27,7 @@ export function useBlockSuiteMetaHelper( const currentMode = useAtomValue(currentModeAtom); const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); const { openPage } = useNavigateHelper(); + const collectionService = useService(CollectionService); const switchToPageMode = useCallback( (pageId: string) => { @@ -89,9 +91,9 @@ export function useBlockSuiteMetaHelper( trashRelate: isRoot ? parentMeta?.id : undefined, }); setPageReadonly(pageId, true); - getWorkspaceSetting(blockSuiteWorkspace).deletePages([pageId]); + collectionService.deletePagesFromCollections([pageId]); }, - [blockSuiteWorkspace, getPageMeta, metas, setPageMeta, setPageReadonly] + [collectionService, getPageMeta, metas, setPageMeta, setPageReadonly] ); const restoreFromTrash = useCallback( diff --git a/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts b/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts index 4a8cd494b2..21ac3b4828 100644 --- a/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts +++ b/packages/frontend/core/src/hooks/affine/use-is-workspace-owner.ts @@ -1,6 +1,6 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; import { getIsOwnerQuery } from '@affine/graphql'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import type { WorkspaceMetadata } from '@toeverything/infra'; import { useQueryImmutable } from '../use-query'; diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx index afd030693b..e9a2002cf5 100644 --- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx +++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx @@ -1,15 +1,16 @@ import { toast } from '@affine/component'; import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons'; +import { Workspace } from '@toeverything/infra'; import { PreconditionStrategy, registerAffineCommand, } from '@toeverything/infra/command'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useService } from '@toeverything/infra/di'; +import { useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; import { pageHistoryModalAtom } from '../../atoms/page-history'; @@ -22,7 +23,7 @@ export function useRegisterBlocksuiteEditorCommands( mode: 'page' | 'edgeless' ) { const t = useAFFiNEI18N(); - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace); const currentPage = blockSuiteWorkspace.getPage(pageId); diff --git a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts index 2065117e80..8ceb1e5850 100644 --- a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts +++ b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts @@ -1,10 +1,10 @@ import { toast } from '@affine/component'; import type { DraggableTitleCellData } from '@affine/core/components/page-list'; import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core'; -import { useAtomValue } from 'jotai'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useCallback } from 'react'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; @@ -69,7 +69,7 @@ export function getDragItemId( export const useSidebarDrag = () => { const t = useAFFiNEI18N(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const workspace = currentWorkspace.blockSuiteWorkspace; const { setTrashModal } = useTrashModalHelper(workspace); const { addToFavorite, removeFromFavorite } = diff --git a/packages/frontend/core/src/hooks/current/use-current-page.ts b/packages/frontend/core/src/hooks/current/use-current-page.ts index 9d1ceb9b8c..47f73cc340 100644 --- a/packages/frontend/core/src/hooks/current/use-current-page.ts +++ b/packages/frontend/core/src/hooks/current/use-current-page.ts @@ -1,15 +1,15 @@ import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useAtomValue } from 'jotai'; import { currentPageIdAtom } from '../../atoms/mode'; export const useCurrentPage = () => { const currentPageId = useAtomValue(currentPageIdAtom); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - + const currentWorkspace = useService(Workspace); return useBlockSuiteWorkspacePage( - currentWorkspace?.blockSuiteWorkspace, + currentWorkspace.blockSuiteWorkspace, currentPageId ); }; diff --git a/packages/frontend/core/src/hooks/use-affine-adapter.ts b/packages/frontend/core/src/hooks/use-affine-adapter.ts index 00efa2fe36..4a434c2650 100644 --- a/packages/frontend/core/src/hooks/use-affine-adapter.ts +++ b/packages/frontend/core/src/hooks/use-affine-adapter.ts @@ -1,12 +1,7 @@ -import type { Workspace } from '@blocksuite/store'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra/di'; import { useEffect, useState } from 'react'; -import type { WorkspacePropertiesAdapter } from '../modules/workspace/properties'; -import { - currentWorkspacePropertiesAdapterAtom, - workspaceAdapterAtomFamily, -} from '../modules/workspace/properties'; +import { WorkspacePropertiesAdapter } from '../modules/workspace/properties'; function getProxy(obj: T) { return new Proxy(obj, {}); @@ -31,11 +26,6 @@ const useReactiveAdapter = (adapter: WorkspacePropertiesAdapter) => { }; export function useCurrentWorkspacePropertiesAdapter() { - const adapter = useAtomValue(currentWorkspacePropertiesAdapterAtom); - return useReactiveAdapter(adapter); -} - -export function useWorkspacePropertiesAdapter(workspace: Workspace) { - const adapter = useAtomValue(workspaceAdapterAtomFamily(workspace)); + const adapter = useService(WorkspacePropertiesAdapter); return useReactiveAdapter(adapter); } diff --git a/packages/frontend/core/src/hooks/use-data-source-status.ts b/packages/frontend/core/src/hooks/use-data-source-status.ts deleted file mode 100644 index b5c4906540..0000000000 --- a/packages/frontend/core/src/hooks/use-data-source-status.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback, useSyncExternalStore } from 'react'; -import type { DataSourceAdapter, Status } from 'y-provider'; - -type UIStatus = - | Status - | { - type: 'unknown'; - }; - -export function useDataSourceStatus(provider: DataSourceAdapter): UIStatus { - return useSyncExternalStore( - provider.subscribeStatusChange, - useCallback(() => provider.status, [provider]) - ); -} diff --git a/packages/frontend/core/src/hooks/use-journal.ts b/packages/frontend/core/src/hooks/use-journal.ts index f02a5d8bcb..8f6a04827a 100644 --- a/packages/frontend/core/src/hooks/use-journal.ts +++ b/packages/frontend/core/src/hooks/use-journal.ts @@ -4,7 +4,7 @@ import { useCallback, useMemo } from 'react'; import type { BlockSuiteWorkspace } from '../shared'; import { timestampToLocalDate } from '../utils'; -import { useWorkspacePropertiesAdapter } from './use-affine-adapter'; +import { useCurrentWorkspacePropertiesAdapter } from './use-affine-adapter'; import { useBlockSuiteWorkspaceHelper } from './use-block-suite-workspace-helper'; import { useNavigateHelper } from './use-navigate-helper'; @@ -24,7 +24,7 @@ function toDayjs(j?: string | false) { export const useJournalHelper = (workspace: BlockSuiteWorkspace) => { const bsWorkspaceHelper = useBlockSuiteWorkspaceHelper(workspace); - const adapter = useWorkspacePropertiesAdapter(workspace); + const adapter = useCurrentWorkspacePropertiesAdapter(); /** * @internal diff --git a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts index 6075b1dc4c..3a54a08199 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -1,6 +1,7 @@ -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useAtomValue, useSetAtom, useStore } from 'jotai'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useSetAtom, useStore } from 'jotai'; import { useTheme } from 'next-themes'; import { useEffect } from 'react'; @@ -22,7 +23,7 @@ export function useRegisterWorkspaceCommands() { const store = useStore(); const t = useAFFiNEI18N(); const theme = useTheme(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const languageHelper = useLanguageHelper(); const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); const navigationHelper = useNavigateHelper(); diff --git a/packages/frontend/core/src/hooks/use-workspace-blob.ts b/packages/frontend/core/src/hooks/use-workspace-blob.ts index 7f4db3e478..ddca25f34f 100644 --- a/packages/frontend/core/src/hooks/use-workspace-blob.ts +++ b/packages/frontend/core/src/hooks/use-workspace-blob.ts @@ -1,13 +1,13 @@ -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; -import { useAtomValue } from 'jotai'; +import type { WorkspaceMetadata } from '@toeverything/infra'; +import { WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra'; import { useEffect, useState } from 'react'; export function useWorkspaceBlobObjectUrl( meta?: WorkspaceMetadata, blobKey?: string | null ) { - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); const [blob, setBlob] = useState(undefined); diff --git a/packages/frontend/core/src/hooks/use-workspace-features.ts b/packages/frontend/core/src/hooks/use-workspace-features.ts index aa234e3b5e..7391440afd 100644 --- a/packages/frontend/core/src/hooks/use-workspace-features.ts +++ b/packages/frontend/core/src/hooks/use-workspace-features.ts @@ -5,7 +5,7 @@ import { enabledFeaturesQuery, setWorkspaceExperimentalFeatureMutation, } from '@affine/graphql'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import { type WorkspaceMetadata } from '@toeverything/infra'; import { useAsyncCallback } from './affine-async-hooks'; import { useMutateQueryResource, useMutation } from './use-mutation'; diff --git a/packages/frontend/core/src/hooks/use-workspace-info.ts b/packages/frontend/core/src/hooks/use-workspace-info.ts index b0d582bc3c..e991c097b3 100644 --- a/packages/frontend/core/src/hooks/use-workspace-info.ts +++ b/packages/frontend/core/src/hooks/use-workspace-info.ts @@ -1,12 +1,11 @@ -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; -import type { WorkspaceMetadata } from '@affine/workspace'; -import { useAtomValue } from 'jotai'; +import { WorkspaceManager, type WorkspaceMetadata } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useEffect, useState } from 'react'; import { useWorkspaceBlobObjectUrl } from './use-workspace-blob'; export function useWorkspaceInfo(meta: WorkspaceMetadata) { - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); const [information, setInformation] = useState( () => workspaceManager.list.getInformation(meta).info diff --git a/packages/frontend/core/src/hooks/use-workspace-status.ts b/packages/frontend/core/src/hooks/use-workspace-status.ts index 96e8cf0e18..d826169095 100644 --- a/packages/frontend/core/src/hooks/use-workspace-status.ts +++ b/packages/frontend/core/src/hooks/use-workspace-status.ts @@ -1,4 +1,4 @@ -import type { Workspace, WorkspaceStatus } from '@affine/workspace'; +import type { Workspace, WorkspaceStatus } from '@toeverything/infra'; import { useEffect, useState } from 'react'; export function useWorkspaceStatus< diff --git a/packages/frontend/core/src/hooks/use-workspace.ts b/packages/frontend/core/src/hooks/use-workspace.ts index 0c914b04c3..ce793200ce 100644 --- a/packages/frontend/core/src/hooks/use-workspace.ts +++ b/packages/frontend/core/src/hooks/use-workspace.ts @@ -1,14 +1,13 @@ -import { workspaceManagerAtom } from '@affine/core/modules/workspace'; -import type { Workspace } from '@affine/workspace'; -import type { WorkspaceMetadata } from '@affine/workspace/metadata'; -import { useAtomValue } from 'jotai'; +import type { WorkspaceMetadata } from '@toeverything/infra'; +import { type Workspace, WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useEffect, useState } from 'react'; /** * definitely be careful when using this hook, open workspace is a heavy operation */ export function useWorkspace(meta?: WorkspaceMetadata | null) { - const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceManager = useService(WorkspaceManager); const [workspace, setWorkspace] = useState(null); @@ -17,7 +16,7 @@ export function useWorkspace(meta?: WorkspaceMetadata | null) { setWorkspace(null); // set to null if meta is null or undefined return; } - const ref = workspaceManager.use(meta); + const ref = workspaceManager.open(meta); setWorkspace(ref.workspace); return () => { ref.release(); diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index f3bbf655bb..83afa1f054 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -5,7 +5,6 @@ import { import { MainContainer, WorkspaceFallback } from '@affine/component/workspace'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { assertExists } from '@blocksuite/global/utils'; import { DndContext, @@ -16,6 +15,8 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; +import { Workspace } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { PropsWithChildren, ReactNode } from 'react'; import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; @@ -54,7 +55,7 @@ export const QuickSearch = () => { openQuickSearchModalAtom ); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const { pageId } = useParams(); const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const pageMeta = useBlockSuitePageMeta( @@ -92,7 +93,7 @@ export const WorkspaceLayout = function WorkspaceLayout({ }; export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const { openPage } = useNavigateHelper(); const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); diff --git a/packages/frontend/core/src/modules/collection/index.ts b/packages/frontend/core/src/modules/collection/index.ts new file mode 100644 index 0000000000..f78beabc33 --- /dev/null +++ b/packages/frontend/core/src/modules/collection/index.ts @@ -0,0 +1 @@ +export * from './service'; diff --git a/packages/frontend/core/src/utils/workspace-setting.ts b/packages/frontend/core/src/modules/collection/service.ts similarity index 57% rename from packages/frontend/core/src/utils/workspace-setting.ts rename to packages/frontend/core/src/modules/collection/service.ts index c4c86e0049..69093c7b90 100644 --- a/packages/frontend/core/src/utils/workspace-setting.ts +++ b/packages/frontend/core/src/modules/collection/service.ts @@ -3,40 +3,70 @@ import type { DeleteCollectionInfo, DeletedCollection, } from '@affine/env/filter'; -import type { Workspace } from '@blocksuite/store'; +import type { Workspace } from '@toeverything/infra'; +import { LiveData } from '@toeverything/infra/livedata'; +import { Observable } from 'rxjs'; import { Array as YArray } from 'yjs'; -import { updateFirstOfYArray } from './yjs-utils'; +const SETTING_KEY = 'setting'; const COLLECTIONS_KEY = 'collections'; const COLLECTIONS_TRASH_KEY = 'collections_trash'; -const SETTING_KEY = 'setting'; -export class WorkspaceSetting { +export class CollectionService { constructor(private readonly workspace: Workspace) {} - get doc() { - return this.workspace.doc; + private get doc() { + return this.workspace.blockSuiteWorkspace.doc; } - get setting() { - return this.workspace.doc.getMap(SETTING_KEY); + private get setting() { + return this.workspace.blockSuiteWorkspace.doc.getMap(SETTING_KEY); } - get collectionsYArray(): YArray | undefined { + private get collectionsYArray(): YArray | undefined { return this.setting.get(COLLECTIONS_KEY) as YArray; } - get collectionsTrashYArray(): YArray | undefined { + private get collectionsTrashYArray(): YArray | undefined { return this.setting.get(COLLECTIONS_TRASH_KEY) as YArray; } - get collections(): Collection[] { - return this.collectionsYArray?.toArray() ?? []; - } + readonly collections = LiveData.from( + new Observable(subscriber => { + subscriber.next(this.collectionsYArray?.toArray() ?? []); + const fn = () => { + subscriber.next(this.collectionsYArray?.toArray() ?? []); + }; + this.setting.observeDeep(fn); + return () => { + this.setting.unobserveDeep(fn); + }; + }), + [] + ); - get collectionsTrash(): DeletedCollection[] { - return this.collectionsTrashYArray?.toArray() ?? []; + readonly collectionsTrash = LiveData.from( + new Observable(subscriber => { + subscriber.next(this.collectionsTrashYArray?.toArray() ?? []); + const fn = () => { + subscriber.next(this.collectionsTrashYArray?.toArray() ?? []); + }; + this.setting.observeDeep(fn); + return () => { + this.setting.unobserveDeep(fn); + }; + }), + [] + ); + + addCollection(...collections: Collection[]) { + if (!this.setting.has(COLLECTIONS_KEY)) { + this.setting.set(COLLECTIONS_KEY, new YArray()); + } + this.doc.transact(() => { + this.collectionsYArray?.insert(0, collections); + }); } updateCollection(id: string, updater: (value: Collection) => Collection) { @@ -51,22 +81,13 @@ export class WorkspaceSetting { } } - addCollection(...collections: Collection[]) { - if (!this.setting.has(COLLECTIONS_KEY)) { - this.setting.set(COLLECTIONS_KEY, new YArray()); - } - this.doc.transact(() => { - this.collectionsYArray?.insert(0, collections); - }); - } - deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) { const collectionsYArray = this.collectionsYArray; if (!collectionsYArray) { return; } const set = new Set(ids); - this.workspace.doc.transact(() => { + this.workspace.blockSuiteWorkspace.doc.transact(() => { const indexList: number[] = []; const list: Collection[] = []; collectionsYArray.forEach((collection, i) => { @@ -100,7 +121,10 @@ export class WorkspaceSetting { }); } - deletePagesFromCollection(collection: Collection, idSet: Set) { + private deletePagesFromCollection( + collection: Collection, + idSet: Set + ) { const newAllowList = collection.allowList.filter(id => !idSet.has(id)); if (newAllowList.length !== collection.allowList.length) { this.updateCollection(collection.id, old => { @@ -112,16 +136,29 @@ export class WorkspaceSetting { } } - deletePages(ids: string[]) { + deletePagesFromCollections(ids: string[]) { const idSet = new Set(ids); - this.workspace.doc.transact(() => { - this.collections.forEach(collection => { + this.doc.transact(() => { + this.collections.value.forEach(collection => { this.deletePagesFromCollection(collection, idSet); }); }); } } -export const getWorkspaceSetting = (workspace: Workspace) => { - return new WorkspaceSetting(workspace); +const updateFirstOfYArray = ( + array: YArray, + p: (value: T) => boolean, + update: (value: T) => T +) => { + array.doc?.transact(() => { + for (let i = 0; i < array.length; i++) { + const ele = array.get(i); + if (p(ele)) { + array.delete(i); + array.insert(i, [update(ele)]); + return; + } + } + }); }; diff --git a/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx b/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx new file mode 100644 index 0000000000..ce31d11801 --- /dev/null +++ b/packages/frontend/core/src/modules/infra-web/global-scope/index.tsx @@ -0,0 +1,42 @@ +import type { Page } from '@toeverything/infra'; +import { + LiveData, + ServiceCollection, + type ServiceProvider, + ServiceProviderContext, + useLiveData, + useService, + useServiceOptional, +} from '@toeverything/infra'; +import type React from 'react'; + +import { CurrentPageService } from '../../page'; +import { CurrentWorkspaceService } from '../../workspace'; + +export const GlobalScopeProvider: React.FC< + React.PropsWithChildren<{ provider: ServiceProvider }> +> = ({ provider: rootProvider, children }) => { + const currentWorkspaceService = useService(CurrentWorkspaceService, { + provider: rootProvider, + }); + + const workspaceProvider = useLiveData( + currentWorkspaceService.currentWorkspace + )?.services; + + const currentPageService = useServiceOptional(CurrentPageService, { + provider: workspaceProvider ?? ServiceCollection.EMPTY.provider(), + }); + + const pageProvider = useLiveData( + currentPageService?.currentPage ?? new LiveData(null) + )?.services; + + return ( + + {children} + + ); +}; diff --git a/packages/frontend/core/src/modules/infra-web/storage/index.ts b/packages/frontend/core/src/modules/infra-web/storage/index.ts new file mode 100644 index 0000000000..bff8cc5e18 --- /dev/null +++ b/packages/frontend/core/src/modules/infra-web/storage/index.ts @@ -0,0 +1,32 @@ +import type { GlobalCache } from '@toeverything/infra'; +import { Observable } from 'rxjs'; + +export class LocalStorageGlobalCache implements GlobalCache { + prefix = 'cache:'; + + get(key: string): T | null { + const json = localStorage.getItem(this.prefix + key); + return json ? JSON.parse(json) : null; + } + watch(key: string): Observable { + return new Observable(subscriber => { + const json = localStorage.getItem(this.prefix + key); + const first = json ? JSON.parse(json) : null; + subscriber.next(first); + + const channel = new BroadcastChannel(this.prefix + key); + channel.addEventListener('message', event => { + subscriber.next(event.data); + }); + return () => { + channel.close(); + }; + }); + } + set(key: string, value: T | null): void { + localStorage.setItem(this.prefix + key, JSON.stringify(value)); + const channel = new BroadcastChannel(this.prefix + key); + channel.postMessage(value); + channel.close(); + } +} diff --git a/packages/frontend/core/src/modules/page/current-page.tsx b/packages/frontend/core/src/modules/page/current-page.tsx new file mode 100644 index 0000000000..1e4a8bcef8 --- /dev/null +++ b/packages/frontend/core/src/modules/page/current-page.tsx @@ -0,0 +1,24 @@ +import type { Page } from '@toeverything/infra'; +import { LiveData } from '@toeverything/infra/livedata'; + +/** + * service to manage current page + */ +export class CurrentPageService { + currentPage = new LiveData(null); + + /** + * open page, current page will be set to the page + * @param page + */ + openPage(page: Page) { + this.currentPage.next(page); + } + + /** + * close current page, current page will be null + */ + closePage() { + this.currentPage.next(null); + } +} diff --git a/packages/frontend/core/src/modules/page/index.ts b/packages/frontend/core/src/modules/page/index.ts new file mode 100644 index 0000000000..8875f2bc6e --- /dev/null +++ b/packages/frontend/core/src/modules/page/index.ts @@ -0,0 +1 @@ +export * from './current-page'; diff --git a/packages/frontend/core/src/modules/services.ts b/packages/frontend/core/src/modules/services.ts new file mode 100644 index 0000000000..e5215e94ae --- /dev/null +++ b/packages/frontend/core/src/modules/services.ts @@ -0,0 +1,27 @@ +import { + GlobalCache, + type ServiceCollection, + Workspace, + WorkspaceScope, +} from '@toeverything/infra'; + +import { CollectionService } from './collection'; +import { LocalStorageGlobalCache } from './infra-web/storage'; +import { CurrentPageService } from './page'; +import { + CurrentWorkspaceService, + WorkspacePropertiesAdapter, +} from './workspace'; + +export function configureBusinessServices(services: ServiceCollection) { + services.add(CurrentWorkspaceService); + services + .scope(WorkspaceScope) + .add(CurrentPageService) + .add(WorkspacePropertiesAdapter, [Workspace]) + .add(CollectionService, [Workspace]); +} + +export function configureWebInfraServices(services: ServiceCollection) { + services.addImpl(GlobalCache, LocalStorageGlobalCache); +} diff --git a/packages/frontend/core/src/modules/workspace/atoms.ts b/packages/frontend/core/src/modules/workspace/atoms.ts deleted file mode 100644 index 1833db2178..0000000000 --- a/packages/frontend/core/src/modules/workspace/atoms.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { Workspace, WorkspaceMetadata } from '@affine/workspace'; -import { workspaceManager } from '@affine/workspace-impl'; -import { atom } from 'jotai'; -import { atomWithObservable } from 'jotai/utils'; -import { Observable } from 'rxjs'; - -const logger = new DebugLogger('affine:workspace:atom'); - -// readonly atom for workspace manager, currently only one workspace manager is supported -export const workspaceManagerAtom = atom(() => workspaceManager); - -// workspace metadata list, use rxjs to push updates -export const workspaceListAtom = atomWithObservable( - get => { - const workspaceManager = get(workspaceManagerAtom); - return new Observable(subscriber => { - subscriber.next(workspaceManager.list.workspaceList); - return workspaceManager.list.onStatusChanged.on(status => { - subscriber.next(status.workspaceList); - }).dispose; - }); - }, - { - initialValue: [], - } -); - -// workspace list loading status, if is false, UI can display not found page when workspace id is not in the list. -export const workspaceListLoadingStatusAtom = atomWithObservable( - get => { - const workspaceManager = get(workspaceManagerAtom); - return new Observable(subscriber => { - subscriber.next(workspaceManager.list.status.loading); - return workspaceManager.list.onStatusChanged.on(status => { - subscriber.next(status.loading); - }).dispose; - }); - }, - { - initialValue: true, - } -); - -// current workspace -export const currentWorkspaceAtom = atom(null); - -// wait for current workspace, if current workspace is null, it will suspend -export const waitForCurrentWorkspaceAtom = atom(get => { - const currentWorkspace = get(currentWorkspaceAtom); - if (!currentWorkspace) { - // suspended - logger.info('suspended for current workspace'); - return new Promise(_ => {}); - } - return currentWorkspace; -}); diff --git a/packages/frontend/core/src/modules/workspace/current-workspace.ts b/packages/frontend/core/src/modules/workspace/current-workspace.ts new file mode 100644 index 0000000000..f8cc6b662d --- /dev/null +++ b/packages/frontend/core/src/modules/workspace/current-workspace.ts @@ -0,0 +1,24 @@ +import type { Workspace } from '@toeverything/infra'; +import { LiveData } from '@toeverything/infra/livedata'; + +/** + * service to manage current workspace + */ +export class CurrentWorkspaceService { + currentWorkspace = new LiveData(null); + + /** + * open workspace, current workspace will be set to the workspace + * @param workspace + */ + openWorkspace(workspace: Workspace) { + this.currentWorkspace.next(workspace); + } + + /** + * close current workspace, current workspace will be null + */ + closeWorkspace() { + this.currentWorkspace.next(null); + } +} diff --git a/packages/frontend/core/src/modules/workspace/index.ts b/packages/frontend/core/src/modules/workspace/index.ts index 645c9ae880..d633b8d2ea 100644 --- a/packages/frontend/core/src/modules/workspace/index.ts +++ b/packages/frontend/core/src/modules/workspace/index.ts @@ -1,2 +1,2 @@ -export * from './atoms'; +export * from './current-workspace'; export * from './properties'; diff --git a/packages/frontend/core/src/modules/workspace/properties/adapter.ts b/packages/frontend/core/src/modules/workspace/properties/adapter.ts index dad50bdb86..6af26b6ab5 100644 --- a/packages/frontend/core/src/modules/workspace/properties/adapter.ts +++ b/packages/frontend/core/src/modules/workspace/properties/adapter.ts @@ -1,6 +1,7 @@ // the adapter is to bridge the workspace rootdoc & native js bindings -import { createYProxy, type Workspace, type Y } from '@blocksuite/store'; +import { createYProxy, type Y } from '@blocksuite/store'; +import type { Workspace } from '@toeverything/infra'; import { defaultsDeep } from 'lodash-es'; import { @@ -29,7 +30,7 @@ export class WorkspacePropertiesAdapter { constructor(private readonly workspace: Workspace) { // check if properties exists, if not, create one - const rootDoc = workspace.doc; + const rootDoc = workspace.blockSuiteWorkspace.doc; this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID); this.proxy = createYProxy(this.properties); @@ -56,7 +57,9 @@ export class WorkspacePropertiesAdapter { name: 'Tags', source: 'system', type: PagePropertyType.Tags, - options: this.workspace.meta.properties.tags?.options ?? [], // better use a one time migration + options: + this.workspace.blockSuiteWorkspace.meta.properties.tags + ?.options ?? [], // better use a one time migration }, }, }, diff --git a/packages/frontend/core/src/modules/workspace/properties/atom.ts b/packages/frontend/core/src/modules/workspace/properties/atom.ts deleted file mode 100644 index df4e0da478..0000000000 --- a/packages/frontend/core/src/modules/workspace/properties/atom.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { atom } from 'jotai'; -import { atomFamily } from 'jotai/utils'; - -import { waitForCurrentWorkspaceAtom } from '../atoms'; -import { WorkspacePropertiesAdapter } from './adapter'; - -// todo: remove the inner atom when workspace is closed by using workspaceAdapterAtomFamily.remove -export const workspaceAdapterAtomFamily = atomFamily( - (workspace: BlockSuiteWorkspace) => { - return atom(async () => { - await workspace.doc.whenLoaded; - return new WorkspacePropertiesAdapter(workspace); - }); - } -); - -export const currentWorkspacePropertiesAdapterAtom = atom(async get => { - const workspace = await get(waitForCurrentWorkspaceAtom); - return get(workspaceAdapterAtomFamily(workspace.blockSuiteWorkspace)); -}); diff --git a/packages/frontend/core/src/modules/workspace/properties/index.ts b/packages/frontend/core/src/modules/workspace/properties/index.ts index ed27f89893..7e77e9fc24 100644 --- a/packages/frontend/core/src/modules/workspace/properties/index.ts +++ b/packages/frontend/core/src/modules/workspace/properties/index.ts @@ -1,2 +1 @@ export * from './adapter'; -export * from './atom'; diff --git a/packages/frontend/core/src/pages/index.tsx b/packages/frontend/core/src/pages/index.tsx index 03ea0d3d5c..ad3f2055e4 100644 --- a/packages/frontend/core/src/pages/index.tsx +++ b/packages/frontend/core/src/pages/index.tsx @@ -1,8 +1,9 @@ import { Menu } from '@affine/component/ui/menu'; import { WorkspaceFallback } from '@affine/component/workspace'; -import { workspaceListAtom } from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; -import { useAtomValue } from 'jotai'; +import { WorkspaceManager } from '@toeverything/infra'; +import { WorkspaceListService } from '@toeverything/infra'; +import { useService } from '@toeverything/infra'; +import { useLiveData } from '@toeverything/infra'; import { lazy, useEffect, useLayoutEffect, useState } from 'react'; import { type LoaderFunction, redirect } from 'react-router-dom'; @@ -10,6 +11,7 @@ import { createFirstAppData } from '../bootstrap/first-app-data'; import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list'; import { appConfigStorage } from '../hooks/use-app-config-storage'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../shared'; const AllWorkspaceModals = lazy(() => import('../providers/modal-provider').then(({ AllWorkspaceModals }) => ({ @@ -29,7 +31,7 @@ export const Component = () => { const [navigating, setNavigating] = useState(false); const [creating, setCreating] = useState(false); - const list = useAtomValue(workspaceListAtom); + const list = useLiveData(useService(WorkspaceListService).workspaceList); const { openPage } = useNavigateHelper(); useLayoutEffect(() => { @@ -44,16 +46,18 @@ export const Component = () => { setNavigating(true); }, [list, openPage]); + const workspaceManager = useService(WorkspaceManager); + useEffect(() => { setCreating(true); - createFirstAppData() + createFirstAppData(workspaceManager) .catch(err => { console.error('Failed to create first app data', err); }) .finally(() => { setCreating(false); }); - }, []); + }, [workspaceManager]); if (navigating || creating) { return ; diff --git a/packages/frontend/core/src/pages/share/share-detail-page.tsx b/packages/frontend/core/src/pages/share/share-detail-page.tsx index f3b1661d8d..9e534bd9df 100644 --- a/packages/frontend/core/src/pages/share/share-detail-page.tsx +++ b/packages/frontend/core/src/pages/share/share-detail-page.tsx @@ -1,17 +1,27 @@ import { MainContainer } from '@affine/component/workspace'; import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state'; -import { DebugLogger } from '@affine/debug'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import { fetchWithTraceReport } from '@affine/graphql'; -import { globalBlockSuiteSchema } from '@affine/workspace'; import { - createAffineCloudBlobStorage, - createStaticBlobStorage, + AffineCloudBlobStorage, + StaticBlobStorage, } from '@affine/workspace-impl'; -import { assertExists } from '@blocksuite/global/utils'; -import { type Page, Workspace } from '@blocksuite/store'; +import { + EmptyBlobStorage, + LocalBlobStorage, + LocalSyncStorage, + Page, + PageManager, + ReadonlyMappingSyncStorage, + RemoteBlobStorage, + useService, + useServiceOptional, + WorkspaceIdContext, + WorkspaceManager, + WorkspaceScope, +} from '@toeverything/infra'; import { noop } from 'foxact/noop'; -import type { ReactElement } from 'react'; -import { useCallback } from 'react'; +import { useEffect } from 'react'; import type { LoaderFunction } from 'react-router-dom'; import { isRouteErrorResponse, @@ -19,12 +29,13 @@ import { useLoaderData, useRouteError, } from 'react-router-dom'; -import { applyUpdate } from 'yjs'; import type { PageMode } from '../../atoms'; import { AppContainer } from '../../components/affine/app-container'; import { PageDetailEditor } from '../../components/page-detail-editor'; import { SharePageNotFoundError } from '../../components/share-page-not-found-error'; +import { CurrentPageService } from '../../modules/page'; +import { CurrentWorkspaceService } from '../../modules/workspace'; import { ShareHeader } from './share-header'; type DocPublishMode = 'edgeless' | 'page'; @@ -57,8 +68,11 @@ export async function downloadBinaryFromCloud( } type LoaderData = { - page: Page; + pageId: string; + workspaceId: string; publishMode: PageMode; + pageArrayBuffer: ArrayBuffer; + workspaceArrayBuffer: ArrayBuffer; }; function assertDownloadResponse( @@ -73,55 +87,104 @@ function assertDownloadResponse( } } -const logger = new DebugLogger('public:share-page'); - export const loader: LoaderFunction = async ({ params }) => { const workspaceId = params?.workspaceId; const pageId = params?.pageId; if (!workspaceId || !pageId) { return redirect('/404'); } - const workspace = new Workspace({ - id: workspaceId, - blobStorages: [ - () => ({ - crud: createAffineCloudBlobStorage(workspaceId), - }), - () => ({ - crud: createStaticBlobStorage(), - }), - ], - schema: globalBlockSuiteSchema, - }); - // download root workspace - { - const response = await downloadBinaryFromCloud(workspaceId, workspaceId); - assertDownloadResponse(response); - const { arrayBuffer } = response; - applyUpdate(workspace.doc, new Uint8Array(arrayBuffer)); - workspace.doc.emit('sync', []); - } - const page = workspace.getPage(pageId); - assertExists(page, 'cannot find page'); - // download page - const response = await downloadBinaryFromCloud( + const [workspaceResponse, pageResponse] = await Promise.all([ + downloadBinaryFromCloud(workspaceId, workspaceId), + downloadBinaryFromCloud(workspaceId, pageId), + ]); + assertDownloadResponse(workspaceResponse); + const { arrayBuffer: workspaceArrayBuffer } = workspaceResponse; + assertDownloadResponse(pageResponse); + const { arrayBuffer: pageArrayBuffer, publishMode } = pageResponse; + + return { workspaceId, - page.spaceDoc.guid - ); - assertDownloadResponse(response); - const { arrayBuffer, publishMode } = response; - - applyUpdate(page.spaceDoc, new Uint8Array(arrayBuffer)); - - logger.info('workspace', workspace); - workspace.awarenessStore.setReadonly(page, true); - return { page, publishMode }; + pageId, + publishMode, + workspaceArrayBuffer, + pageArrayBuffer, + } satisfies LoaderData; }; -export const Component = (): ReactElement => { - const { page, publishMode } = useLoaderData() as LoaderData; - usePageDocumentTitle(page.meta); +export const Component = () => { + const { + workspaceId, + pageId, + publishMode, + workspaceArrayBuffer, + pageArrayBuffer, + } = useLoaderData() as LoaderData; + const workspaceManager = useService(WorkspaceManager); + + const currentWorkspace = useService(CurrentWorkspaceService); + + useEffect(() => { + // create a workspace for share page + const workspace = workspaceManager.instantiate( + { + id: workspaceId, + flavour: WorkspaceFlavour.AFFINE_CLOUD, + }, + services => { + services + .scope(WorkspaceScope) + .addImpl(LocalBlobStorage, EmptyBlobStorage) + .addImpl(RemoteBlobStorage('affine'), AffineCloudBlobStorage, [ + WorkspaceIdContext, + ]) + .addImpl(RemoteBlobStorage('static'), StaticBlobStorage) + .addImpl( + LocalSyncStorage, + ReadonlyMappingSyncStorage({ + [workspaceId]: new Uint8Array(workspaceArrayBuffer), + [pageId]: new Uint8Array(pageArrayBuffer), + }) + ); + } + ); + + workspace.engine.sync + .waitForSynced() + .then(() => { + const { page } = workspace.services + .get(PageManager) + .openByPageId(pageId); + + workspace.blockSuiteWorkspace.awarenessStore.setReadonly( + page.blockSuitePage, + true + ); + + const currentPage = workspace.services.get(CurrentPageService); + + currentWorkspace.openWorkspace(workspace); + currentPage.openPage(page); + }) + .catch(err => { + console.error(err); + }); + }, [ + currentWorkspace, + pageArrayBuffer, + pageId, + workspaceArrayBuffer, + workspaceId, + workspaceManager, + ]); + + const page = useServiceOptional(Page); + + usePageDocumentTitle(page?.meta); + + if (!page) { + return; + } return ( @@ -129,14 +192,14 @@ export const Component = (): ReactElement => { noop, [])} + onLoad={() => noop} /> diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx index fa006f4c59..94e15aaca1 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx @@ -1,9 +1,9 @@ -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { CollectionService } from '@affine/core/modules/collection'; import type { Collection, Filter } from '@affine/env/filter'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra'; +import { Workspace } from '@toeverything/infra'; import { useCallback } from 'react'; -import { collectionsCRUDAtom } from '../../../atoms/collections'; import { filterContainerStyle } from '../../../components/filter-container.css'; import { FilterList, @@ -13,9 +13,9 @@ import { import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; export const FilterContainer = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const navigateHelper = useNavigateHelper(); - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const saveToCollection = useCallback( (collection: Collection) => { setting.createCollection({ diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx index c536cb9f89..787a029607 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx @@ -1,6 +1,5 @@ import { IconButton } from '@affine/component'; import type { AllPageFilterOption } from '@affine/core/atoms'; -import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { CollectionList, PageListNewPageButton, @@ -13,9 +12,11 @@ import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-lis import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info'; import { PlusIcon } from '@blocksuite/icons'; import type { Workspace } from '@blocksuite/store'; +import { useService } from '@toeverything/infra/di'; import clsx from 'clsx'; import { useMemo } from 'react'; +import { CollectionService } from '../../../modules/collection'; import * as styles from './all-page.css'; import { FilterContainer } from './all-page-filter'; @@ -32,7 +33,7 @@ export const AllPageHeader = ({ activeFilter: AllPageFilterOption; onCreateCollection?: () => void; }) => { - const setting = useCollectionManager(collectionsCRUDAtom); + const setting = useCollectionManager(useService(CollectionService)); const config = useAllPageListConfig(); const userInfo = useDeleteCollectionInfo(); const isWindowsDesktop = environment.isDesktop && environment.isWindows; diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx index 5642330c48..a8ddf7e77f 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx @@ -1,5 +1,4 @@ import type { AllPageFilterOption } from '@affine/core/atoms'; -import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { HubIsland } from '@affine/core/components/affine/hub-island'; import { CollectionListHeader, @@ -10,7 +9,6 @@ import { useCollectionManager, useEditCollectionName, useFilteredPageMetas, - useSavedCollections, useTagMetas, VirtualizedCollectionList, VirtualizedPageList, @@ -22,15 +20,18 @@ import { import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { performanceRenderLogger } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useService } from '@toeverything/infra'; +import { useLiveData } from '@toeverything/infra'; +import { Workspace } from '@toeverything/infra'; +import { useSetAtom } from 'jotai'; import { nanoid } from 'nanoid'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { NIL } from 'uuid'; +import { CollectionService } from '../../../modules/collection'; import { EmptyCollectionList, EmptyPageList, @@ -47,13 +48,14 @@ export const AllPage = ({ }) => { const t = useAFFiNEI18N(); const params = useParams(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true); - const setting = useCollectionManager(collectionsCRUDAtom); + const collectionService = useService(CollectionService); + const collections = useLiveData(collectionService.collections); + const setting = useCollectionManager(collectionService); const config = useAllPageListConfig(); - const { collections } = useSavedCollections(collectionsCRUDAtom); const { tags, tagMetas, filterPageMetaByTag, deleteTags } = useTagMetas( currentWorkspace.blockSuiteWorkspace, pageMetas @@ -212,7 +214,7 @@ export const AllPage = ({ export const Component = () => { performanceRenderLogger.info('AllPage'); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const currentCollection = useSetAtom(currentCollectionAtom); const navigateHelper = useNavigateHelper(); diff --git a/packages/frontend/core/src/pages/workspace/collection.tsx b/packages/frontend/core/src/pages/workspace/collection.tsx index 53c478a2db..4b0d0425ec 100644 --- a/packages/frontend/core/src/pages/workspace/collection.tsx +++ b/packages/frontend/core/src/pages/workspace/collection.tsx @@ -12,7 +12,7 @@ import { import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { CollectionService } from '@affine/core/modules/collection'; import type { Collection } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -22,19 +22,17 @@ import { PageIcon, ViewLayersIcon, } from '@blocksuite/icons'; +import { Workspace } from '@toeverything/infra'; import { getCurrentStore } from '@toeverything/infra/atom'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; import { useAtomValue } from 'jotai'; import { useSetAtom } from 'jotai'; import { useCallback, useEffect, useState } from 'react'; import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; -import { - collectionsCRUDAtom, - pageCollectionBaseAtom, -} from '../../atoms/collections'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; import { WorkspaceSubPath } from '../../shared'; -import { getWorkspaceSetting } from '../../utils/workspace-setting'; import { AllPage } from './all-page/all-page'; import * as styles from './collection.css'; @@ -48,18 +46,19 @@ export const loader: LoaderFunction = async args => { }; export const Component = function CollectionPage() { - const { collections, loading } = useAtomValue(pageCollectionBaseAtom); + const collectionService = useService(CollectionService); + const collections = useLiveData(collectionService.collections); const navigate = useNavigateHelper(); const params = useParams(); - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const collection = collections.find(v => v.id === params.collectionId); const pushNotification = useSetAtom(pushNotificationAtom); useEffect(() => { - if (!loading && !collection) { + if (!collection) { navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - const collection = getWorkspaceSetting( - workspace.blockSuiteWorkspace - ).collectionsTrash.find(v => v.collection.id === params.collectionId); + const collection = collectionService.collectionsTrash.value.find( + v => v.collection.id === params.collectionId + ); let text = 'Collection is not exist'; if (collection) { if (collection.userId) { @@ -75,21 +74,18 @@ export const Component = function CollectionPage() { } }, [ collection, - loading, + collectionService.collectionsTrash.value, navigate, params.collectionId, pushNotification, workspace.blockSuiteWorkspace, workspace.id, ]); - if (loading) { - return null; - } if (!collection) { return null; } return isEmpty(collection) ? ( - + ) : ( ); @@ -97,24 +93,19 @@ export const Component = function CollectionPage() { const isWindowsDesktop = environment.isDesktop && environment.isWindows; -const Placeholder = ({ - collection, - workspaceId, -}: { - collection: Collection; - workspaceId: string; -}) => { - const { updateCollection } = useCollectionManager(collectionsCRUDAtom); +const Placeholder = ({ collection }: { collection: Collection }) => { + const workspace = useService(Workspace); + const collectionService = useCollectionManager(useService(CollectionService)); const { node, open } = useEditCollection(useAllPageListConfig()); const { jumpToCollections } = useNavigateHelper(); const openPageEdit = useAsyncCallback(async () => { const ret = await open({ ...collection }, 'page'); - updateCollection(ret); - }, [open, collection, updateCollection]); + collectionService.updateCollection(ret); + }, [open, collection, collectionService]); const openRuleEdit = useAsyncCallback(async () => { const ret = await open({ ...collection }, 'rule'); - updateCollection(ret); - }, [collection, open, updateCollection]); + collectionService.updateCollection(ret); + }, [collection, open, collectionService]); const [showTips, setShowTips] = useState(false); useEffect(() => { setShowTips(!localStorage.getItem('hide-empty-collection-help-info')); @@ -127,8 +118,8 @@ const Placeholder = ({ const leftSidebarOpen = useAtomValue(appSidebarOpenAtom); const handleJumpToCollections = useCallback(() => { - jumpToCollections(workspaceId); - }, [jumpToCollections, workspaceId]); + jumpToCollections(workspace.id); + }, [jumpToCollections, workspace]); return (
{ + (page: BlockSuitePage, editor: AffineEditorContainer) => { try { // todo(joooye34): improve the following migration code const surfaceBlock = page.getBlockByFlavour('affine:surface')[0]; @@ -182,7 +190,7 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) { header={ <> @@ -206,8 +214,14 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) { sidebar={ !isInTrash ? (
- - + +
) : null } @@ -221,38 +235,44 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) { ); }); -const useForceUpdate = () => { - const [, setCount] = useState(0); - return useCallback(() => setCount(count => count + 1), []); -}; -const useSafePage = (workspace: Workspace, pageId: string) => { - const forceUpdate = useForceUpdate(); - useEffect(() => { - const disposable = workspace.slots.pagesUpdated.on(() => { - forceUpdate(); - }); - return disposable.dispose; - }, [pageId, workspace.slots.pagesUpdated, forceUpdate]); - - return workspace.getPage(pageId); -}; - export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - const currentSyncEngineStep = useWorkspaceStatus( - currentWorkspace, - s => s.engine.sync.step + const pageListService = useService(PageListService); + + const pageListReady = useLiveData(pageListService.isReady); + + const pageMetas = useLiveData(pageListService.pages); + + const pageMeta = useMemo( + () => pageMetas.find(page => page.id === pageId), + [pageMetas, pageId] ); + const pageManager = useService(PageManager); + const currentPageService = useService(CurrentPageService); + + useEffect(() => { + if (!pageMeta) { + return; + } + const { page, release } = pageManager.open(pageMeta); + currentPageService.openPage(page); + return () => { + currentPageService.closePage(); + release(); + }; + }, [currentPageService, pageManager, pageMeta]); + + const page = useServiceOptional(Page); + + const currentWorkspace = useService(Workspace); + // set sync engine priority target useEffect(() => { currentWorkspace.setPriorityRule(id => id.endsWith(pageId)); }, [pageId, currentWorkspace]); - const page = useSafePage(currentWorkspace?.blockSuiteWorkspace, pageId); - // if sync engine has been synced and the page is null, show 404 page. - if (currentSyncEngineStep === SyncEngineStep.Synced && !page) { + if (pageListReady && !page) { return ; } @@ -266,7 +286,7 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { }); } - return ; + return ; }; export const Component = () => { diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx index 2132a0fed7..76c1cdae96 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx @@ -2,8 +2,8 @@ import { IconButton } from '@affine/component'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { useWorkspaceEnabledFeatures } from '@affine/core/hooks/use-workspace-features'; import { FeatureType } from '@affine/graphql'; -import type { Workspace } from '@affine/workspace/workspace'; import type { Page } from '@blocksuite/store'; +import type { Workspace } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import { useAtom, useAtomValue } from 'jotai'; import { useEffect } from 'react'; diff --git a/packages/frontend/core/src/pages/workspace/index.tsx b/packages/frontend/core/src/pages/workspace/index.tsx index d87c31fc95..c19e99ef63 100644 --- a/packages/frontend/core/src/pages/workspace/index.tsx +++ b/packages/frontend/core/src/pages/workspace/index.tsx @@ -1,18 +1,18 @@ import { WorkspaceFallback } from '@affine/component/workspace'; import { useWorkspace } from '@affine/core/hooks/use-workspace'; import { - currentWorkspaceAtom, - workspaceListAtom, - workspaceListLoadingStatusAtom, - workspaceManagerAtom, -} from '@affine/core/modules/workspace'; -import { type Workspace } from '@affine/workspace'; -import { useAtom, useAtomValue } from 'jotai'; + Workspace, + WorkspaceListService, + WorkspaceManager, +} from '@toeverything/infra'; +import { useService, useServiceOptional } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; import { type ReactElement, Suspense, useEffect, useMemo } from 'react'; import { Outlet, useParams } from 'react-router-dom'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; import { WorkspaceLayout } from '../../layouts/workspace-layout'; +import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace'; import { performanceRenderLogger } from '../../shared'; import { PageNotFound } from '../404'; @@ -30,29 +30,27 @@ declare global { export const Component = (): ReactElement => { performanceRenderLogger.info('WorkspaceLayout'); - const [ - _ /* read this atom here to make sure children refresh when currentWorkspace changed */, - setCurrentWorkspace, - ] = useAtom(currentWorkspaceAtom); + const currentWorkspaceService = useService(CurrentWorkspaceService); const params = useParams(); - const list = useAtomValue(workspaceListAtom); - const listLoading = useAtomValue(workspaceListLoadingStatusAtom); - const workspaceManager = useAtomValue(workspaceManagerAtom); + const { workspaceList, loading: listLoading } = useLiveData( + useService(WorkspaceListService).status + ); + const workspaceManager = useService(WorkspaceManager); const meta = useMemo(() => { - return list.find(({ id }) => id === params.workspaceId); - }, [list, params.workspaceId]); + return workspaceList.find(({ id }) => id === params.workspaceId); + }, [workspaceList, params.workspaceId]); const workspace = useWorkspace(meta); useEffect(() => { if (!workspace) { - setCurrentWorkspace(null); + currentWorkspaceService.closeWorkspace(); return undefined; } - setCurrentWorkspace(workspace); + currentWorkspaceService.openWorkspace(workspace ?? null); // for debug purpose window.currentWorkspace = workspace; @@ -65,14 +63,16 @@ export const Component = (): ReactElement => { ); localStorage.setItem('last_workspace_id', workspace.id); - }, [setCurrentWorkspace, meta, workspaceManager, workspace]); + }, [meta, workspaceManager, workspace, currentWorkspaceService]); + + const currentWorkspace = useServiceOptional(Workspace); // if listLoading is false, we can show 404 page, otherwise we should show loading page. if (listLoading === false && meta === undefined) { return ; } - if (!workspace) { + if (!currentWorkspace) { return ; } diff --git a/packages/frontend/core/src/pages/workspace/tag.tsx b/packages/frontend/core/src/pages/workspace/tag.tsx index 9cc95d5aa9..16865c16ef 100644 --- a/packages/frontend/core/src/pages/workspace/tag.tsx +++ b/packages/frontend/core/src/pages/workspace/tag.tsx @@ -1,7 +1,6 @@ import { TagListHeader, useTagMetas } from '@affine/core/components/page-list'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { useAtomValue } from 'jotai'; +import { useService, Workspace } from '@toeverything/infra'; import { useMemo } from 'react'; import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; @@ -19,7 +18,7 @@ export const loader: LoaderFunction = async args => { export const Component = function TagPage() { const params = useParams(); - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); const { tagUsageCounts } = useTagMetas( currentWorkspace.blockSuiteWorkspace, diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index d7e07a77b7..5a4baeb602 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -14,13 +14,13 @@ import { Header } from '@affine/core/components/pure/header'; import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons'; import type { PageMeta } from '@blocksuite/store'; +import { Workspace } from '@toeverything/infra'; import { getCurrentStore } from '@toeverything/infra/atom'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra/di'; import { useCallback } from 'react'; import { type LoaderFunction } from 'react-router-dom'; import { NIL } from 'uuid'; @@ -61,7 +61,7 @@ export const loader: LoaderFunction = async () => { }; export const TrashPage = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useService(Workspace); const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; assertExists(blockSuiteWorkspace); diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 2d1e2aa43b..4f89bc15e1 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -1,26 +1,24 @@ -import { - currentWorkspaceAtom, - waitForCurrentWorkspaceAtom, - workspaceListAtom, -} from '@affine/core/modules/workspace'; -import { WorkspaceSubPath } from '@affine/core/shared'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { assertExists } from '@blocksuite/global/utils'; -import { useAtom, useAtomValue } from 'jotai'; +import { WorkspaceManager } from '@toeverything/infra'; +import { useService } from '@toeverything/infra/di'; +import { useLiveData } from '@toeverything/infra/livedata'; +import { useAtom } from 'jotai'; import type { ReactElement } from 'react'; import { lazy, Suspense, useCallback } from 'react'; -import type { SettingAtom } from '../atoms'; import { authAtom, openCreateWorkspaceModalAtom, openDisableCloudAlertModalAtom, openSettingModalAtom, openSignOutModalAtom, + type SettingAtom, } from '../atoms'; import { PaymentDisableModal } from '../components/affine/payment-disable'; import { useAsyncCallback } from '../hooks/affine-async-hooks'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { CurrentWorkspaceService } from '../modules/workspace/current-workspace'; +import { WorkspaceSubPath } from '../shared'; import { signOutCloud } from '../utils/cloud-utils'; const SettingModal = lazy(() => @@ -28,6 +26,7 @@ const SettingModal = lazy(() => default: module.SettingModal, })) ); + const Auth = lazy(() => import('../components/affine/auth').then(module => ({ default: module.AuthModal, @@ -80,10 +79,8 @@ const CloudQuotaModal = lazy(() => ); export const Setting = () => { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] = useAtom(openSettingModalAtom); - assertExists(currentWorkspace); const onSettingClick = useCallback( ({ @@ -162,7 +159,9 @@ export const AuthModal = (): ReactElement => { }; export function CurrentWorkspaceModals() { - const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentWorkspace = useLiveData( + useService(CurrentWorkspaceService).currentWorkspace + ); const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom( openDisableCloudAlertModalAtom ); @@ -195,8 +194,12 @@ export function CurrentWorkspaceModals() { export const SignOutConfirmModal = () => { const { openPage } = useNavigateHelper(); const [open, setOpen] = useAtom(openSignOutModalAtom); - const currentWorkspace = useAtomValue(currentWorkspaceAtom); - const workspaceList = useAtomValue(workspaceListAtom); + const currentWorkspace = useLiveData( + useService(CurrentWorkspaceService).currentWorkspace + ); + const workspaces = useLiveData( + useService(WorkspaceManager).list.workspaceList + ); const onConfirm = useAsyncCallback(async () => { setOpen(false); @@ -204,14 +207,14 @@ export const SignOutConfirmModal = () => { // if current workspace is affine cloud, switch to local workspace if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { - const localWorkspace = workspaceList.find( + const localWorkspace = workspaces.find( w => w.flavour === WorkspaceFlavour.LOCAL ); if (localWorkspace) { openPage(localWorkspace.id, WorkspaceSubPath.ALL); } } - }, [currentWorkspace?.flavour, openPage, setOpen, workspaceList]); + }, [currentWorkspace?.flavour, openPage, setOpen, workspaces]); return ( diff --git a/packages/frontend/core/src/testing.ts b/packages/frontend/core/src/testing.ts new file mode 100644 index 0000000000..cf017d555c --- /dev/null +++ b/packages/frontend/core/src/testing.ts @@ -0,0 +1,46 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; +import type { Page as BlockSuitePage } from '@blocksuite/store'; +import { + configureTestingInfraServices, + PageManager, + ServiceCollection, + WorkspaceManager, +} from '@toeverything/infra'; + +import { CurrentPageService } from './modules/page'; +import { CurrentWorkspaceService } from './modules/workspace'; +import { configureWebServices } from './web'; + +export async function configureTestingEnvironment() { + const serviceCollection = new ServiceCollection(); + + configureWebServices(serviceCollection); + configureTestingInfraServices(serviceCollection); + + const rootServices = serviceCollection.provider(); + + const workspaceManager = rootServices.get(WorkspaceManager); + + const { workspace } = workspaceManager.open( + await workspaceManager.createWorkspace(WorkspaceFlavour.LOCAL, async ws => { + const initPage = async (page: BlockSuitePage) => { + await page.load(); + const pageBlockId = page.addBlock('affine:page', { + title: new page.Text(''), + }); + const frameId = page.addBlock('affine:note', {}, pageBlockId); + page.addBlock('affine:paragraph', {}, frameId); + }; + await initPage(ws.createPage({ id: 'page0' })); + }) + ); + + await workspace.engine.sync.waitForSynced(); + + const { page } = workspace.services.get(PageManager).openByPageId('page0'); + + rootServices.get(CurrentWorkspaceService).openWorkspace(workspace); + workspace.services.get(CurrentPageService).openPage(page); + + return { services: rootServices, workspace, page }; +} diff --git a/packages/frontend/core/src/utils/yjs-utils.ts b/packages/frontend/core/src/utils/yjs-utils.ts deleted file mode 100644 index 4cfb7ee71e..0000000000 --- a/packages/frontend/core/src/utils/yjs-utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Array as YArray } from 'yjs'; - -export const updateFirstOfYArray = ( - array: YArray, - p: (value: T) => boolean, - update: (value: T) => T -) => { - array.doc?.transact(() => { - for (let i = 0; i < array.length; i++) { - const ele = array.get(i); - if (p(ele)) { - array.delete(i); - array.insert(i, [update(ele)]); - return; - } - } - }); -}; diff --git a/packages/frontend/core/src/web.ts b/packages/frontend/core/src/web.ts new file mode 100644 index 0000000000..b1b41ac412 --- /dev/null +++ b/packages/frontend/core/src/web.ts @@ -0,0 +1,15 @@ +import { configureWorkspaceImplServices } from '@affine/workspace-impl'; +import type { ServiceCollection } from '@toeverything/infra'; +import { configureInfraServices } from '@toeverything/infra'; + +import { + configureBusinessServices, + configureWebInfraServices, +} from './modules/services'; + +export function configureWebServices(services: ServiceCollection) { + configureInfraServices(services); + configureWebInfraServices(services); + configureBusinessServices(services); + configureWorkspaceImplServices(services); +} diff --git a/packages/frontend/core/tsconfig.json b/packages/frontend/core/tsconfig.json index 0251c35af4..70be8ec541 100644 --- a/packages/frontend/core/tsconfig.json +++ b/packages/frontend/core/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../frontend/i18n" }, - { - "path": "../../common/workspace" - }, { "path": "../../frontend/workspace-impl" }, diff --git a/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/meta.json b/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/meta.json new file mode 100644 index 0000000000..3b79ffde9e --- /dev/null +++ b/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/meta.json @@ -0,0 +1,4 @@ +{ + "id": "709a97a0-aa01-4d0b-b29a-f3ed2f63033c", + "mainDBPath": "/Users/eyhn/AFFiNE/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/storage.db" +} diff --git a/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/storage.db b/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/storage.db new file mode 100644 index 0000000000..0c76fa0aca Binary files /dev/null and b/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/storage.db differ diff --git a/packages/frontend/workspace-impl/package.json b/packages/frontend/workspace-impl/package.json index a25e9a0814..ecf9ab13e7 100644 --- a/packages/frontend/workspace-impl/package.json +++ b/packages/frontend/workspace-impl/package.json @@ -15,7 +15,6 @@ "@affine/electron-api": "workspace:*", "@affine/env": "workspace:*", "@affine/graphql": "workspace:*", - "@affine/workspace": "workspace:*", "@toeverything/infra": "workspace:*", "idb": "^8.0.0", "idb-keyval": "^6.2.1", diff --git a/packages/frontend/workspace-impl/src/cloud/awareness.ts b/packages/frontend/workspace-impl/src/cloud/awareness.ts index 0add462ef6..348e31db3b 100644 --- a/packages/frontend/workspace-impl/src/cloud/awareness.ts +++ b/packages/frontend/workspace-impl/src/cloud/awareness.ts @@ -1,5 +1,5 @@ import { DebugLogger } from '@affine/debug'; -import type { AwarenessProvider } from '@affine/workspace'; +import type { AwarenessProvider } from '@toeverything/infra'; import { applyAwarenessUpdate, type Awareness, @@ -14,30 +14,66 @@ const logger = new DebugLogger('affine:awareness:socketio'); type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>; -export function createCloudAwarenessProvider( - workspaceId: string, - awareness: Awareness -): AwarenessProvider { - const socket = getIoManager().socket('/'); +export class AffineCloudAwarenessProvider implements AwarenessProvider { + socket = getIoManager().socket('/'); - const awarenessBroadcast = ({ + constructor( + private readonly workspaceId: string, + private readonly awareness: Awareness + ) {} + + connect(): void { + this.socket.on('server-awareness-broadcast', this.awarenessBroadcast); + this.socket.on( + 'new-client-awareness-init', + this.newClientAwarenessInitHandler + ); + this.awareness.on('update', this.awarenessUpdate); + + window.addEventListener('beforeunload', this.windowBeforeUnloadHandler); + + this.socket.connect(); + + this.socket.on('connect', () => this.handleConnect()); + + this.socket.emit('client-handshake-awareness', this.workspaceId); + this.socket.emit('awareness-init', this.workspaceId); + } + disconnect(): void { + removeAwarenessStates( + this.awareness, + [this.awareness.clientID], + 'disconnect' + ); + this.awareness.off('update', this.awarenessUpdate); + this.socket.emit('client-leave-awareness', this.workspaceId); + this.socket.off('server-awareness-broadcast', this.awarenessBroadcast); + this.socket.off( + 'new-client-awareness-init', + this.newClientAwarenessInitHandler + ); + this.socket.off('connect', this.handleConnect); + window.removeEventListener('unload', this.windowBeforeUnloadHandler); + } + + awarenessBroadcast = ({ workspaceId: wsId, awarenessUpdate, }: { workspaceId: string; awarenessUpdate: string; }) => { - if (wsId !== workspaceId) { + if (wsId !== this.workspaceId) { return; } applyAwarenessUpdate( - awareness, + this.awareness, base64ToUint8Array(awarenessUpdate), 'remote' ); }; - const awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => { + awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => { if (origin === 'remote') { return; } @@ -46,63 +82,41 @@ export function createCloudAwarenessProvider( res.concat(cur) ); - const update = encodeAwarenessUpdate(awareness, changedClients); + const update = encodeAwarenessUpdate(this.awareness, changedClients); uint8ArrayToBase64(update) .then(encodedUpdate => { - socket.emit('awareness-update', { - workspaceId: workspaceId, + this.socket.emit('awareness-update', { + workspaceId: this.workspaceId, awarenessUpdate: encodedUpdate, }); }) .catch(err => logger.error(err)); }; - const newClientAwarenessInitHandler = () => { - const awarenessUpdate = encodeAwarenessUpdate(awareness, [ - awareness.clientID, + newClientAwarenessInitHandler = () => { + const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [ + this.awareness.clientID, ]); uint8ArrayToBase64(awarenessUpdate) .then(encodedAwarenessUpdate => { - socket.emit('awareness-update', { - guid: workspaceId, + this.socket.emit('awareness-update', { + guid: this.workspaceId, awarenessUpdate: encodedAwarenessUpdate, }); }) .catch(err => logger.error(err)); }; - const windowBeforeUnloadHandler = () => { - removeAwarenessStates(awareness, [awareness.clientID], 'window unload'); + windowBeforeUnloadHandler = () => { + removeAwarenessStates( + this.awareness, + [this.awareness.clientID], + 'window unload' + ); }; - function handleConnect() { - socket.emit('client-handshake-awareness', workspaceId); - socket.emit('awareness-init', workspaceId); - } - - return { - connect: () => { - socket.on('server-awareness-broadcast', awarenessBroadcast); - socket.on('new-client-awareness-init', newClientAwarenessInitHandler); - awareness.on('update', awarenessUpdate); - - window.addEventListener('beforeunload', windowBeforeUnloadHandler); - - socket.connect(); - - socket.on('connect', handleConnect); - - socket.emit('client-handshake-awareness', workspaceId); - socket.emit('awareness-init', workspaceId); - }, - disconnect: () => { - removeAwarenessStates(awareness, [awareness.clientID], 'disconnect'); - awareness.off('update', awarenessUpdate); - socket.emit('client-leave-awareness', workspaceId); - socket.off('server-awareness-broadcast', awarenessBroadcast); - socket.off('new-client-awareness-init', newClientAwarenessInitHandler); - socket.off('connect', handleConnect); - window.removeEventListener('unload', windowBeforeUnloadHandler); - }, + handleConnect = () => { + this.socket.emit('client-handshake-awareness', this.workspaceId); + this.socket.emit('awareness-init', this.workspaceId); }; } diff --git a/packages/frontend/workspace-impl/src/cloud/blob.ts b/packages/frontend/workspace-impl/src/cloud/blob.ts index 8e15df7633..f3f127d788 100644 --- a/packages/frontend/workspace-impl/src/cloud/blob.ts +++ b/packages/frontend/workspace-impl/src/cloud/blob.ts @@ -7,69 +7,70 @@ import { setBlobMutation, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; -import type { BlobStorage } from '@affine/workspace'; -import { BlobStorageOverCapacity } from '@affine/workspace'; +import { type BlobStorage, BlobStorageOverCapacity } from '@toeverything/infra'; import { isArray } from 'lodash-es'; import { bufferToBlob } from '../utils/buffer-to-blob'; -export const createAffineCloudBlobStorage = ( - workspaceId: string -): BlobStorage => { - return { - name: 'affine-cloud', - readonly: false, - get: async key => { - const suffix = key.startsWith('/') - ? key - : `/api/workspaces/${workspaceId}/blobs/${key}`; +export class AffineCloudBlobStorage implements BlobStorage { + constructor(private readonly workspaceId: string) {} - return fetchWithTraceReport(getBaseUrl() + suffix).then(async res => { - if (!res.ok) { - // status not in the range 200-299 - return null; + name = 'affine-cloud'; + readonly = false; + + async get(key: string) { + const suffix = key.startsWith('/') + ? key + : `/api/workspaces/${this.workspaceId}/blobs/${key}`; + + return fetchWithTraceReport(getBaseUrl() + suffix).then(async res => { + if (!res.ok) { + // status not in the range 200-299 + return null; + } + return bufferToBlob(await res.arrayBuffer()); + }); + } + + async set(key: string, value: Blob) { + // set blob will check blob size & quota + return await fetcher({ + query: setBlobMutation, + variables: { + workspaceId: this.workspaceId, + blob: new File([value], key), + }, + }) + .then(res => res.setBlob) + .catch(err => { + if (isArray(err)) { + err.map(e => { + if (e instanceof GraphQLError && e.extensions.code === 413) { + throw new BlobStorageOverCapacity(e); + } else throw e; + }); } - return bufferToBlob(await res.arrayBuffer()); + throw err; }); - }, - set: async (key, value) => { - // set blob will check blob size & quota - return await fetcher({ - query: setBlobMutation, - variables: { - workspaceId, - blob: new File([value], key), - }, - }) - .then(res => res.setBlob) - .catch(err => { - if (isArray(err)) { - err.map(e => { - if (e instanceof GraphQLError && e.extensions.code === 413) { - throw new BlobStorageOverCapacity(e); - } else throw e; - }); - } - throw err; - }); - }, - list: async () => { - const result = await fetcher({ - query: listBlobsQuery, - variables: { - workspaceId, - }, - }); - return result.listBlobs; - }, - delete: async (key: string) => { - await fetcher({ - query: deleteBlobMutation, - variables: { - workspaceId, - hash: key, - }, - }); - }, - }; -}; + } + + async delete(key: string) { + await fetcher({ + query: deleteBlobMutation, + variables: { + workspaceId: key, + hash: key, + }, + }); + } + + async list() { + const result = await fetcher({ + query: listBlobsQuery, + variables: { + workspaceId: this.workspaceId, + }, + }); + return result.listBlobs; + } +} diff --git a/packages/frontend/workspace-impl/src/cloud/list.ts b/packages/frontend/workspace-impl/src/cloud/list.ts index 03e04baf13..38965fa2b3 100644 --- a/packages/frontend/workspace-impl/src/cloud/list.ts +++ b/packages/frontend/workspace-impl/src/cloud/list.ts @@ -5,18 +5,26 @@ import { getWorkspacesQuery, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; -import type { WorkspaceListProvider } from '@affine/workspace'; -import { globalBlockSuiteSchema } from '@affine/workspace'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { WorkspaceListProvider } from '@toeverything/infra'; +import { + type BlobStorage, + type SyncStorage, + type WorkspaceInfo, + type WorkspaceMetadata, +} from '@toeverything/infra'; +import { globalBlockSuiteSchema } from '@toeverything/infra'; import { difference } from 'lodash-es'; import { nanoid } from 'nanoid'; import { getSession } from 'next-auth/react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; -import { createLocalBlobStorage } from '../local/blob'; -import { createLocalStorage } from '../local/sync'; +import { IndexedDBBlobStorage } from '../local/blob-indexeddb'; +import { SQLiteBlobStorage } from '../local/blob-sqlite'; +import { IndexedDBSyncStorage } from '../local/sync-indexeddb'; +import { SQLiteSyncStorage } from '../local/sync-sqlite'; import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts'; -import { createAffineStaticStorage } from './sync'; +import { AffineStaticSyncStorage } from './sync'; async function getCloudWorkspaceList() { const session = await getSession(); @@ -41,120 +49,134 @@ async function getCloudWorkspaceList() { } } -export function createCloudWorkspaceListProvider(): WorkspaceListProvider { - const notifyChannel = new BroadcastChannel( +export class CloudWorkspaceListProvider implements WorkspaceListProvider { + name = WorkspaceFlavour.AFFINE_CLOUD; + notifyChannel = new BroadcastChannel( CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY ); - return { - name: WorkspaceFlavour.AFFINE_CLOUD, - async getList() { - return getCloudWorkspaceList(); - }, - async create(initial) { - const tempId = nanoid(); + getList(): Promise { + return getCloudWorkspaceList(); + } + async delete(workspaceId: string): Promise { + await fetcher({ + query: deleteWorkspaceMutation, + variables: { + id: workspaceId, + }, + }); + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(null); + } + async create( + initial: ( + workspace: BlockSuiteWorkspace, + blobStorage: BlobStorage + ) => Promise + ): Promise { + const tempId = nanoid(); - const workspace = new BlockSuiteWorkspace({ - id: tempId, - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - }); + const workspace = new BlockSuiteWorkspace({ + id: tempId, + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + }); - // create workspace on cloud, get workspace id - const { - createWorkspace: { id: workspaceId }, - } = await fetcher({ - query: createWorkspaceMutation, - }); + // create workspace on cloud, get workspace id + const { + createWorkspace: { id: workspaceId }, + } = await fetcher({ + query: createWorkspaceMutation, + }); - // save the initial state to local storage, then sync to cloud - const blobStorage = createLocalBlobStorage(workspaceId); - const syncStorage = createLocalStorage(workspaceId); + // save the initial state to local storage, then sync to cloud + const blobStorage = environment.isDesktop + ? new SQLiteBlobStorage(workspaceId) + : new IndexedDBBlobStorage(workspaceId); + const syncStorage = environment.isDesktop + ? new SQLiteSyncStorage(workspaceId) + : new IndexedDBSyncStorage(workspaceId); - // apply initial state - await initial(workspace, blobStorage); + // apply initial state + await initial(workspace, blobStorage); - // save workspace to local storage, should be vary fast - await syncStorage.push(workspaceId, encodeStateAsUpdate(workspace.doc)); - for (const subdocs of workspace.doc.getSubdocs()) { - await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs)); - } + // save workspace to local storage, should be vary fast + await syncStorage.push(workspaceId, encodeStateAsUpdate(workspace.doc)); + for (const subdocs of workspace.doc.getSubdocs()) { + await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs)); + } - // notify all browser tabs, so they can update their workspace list - notifyChannel.postMessage(null); + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(null); - return workspaceId; - }, - async delete(id) { - await fetcher({ - query: deleteWorkspaceMutation, - variables: { - id, - }, - }); - // notify all browser tabs, so they can update their workspace list - notifyChannel.postMessage(null); - }, - subscribe(callback) { - let lastWorkspaceIDs: string[] = []; + return { id: workspaceId, flavour: WorkspaceFlavour.AFFINE_CLOUD }; + } + subscribe( + callback: (changed: { + added?: WorkspaceMetadata[] | undefined; + deleted?: WorkspaceMetadata[] | undefined; + }) => void + ): () => void { + let lastWorkspaceIDs: string[] = []; - function scan() { - (async () => { - const allWorkspaceIDs = (await getCloudWorkspaceList()).map( - workspace => workspace.id - ); - const added = difference(allWorkspaceIDs, lastWorkspaceIDs); - const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs); - lastWorkspaceIDs = allWorkspaceIDs; - callback({ - added: added.map(id => ({ - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - })), - deleted: deleted.map(id => ({ - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - })), - }); - })().catch(err => { - console.error(err); + function scan() { + (async () => { + const allWorkspaceIDs = (await getCloudWorkspaceList()).map( + workspace => workspace.id + ); + const added = difference(allWorkspaceIDs, lastWorkspaceIDs); + const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs); + lastWorkspaceIDs = allWorkspaceIDs; + callback({ + added: added.map(id => ({ + id, + flavour: WorkspaceFlavour.AFFINE_CLOUD, + })), + deleted: deleted.map(id => ({ + id, + flavour: WorkspaceFlavour.AFFINE_CLOUD, + })), }); - } - - scan(); - - // rescan if other tabs notify us - notifyChannel.addEventListener('message', scan); - return () => { - notifyChannel.removeEventListener('message', scan); - }; - }, - async getInformation(id) { - // get information from both cloud and local storage - - // we use affine 'static' storage here, which use http protocol, no need to websocket. - const cloudStorage = createAffineStaticStorage(id); - const localStorage = createLocalStorage(id); - // download root doc - const localData = await localStorage.pull(id, new Uint8Array([])); - const cloudData = await cloudStorage.pull(id, new Uint8Array([])); - - if (!cloudData && !localData) { - return; - } - - const bs = new BlockSuiteWorkspace({ - id, - schema: globalBlockSuiteSchema, + })().catch(err => { + console.error(err); }); + } - if (localData) applyUpdate(bs.doc, localData.data); - if (cloudData) applyUpdate(bs.doc, cloudData.data); + scan(); - return { - name: bs.meta.name, - avatar: bs.meta.avatar, - }; - }, - }; + // rescan if other tabs notify us + this.notifyChannel.addEventListener('message', scan); + return () => { + this.notifyChannel.removeEventListener('message', scan); + }; + } + async getInformation(id: string): Promise { + // get information from both cloud and local storage + + // we use affine 'static' storage here, which use http protocol, no need to websocket. + const cloudStorage: SyncStorage = new AffineStaticSyncStorage(id); + const localStorage = environment.isDesktop + ? new SQLiteSyncStorage(id) + : new IndexedDBSyncStorage(id); + // download root doc + const localData = await localStorage.pull(id, new Uint8Array([])); + const cloudData = await cloudStorage.pull(id, new Uint8Array([])); + + if (!cloudData && !localData) { + return; + } + + const bs = new BlockSuiteWorkspace({ + id, + schema: globalBlockSuiteSchema, + }); + + if (localData) applyUpdate(bs.doc, localData.data); + if (cloudData) applyUpdate(bs.doc, cloudData.data); + + return { + name: bs.meta.name, + avatar: bs.meta.avatar, + }; + } } diff --git a/packages/frontend/workspace-impl/src/cloud/sync/index.ts b/packages/frontend/workspace-impl/src/cloud/sync/index.ts index 22225fcc3a..f3b348863f 100644 --- a/packages/frontend/workspace-impl/src/cloud/sync/index.ts +++ b/packages/frontend/workspace-impl/src/cloud/sync/index.ts @@ -1,6 +1,7 @@ import { DebugLogger } from '@affine/debug'; import { fetchWithTraceReport } from '@affine/graphql'; -import type { SyncStorage } from '@affine/workspace'; +import { type SyncStorage } from '@toeverything/infra'; +import type { CleanupService } from '@toeverything/infra/lifecycle'; import { getIoManager } from '../../utils/affine-io'; import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64'; @@ -8,22 +9,21 @@ import { MultipleBatchSyncSender } from './batch-sync-sender'; const logger = new DebugLogger('affine:storage:socketio'); -export function createAffineStorage( - workspaceId: string -): SyncStorage & { disconnect: () => void } { - logger.debug('createAffineStorage', workspaceId); - const socket = getIoManager().socket('/'); +export class AffineSyncStorage implements SyncStorage { + name = 'affine-cloud'; - const syncSender = new MultipleBatchSyncSender(async (guid, updates) => { + socket = getIoManager().socket('/'); + + syncSender = new MultipleBatchSyncSender(async (guid, updates) => { const payload = await Promise.all( updates.map(update => uint8ArrayToBase64(update)) ); return new Promise(resolve => { - socket.emit( + this.socket.emit( 'client-update-v2', { - workspaceId, + workspaceId: this.workspaceId, guid, updates: payload, }, @@ -35,7 +35,7 @@ export function createAffineStorage( // TODO: raise error with different code to users if (response.error) { logger.error('client-update-v2 error', { - workspaceId, + workspaceId: this.workspaceId, guid, response, }); @@ -51,145 +51,160 @@ export function createAffineStorage( }); }); - function handleConnect() { - socket.emit( + constructor( + private readonly workspaceId: string, + cleanupService: CleanupService + ) { + this.socket.on('connect', this.handleConnect); + + this.socket.connect(); + + this.socket.emit( 'client-handshake-sync', - workspaceId, + this.workspaceId, (response: { error?: any }) => { if (!response.error) { - syncSender.start(); + this.syncSender.start(); } } ); + + cleanupService.add(() => { + this.cleanup(); + }); } - socket.on('connect', handleConnect); - - socket.connect(); - - socket.emit( - 'client-handshake-sync', - workspaceId, - (response: { error?: any }) => { - if (!response.error) { - syncSender.start(); + handleConnect = () => { + this.socket.emit( + 'client-handshake-sync', + this.workspaceId, + (response: { error?: any }) => { + if (!response.error) { + this.syncSender.start(); + } } - } - ); + ); + }; - return { - name: 'affine-cloud', - async pull(docId, state) { - const stateVector = state ? await uint8ArrayToBase64(state) : undefined; + async pull( + docId: string, + state: Uint8Array + ): Promise<{ data: Uint8Array; state?: Uint8Array } | null> { + const stateVector = state ? await uint8ArrayToBase64(state) : undefined; - return new Promise((resolve, reject) => { - logger.debug('doc-load-v2', { - workspaceId: workspaceId, + return new Promise((resolve, reject) => { + logger.debug('doc-load-v2', { + workspaceId: this.workspaceId, + guid: docId, + stateVector, + }); + this.socket.emit( + 'doc-load-v2', + { + workspaceId: this.workspaceId, guid: docId, stateVector, - }); - socket.emit( - 'doc-load-v2', - { - workspaceId: workspaceId, + }, + ( + response: // TODO: reuse `EventError` with server + { error: any } | { data: { missing: string; state: string } } + ) => { + logger.debug('doc-load callback', { + workspaceId: this.workspaceId, guid: docId, stateVector, - }, - ( - response: // TODO: reuse `EventError` with server - { error: any } | { data: { missing: string; state: string } } - ) => { - logger.debug('doc-load callback', { - workspaceId: workspaceId, - guid: docId, - stateVector, - response, - }); - - if ('error' in response) { - // TODO: result `EventError` with server - if (response.error.code === 'DOC_NOT_FOUND') { - resolve(null); - } else { - reject(new Error(response.error.message)); - } - } else { - resolve({ - data: base64ToUint8Array(response.data.missing), - state: response.data.state - ? base64ToUint8Array(response.data.state) - : undefined, - }); - } - } - ); - }); - }, - async push(docId, update) { - logger.debug('client-update-v2', { - workspaceId, - guid: docId, - update, - }); - - await syncSender.send(docId, update); - }, - async subscribe(cb, disconnect) { - const handleUpdate = async (message: { - workspaceId: string; - guid: string; - updates: string[]; - }) => { - if (message.workspaceId === workspaceId) { - message.updates.forEach(update => { - cb(message.guid, base64ToUint8Array(update)); + response, }); - } - }; - socket.on('server-updates', handleUpdate); - socket.on('disconnect', reason => { - socket.off('server-updates', handleUpdate); - disconnect(reason); - }); - - return () => { - socket.off('server-updates', handleUpdate); - }; - }, - disconnect() { - syncSender.stop(); - socket.emit('client-leave-sync', workspaceId); - socket.off('connect', handleConnect); - }, - }; -} - -export function createAffineStaticStorage(workspaceId: string): SyncStorage { - logger.debug('createAffineStaticStorage', workspaceId); - - return { - name: 'affine-cloud-static', - async pull(docId) { - const response = await fetchWithTraceReport( - `/api/workspaces/${workspaceId}/docs/${docId}`, - { - priority: 'high', + if ('error' in response) { + // TODO: result `EventError` with server + if (response.error.code === 'DOC_NOT_FOUND') { + resolve(null); + } else { + reject(new Error(response.error.message)); + } + } else { + resolve({ + data: base64ToUint8Array(response.data.missing), + state: response.data.state + ? base64ToUint8Array(response.data.state) + : undefined, + }); + } } ); - if (response.ok) { - const arrayBuffer = await response.arrayBuffer(); + }); + } - return { data: new Uint8Array(arrayBuffer) }; + async push(docId: string, update: Uint8Array) { + logger.debug('client-update-v2', { + workspaceId: this.workspaceId, + guid: docId, + update, + }); + + await this.syncSender.send(docId, update); + } + + async subscribe( + cb: (docId: string, data: Uint8Array) => void, + disconnect: (reason: string) => void + ) { + const handleUpdate = async (message: { + workspaceId: string; + guid: string; + updates: string[]; + }) => { + if (message.workspaceId === this.workspaceId) { + message.updates.forEach(update => { + cb(message.guid, base64ToUint8Array(update)); + }); } + }; + this.socket.on('server-updates', handleUpdate); - return null; - }, - async push() { - throw new Error('Not implemented'); - }, - async subscribe() { - throw new Error('Not implemented'); - }, - }; + this.socket.on('disconnect', reason => { + this.socket.off('server-updates', handleUpdate); + disconnect(reason); + }); + + return () => { + this.socket.off('server-updates', handleUpdate); + }; + } + + cleanup() { + this.syncSender.stop(); + this.socket.emit('client-leave-sync', this.workspaceId); + this.socket.off('connect', this.handleConnect); + } +} + +export class AffineStaticSyncStorage implements SyncStorage { + name = 'affine-cloud-static'; + constructor(private readonly workspaceId: string) {} + + async pull( + docId: string + ): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> { + const response = await fetchWithTraceReport( + `/api/workspaces/${this.workspaceId}/docs/${docId}`, + { + priority: 'high', + } + ); + if (response.ok) { + const arrayBuffer = await response.arrayBuffer(); + + return { data: new Uint8Array(arrayBuffer) }; + } + + return null; + } + push(): Promise { + throw new Error('Method not implemented.'); + } + subscribe(): Promise<() => void> { + throw new Error('Method not implemented.'); + } } diff --git a/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts b/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts index 1b747e2c1e..b2b84a1f4c 100644 --- a/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts +++ b/packages/frontend/workspace-impl/src/cloud/workspace-factory.ts @@ -1,77 +1,55 @@ -import { setupEditorFlags } from '@affine/env/global'; -import type { WorkspaceFactory } from '@affine/workspace'; -import { BlobEngine, SyncEngine, WorkspaceEngine } from '@affine/workspace'; -import { globalBlockSuiteSchema } from '@affine/workspace'; -import { Workspace } from '@affine/workspace'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { nanoid } from 'nanoid'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import type { WorkspaceFactory } from '@toeverything/infra'; +import { + AwarenessContext, + AwarenessProvider, + RemoteBlobStorage, + RemoteSyncStorage, + WorkspaceIdContext, + WorkspaceScope, +} from '@toeverything/infra'; +import type { ServiceCollection } from '@toeverything/infra/di'; +import { CleanupService } from '@toeverything/infra/lifecycle'; -import { createBroadcastChannelAwarenessProvider } from '../local/awareness'; -import { createLocalBlobStorage } from '../local/blob'; -import { createStaticBlobStorage } from '../local/blob-static'; -import { createLocalStorage } from '../local/sync'; -import { createCloudAwarenessProvider } from './awareness'; -import { createAffineCloudBlobStorage } from './blob'; -import { createAffineStorage } from './sync'; +import { LocalWorkspaceFactory } from '../local'; +import { IndexedDBBlobStorage, SQLiteBlobStorage } from '../local'; +import { AffineCloudAwarenessProvider } from './awareness'; +import { AffineCloudBlobStorage } from './blob'; +import { AffineSyncStorage } from './sync'; -export const cloudWorkspaceFactory: WorkspaceFactory = { - name: 'affine-cloud', - openWorkspace(metadata) { - const blobEngine = new BlobEngine(createLocalBlobStorage(metadata.id), [ - createAffineCloudBlobStorage(metadata.id), - createStaticBlobStorage(), - ]); +export class CloudWorkspaceFactory implements WorkspaceFactory { + name = WorkspaceFlavour.AFFINE_CLOUD; + configureWorkspace(services: ServiceCollection): void { + // configure local-first providers + new LocalWorkspaceFactory().configureWorkspace(services); - // create blocksuite workspace - const bs = new BlockSuiteWorkspace({ - id: metadata.id, - blobStorages: [ - () => ({ - crud: blobEngine, - }), - ], - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - }); - - const affineStorage = createAffineStorage(metadata.id); - const syncEngine = new SyncEngine(bs.doc, createLocalStorage(metadata.id), [ - affineStorage, - ]); - - const awarenessProviders = [ - createBroadcastChannelAwarenessProvider( - metadata.id, - bs.awarenessStore.awareness - ), - createCloudAwarenessProvider(metadata.id, bs.awarenessStore.awareness), - ]; - const engine = new WorkspaceEngine( - blobEngine, - syncEngine, - awarenessProviders - ); - - setupEditorFlags(bs); - - const workspace = new Workspace(metadata, engine, bs); - - workspace.onStop.once(() => { - // affine sync storage need manually disconnect - affineStorage.disconnect(); - }); - - return workspace; - }, + services + .scope(WorkspaceScope) + .addImpl(RemoteBlobStorage('affine-cloud'), AffineCloudBlobStorage, [ + WorkspaceIdContext, + ]) + .addImpl(RemoteSyncStorage('affine-cloud'), AffineSyncStorage, [ + WorkspaceIdContext, + CleanupService, + ]) + .addImpl( + AwarenessProvider('affine-cloud'), + AffineCloudAwarenessProvider, + [WorkspaceIdContext, AwarenessContext] + ); + } async getWorkspaceBlob(id: string, blobKey: string): Promise { // try to get blob from local storage first - const localBlobStorage = createLocalBlobStorage(id); + const localBlobStorage = environment.isDesktop + ? new SQLiteBlobStorage(id) + : new IndexedDBBlobStorage(id); + const localBlob = await localBlobStorage.get(blobKey); if (localBlob) { return localBlob; } - const blobStorage = createAffineCloudBlobStorage(id); + const blobStorage = new AffineCloudBlobStorage(id); return await blobStorage.get(blobKey); - }, -}; + } +} diff --git a/packages/frontend/workspace-impl/src/index.ts b/packages/frontend/workspace-impl/src/index.ts index 27a2e38a91..a517d5871e 100644 --- a/packages/frontend/workspace-impl/src/index.ts +++ b/packages/frontend/workspace-impl/src/index.ts @@ -1,30 +1,24 @@ -import { WorkspaceList, WorkspaceManager } from '@affine/workspace'; +import { WorkspaceFactory, WorkspaceListProvider } from '@toeverything/infra'; +import type { ServiceCollection } from '@toeverything/infra/di'; +import { CloudWorkspaceFactory, CloudWorkspaceListProvider } from './cloud'; import { - cloudWorkspaceFactory, - createCloudWorkspaceListProvider, -} from './cloud'; -import { - createLocalWorkspaceListProvider, LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - localWorkspaceFactory, + LocalWorkspaceFactory, + LocalWorkspaceListProvider, } from './local'; -const list = new WorkspaceList([ - createLocalWorkspaceListProvider(), - createCloudWorkspaceListProvider(), -]); - -export const workspaceManager = new WorkspaceManager(list, [ - localWorkspaceFactory, - cloudWorkspaceFactory, -]); - -(window as any).workspaceManager = workspaceManager; - export * from './cloud'; export * from './local'; +export function configureWorkspaceImplServices(services: ServiceCollection) { + services + .addImpl(WorkspaceListProvider('affine-cloud'), CloudWorkspaceListProvider) + .addImpl(WorkspaceFactory('affine-cloud'), CloudWorkspaceFactory) + .addImpl(WorkspaceListProvider('local'), LocalWorkspaceListProvider) + .addImpl(WorkspaceFactory('local'), LocalWorkspaceFactory); +} + /** * a hack for directly add local workspace to workspace list * Used after copying sqlite database file to appdata folder diff --git a/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts b/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts index 30faa2f0ed..2ecda17ad1 100644 --- a/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts +++ b/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts @@ -1,12 +1,12 @@ import 'fake-indexeddb/auto'; -import { SyncEngine, SyncEngineStep, SyncPeerStep } from '@affine/workspace'; import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; import { Schema, Workspace } from '@blocksuite/store'; +import { SyncEngine, SyncEngineStep, SyncPeerStep } from '@toeverything/infra'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Doc } from 'yjs'; -import { createIndexedDBStorage } from '..'; +import { IndexedDBSyncStorage } from '..'; import { createTestStorage } from './test-storage'; const schema = new Schema(); @@ -29,10 +29,10 @@ describe('SyncEngine', () => { const syncEngine = new SyncEngine( workspace.doc, - createIndexedDBStorage(workspace.doc.guid), + new IndexedDBSyncStorage(workspace.doc.guid), [ - createIndexedDBStorage(workspace.doc.guid + '1'), - createIndexedDBStorage(workspace.doc.guid + '2'), + new IndexedDBSyncStorage(workspace.doc.guid + '1'), + new IndexedDBSyncStorage(workspace.doc.guid + '2'), ] ); syncEngine.start(); @@ -60,7 +60,7 @@ describe('SyncEngine', () => { }); const syncEngine = new SyncEngine( workspace.doc, - createIndexedDBStorage(workspace.doc.guid), + new IndexedDBSyncStorage(workspace.doc.guid), [] ); syncEngine.start(); @@ -79,7 +79,7 @@ describe('SyncEngine', () => { }); const syncEngine = new SyncEngine( workspace.doc, - createIndexedDBStorage(workspace.doc.guid + '1'), + new IndexedDBSyncStorage(workspace.doc.guid + '1'), [] ); syncEngine.start(); @@ -98,7 +98,7 @@ describe('SyncEngine', () => { }); const syncEngine = new SyncEngine( workspace.doc, - createIndexedDBStorage(workspace.doc.guid + '2'), + new IndexedDBSyncStorage(workspace.doc.guid + '2'), [] ); syncEngine.start(); @@ -113,9 +113,9 @@ describe('SyncEngine', () => { test('status', async () => { const ydoc = new Doc({ guid: 'test - syncengine - status' }); - const localStorage = createTestStorage(createIndexedDBStorage(ydoc.guid)); + const localStorage = createTestStorage(new IndexedDBSyncStorage(ydoc.guid)); const remoteStorage = createTestStorage( - createIndexedDBStorage(ydoc.guid + '1') + new IndexedDBSyncStorage(ydoc.guid + '1') ); localStorage.pausePull(); diff --git a/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts b/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts index 11e28dc232..e4b62de856 100644 --- a/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts +++ b/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts @@ -1,11 +1,11 @@ import 'fake-indexeddb/auto'; -import { SyncPeer, SyncPeerStep } from '@affine/workspace'; import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; import { Schema, Workspace } from '@blocksuite/store'; +import { SyncPeer, SyncPeerStep } from '@toeverything/infra'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { createIndexedDBStorage } from '..'; +import { IndexedDBSyncStorage } from '..'; const schema = new Schema(); @@ -27,7 +27,7 @@ describe('SyncPeer', () => { const syncPeer = new SyncPeer( workspace.doc, - createIndexedDBStorage(workspace.doc.guid) + new IndexedDBSyncStorage(workspace.doc.guid) ); await syncPeer.waitForLoaded(); @@ -54,7 +54,7 @@ describe('SyncPeer', () => { }); const syncPeer = new SyncPeer( workspace.doc, - createIndexedDBStorage(workspace.doc.guid) + new IndexedDBSyncStorage(workspace.doc.guid) ); await syncPeer.waitForSynced(); expect(workspace.doc.toJSON()).toEqual({ @@ -73,7 +73,7 @@ describe('SyncPeer', () => { const syncPeer = new SyncPeer( workspace.doc, - createIndexedDBStorage(workspace.doc.guid) + new IndexedDBSyncStorage(workspace.doc.guid) ); expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingRootDoc); await syncPeer.waitForSynced(); diff --git a/packages/frontend/workspace-impl/src/local/__tests__/test-storage.ts b/packages/frontend/workspace-impl/src/local/__tests__/test-storage.ts index a0653d4d30..8d318d03f9 100644 --- a/packages/frontend/workspace-impl/src/local/__tests__/test-storage.ts +++ b/packages/frontend/workspace-impl/src/local/__tests__/test-storage.ts @@ -1,4 +1,4 @@ -import type { SyncStorage } from '@affine/workspace'; +import type { SyncStorage } from '@toeverything/infra'; export function createTestStorage(origin: SyncStorage) { const controler = { diff --git a/packages/frontend/workspace-impl/src/local/awareness.ts b/packages/frontend/workspace-impl/src/local/awareness.ts index 8674046c2d..cfbcbedb46 100644 --- a/packages/frontend/workspace-impl/src/local/awareness.ts +++ b/packages/frontend/workspace-impl/src/local/awareness.ts @@ -1,4 +1,4 @@ -import type { AwarenessProvider } from '@affine/workspace'; +import type { AwarenessProvider } from '@toeverything/infra'; import type { Awareness } from 'y-protocols/awareness.js'; import { applyAwarenessUpdate, @@ -11,13 +11,35 @@ type ChannelMessage = | { type: 'connect' } | { type: 'update'; update: Uint8Array }; -export function createBroadcastChannelAwarenessProvider( - workspaceId: string, - awareness: Awareness -): AwarenessProvider { - const channel = new BroadcastChannel('awareness:' + workspaceId); +export class BroadcastChannelAwarenessProvider implements AwarenessProvider { + channel: BroadcastChannel | null = null; - function handleAwarenessUpdate(changes: AwarenessChanges, origin: unknown) { + constructor( + private readonly workspaceId: string, + private readonly awareness: Awareness + ) {} + + connect(): void { + this.channel = new BroadcastChannel('awareness:' + this.workspaceId); + this.channel.postMessage({ + type: 'connect', + } satisfies ChannelMessage); + this.awareness.on('update', (changes: AwarenessChanges, origin: unknown) => + this.handleAwarenessUpdate(changes, origin) + ); + this.channel.addEventListener( + 'message', + (event: MessageEvent) => { + this.handleChannelMessage(event); + } + ); + } + disconnect(): void { + this.channel?.close(); + this.channel = null; + } + + handleAwarenessUpdate(changes: AwarenessChanges, origin: unknown) { if (origin === 'remote') { return; } @@ -26,37 +48,25 @@ export function createBroadcastChannelAwarenessProvider( res.concat(cur) ); - const update = encodeAwarenessUpdate(awareness, changedClients); - channel.postMessage({ + const update = encodeAwarenessUpdate(this.awareness, changedClients); + this.channel?.postMessage({ type: 'update', update: update, } satisfies ChannelMessage); } - function handleChannelMessage(event: MessageEvent) { + handleChannelMessage(event: MessageEvent) { if (event.data.type === 'update') { const update = event.data.update; - applyAwarenessUpdate(awareness, update, 'remote'); + applyAwarenessUpdate(this.awareness, update, 'remote'); } if (event.data.type === 'connect') { - channel.postMessage({ + this.channel?.postMessage({ type: 'update', - update: encodeAwarenessUpdate(awareness, [awareness.clientID]), + update: encodeAwarenessUpdate(this.awareness, [ + this.awareness.clientID, + ]), } satisfies ChannelMessage); } } - - return { - connect() { - channel.postMessage({ - type: 'connect', - } satisfies ChannelMessage); - awareness.on('update', handleAwarenessUpdate); - channel.addEventListener('message', handleChannelMessage); - }, - disconnect() { - awareness.off('update', handleAwarenessUpdate); - channel.removeEventListener('message', handleChannelMessage); - }, - }; } diff --git a/packages/frontend/workspace-impl/src/local/blob-indexeddb.ts b/packages/frontend/workspace-impl/src/local/blob-indexeddb.ts index fe9ec6314a..e05ec610da 100644 --- a/packages/frontend/workspace-impl/src/local/blob-indexeddb.ts +++ b/packages/frontend/workspace-impl/src/local/blob-indexeddb.ts @@ -1,34 +1,33 @@ -import type { BlobStorage } from '@affine/workspace'; +import { type BlobStorage } from '@toeverything/infra'; import { createStore, del, get, keys, set } from 'idb-keyval'; import { bufferToBlob } from '../utils/buffer-to-blob'; -export const createIndexeddbBlobStorage = ( - workspaceId: string -): BlobStorage => { - const db = createStore(`${workspaceId}_blob`, 'blob'); - const mimeTypeDb = createStore(`${workspaceId}_blob_mime`, 'blob_mime'); - return { - name: 'indexeddb', - readonly: false, - get: async (key: string) => { - const res = await get(key, db); - if (res) { - return bufferToBlob(res); - } - return null; - }, - set: async (key: string, value: Blob) => { - await set(key, await value.arrayBuffer(), db); - await set(key, value.type, mimeTypeDb); - return key; - }, - delete: async (key: string) => { - await del(key, db); - await del(key, mimeTypeDb); - }, - list: async () => { - return keys(db); - }, - }; -}; +export class IndexedDBBlobStorage implements BlobStorage { + constructor(private readonly workspaceId: string) {} + + name = 'indexeddb'; + readonly = false; + db = createStore(`${this.workspaceId}_blob`, 'blob'); + mimeTypeDb = createStore(`${this.workspaceId}_blob_mime`, 'blob_mime'); + + async get(key: string) { + const res = await get(key, this.db); + if (res) { + return bufferToBlob(res); + } + return null; + } + async set(key: string, value: Blob) { + await set(key, await value.arrayBuffer(), this.db); + await set(key, value.type, this.mimeTypeDb); + return key; + } + async delete(key: string) { + await del(key, this.db); + await del(key, this.mimeTypeDb); + } + async list() { + return keys(this.db); + } +} diff --git a/packages/frontend/workspace-impl/src/local/blob-sqlite.ts b/packages/frontend/workspace-impl/src/local/blob-sqlite.ts index f92ba06e51..aabf2d904d 100644 --- a/packages/frontend/workspace-impl/src/local/blob-sqlite.ts +++ b/packages/frontend/workspace-impl/src/local/blob-sqlite.ts @@ -1,38 +1,36 @@ import { apis } from '@affine/electron-api'; -import type { BlobStorage } from '@affine/workspace'; import { assertExists } from '@blocksuite/global/utils'; +import { type BlobStorage } from '@toeverything/infra'; import { bufferToBlob } from '../utils/buffer-to-blob'; -export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => { - assertExists(apis); - return { - name: 'sqlite', - readonly: false, - get: async (key: string) => { - assertExists(apis); - const buffer = await apis.db.getBlob(workspaceId, key); - if (buffer) { - return bufferToBlob(buffer); - } - return null; - }, - set: async (key: string, value: Blob) => { - assertExists(apis); - await apis.db.addBlob( - workspaceId, - key, - new Uint8Array(await value.arrayBuffer()) - ); - return key; - }, - delete: async (key: string) => { - assertExists(apis); - return apis.db.deleteBlob(workspaceId, key); - }, - list: async () => { - assertExists(apis); - return apis.db.getBlobKeys(workspaceId); - }, - }; -}; +export class SQLiteBlobStorage implements BlobStorage { + constructor(private readonly workspaceId: string) {} + name = 'sqlite'; + readonly = false; + async get(key: string) { + assertExists(apis); + const buffer = await apis.db.getBlob(this.workspaceId, key); + if (buffer) { + return bufferToBlob(buffer); + } + return null; + } + async set(key: string, value: Blob) { + assertExists(apis); + await apis.db.addBlob( + this.workspaceId, + key, + new Uint8Array(await value.arrayBuffer()) + ); + return key; + } + delete(key: string) { + assertExists(apis); + return apis.db.deleteBlob(this.workspaceId, key); + } + list() { + assertExists(apis); + return apis.db.getBlobKeys(this.workspaceId); + } +} diff --git a/packages/frontend/workspace-impl/src/local/blob-static.ts b/packages/frontend/workspace-impl/src/local/blob-static.ts index 35ff5d1299..08f93c5fd0 100644 --- a/packages/frontend/workspace-impl/src/local/blob-static.ts +++ b/packages/frontend/workspace-impl/src/local/blob-static.ts @@ -1,4 +1,4 @@ -import type { BlobStorage } from '@affine/workspace'; +import { type BlobStorage } from '@toeverything/infra'; export const predefinedStaticFiles = [ '029uztLz2CzJezK7UUhrbGiWUdZ0J7NVs_qR6RDsvb8=', @@ -36,37 +36,36 @@ export const predefinedStaticFiles = [ 'v2yF7lY2L5rtorTtTmYFsoMb9dBPKs5M1y9cUKxcI1M=', ]; -export const createStaticBlobStorage = (): BlobStorage => { - return { - name: 'static', - readonly: true, - get: async (key: string) => { - const isStaticResource = - predefinedStaticFiles.includes(key) || key.startsWith('/static/'); - - if (!isStaticResource) { - return null; - } - - const path = key.startsWith('/static/') ? key : `/static/${key}`; - const response = await fetch(path); - - if (response.ok) { - return await response.blob(); - } +export class StaticBlobStorage implements BlobStorage { + name = 'static'; + readonly = true; + async get(key: string) { + const isStaticResource = + predefinedStaticFiles.includes(key) || key.startsWith('/static/'); + if (!isStaticResource) { return null; - }, - set: async key => { - // ignore - return key; - }, - delete: async () => { - // ignore - }, - list: async () => { - // ignore - return []; - }, - }; -}; + } + + const path = key.startsWith('/static/') ? key : `/static/${key}`; + const response = await fetch(path); + + if (response.ok) { + return await response.blob(); + } + + return null; + } + + async set(key: string) { + // ignore + return key; + } + async delete() { + // ignore + } + async list() { + // ignore + return []; + } +} diff --git a/packages/frontend/workspace-impl/src/local/blob.ts b/packages/frontend/workspace-impl/src/local/blob.ts deleted file mode 100644 index 746f119b60..0000000000 --- a/packages/frontend/workspace-impl/src/local/blob.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createIndexeddbBlobStorage } from './blob-indexeddb'; -import { createSQLiteBlobStorage } from './blob-sqlite'; - -export function createLocalBlobStorage(workspaceId: string) { - if (environment.isDesktop) { - return createSQLiteBlobStorage(workspaceId); - } else { - return createIndexeddbBlobStorage(workspaceId); - } -} diff --git a/packages/frontend/workspace-impl/src/local/index.ts b/packages/frontend/workspace-impl/src/local/index.ts index 0fbc7662e3..c8ba0d7282 100644 --- a/packages/frontend/workspace-impl/src/local/index.ts +++ b/packages/frontend/workspace-impl/src/local/index.ts @@ -1,11 +1,9 @@ export * from './awareness'; -export * from './blob'; export * from './blob-indexeddb'; export * from './blob-sqlite'; export * from './blob-static'; export * from './consts'; export * from './list'; -export * from './sync'; export * from './sync-indexeddb'; export * from './sync-sqlite'; export * from './workspace-factory'; diff --git a/packages/frontend/workspace-impl/src/local/list.ts b/packages/frontend/workspace-impl/src/local/list.ts index d147da76d2..ea616586d6 100644 --- a/packages/frontend/workspace-impl/src/local/list.ts +++ b/packages/frontend/workspace-impl/src/local/list.ts @@ -1,130 +1,151 @@ import { apis } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { WorkspaceListProvider } from '@affine/workspace'; -import { globalBlockSuiteSchema } from '@affine/workspace'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { WorkspaceListProvider } from '@toeverything/infra'; +import { + type BlobStorage, + type WorkspaceInfo, + type WorkspaceMetadata, +} from '@toeverything/infra'; +import { globalBlockSuiteSchema } from '@toeverything/infra'; import { difference } from 'lodash-es'; import { nanoid } from 'nanoid'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; -import { createLocalBlobStorage } from './blob'; +import { IndexedDBBlobStorage } from './blob-indexeddb'; +import { SQLiteBlobStorage } from './blob-sqlite'; import { LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY, LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, } from './consts'; -import { createLocalStorage } from './sync'; +import { IndexedDBSyncStorage } from './sync-indexeddb'; +import { SQLiteSyncStorage } from './sync-sqlite'; -export function createLocalWorkspaceListProvider(): WorkspaceListProvider { - const notifyChannel = new BroadcastChannel( +export class LocalWorkspaceListProvider implements WorkspaceListProvider { + name = WorkspaceFlavour.LOCAL; + + notifyChannel = new BroadcastChannel( LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY ); - return { - name: WorkspaceFlavour.LOCAL, - getList() { - return Promise.resolve( - JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL })) - ); - }, - subscribe(callback) { - let lastWorkspaceIDs: string[] = []; + async getList() { + return JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL })); + } - function scan() { - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - const added = difference(allWorkspaceIDs, lastWorkspaceIDs); - const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs); - lastWorkspaceIDs = allWorkspaceIDs; - callback({ - added: added.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })), - deleted: deleted.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })), - }); - } + async delete(workspaceId: string) { + const allWorkspaceIDs: string[] = JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ); + localStorage.setItem( + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId)) + ); - scan(); + if (apis && environment.isDesktop) { + await apis.workspace.delete(workspaceId); + } - // rescan if other tabs notify us - notifyChannel.addEventListener('message', scan); - return () => { - notifyChannel.removeEventListener('message', scan); - }; - }, - async create(initial) { - const id = nanoid(); + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(workspaceId); + } - const blobStorage = createLocalBlobStorage(id); - const syncStorage = createLocalStorage(id); + async create( + initial: ( + workspace: BlockSuiteWorkspace, + blobStorage: BlobStorage + ) => Promise + ): Promise { + const id = nanoid(); - const workspace = new BlockSuiteWorkspace({ - id: id, - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - }); + const blobStorage = environment.isDesktop + ? new SQLiteBlobStorage(id) + : new IndexedDBBlobStorage(id); + const syncStorage = environment.isDesktop + ? new SQLiteSyncStorage(id) + : new IndexedDBSyncStorage(id); - // apply initial state - await initial(workspace, blobStorage); + const workspace = new BlockSuiteWorkspace({ + id: id, + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + }); - // save workspace to local storage - await syncStorage.push(id, encodeStateAsUpdate(workspace.doc)); - for (const subdocs of workspace.doc.getSubdocs()) { - await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs)); - } + // apply initial state + await initial(workspace, blobStorage); - // save workspace id to local storage + // save workspace to local storage + await syncStorage.push(id, encodeStateAsUpdate(workspace.doc)); + for (const subdocs of workspace.doc.getSubdocs()) { + await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs)); + } + + // save workspace id to local storage + const allWorkspaceIDs: string[] = JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ); + allWorkspaceIDs.push(id); + localStorage.setItem( + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + JSON.stringify(allWorkspaceIDs) + ); + + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(id); + + return { id, flavour: WorkspaceFlavour.LOCAL }; + } + subscribe( + callback: (changed: { + added?: WorkspaceMetadata[] | undefined; + deleted?: WorkspaceMetadata[] | undefined; + }) => void + ): () => void { + let lastWorkspaceIDs: string[] = []; + + function scan() { const allWorkspaceIDs: string[] = JSON.parse( localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' ); - allWorkspaceIDs.push(id); - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify(allWorkspaceIDs) - ); - - // notify all browser tabs, so they can update their workspace list - notifyChannel.postMessage(id); - - return id; - }, - async delete(workspaceId) { - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId)) - ); - - if (apis && environment.isDesktop) { - await apis.workspace.delete(workspaceId); - } - - // notify all browser tabs, so they can update their workspace list - notifyChannel.postMessage(workspaceId); - }, - async getInformation(id) { - // get information from root doc - - const storage = createLocalStorage(id); - const data = await storage.pull(id, new Uint8Array([])); - - if (!data) { - return; - } - - const bs = new BlockSuiteWorkspace({ - id, - schema: globalBlockSuiteSchema, + const added = difference(allWorkspaceIDs, lastWorkspaceIDs); + const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs); + lastWorkspaceIDs = allWorkspaceIDs; + callback({ + added: added.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })), + deleted: deleted.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })), }); + } - applyUpdate(bs.doc, data.data); + scan(); - return { - name: bs.meta.name, - avatar: bs.meta.avatar, - }; - }, - }; + // rescan if other tabs notify us + this.notifyChannel.addEventListener('message', scan); + return () => { + this.notifyChannel.removeEventListener('message', scan); + }; + } + async getInformation(id: string): Promise { + // get information from root doc + const storage = environment.isDesktop + ? new SQLiteSyncStorage(id) + : new IndexedDBSyncStorage(id); + const data = await storage.pull(id, new Uint8Array([])); + + if (!data) { + return; + } + + const bs = new BlockSuiteWorkspace({ + id, + schema: globalBlockSuiteSchema, + }); + + applyUpdate(bs.doc, data.data); + + return { + name: bs.meta.name, + avatar: bs.meta.avatar, + }; + } } diff --git a/packages/frontend/workspace-impl/src/local/sync-indexeddb.ts b/packages/frontend/workspace-impl/src/local/sync-indexeddb.ts index fe418239e9..a29ca22fbb 100644 --- a/packages/frontend/workspace-impl/src/local/sync-indexeddb.ts +++ b/packages/frontend/workspace-impl/src/local/sync-indexeddb.ts @@ -1,9 +1,7 @@ -import type { SyncStorage } from '@affine/workspace'; +import { mergeUpdates, type SyncStorage } from '@toeverything/infra'; import { type DBSchema, type IDBPDatabase, openDB } from 'idb'; import { diffUpdate, encodeStateVectorFromUpdate } from 'yjs'; -import { mergeUpdates } from '../utils/merge-updates'; - export const dbVersion = 1; export const DEFAULT_DB_NAME = 'affine-local'; @@ -38,81 +36,83 @@ type ChannelMessage = { payload: { docId: string; update: Uint8Array }; }; -export function createIndexedDBStorage( - workspaceId: string, - dbName = DEFAULT_DB_NAME, - mergeCount = 1 -): SyncStorage { - let dbPromise: Promise> | null = null; - const getDb = async () => { - if (dbPromise === null) { - dbPromise = openDB(dbName, dbVersion, { +export class IndexedDBSyncStorage implements SyncStorage { + name = 'indexeddb'; + dbName = DEFAULT_DB_NAME; + mergeCount = 1; + dbPromise: Promise> | null = null; + // indexeddb could be shared between tabs, so we use broadcast channel to notify other tabs + channel = new BroadcastChannel('indexeddb:' + this.workspaceId); + + constructor(private readonly workspaceId: string) {} + + getDb() { + if (this.dbPromise === null) { + this.dbPromise = openDB(this.dbName, dbVersion, { upgrade: upgradeDB, }); } - return dbPromise; - }; + return this.dbPromise; + } - // indexeddb could be shared between tabs, so we use broadcast channel to notify other tabs - const channel = new BroadcastChannel('indexeddb:' + workspaceId); + async pull( + docId: string, + state: Uint8Array + ): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> { + const db = await this.getDb(); + const store = db + .transaction('workspace', 'readonly') + .objectStore('workspace'); + const data = await store.get(docId); - return { - name: 'indexeddb', - async pull(docId, state) { - const db = await getDb(); - const store = db - .transaction('workspace', 'readonly') - .objectStore('workspace'); - const data = await store.get(docId); + if (!data) { + return null; + } - if (!data) { - return null; + const { updates } = data; + const update = mergeUpdates(updates.map(({ update }) => update)); + + const diff = state.length ? diffUpdate(update, state) : update; + + return { data: diff, state: encodeStateVectorFromUpdate(update) }; + } + + async push(docId: string, data: Uint8Array): Promise { + const db = await this.getDb(); + const store = db + .transaction('workspace', 'readwrite') + .objectStore('workspace'); + + // TODO: maybe we do not need to get data every time + const { updates } = (await store.get(docId)) ?? { updates: [] }; + let rows: UpdateMessage[] = [ + ...updates, + { timestamp: Date.now(), update: data }, + ]; + if (this.mergeCount && rows.length >= this.mergeCount) { + const merged = mergeUpdates(rows.map(({ update }) => update)); + rows = [{ timestamp: Date.now(), update: merged }]; + } + await store.put({ + id: docId, + updates: rows, + }); + this.channel.postMessage({ + type: 'db-updated', + payload: { docId, update: data }, + } satisfies ChannelMessage); + } + async subscribe(cb: (docId: string, data: Uint8Array) => void) { + function onMessage(event: MessageEvent) { + const { type, payload } = event.data; + if (type === 'db-updated') { + const { docId, update } = payload; + cb(docId, update); } - - const { updates } = data; - const update = mergeUpdates(updates.map(({ update }) => update)); - - const diff = state.length ? diffUpdate(update, state) : update; - - return { data: diff, state: encodeStateVectorFromUpdate(update) }; - }, - async push(docId, update) { - const db = await getDb(); - const store = db - .transaction('workspace', 'readwrite') - .objectStore('workspace'); - - // TODO: maybe we do not need to get data every time - const { updates } = (await store.get(docId)) ?? { updates: [] }; - let rows: UpdateMessage[] = [ - ...updates, - { timestamp: Date.now(), update }, - ]; - if (mergeCount && rows.length >= mergeCount) { - const merged = mergeUpdates(rows.map(({ update }) => update)); - rows = [{ timestamp: Date.now(), update: merged }]; - } - await store.put({ - id: docId, - updates: rows, - }); - channel.postMessage({ - type: 'db-updated', - payload: { docId, update }, - } satisfies ChannelMessage); - }, - async subscribe(cb, _disconnect) { - function onMessage(event: MessageEvent) { - const { type, payload } = event.data; - if (type === 'db-updated') { - const { docId, update } = payload; - cb(docId, update); - } - } - channel.addEventListener('message', onMessage); - return () => { - channel.removeEventListener('message', onMessage); - }; - }, - }; + } + this.channel.addEventListener('message', onMessage); + return () => { + this.channel.removeEventListener('message', onMessage); + }; + } } diff --git a/packages/frontend/workspace-impl/src/local/sync-sqlite.ts b/packages/frontend/workspace-impl/src/local/sync-sqlite.ts index a0f3b99c93..5372ca9439 100644 --- a/packages/frontend/workspace-impl/src/local/sync-sqlite.ts +++ b/packages/frontend/workspace-impl/src/local/sync-sqlite.ts @@ -1,44 +1,46 @@ import { apis } from '@affine/electron-api'; -import type { SyncStorage } from '@affine/workspace'; +import { type SyncStorage } from '@toeverything/infra'; import { encodeStateVectorFromUpdate } from 'yjs'; -export function createSQLiteStorage(workspaceId: string): SyncStorage { - if (!apis?.db) { - throw new Error('sqlite datasource is not available'); +export class SQLiteSyncStorage implements SyncStorage { + name = 'sqlite'; + constructor(private readonly workspaceId: string) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } } - return { - name: 'sqlite', - async pull(docId, _state) { - if (!apis?.db) { - throw new Error('sqlite datasource is not available'); - } - const update = await apis.db.getDocAsUpdates( - workspaceId, - workspaceId === docId ? undefined : docId - ); + async pull(docId: string, _state: Uint8Array) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + const update = await apis.db.getDocAsUpdates( + this.workspaceId, + this.workspaceId === docId ? undefined : docId + ); - if (update) { - return { - data: update, - state: encodeStateVectorFromUpdate(update), - }; - } + if (update) { + return { + data: update, + state: encodeStateVectorFromUpdate(update), + }; + } - return null; - }, - async push(docId, data) { - if (!apis?.db) { - throw new Error('sqlite datasource is not available'); - } - return apis.db.applyDocUpdate( - workspaceId, - data, - workspaceId === docId ? undefined : docId - ); - }, - async subscribe(_cb, _disconnect) { - return () => {}; - }, - }; + return null; + } + + async push(docId: string, data: Uint8Array) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.applyDocUpdate( + this.workspaceId, + data, + this.workspaceId === docId ? undefined : docId + ); + } + + async subscribe() { + return () => {}; + } } diff --git a/packages/frontend/workspace-impl/src/local/sync.ts b/packages/frontend/workspace-impl/src/local/sync.ts deleted file mode 100644 index 4b58c4e45b..0000000000 --- a/packages/frontend/workspace-impl/src/local/sync.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createIndexedDBStorage } from './sync-indexeddb'; -import { createSQLiteStorage } from './sync-sqlite'; - -export const createLocalStorage = (workspaceId: string) => - environment.isDesktop - ? createSQLiteStorage(workspaceId) - : createIndexedDBStorage(workspaceId); diff --git a/packages/frontend/workspace-impl/src/local/workspace-factory.ts b/packages/frontend/workspace-impl/src/local/workspace-factory.ts index d143924142..93f983620c 100644 --- a/packages/frontend/workspace-impl/src/local/workspace-factory.ts +++ b/packages/frontend/workspace-impl/src/local/workspace-factory.ts @@ -1,54 +1,50 @@ -import { setupEditorFlags } from '@affine/env/global'; -import type { WorkspaceFactory } from '@affine/workspace'; -import { WorkspaceEngine } from '@affine/workspace'; -import { BlobEngine } from '@affine/workspace'; -import { SyncEngine } from '@affine/workspace'; -import { globalBlockSuiteSchema } from '@affine/workspace'; -import { Workspace } from '@affine/workspace'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { nanoid } from 'nanoid'; +import type { ServiceCollection, WorkspaceFactory } from '@toeverything/infra'; +import { + AwarenessContext, + AwarenessProvider, + LocalBlobStorage, + LocalSyncStorage, + RemoteBlobStorage, + WorkspaceIdContext, + WorkspaceScope, +} from '@toeverything/infra'; -import { createBroadcastChannelAwarenessProvider } from './awareness'; -import { createLocalBlobStorage } from './blob'; -import { createStaticBlobStorage } from './blob-static'; -import { createLocalStorage } from './sync'; +import { BroadcastChannelAwarenessProvider } from './awareness'; +import { IndexedDBBlobStorage } from './blob-indexeddb'; +import { SQLiteBlobStorage } from './blob-sqlite'; +import { StaticBlobStorage } from './blob-static'; +import { IndexedDBSyncStorage } from './sync-indexeddb'; +import { SQLiteSyncStorage } from './sync-sqlite'; -export const localWorkspaceFactory: WorkspaceFactory = { - name: 'local', - openWorkspace(metadata) { - const blobEngine = new BlobEngine(createLocalBlobStorage(metadata.id), [ - createStaticBlobStorage(), - ]); - const bs = new BlockSuiteWorkspace({ - id: metadata.id, - blobStorages: [ - () => ({ - crud: blobEngine, - }), - ], - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - }); - const syncEngine = new SyncEngine( - bs.doc, - createLocalStorage(metadata.id), - [] - ); - const awarenessProvider = createBroadcastChannelAwarenessProvider( - metadata.id, - bs.awarenessStore.awareness - ); - const engine = new WorkspaceEngine(blobEngine, syncEngine, [ - awarenessProvider, - ]); +export class LocalWorkspaceFactory implements WorkspaceFactory { + name = 'local'; + configureWorkspace(services: ServiceCollection): void { + if (environment.isDesktop) { + services + .scope(WorkspaceScope) + .addImpl(LocalBlobStorage, SQLiteBlobStorage, [WorkspaceIdContext]) + .addImpl(LocalSyncStorage, SQLiteSyncStorage, [WorkspaceIdContext]); + } else { + services + .scope(WorkspaceScope) + .addImpl(LocalBlobStorage, IndexedDBBlobStorage, [WorkspaceIdContext]) + .addImpl(LocalSyncStorage, IndexedDBSyncStorage, [WorkspaceIdContext]); + } - setupEditorFlags(bs); - - return new Workspace(metadata, engine, bs); - }, - async getWorkspaceBlob(id, blobKey) { - const blobStorage = createLocalBlobStorage(id); + services + .scope(WorkspaceScope) + .addImpl(RemoteBlobStorage('static'), StaticBlobStorage) + .addImpl( + AwarenessProvider('broadcast-channel'), + BroadcastChannelAwarenessProvider, + [WorkspaceIdContext, AwarenessContext] + ); + } + async getWorkspaceBlob(id: string, blobKey: string): Promise { + const blobStorage = environment.isDesktop + ? new SQLiteBlobStorage(id) + : new IndexedDBBlobStorage(id); return await blobStorage.get(blobKey); - }, -}; + } +} diff --git a/packages/frontend/workspace-impl/src/utils/merge-updates.ts b/packages/frontend/workspace-impl/src/utils/merge-updates.ts deleted file mode 100644 index e3c8a4a06a..0000000000 --- a/packages/frontend/workspace-impl/src/utils/merge-updates.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'; - -export function mergeUpdates(updates: Uint8Array[]) { - if (updates.length === 0) { - return new Uint8Array(); - } - if (updates.length === 1) { - return updates[0]; - } - const doc = new Doc(); - doc.transact(() => { - updates.forEach(update => { - applyUpdate(doc, update); - }); - }); - return encodeStateAsUpdate(doc); -} diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx index 161352956b..9c5b0ade89 100644 --- a/tests/storybook/.storybook/preview.tsx +++ b/tests/storybook/.storybook/preview.tsx @@ -8,10 +8,8 @@ import MockSessionContext, { import { ThemeProvider, useTheme } from 'next-themes'; import { useDarkMode } from 'storybook-dark-mode'; import { AffineContext } from '@affine/component/context'; -import { workspaceManager } from '@affine/workspace-impl'; import useSWR from 'swr'; import type { Decorator } from '@storybook/react'; -import { createStore } from 'jotai/vanilla'; import { _setCurrentStore } from '@toeverything/infra/atom'; import { setupGlobal, type Environment } from '@affine/env/global'; @@ -19,7 +17,16 @@ import type { Preview } from '@storybook/react'; import { useLayoutEffect, useRef } from 'react'; import { setup } from '@affine/core/bootstrap/setup'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { currentWorkspaceAtom } from '@affine/core/modules/workspace'; +import { ServiceCollection } from '@toeverything/infra/di'; +import { + WorkspaceManager, + configureInfraServices, + configureTestingInfraServices, +} from '@toeverything/infra'; +import { CurrentWorkspaceService } from '@affine/core/modules/workspace'; +import { configureBusinessServices } from '@affine/core/modules/services'; +import { createStore } from 'jotai'; +import { GlobalScopeProvider } from '@affine/core/modules/infra-web/global-scope'; setupGlobal(); export const parameters = { @@ -112,29 +119,42 @@ window.localStorage.setItem( '{"onBoarding":false, "dismissWorkspaceGuideModal":true}' ); +const services = new ServiceCollection(); + +configureInfraServices(services); +configureTestingInfraServices(services); +configureBusinessServices(services); + +const provider = services.provider(); + const store = createStore(); _setCurrentStore(store); setup(); -workspaceManager + +provider + .get(WorkspaceManager) .createWorkspace(WorkspaceFlavour.LOCAL, async w => { w.meta.setName('test-workspace'); w.meta.writeVersion(w); }) - .then(id => { - store.set( - currentWorkspaceAtom, - workspaceManager.use({ flavour: WorkspaceFlavour.LOCAL, id }).workspace + .then(workspaceMetadata => { + const currentWorkspace = provider.get(CurrentWorkspaceService); + const workspaceManager = provider.get(WorkspaceManager); + currentWorkspace.openWorkspace( + workspaceManager.open(workspaceMetadata).workspace ); }); const withContextDecorator: Decorator = (Story, context) => { return ( - - - - - - + + + + + + + + ); }; diff --git a/tests/storybook/src/stories/image-preview-modal.stories.tsx b/tests/storybook/src/stories/image-preview-modal.stories.tsx index b0a017eed1..2df6709b9c 100644 --- a/tests/storybook/src/stories/image-preview-modal.stories.tsx +++ b/tests/storybook/src/stories/image-preview-modal.stories.tsx @@ -1,10 +1,9 @@ import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; import { ImagePreviewModal } from '@affine/core/components/image-preview'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import type { Page } from '@blocksuite/store'; import type { Meta } from '@storybook/react'; +import { useService, Workspace } from '@toeverything/infra'; import { initEmptyPage } from '@toeverything/infra/blocksuite'; -import { useAtomValue } from 'jotai'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -14,7 +13,7 @@ export default { } satisfies Meta; export const Default = () => { - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const [page, setPage] = useState(null); diff --git a/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx b/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx index ec71d437b6..20870fd266 100644 --- a/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx +++ b/tests/storybook/src/stories/quick-search/quick-search-main.stories.tsx @@ -5,14 +5,11 @@ import { } from '@affine/core/commands'; import { CMDKQuickSearchModal } from '@affine/core/components/pure/cmdk'; import { HighlightLabel } from '@affine/core/components/pure/cmdk/highlight'; -import { useWorkspace } from '@affine/core/hooks/use-workspace'; -import { currentWorkspaceAtom } from '@affine/core/modules/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { Page } from '@blocksuite/store'; import type { Meta, StoryFn } from '@storybook/react'; import { useStore } from 'jotai'; -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { withRouter } from 'storybook-addon-react-router-v6'; export default { @@ -79,20 +76,7 @@ function useRegisterCommands() { }, [store, t]); } -function usePrepareWorkspace() { - const workspaceId = 'test-workspace'; - const store = useStore(); - const workspace = useWorkspace({ - id: workspaceId, - flavour: WorkspaceFlavour.LOCAL, - }); - useLayoutEffect(() => { - store.set(currentWorkspaceAtom, workspace); - }, [store, workspace]); -} - export const CMDKStoryWithCommands: StoryFn = () => { - usePrepareWorkspace(); useRegisterCommands(); return ; diff --git a/tests/storybook/src/stories/share-menu.stories.tsx b/tests/storybook/src/stories/share-menu.stories.tsx index 307caaf1be..ffdf3f2b2c 100644 --- a/tests/storybook/src/stories/share-menu.stories.tsx +++ b/tests/storybook/src/stories/share-menu.stories.tsx @@ -1,13 +1,13 @@ import { toast } from '@affine/component'; import { PublicLinkDisableModal } from '@affine/component/disable-public-link'; import { ShareMenu } from '@affine/core/components/affine/share-page-modal/share-menu'; -import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { type Page } from '@blocksuite/store'; import { expect } from '@storybook/jest'; import type { Meta, StoryFn } from '@storybook/react'; +import { Workspace } from '@toeverything/infra'; import { initEmptyPage } from '@toeverything/infra/blocksuite'; -import { useAtomValue } from 'jotai'; +import { useService } from '@toeverything/infra/di'; import { nanoid } from 'nanoid'; import { useEffect, useState } from 'react'; @@ -24,7 +24,7 @@ async function unimplemented() { } export const Basic: StoryFn = () => { - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const [page, setPage] = useState(null); @@ -66,7 +66,7 @@ Basic.play = async ({ canvasElement }) => { }; export const AffineBasic: StoryFn = () => { - const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspace = useService(Workspace); const [page, setPage] = useState(null); diff --git a/tests/storybook/src/stories/workspace-list.stories.tsx b/tests/storybook/src/stories/workspace-list.stories.tsx index 58094129ff..07338f0dea 100644 --- a/tests/storybook/src/stories/workspace-list.stories.tsx +++ b/tests/storybook/src/stories/workspace-list.stories.tsx @@ -1,8 +1,8 @@ import type { WorkspaceListProps } from '@affine/component/workspace-list'; import { WorkspaceList } from '@affine/component/workspace-list'; -import { workspaceListAtom } from '@affine/core/modules/workspace'; import type { Meta } from '@storybook/react'; -import { useAtomValue } from 'jotai'; +import { WorkspaceManager } from '@toeverything/infra'; +import { useLiveData, useService } from '@toeverything/infra'; export default { title: 'AFFiNE/WorkspaceList', @@ -13,7 +13,7 @@ export default { } satisfies Meta; export const Default = () => { - const list = useAtomValue(workspaceListAtom); + const list = useLiveData(useService(WorkspaceManager).list.workspaceList); return (