From fe2851d3e9eec63c87190f949d7f8e99514ef2ea Mon Sep 17 00:00:00 2001 From: EYHN Date: Fri, 15 Dec 2023 07:20:50 +0000 Subject: [PATCH] refactor: workspace manager (#5060) --- packages/common/env/src/global.ts | 10 + packages/common/env/src/workspace.ts | 134 +------- .../infra/src/__internal__/workspace.ts | 79 ----- .../infra/src/__tests__/blocksuite-atom.ts | 55 ---- packages/common/infra/src/atom/workspace.ts | 11 - packages/common/infra/src/blocksuite/index.ts | 8 +- .../src/blocksuite/initialization/index.ts | 16 +- .../infra/src/blocksuite/migration/blob.ts | 15 - .../src/blocksuite/migration/blocksuite.ts | 76 ++++- .../infra/src/blocksuite/migration/fixing.ts | 81 ++--- .../infra/src/blocksuite/migration/subdoc.ts | 15 +- .../src/blocksuite/migration/workspace.ts | 73 ++--- packages/common/infra/vite.config.ts | 4 - .../components/card/workspace-card/index.tsx | 39 +-- .../__tests__/use-all-page-setting.spec.ts | 16 +- .../page-list/use-collection-manager.ts | 21 +- .../page-list/view/collection-list.tsx | 16 +- .../page-list/view/collection-operations.tsx | 4 +- .../page-list/view/create-collection.tsx | 11 +- .../view/edit-collection/edit-collection.tsx | 10 +- .../view/save-as-collection-button.tsx | 2 +- .../components/page-list/view/use-action.tsx | 4 +- .../page-list/view/use-edit-collection.tsx | 8 +- .../src/components/workspace-list/index.tsx | 22 +- packages/frontend/core/package.json | 2 + .../frontend/core/src/_plugin/index.test.tsx | 2 +- .../frontend/core/src/adapters/cloud/ui.tsx | 29 +- .../core/src/adapters/local/index.tsx | 66 +--- .../core/src/adapters/public-cloud/ui.tsx | 11 - .../frontend/core/src/adapters/workspace.ts | 42 +-- packages/frontend/core/src/app.tsx | 10 +- .../frontend/core/src/atoms/collections.ts | 120 +++---- packages/frontend/core/src/atoms/index.ts | 6 +- .../core/src/bootstrap/first-app-data.ts | 43 +++ .../core/src/bootstrap/plugins/setup.ts | 7 +- packages/frontend/core/src/bootstrap/setup.ts | 47 +-- .../core/src/commands/affine-help.tsx | 2 +- .../core/src/commands/affine-navigation.tsx | 1 - .../components/adapter-worksapce-wrapper.tsx | 5 +- .../error-basic/info-logger.tsx | 18 +- .../affine/auth/user-plan-button.tsx | 1 - .../src/components/affine/awareness/index.tsx | 25 +- .../affine/create-workspace-modal/index.tsx | 260 +++------------ .../delete-leave-workspace/delete/index.tsx | 16 +- .../delete-leave-workspace/index.tsx | 80 +++-- .../enable-cloud.tsx | 90 ++++++ .../new-workspace-setting-detail/export.tsx | 68 +--- .../new-workspace-setting-detail/index.tsx | 59 ++-- .../new-workspace-setting-detail/labels.tsx | 17 +- .../new-workspace-setting-detail/members.tsx | 9 +- .../new-workspace-setting-detail/profile.tsx | 113 +++++-- .../new-workspace-setting-detail/publish.tsx | 169 ---------- .../new-workspace-setting-detail/storage.tsx | 8 +- .../new-workspace-setting-detail/types.ts | 18 +- .../affine/page-history-modal/data.ts | 24 +- .../page-history-modal/history-modal.tsx | 4 +- .../setting-modal/account-setting/index.tsx | 3 +- .../general-setting/billing/index.tsx | 1 - .../components/affine/setting-modal/index.tsx | 24 +- .../setting-modal/setting-sidebar/index.tsx | 53 ++-- .../setting-modal/workspace-setting/index.tsx | 119 +------ .../affine/share-page-modal/index.tsx | 34 +- .../share-menu/share-export.tsx | 5 +- .../share-menu/share-menu.tsx | 24 +- .../share-menu/share-page.tsx | 8 +- .../block-suite-header-title/index.tsx | 20 +- .../operation-menu.tsx | 16 +- .../block-suite-mode-switch/index.tsx | 4 +- .../src/components/page-detail-editor.tsx | 10 +- .../core/src/components/pure/cmdk/data.tsx | 43 ++- .../src/components/pure/help-island/index.tsx | 1 - .../pure/trash-page-footer/index.tsx | 6 +- .../collections/collections-list.tsx | 17 +- .../user-with-workspace-list/index.tsx | 21 +- .../workspace-list/index.tsx | 79 ++--- .../workspace-card/index.tsx | 52 ++- .../src/components/root-app-sidebar/index.tsx | 9 +- .../frontend/core/src/components/top-tip.tsx | 33 +- .../workspace-upgrade/upgrade-hooks.ts | 122 ------- .../components/workspace-upgrade/upgrade.tsx | 112 ++++--- .../hooks/affine/use-all-page-list-config.tsx | 5 +- .../hooks/affine/use-is-workspace-owner.ts | 24 +- .../src/hooks/affine/use-leave-workspace.ts | 26 -- ...se-register-blocksuite-editor-commands.tsx | 6 +- .../core/src/hooks/affine/use-sidebar-drag.ts | 5 +- .../src/hooks/current/use-current-page.ts | 14 +- .../hooks/current/use-current-sync-engine.ts | 33 -- .../hooks/current/use-current-workspace.ts | 54 ---- .../hooks/root/use-on-transform-workspace.ts | 90 ------ .../hooks/use-register-workspace-commands.ts | 6 +- .../core/src/hooks/use-workspace-blob.ts | 58 ---- .../frontend/core/src/hooks/use-workspace.ts | 53 ---- .../frontend/core/src/hooks/use-workspaces.ts | 122 ------- packages/frontend/core/src/index.tsx | 21 +- .../core/src/layouts/workspace-layout.tsx | 108 ++----- packages/frontend/core/src/pages/404.tsx | 4 +- packages/frontend/core/src/pages/index.tsx | 70 ++-- packages/frontend/core/src/pages/invite.tsx | 5 +- .../src/pages/share/share-detail-page.tsx | 60 +++- .../core/src/pages/share/share-header.tsx | 13 +- .../workspace/all-page/all-page-filter.tsx | 22 +- .../src/pages/workspace/all-page/all-page.tsx | 80 ++--- .../core/src/pages/workspace/collection.tsx | 8 +- .../detail-page/detail-page-header.tsx | 9 +- .../workspace/detail-page/detail-page.tsx | 69 ++-- .../core/src/pages/workspace/index.tsx | 142 ++++----- .../core/src/pages/workspace/trash-page.tsx | 5 +- .../core/src/providers/modal-provider.tsx | 63 ++-- .../core/src/providers/session-provider.tsx | 9 +- packages/frontend/core/src/shared/index.ts | 3 - .../frontend/core/src/utils/cloud-utils.tsx | 11 +- .../frontend/core/src/utils/reduce-image.ts | 42 +++ .../electron/src/helper/db/migration.ts | 6 +- packages/frontend/hooks/package.json | 2 +- .../hooks/src/__tests__/index.spec.ts | 16 - .../hooks/src/use-block-suite-page-meta.ts | 1 + .../use-block-suite-workspace-avatar-url.ts | 102 ------ .../src/use-block-suite-workspace-name.ts | 37 --- .../frontend/hooks/src/use-workspace-blob.ts | 40 +++ .../frontend/hooks/src/use-workspace-info.ts | 26 ++ .../hooks/src/use-workspace-status.ts | 34 ++ packages/frontend/hooks/src/use-workspace.ts | 28 ++ packages/frontend/hooks/tsconfig.json | 3 +- packages/frontend/workspace/package.json | 11 +- .../frontend/workspace/src/affine/crud.ts | 162 ---------- .../frontend/workspace/src/affine/download.ts | 37 --- packages/frontend/workspace/src/affine/gql.ts | 11 +- packages/frontend/workspace/src/atom.ts | 298 +++-------------- packages/frontend/workspace/src/blob/index.ts | 37 --- .../workspace/src/blob/storage/index.ts | 4 - .../index.ts => engine/awareness.ts} | 3 - .../src/{blob/engine.ts => engine/blob.ts} | 87 ++++- .../frontend/workspace/src/engine/index.ts | 74 +++++ .../sync/__tests__/engine.spec.ts | 10 +- .../sync/__tests__/peer.spec.ts | 2 +- .../sync/__tests__/test-storage.ts | 4 +- .../workspace/src/engine/sync/consts.ts | 15 + .../src/{providers => engine}/sync/engine.ts | 47 ++- .../src/{providers => engine}/sync/index.ts | 1 + .../src/{providers => engine}/sync/peer.ts | 30 +- .../index.ts => engine/sync/storage.ts} | 6 +- packages/frontend/workspace/src/factory.ts | 13 + .../frontend/workspace/src/global-schema.ts | 6 + .../index.ts => impl/cloud/awareness.ts} | 14 +- .../affine-cloud.ts => impl/cloud/blob.ts} | 9 +- .../workspace/src/impl/cloud/consts.ts | 2 + .../workspace/src/impl/cloud/index.ts | 6 + .../frontend/workspace/src/impl/cloud/list.ts | 155 +++++++++ .../cloud/sync}/batch-sync-sender.ts | 0 .../affine => impl/cloud/sync}/index.ts | 47 ++- .../src/impl/cloud/workspace-factory.ts | 77 +++++ packages/frontend/workspace/src/impl/index.ts | 2 + .../index.ts => impl/local/awareness.ts} | 5 +- .../local/blob-indexeddb.ts} | 7 +- .../sqlite.ts => impl/local/blob-sqlite.ts} | 7 +- .../static.ts => impl/local/blob-static.ts} | 9 +- .../frontend/workspace/src/impl/local/blob.ts | 10 + .../workspace/src/impl/local/consts.ts | 3 + .../workspace/src/impl/local/index.ts | 11 + .../frontend/workspace/src/impl/local/list.ts | 129 ++++++++ .../index.ts => impl/local/sync-indexeddb.ts} | 31 +- .../index.ts => impl/local/sync-sqlite.ts} | 4 +- .../frontend/workspace/src/impl/local/sync.ts | 7 + .../src/impl/local/workspace-factory.ts | 54 ++++ packages/frontend/workspace/src/index.ts | 29 ++ packages/frontend/workspace/src/list/cache.ts | 21 ++ packages/frontend/workspace/src/list/index.ts | 300 ++++++++++++++++++ .../workspace/src/list/information.ts | 97 ++++++ .../src/local/__tests__/crud.spec.ts | 71 ----- packages/frontend/workspace/src/local/crud.ts | 112 ------- packages/frontend/workspace/src/manager.ts | 185 +++++++++++ .../frontend/workspace/src/manager/index.ts | 142 --------- packages/frontend/workspace/src/metadata.ts | 3 + packages/frontend/workspace/src/pool.ts | 86 +++++ .../frontend/workspace/src/providers/index.ts | 142 --------- .../workspace/src/providers/sync/consts.ts | 7 - .../frontend/workspace/src/upgrade/index.ts | 146 +++++++++ .../utils/__tests__/async-queue.spec.ts | 0 .../__tests__/buffer-to-blob.spec.ts} | 2 +- .../utils/__tests__/throw-if-aborted.spec.ts | 0 .../src/{providers => }/utils/affine-io.ts | 0 .../src/{providers => }/utils/async-queue.ts | 0 .../src/{providers => }/utils/base64.ts | 0 .../{blob/util.ts => utils/buffer-to-blob.ts} | 0 .../workspace/src/utils/merge-updates.ts | 17 + .../{providers => }/utils/throw-if-aborted.ts | 2 + packages/frontend/workspace/src/workspace.ts | 137 ++++++++ packages/frontend/workspace/tsconfig.json | 3 - tests/affine-cloud/e2e/collaboration.spec.ts | 8 +- tests/affine-desktop/e2e/basic.spec.ts | 16 +- tests/affine-desktop/e2e/workspace.spec.ts | 10 +- .../e2e/local-first-avatar.spec.ts | 2 +- .../e2e/local-first-delete-page.spec.ts | 6 +- .../e2e/local-first-delete-workspace.spec.ts | 2 +- .../e2e/local-first-export-page.spec.ts | 4 +- .../e2e/local-first-favorite-page.spec.ts | 4 +- .../e2e/local-first-favorites-items.spec.ts | 6 +- .../e2e/local-first-new-page.spec.ts | 4 +- .../e2e/local-first-openpage-newtab.spec.ts | 2 +- .../e2e/local-first-restore-page.spec.ts | 2 +- .../e2e/local-first-show-delete-modal.spec.ts | 4 +- .../e2e/local-first-trash-page.spec.ts | 2 +- .../e2e/local-first-workspace-list.spec.ts | 8 +- .../e2e/local-first-workspace.spec.ts | 2 +- tests/affine-local/e2e/router.spec.ts | 5 +- tests/affine-migration/e2e/basic.spec.ts | 13 - tests/kit/playwright.ts | 3 +- tests/kit/utils/cloud.ts | 3 +- tests/storybook/.storybook/preview.tsx | 73 ++--- tests/storybook/package.json | 1 + tests/storybook/src/stories/card.stories.tsx | 8 - .../stories/image-preview-modal.stories.tsx | 74 +++-- .../quick-search-main.stories.tsx | 23 +- .../src/stories/share-menu.stories.tsx | 99 +++--- .../src/stories/workspace-list.stories.tsx | 47 +-- tests/storybook/tsconfig.json | 5 +- yarn.lock | 11 +- 217 files changed, 3605 insertions(+), 4244 deletions(-) delete mode 100644 packages/common/infra/src/__internal__/workspace.ts delete mode 100644 packages/common/infra/src/__tests__/blocksuite-atom.ts delete mode 100644 packages/common/infra/src/blocksuite/migration/blob.ts delete mode 100644 packages/frontend/core/src/adapters/public-cloud/ui.tsx create mode 100644 packages/frontend/core/src/bootstrap/first-app-data.ts create mode 100644 packages/frontend/core/src/components/affine/new-workspace-setting-detail/enable-cloud.tsx delete mode 100644 packages/frontend/core/src/components/affine/new-workspace-setting-detail/publish.tsx delete mode 100644 packages/frontend/core/src/components/workspace-upgrade/upgrade-hooks.ts delete mode 100644 packages/frontend/core/src/hooks/affine/use-leave-workspace.ts delete mode 100644 packages/frontend/core/src/hooks/current/use-current-sync-engine.ts delete mode 100644 packages/frontend/core/src/hooks/current/use-current-workspace.ts delete mode 100644 packages/frontend/core/src/hooks/root/use-on-transform-workspace.ts delete mode 100644 packages/frontend/core/src/hooks/use-workspace-blob.ts delete mode 100644 packages/frontend/core/src/hooks/use-workspace.ts delete mode 100644 packages/frontend/core/src/hooks/use-workspaces.ts create mode 100644 packages/frontend/core/src/utils/reduce-image.ts delete mode 100644 packages/frontend/hooks/src/use-block-suite-workspace-avatar-url.ts delete mode 100644 packages/frontend/hooks/src/use-block-suite-workspace-name.ts create mode 100644 packages/frontend/hooks/src/use-workspace-blob.ts create mode 100644 packages/frontend/hooks/src/use-workspace-info.ts create mode 100644 packages/frontend/hooks/src/use-workspace-status.ts create mode 100644 packages/frontend/hooks/src/use-workspace.ts delete mode 100644 packages/frontend/workspace/src/affine/crud.ts delete mode 100644 packages/frontend/workspace/src/affine/download.ts delete mode 100644 packages/frontend/workspace/src/blob/index.ts delete mode 100644 packages/frontend/workspace/src/blob/storage/index.ts rename packages/frontend/workspace/src/{providers/awareness/index.ts => engine/awareness.ts} (55%) rename packages/frontend/workspace/src/{blob/engine.ts => engine/blob.ts} (65%) create mode 100644 packages/frontend/workspace/src/engine/index.ts rename packages/frontend/workspace/src/{providers => engine}/sync/__tests__/engine.spec.ts (96%) rename packages/frontend/workspace/src/{providers => engine}/sync/__tests__/peer.spec.ts (96%) rename packages/frontend/workspace/src/{providers => engine}/sync/__tests__/test-storage.ts (91%) create mode 100644 packages/frontend/workspace/src/engine/sync/consts.ts rename packages/frontend/workspace/src/{providers => engine}/sync/engine.ts (85%) rename packages/frontend/workspace/src/{providers => engine}/sync/index.ts (93%) rename packages/frontend/workspace/src/{providers => engine}/sync/peer.ts (95%) rename packages/frontend/workspace/src/{providers/storage/index.ts => engine/sync/storage.ts} (84%) create mode 100644 packages/frontend/workspace/src/factory.ts create mode 100644 packages/frontend/workspace/src/global-schema.ts rename packages/frontend/workspace/src/{providers/awareness/affine/index.ts => impl/cloud/awareness.ts} (89%) rename packages/frontend/workspace/src/{blob/storage/affine-cloud.ts => impl/cloud/blob.ts} (88%) create mode 100644 packages/frontend/workspace/src/impl/cloud/consts.ts create mode 100644 packages/frontend/workspace/src/impl/cloud/index.ts create mode 100644 packages/frontend/workspace/src/impl/cloud/list.ts rename packages/frontend/workspace/src/{providers/storage/affine => impl/cloud/sync}/batch-sync-sender.ts (100%) rename packages/frontend/workspace/src/{providers/storage/affine => impl/cloud/sync}/index.ts (77%) create mode 100644 packages/frontend/workspace/src/impl/cloud/workspace-factory.ts create mode 100644 packages/frontend/workspace/src/impl/index.ts rename packages/frontend/workspace/src/{providers/awareness/broadcast-channel/index.ts => impl/local/awareness.ts} (92%) rename packages/frontend/workspace/src/{blob/storage/indexeddb.ts => impl/local/blob-indexeddb.ts} (84%) rename packages/frontend/workspace/src/{blob/storage/sqlite.ts => impl/local/blob-sqlite.ts} (83%) rename packages/frontend/workspace/src/{blob/storage/static.ts => impl/local/blob-static.ts} (94%) create mode 100644 packages/frontend/workspace/src/impl/local/blob.ts create mode 100644 packages/frontend/workspace/src/impl/local/consts.ts create mode 100644 packages/frontend/workspace/src/impl/local/index.ts create mode 100644 packages/frontend/workspace/src/impl/local/list.ts rename packages/frontend/workspace/src/{providers/storage/indexeddb/index.ts => impl/local/sync-indexeddb.ts} (84%) rename packages/frontend/workspace/src/{providers/storage/sqlite/index.ts => impl/local/sync-sqlite.ts} (85%) create mode 100644 packages/frontend/workspace/src/impl/local/sync.ts create mode 100644 packages/frontend/workspace/src/impl/local/workspace-factory.ts create mode 100644 packages/frontend/workspace/src/index.ts create mode 100644 packages/frontend/workspace/src/list/cache.ts create mode 100644 packages/frontend/workspace/src/list/index.ts create mode 100644 packages/frontend/workspace/src/list/information.ts delete mode 100644 packages/frontend/workspace/src/local/__tests__/crud.spec.ts delete mode 100644 packages/frontend/workspace/src/local/crud.ts create mode 100644 packages/frontend/workspace/src/manager.ts delete mode 100644 packages/frontend/workspace/src/manager/index.ts create mode 100644 packages/frontend/workspace/src/metadata.ts create mode 100644 packages/frontend/workspace/src/pool.ts delete mode 100644 packages/frontend/workspace/src/providers/index.ts delete mode 100644 packages/frontend/workspace/src/providers/sync/consts.ts create mode 100644 packages/frontend/workspace/src/upgrade/index.ts rename packages/frontend/workspace/src/{providers => }/utils/__tests__/async-queue.spec.ts (100%) rename packages/frontend/workspace/src/{blob/__tests__/util.spec.ts => utils/__tests__/buffer-to-blob.spec.ts} (87%) rename packages/frontend/workspace/src/{providers => }/utils/__tests__/throw-if-aborted.spec.ts (100%) rename packages/frontend/workspace/src/{providers => }/utils/affine-io.ts (100%) rename packages/frontend/workspace/src/{providers => }/utils/async-queue.ts (100%) rename packages/frontend/workspace/src/{providers => }/utils/base64.ts (100%) rename packages/frontend/workspace/src/{blob/util.ts => utils/buffer-to-blob.ts} (100%) create mode 100644 packages/frontend/workspace/src/utils/merge-updates.ts rename packages/frontend/workspace/src/{providers => }/utils/throw-if-aborted.ts (82%) create mode 100644 packages/frontend/workspace/src/workspace.ts diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 6d37809326..87dc1b3f63 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -1,5 +1,6 @@ /// import { assertEquals } from '@blocksuite/global/utils'; +import type { Workspace } from '@blocksuite/store'; import { z } from 'zod'; import { isDesktop, isServer } from './constant.js'; @@ -149,3 +150,12 @@ export function setupGlobal() { globalThis.$AFFINE_SETUP = true; } + +export function setupEditorFlags(workspace: Workspace) { + Object.entries(runtimeConfig.editorFlags).forEach(([key, value]) => { + workspace.awarenessStore.setFlag( + key as keyof BlockSuiteFeatureFlags, + value + ); + }); +} diff --git a/packages/common/env/src/workspace.ts b/packages/common/env/src/workspace.ts index 4ac9e7d4d3..9f99f5c077 100644 --- a/packages/common/env/src/workspace.ts +++ b/packages/common/env/src/workspace.ts @@ -1,10 +1,4 @@ -import type { - ActiveDocProvider, - PassiveDocProvider, - Workspace as BlockSuiteWorkspace, -} from '@blocksuite/store'; import type { PropsWithChildren, ReactNode } from 'react'; -import type { DataSourceAdapter } from 'y-provider'; export enum WorkspaceSubPath { ALL = 'all', @@ -14,73 +8,6 @@ export enum WorkspaceSubPath { SHARED = 'shared', } -export interface AffineDownloadProvider extends PassiveDocProvider { - flavour: 'affine-download'; -} - -/** - * Download the first binary from local IndexedDB - */ -export interface BroadCastChannelProvider extends PassiveDocProvider { - flavour: 'broadcast-channel'; -} - -/** - * Long polling provider with local IndexedDB - */ -export interface LocalIndexedDBBackgroundProvider - extends DataSourceAdapter, - PassiveDocProvider { - flavour: 'local-indexeddb-background'; -} - -export interface LocalIndexedDBDownloadProvider extends ActiveDocProvider { - flavour: 'local-indexeddb'; -} - -export interface SQLiteProvider extends PassiveDocProvider, DataSourceAdapter { - flavour: 'sqlite'; -} - -export interface SQLiteDBDownloadProvider extends ActiveDocProvider { - flavour: 'sqlite-download'; -} - -export interface AffineSocketIOProvider - extends PassiveDocProvider, - DataSourceAdapter { - flavour: 'affine-socket-io'; -} - -type BaseWorkspace = { - flavour: string; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -}; - -export interface AffineCloudWorkspace extends BaseWorkspace { - flavour: WorkspaceFlavour.AFFINE_CLOUD; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -} - -export interface LocalWorkspace extends BaseWorkspace { - flavour: WorkspaceFlavour.LOCAL; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -} - -export interface AffinePublicWorkspace extends BaseWorkspace { - flavour: WorkspaceFlavour.AFFINE_PUBLIC; - id: string; - blockSuiteWorkspace: BlockSuiteWorkspace; -} - -export type AffineOfficialWorkspace = - | AffineCloudWorkspace - | LocalWorkspace - | AffinePublicWorkspace; - export enum ReleaseType { // if workspace is not released yet, we will not show it in the workspace list UNRELEASED = 'unreleased', @@ -99,7 +26,6 @@ export enum WorkspaceFlavour { */ AFFINE_CLOUD = 'affine-cloud', LOCAL = 'local', - AFFINE_PUBLIC = 'affine-public', } export const settingPanel = { @@ -112,68 +38,30 @@ export const settingPanel = { export const settingPanelValues = Object.values(settingPanel); export type SettingPanel = (typeof settingPanel)[keyof typeof settingPanel]; -// built-in workspaces -export interface WorkspaceRegistry { - [WorkspaceFlavour.LOCAL]: LocalWorkspace; - [WorkspaceFlavour.AFFINE_PUBLIC]: AffinePublicWorkspace; - [WorkspaceFlavour.AFFINE_CLOUD]: AffineCloudWorkspace; -} - -export interface WorkspaceCRUD { - create: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise; - delete: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise; - get: (workspaceId: string) => Promise; - // not supported yet - // update: (workspace: FlavourToWorkspace[Flavour]) => Promise; - list: () => Promise; -} - -type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = { - currentWorkspaceId: string; +export type WorkspaceHeaderProps = { + rightSlot?: ReactNode; + currentEntry: + | { + subPath: WorkspaceSubPath; + } + | { + pageId: string; + }; }; -type NewSettingProps = - UIBaseProps & { - onDeleteLocalWorkspace: () => void; - onDeleteCloudWorkspace: () => void; - onLeaveWorkspace: () => void; - onTransformWorkspace: < - From extends keyof WorkspaceRegistry, - To extends keyof WorkspaceRegistry, - >( - from: From, - to: To, - workspace: WorkspaceRegistry[From] - ) => void; - }; - interface FC

{ (props: P): ReactNode; } -export interface WorkspaceUISchema { - NewSettingsDetail: FC>; +export interface WorkspaceUISchema { Provider: FC; LoginCard?: FC; } -export interface AppEvents { - // event there is no workspace - // usually used to initialize workspace adapter - 'app:init': () => string[]; - // event if you have access to workspace adapter - 'app:access': () => Promise; - 'service:start': () => void; - 'service:stop': () => void; -} - export interface WorkspaceAdapter { releaseType: ReleaseType; flavour: Flavour; // The Adapter will be loaded according to the priority loadPriority: LoadPriority; - Events: Partial; - // Fetch necessary data for the first render - CRUD: WorkspaceCRUD; - UI: WorkspaceUISchema; + UI: WorkspaceUISchema; } diff --git a/packages/common/infra/src/__internal__/workspace.ts b/packages/common/infra/src/__internal__/workspace.ts deleted file mode 100644 index e788702d84..0000000000 --- a/packages/common/infra/src/__internal__/workspace.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ActiveDocProvider, Workspace } from '@blocksuite/store'; -import type { PassiveDocProvider } from '@blocksuite/store'; -import type { Atom } from 'jotai/vanilla'; -import { atom } from 'jotai/vanilla'; -import { atomEffect } from 'jotai-effect'; - -/** - * Map: guid -> Workspace - */ -export const INTERNAL_BLOCKSUITE_HASH_MAP = new Map([]); - -const workspaceActiveAtomWeakMap = new WeakMap< - Workspace, - Atom> ->(); - -const workspaceActiveWeakMap = new WeakMap(); -const workspaceEffectAtomWeakMap = new WeakMap>(); - -export async function waitForWorkspace(workspace: Workspace) { - if (workspaceActiveWeakMap.get(workspace) !== true) { - const providers = workspace.providers.filter( - (provider): provider is ActiveDocProvider => - 'active' in provider && provider.active === true - ); - for (const provider of providers) { - provider.sync(); - // we will wait for the necessary providers to be ready - await provider.whenReady; - } - // timeout is INFINITE - workspaceActiveWeakMap.set(workspace, true); - } -} - -export function getWorkspace(id: string) { - if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) { - throw new Error('Workspace not found'); - } - return INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace; -} - -export function getBlockSuiteWorkspaceAtom( - id: string -): [workspaceAtom: Atom>, workspaceEffectAtom: Atom] { - if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) { - throw new Error('Workspace not found'); - } - const workspace = INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace; - if (!workspaceActiveAtomWeakMap.has(workspace)) { - const baseAtom = atom(async () => { - await waitForWorkspace(workspace); - return workspace; - }); - workspaceActiveAtomWeakMap.set(workspace, baseAtom); - } - if (!workspaceEffectAtomWeakMap.has(workspace)) { - const effectAtom = atomEffect(() => { - const providers = workspace.providers.filter( - (provider): provider is PassiveDocProvider => - 'passive' in provider && provider.passive === true - ); - providers.forEach(provider => { - provider.connect(); - }); - return () => { - providers.forEach(provider => { - provider.disconnect(); - }); - }; - }); - workspaceEffectAtomWeakMap.set(workspace, effectAtom); - } - - return [ - workspaceActiveAtomWeakMap.get(workspace) as Atom>, - workspaceEffectAtomWeakMap.get(workspace) as Atom, - ]; -} diff --git a/packages/common/infra/src/__tests__/blocksuite-atom.ts b/packages/common/infra/src/__tests__/blocksuite-atom.ts deleted file mode 100644 index 9644d35b89..0000000000 --- a/packages/common/infra/src/__tests__/blocksuite-atom.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { Schema, Workspace } from '@blocksuite/store'; -import { waitFor } from '@testing-library/react'; -import { getDefaultStore } from 'jotai/vanilla'; -import { expect, test, vi } from 'vitest'; - -import { - getBlockSuiteWorkspaceAtom, - INTERNAL_BLOCKSUITE_HASH_MAP, -} from '../__internal__/workspace.js'; - -test('blocksuite atom', async () => { - const sync = vi.fn(); - let connected = false; - const connect = vi.fn(() => (connected = true)); - const workspace = new Workspace({ - schema: new Schema(), - id: '1', - providerCreators: [ - () => ({ - flavour: 'fake', - active: true, - sync, - get whenReady(): Promise { - return Promise.resolve(); - }, - }), - () => ({ - flavour: 'fake-2', - passive: true, - get connected() { - return connected; - }, - connect, - disconnect: vi.fn(), - }), - ], - }); - INTERNAL_BLOCKSUITE_HASH_MAP.set('1', workspace); - - { - const [atom, effectAtom] = getBlockSuiteWorkspaceAtom('1'); - const store = getDefaultStore(); - const result = await store.get(atom); - expect(result).toBe(workspace); - expect(sync).toBeCalledTimes(1); - expect(connect).not.toHaveBeenCalled(); - - store.sub(effectAtom, vi.fn()); - await waitFor(() => expect(connect).toBeCalledTimes(1)); - expect(connected).toBe(true); - } -}); diff --git a/packages/common/infra/src/atom/workspace.ts b/packages/common/infra/src/atom/workspace.ts index c73ab5a5e7..871333c835 100644 --- a/packages/common/infra/src/atom/workspace.ts +++ b/packages/common/infra/src/atom/workspace.ts @@ -1,14 +1,3 @@ -import { assertExists } from '@blocksuite/global/utils'; -import type { Workspace } from '@blocksuite/store'; import { atom } from 'jotai'; -import { getBlockSuiteWorkspaceAtom } from '../__internal__/workspace'; - -export const currentWorkspaceIdAtom = atom(null); export const currentPageIdAtom = atom(null); -export const currentWorkspaceAtom = atom>(async get => { - const workspaceId = get(currentWorkspaceIdAtom); - assertExists(workspaceId); - const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId); - return get(currentWorkspaceAtom); -}); diff --git a/packages/common/infra/src/blocksuite/index.ts b/packages/common/infra/src/blocksuite/index.ts index a74ca60193..09196653c0 100644 --- a/packages/common/infra/src/blocksuite/index.ts +++ b/packages/common/infra/src/blocksuite/index.ts @@ -1,8 +1,10 @@ export * from './initialization'; -export * from './migration/blob'; -export { migratePages as forceUpgradePages } from './migration/blocksuite'; // campatible with electron +export { + migratePages as forceUpgradePages, + migrateGuidCompatibility, +} from './migration/blocksuite'; // campatible with electron export * from './migration/fixing'; -export { migrateToSubdoc } from './migration/subdoc'; +export { migrateToSubdoc, upgradeV1ToV2 } from './migration/subdoc'; export * from './migration/workspace'; /** diff --git a/packages/common/infra/src/blocksuite/initialization/index.ts b/packages/common/infra/src/blocksuite/initialization/index.ts index caa04c7696..e596607b75 100644 --- a/packages/common/infra/src/blocksuite/initialization/index.ts +++ b/packages/common/infra/src/blocksuite/initialization/index.ts @@ -2,12 +2,9 @@ import { assertExists } from '@blocksuite/global/utils'; import type { Page, PageMeta, Workspace } from '@blocksuite/store'; import type { createStore, WritableAtom } from 'jotai/vanilla'; import { nanoid } from 'nanoid'; +import { Map as YMap } from 'yjs'; -import { migratePages } from '../migration/blocksuite'; -import { - checkWorkspaceCompatibility, - MigrationPoint, -} from '../migration/workspace'; +import { getLatestVersions } from '../migration/blocksuite'; export async function initEmptyPage(page: Page, title?: string) { await page.load(() => { @@ -261,10 +258,11 @@ export async function buildShowcaseWorkspace( // The showcase building will create multiple pages once, and may skip the version writing. // https://github.com/toeverything/blocksuite/blob/master/packages/store/src/workspace/page.ts#L662 - const compatibilityResult = checkWorkspaceCompatibility(workspace); - if (compatibilityResult === MigrationPoint.BlockVersion) { - await migratePages(workspace.doc, workspace.schema); - } + workspace.doc.getMap('meta').set('pageVersion', 2); + const newVersions = getLatestVersions(workspace.schema); + workspace.doc + .getMap('meta') + .set('blockVersions', new YMap(Object.entries(newVersions))); Object.entries(pageMetas).forEach(([oldId, meta]) => { const newId = idMap[oldId]; diff --git a/packages/common/infra/src/blocksuite/migration/blob.ts b/packages/common/infra/src/blocksuite/migration/blob.ts deleted file mode 100644 index 45f5f10820..0000000000 --- a/packages/common/infra/src/blocksuite/migration/blob.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createIndexeddbStorage } from '@blocksuite/store'; - -export async function migrateLocalBlobStorage(from: string, to: string) { - const fromStorage = createIndexeddbStorage(from); - const toStorage = createIndexeddbStorage(to); - const keys = await fromStorage.crud.list(); - for (const key of keys) { - const value = await fromStorage.crud.get(key); - if (!value) { - console.warn('cannot find blob:', key); - continue; - } - await toStorage.crud.set(key, value); - } -} diff --git a/packages/common/infra/src/blocksuite/migration/blocksuite.ts b/packages/common/infra/src/blocksuite/migration/blocksuite.ts index ef61391512..a5e71013ea 100644 --- a/packages/common/infra/src/blocksuite/migration/blocksuite.ts +++ b/packages/common/infra/src/blocksuite/migration/blocksuite.ts @@ -1,8 +1,14 @@ import type { Schema } from '@blocksuite/store'; -import type { Doc as YDoc } from 'yjs'; -import { Map as YMap } from 'yjs'; +import type { Array as YArray } from 'yjs'; +import { + applyUpdate, + Doc as YDoc, + encodeStateAsUpdate, + Map as YMap, + transact, +} from 'yjs'; -const getLatestVersions = (schema: Schema): Record => { +export const getLatestVersions = (schema: Schema): Record => { return [...schema.flavourSchemaMap.entries()].reduce( (record, [flavour, schema]) => { record[flavour] = schema.version; @@ -28,14 +34,62 @@ export async function migratePages( // Hard code to upgrade page version to 2. // Let e2e to ensure the data version is correct. - const pageVersion = meta.get('pageVersion'); - if (typeof pageVersion !== 'number' || pageVersion < 2) { - meta.set('pageVersion', 2); - } + return transact( + rootDoc, + () => { + const pageVersion = meta.get('pageVersion'); + if (typeof pageVersion !== 'number' || pageVersion < 2) { + meta.set('pageVersion', 2); + } - const newVersions = getLatestVersions(schema); - meta.set('blockVersions', new YMap(Object.entries(newVersions))); - return Object.entries(oldVersions).some( - ([flavour, version]) => newVersions[flavour] !== version + const newVersions = getLatestVersions(schema); + meta.set('blockVersions', new YMap(Object.entries(newVersions))); + return Object.entries(oldVersions).some( + ([flavour, version]) => newVersions[flavour] !== version + ); + }, + 'migratePages', + /** + * transact as remote update, because blocksuite will skip local changes. + * https://github.com/toeverything/blocksuite/blob/9c2df3f7aa5617c050e0dccdd73e99bb67e0c0f7/packages/store/src/reactive/utils.ts#L143 + */ + false ); } + +// patch root doc's space guid compatibility issue +// +// in version 0.10, page id in spaces no longer has prefix "space:" +// The data flow for fetching a doc's updates is: +// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid` +// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId +// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces` +// - when fetching the rows of this doc using the doc id === page id, +// it will return empty since there is no updates associated with the page id +export function migrateGuidCompatibility(rootDoc: YDoc) { + const meta = rootDoc.getMap('meta') as YMap; + const pages = meta.get('pages') as YArray>; + pages?.forEach(page => { + const pageId = page.get('id') as string | undefined; + if (pageId?.includes(':')) { + // remove the prefix "space:" from page id + page.set('id', pageId.split(':').at(-1)); + } + }); + const spaces = rootDoc.getMap('spaces') as YMap; + spaces?.forEach((doc: YDoc, pageId: string) => { + if (pageId.includes(':')) { + const newPageId = pageId.split(':').at(-1) ?? pageId; + const newDoc = new YDoc(); + // clone the original doc. yjs is not happy to use the same doc instance + applyUpdate(newDoc, encodeStateAsUpdate(doc)); + newDoc.guid = doc.guid; + spaces.set(newPageId, newDoc); + // should remove the old doc, otherwise we will do it again in the next run + spaces.delete(pageId); + console.debug( + `fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}` + ); + } + }); +} diff --git a/packages/common/infra/src/blocksuite/migration/fixing.ts b/packages/common/infra/src/blocksuite/migration/fixing.ts index f46fd11c0b..5813da65da 100644 --- a/packages/common/infra/src/blocksuite/migration/fixing.ts +++ b/packages/common/infra/src/blocksuite/migration/fixing.ts @@ -1,48 +1,5 @@ -import type { Array as YArray, Map as YMap } from 'yjs'; -import { Doc as YDoc, transact } from 'yjs'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; - -// patch root doc's space guid compatibility issue -// -// in version 0.10, page id in spaces no longer has prefix "space:" -// The data flow for fetching a doc's updates is: -// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid` -// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId -// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces` -// - when fetching the rows of this doc using the doc id === page id, -// it will return empty since there is no updates associated with the page id -export function guidCompatibilityFix(rootDoc: YDoc) { - let changed = false; - transact(rootDoc, () => { - const meta = rootDoc.getMap('meta') as YMap; - const pages = meta.get('pages') as YArray>; - pages?.forEach(page => { - const pageId = page.get('id') as string | undefined; - if (pageId?.includes(':')) { - // remove the prefix "space:" from page id - page.set('id', pageId.split(':').at(-1)); - } - }); - const spaces = rootDoc.getMap('spaces') as YMap; - spaces?.forEach((doc: YDoc, pageId: string) => { - if (pageId.includes(':')) { - const newPageId = pageId.split(':').at(-1) ?? pageId; - const newDoc = new YDoc(); - // clone the original doc. yjs is not happy to use the same doc instance - applyUpdate(newDoc, encodeStateAsUpdate(doc)); - newDoc.guid = doc.guid; - spaces.set(newPageId, newDoc); - // should remove the old doc, otherwise we will do it again in the next run - spaces.delete(pageId); - changed = true; - console.debug( - `fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}` - ); - } - }); - }); - return changed; -} +import type { Doc as YDoc, Map as YMap } from 'yjs'; +import { transact } from 'yjs'; /** * Hard code to fix workspace version to be compatible with legacy data. @@ -55,13 +12,35 @@ export function fixWorkspaceVersion(rootDoc: YDoc) { * It doesn't matter to upgrade workspace version from 1 or undefined to 2. * Blocksuite just set the value, do nothing else. */ - const workspaceVersion = meta.get('workspaceVersion'); - if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { - meta.set('workspaceVersion', 2); - + function doFix() { + const workspaceVersion = meta.get('workspaceVersion'); + if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { + transact( + rootDoc, + () => { + meta.set('workspaceVersion', 2); + }, + 'fixWorkspaceVersion', + // transact as remote update, because blocksuite will skip local changes. + false + ); + } const pageVersion = meta.get('pageVersion'); - if (typeof pageVersion !== 'number') { - meta.set('pageVersion', 1); + if (typeof pageVersion !== 'number' || pageVersion < 2) { + transact( + rootDoc, + () => { + meta.set('pageVersion', 2); + }, + 'fixPageVersion', + // transact as remote update, because blocksuite will skip local changes. + false + ); } } + + doFix(); + + // do fix every time when meta changed + meta.observe(() => doFix()); } diff --git a/packages/common/infra/src/blocksuite/migration/subdoc.ts b/packages/common/infra/src/blocksuite/migration/subdoc.ts index da77756cc2..f45e610f86 100644 --- a/packages/common/infra/src/blocksuite/migration/subdoc.ts +++ b/packages/common/infra/src/blocksuite/migration/subdoc.ts @@ -1,4 +1,3 @@ -import type { Workspace } from '@blocksuite/store'; import { nanoid } from 'nanoid'; import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; @@ -264,19 +263,17 @@ export function migrateToSubdoc(oldDoc: YDoc): YDoc { return newDoc; } -export const upgradeV1ToV2 = async ( - oldDoc: YDoc, - createWorkspace: () => Promise -) => { +/** + * upgrade oldDoc to v2, write to targetDoc + */ +export const upgradeV1ToV2 = async (oldDoc: YDoc, targetDoc: YDoc) => { const newDoc = migrateToSubdoc(oldDoc); - const newWorkspace = await createWorkspace(); - applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin); + applyUpdate(targetDoc, encodeStateAsUpdate(newDoc), migrationOrigin); newDoc.getSubdocs().forEach(subdoc => { - newWorkspace.doc.getSubdocs().forEach(newDoc => { + targetDoc.getSubdocs().forEach(newDoc => { if (subdoc.guid === newDoc.guid) { applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin); } }); }); - return newWorkspace; }; diff --git a/packages/common/infra/src/blocksuite/migration/workspace.ts b/packages/common/infra/src/blocksuite/migration/workspace.ts index e26f644230..b2fa335d75 100644 --- a/packages/common/infra/src/blocksuite/migration/workspace.ts +++ b/packages/common/infra/src/blocksuite/migration/workspace.ts @@ -1,63 +1,50 @@ import type { Workspace } from '@blocksuite/store'; -import type { Schema } from '@blocksuite/store'; -import type { Doc as YDoc } from 'yjs'; - -import { migratePages } from './blocksuite'; -import { upgradeV1ToV2 } from './subdoc'; - -interface MigrationOptions { - doc: YDoc; - schema: Schema; - createWorkspace: () => Promise; -} - -function createMigrationQueue(options: MigrationOptions) { - return [ - async (doc: YDoc) => { - const newWorkspace = await upgradeV1ToV2(doc, options.createWorkspace); - return newWorkspace.doc; - }, - async (doc: YDoc) => { - await migratePages(doc, options.schema); - return doc; - }, - ]; -} +import type { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs'; /** * For split migrate function from MigrationQueue. */ export enum MigrationPoint { SubDoc = 1, - BlockVersion = 2, -} - -export async function migrateWorkspace( - point: MigrationPoint, - options: MigrationOptions -) { - const migrationQueue = createMigrationQueue(options); - const migrationFns = migrationQueue.slice(point - 1); - - let doc = options.doc; - for (const migrate of migrationFns) { - doc = await migrate(doc); - } - return doc; + GuidFix = 2, + BlockVersion = 3, } export function checkWorkspaceCompatibility( workspace: Workspace ): MigrationPoint | null { - const workspaceDocJSON = workspace.doc.toJSON(); - const spaceMetaObj = workspaceDocJSON['space:meta']; - const docKeys = Object.keys(workspaceDocJSON); - const haveSpaceMeta = !!spaceMetaObj && Object.keys(spaceMetaObj).length > 0; + // check if there is any key starts with 'space:' on root doc + const spaceMetaObj = workspace.doc.share.get('space:meta') as + | YMap + | undefined; + const docKeys = Array.from(workspace.doc.share.keys()); + const haveSpaceMeta = !!spaceMetaObj && spaceMetaObj.size > 0; const haveLegacySpace = docKeys.some(key => key.startsWith('space:')); if (haveSpaceMeta || haveLegacySpace) { return MigrationPoint.SubDoc; } + // exit if no pages + if (!workspace.meta.pages?.length) { + return null; + } + + // check guid compatibility + const meta = workspace.doc.getMap('meta') as YMap; + const pages = meta.get('pages') as YArray>; + for (const page of pages) { + const pageId = page.get('id') as string | undefined; + if (pageId?.includes(':')) { + return MigrationPoint.GuidFix; + } + } + const spaces = workspace.doc.getMap('spaces') as YMap; + for (const [pageId, _] of spaces) { + if (pageId.includes(':')) { + return MigrationPoint.GuidFix; + } + } + const hasVersion = workspace.meta.hasVersion; if (!hasVersion) { return MigrationPoint.BlockVersion; diff --git a/packages/common/infra/vite.config.ts b/packages/common/infra/vite.config.ts index efd6f41b66..3f9455e2c3 100644 --- a/packages/common/infra/vite.config.ts +++ b/packages/common/infra/vite.config.ts @@ -18,10 +18,6 @@ export default defineConfig({ type: resolve(root, 'src/type.ts'), 'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'), 'preload/electron': resolve(root, 'src/preload/electron.ts'), - '__internal__/workspace': resolve( - root, - 'src/__internal__/workspace.ts' - ), '__internal__/plugin': resolve(root, 'src/__internal__/plugin.ts'), }, formats: ['es', 'cjs'], 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 a8d6049a9c..84a0287819 100644 --- a/packages/frontend/component/src/components/card/workspace-card/index.tsx +++ b/packages/frontend/component/src/components/card/workspace-card/index.tsx @@ -1,11 +1,10 @@ +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace'; import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons'; -import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url'; -import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; -import { useAtomValue } from 'jotai/react'; +import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob'; +import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info'; import { useCallback } from 'react'; import { Avatar } from '../../../ui/avatar'; @@ -68,10 +67,10 @@ const WorkspaceType = ({ flavour, isOwner }: WorkspaceTypeProps) => { }; export interface WorkspaceCardProps { - currentWorkspaceId: string | null; - meta: RootWorkspaceMetadata; - onClick: (workspaceId: string) => void; - onSettingClick: (workspaceId: string) => void; + currentWorkspaceId?: string | null; + meta: WorkspaceMetadata; + onClick: (metadata: WorkspaceMetadata) => void; + onSettingClick: (metadata: WorkspaceMetadata) => void; isOwner?: boolean; } @@ -98,29 +97,31 @@ export const WorkspaceCard = ({ meta, isOwner = true, }: WorkspaceCardProps) => { - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(meta.id); - const workspace = useAtomValue(workspaceAtom); - const [name] = useBlockSuiteWorkspaceName(workspace); - const [workspaceAvatar] = useBlockSuiteWorkspaceAvatarUrl(workspace); + const information = useWorkspaceInfo(meta); + const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar); + + const name = information?.name ?? UNTITLED_WORKSPACE_NAME; return ( { - onClick(meta.id); - }, [onClick, meta.id])} - active={workspace.id === currentWorkspaceId} + onClick(meta); + }, [onClick, meta])} + active={meta.id === currentWorkspaceId} > - + - {name} + + {information?.name ?? UNTITLED_WORKSPACE_NAME} + { e.stopPropagation(); - onSettingClick(meta.id); + onSettingClick(meta); }} withoutHoverStyle={true} > diff --git a/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts b/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts index 65009d631c..fe0c5d44b8 100644 --- a/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts +++ b/packages/frontend/component/src/components/page-list/__tests__/use-all-page-setting.spec.ts @@ -12,7 +12,7 @@ import { expect, test } from 'vitest'; import { createDefaultFilter, vars } from '../filter/vars'; import { - type CollectionsCRUDAtom, + type CollectionsCRUD, useCollectionManager, } from '../use-collection-manager'; @@ -27,18 +27,18 @@ const baseAtom = atomWithObservable( } ); -const mockAtom: CollectionsCRUDAtom = atom(get => { +const mockAtom = atom(get => { return { collections: get(baseAtom), - addCollection: async (...collections) => { + addCollection: (...collections) => { const prev = collectionsSubject.value; collectionsSubject.next([...collections, ...prev]); }, - deleteCollection: async (...ids) => { + deleteCollection: (...ids) => { const prev = collectionsSubject.value; collectionsSubject.next(prev.filter(v => !ids.includes(v.id))); }, - updateCollection: async (id, updater) => { + updateCollection: (id, updater) => { const prev = collectionsSubject.value; collectionsSubject.next( prev.map(v => { @@ -49,14 +49,14 @@ const mockAtom: CollectionsCRUDAtom = atom(get => { }) ); }, - }; + } satisfies CollectionsCRUD; }); test('useAllPageSetting', async () => { const settingHook = renderHook(() => useCollectionManager(mockAtom)); const prevCollection = settingHook.result.current.currentCollection; expect(settingHook.result.current.savedCollections).toEqual([]); - await settingHook.result.current.updateCollection({ + settingHook.result.current.updateCollection({ ...settingHook.result.current.currentCollection, filterList: [createDefaultFilter(vars[0], defaultMeta)], }); @@ -66,7 +66,7 @@ test('useAllPageSetting', async () => { expect(nextCollection.filterList).toEqual([ createDefaultFilter(vars[0], defaultMeta), ]); - await settingHook.result.current.createCollection({ + settingHook.result.current.createCollection({ ...settingHook.result.current.currentCollection, id: '1', }); diff --git a/packages/frontend/component/src/components/page-list/use-collection-manager.ts b/packages/frontend/component/src/components/page-list/use-collection-manager.ts index 5c204c93fc..9d501d6b82 100644 --- a/packages/frontend/component/src/components/page-list/use-collection-manager.ts +++ b/packages/frontend/component/src/components/page-list/use-collection-manager.ts @@ -33,22 +33,21 @@ export const currentCollectionAtom = atomWithReset(NIL); export type Updater = (value: T) => T; export type CollectionUpdater = Updater; export type CollectionsCRUD = { - addCollection: (...collections: Collection[]) => Promise; + addCollection: (...collections: Collection[]) => void; collections: Collection[]; - updateCollection: (id: string, updater: CollectionUpdater) => Promise; - deleteCollection: ( - info: DeleteCollectionInfo, - ...ids: string[] - ) => Promise; + updateCollection: (id: string, updater: CollectionUpdater) => void; + deleteCollection: (info: DeleteCollectionInfo, ...ids: string[]) => void; }; -export type CollectionsCRUDAtom = Atom; +export type CollectionsCRUDAtom = Atom< + Promise | CollectionsCRUD +>; export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => { const [{ collections, addCollection, deleteCollection, updateCollection }] = useAtom(collectionAtom); const addPage = useCallback( - async (collectionId: string, pageId: string) => { - await updateCollection(collectionId, old => { + (collectionId: string, pageId: string) => { + updateCollection(collectionId, old => { return { ...old, allowList: [pageId, ...(old.allowList ?? [])], @@ -79,11 +78,11 @@ export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => { defaultCollectionAtom ); const update = useCallback( - async (collection: Collection) => { + (collection: Collection) => { if (collection.id === NIL) { updateDefaultCollection(collection); } else { - await updateCollection(collection.id, () => collection); + updateCollection(collection.id, () => collection); } }, [updateDefaultCollection, updateCollection] diff --git a/packages/frontend/component/src/components/page-list/view/collection-list.tsx b/packages/frontend/component/src/components/page-list/view/collection-list.tsx index 80a31205b1..2bede93aa0 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-list.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-list.tsx @@ -35,14 +35,10 @@ export const CollectionList = ({ const [collection, setCollection] = useState(); const onChange = useCallback( (filterList: Filter[]) => { - setting - .updateCollection({ - ...setting.currentCollection, - filterList, - }) - .catch(err => { - console.error(err); - }); + setting.updateCollection({ + ...setting.currentCollection, + filterList, + }); }, [setting] ); @@ -53,8 +49,8 @@ export const CollectionList = ({ }, []); const onConfirm = useCallback( - async (view: Collection) => { - await setting.updateCollection(view); + (view: Collection) => { + setting.updateCollection(view); closeUpdateCollectionModal(false); }, [closeUpdateCollectionModal, setting] diff --git a/packages/frontend/component/src/components/page-list/view/collection-operations.tsx b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx index cc495d2739..97cac902ef 100644 --- a/packages/frontend/component/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/component/src/components/page-list/view/collection-operations.tsx @@ -107,9 +107,7 @@ export const CollectionOperations = ({ ), name: t['Delete'](), click: () => { - setting.deleteCollection(info, collection.id).catch(err => { - console.error(err); - }); + setting.deleteCollection(info, collection.id); }, type: 'danger', }, diff --git a/packages/frontend/component/src/components/page-list/view/create-collection.tsx b/packages/frontend/component/src/components/page-list/view/create-collection.tsx index 086c449e8c..5046eaeda1 100644 --- a/packages/frontend/component/src/components/page-list/view/create-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/create-collection.tsx @@ -10,7 +10,7 @@ export interface CreateCollectionModalProps { title?: string; onConfirmText?: string; init: string; - onConfirm: (title: string) => Promise; + onConfirm: (title: string) => void; open: boolean; showTips?: boolean; onOpenChange: (open: boolean) => void; @@ -27,13 +27,8 @@ export const CreateCollectionModal = ({ const t = useAFFiNEI18N(); const onConfirmTitle = useCallback( (title: string) => { - onConfirm(title) - .then(() => { - onOpenChange(false); - }) - .catch(err => { - console.error(err); - }); + onConfirm(title); + onOpenChange(false); }, [onConfirm, onOpenChange] ); diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx index 6f6272b912..b96a0ad448 100644 --- a/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/edit-collection/edit-collection.tsx @@ -19,7 +19,7 @@ export interface EditCollectionModalProps { open: boolean; mode?: EditCollectionMode; onOpenChange: (open: boolean) => void; - onConfirm: (view: Collection) => Promise; + onConfirm: (view: Collection) => void; allPageListConfig: AllPageListConfig; } @@ -45,13 +45,7 @@ export const EditCollectionModal = ({ const t = useAFFiNEI18N(); const onConfirmOnCollection = useCallback( (view: Collection) => { - onConfirm(view) - .then(() => { - onOpenChange(false); - }) - .catch(err => { - console.error(err); - }); + onConfirm(view); onOpenChange(false); }, [onConfirm, onOpenChange] diff --git a/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx b/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx index 98e7fb4234..eec63d4e4f 100644 --- a/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx +++ b/packages/frontend/component/src/components/page-list/view/save-as-collection-button.tsx @@ -9,7 +9,7 @@ import { createEmptyCollection } from '../use-collection-manager'; import { useEditCollectionName } from './use-edit-collection'; interface SaveAsCollectionButtonProps { - onConfirm: (collection: Collection) => Promise; + onConfirm: (collection: Collection) => void; } export const SaveAsCollectionButton = ({ diff --git a/packages/frontend/component/src/components/page-list/view/use-action.tsx b/packages/frontend/component/src/components/page-list/view/use-action.tsx index 14a104cc39..438c92a51b 100644 --- a/packages/frontend/component/src/components/page-list/view/use-action.tsx +++ b/packages/frontend/component/src/components/page-list/view/use-action.tsx @@ -40,9 +40,7 @@ export const useActions = ({ name: 'delete', tooltip: t['com.affine.collection-bar.action.tooltip.delete'](), click: () => { - setting.deleteCollection(info, collection.id).catch(err => { - console.error(err); - }); + setting.deleteCollection(info, collection.id); }, }, ]; diff --git a/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx b/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx index 063e5cf19a..bcf5b07f9f 100644 --- a/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx +++ b/packages/frontend/component/src/components/page-list/view/use-edit-collection.tsx @@ -12,7 +12,7 @@ export const useEditCollection = (config: AllPageListConfig) => { const [data, setData] = useState<{ collection: Collection; mode?: 'page' | 'rule'; - onConfirm: (collection: Collection) => Promise; + onConfirm: (collection: Collection) => void; }>(); const close = useCallback(() => setData(undefined), []); @@ -35,7 +35,7 @@ export const useEditCollection = (config: AllPageListConfig) => { setData({ collection, mode, - onConfirm: async collection => { + onConfirm: collection => { res(collection); }, }); @@ -52,7 +52,7 @@ export const useEditCollectionName = ({ }) => { const [data, setData] = useState<{ name: string; - onConfirm: (name: string) => Promise; + onConfirm: (name: string) => void; }>(); const close = useCallback(() => setData(undefined), []); @@ -71,7 +71,7 @@ export const useEditCollectionName = ({ new Promise(res => { setData({ name, - onConfirm: async collection => { + onConfirm: collection => { res(collection); }, }); diff --git a/packages/frontend/component/src/components/workspace-list/index.tsx b/packages/frontend/component/src/components/workspace-list/index.tsx index 5557616370..e55f08b78e 100644 --- a/packages/frontend/component/src/components/workspace-list/index.tsx +++ b/packages/frontend/component/src/components/workspace-list/index.tsx @@ -1,8 +1,4 @@ -import type { - AffineCloudWorkspace, - LocalWorkspace, -} from '@affine/env/workspace'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace'; import type { DragEndEvent } from '@dnd-kit/core'; import { DndContext, @@ -26,17 +22,17 @@ import { workspaceItemStyle } from './index.css'; export interface WorkspaceListProps { disabled?: boolean; - currentWorkspaceId: string | null; - items: (AffineCloudWorkspace | LocalWorkspace)[]; - onClick: (workspaceId: string) => void; - onSettingClick: (workspaceId: string) => void; + currentWorkspaceId?: string | null; + items: WorkspaceMetadata[]; + onClick: (workspaceMetadata: WorkspaceMetadata) => void; + onSettingClick: (workspaceMetadata: WorkspaceMetadata) => void; onDragEnd: (event: DragEndEvent) => void; - useIsWorkspaceOwner?: (workspaceId: string) => boolean; + useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean; } interface SortableWorkspaceItemProps extends Omit { - item: RootWorkspaceMetadata; - useIsWorkspaceOwner?: (workspaceId: string) => boolean; + item: WorkspaceMetadata; + useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean; } const SortableWorkspaceItem = ({ @@ -62,7 +58,7 @@ const SortableWorkspaceItem = ({ }), [disabled, transform, transition] ); - const isOwner = useIsWorkspaceOwner?.(item.id); + const isOwner = useIsWorkspaceOwner?.(item); return (
import('../../components/cloud/login-card').then(({ LoginCard }) => ({ @@ -16,23 +12,4 @@ const LoginCard = lazy(() => export const UI = { Provider, LoginCard, - NewSettingsDetail: ({ - currentWorkspaceId, - onTransformWorkspace, - onDeleteLocalWorkspace, - onDeleteCloudWorkspace, - onLeaveWorkspace, - }) => { - const isOwner = useIsWorkspaceOwner(currentWorkspaceId); - return ( - - ); - }, -} satisfies WorkspaceUISchema; +} satisfies WorkspaceUISchema; diff --git a/packages/frontend/core/src/adapters/local/index.tsx b/packages/frontend/core/src/adapters/local/index.tsx index fbb0bee268..4abdd9700c 100644 --- a/packages/frontend/core/src/adapters/local/index.tsx +++ b/packages/frontend/core/src/adapters/local/index.tsx @@ -1,81 +1,17 @@ -import { DebugLogger } from '@affine/debug'; -import { DEFAULT_WORKSPACE_NAME } from '@affine/env/constant'; import type { WorkspaceAdapter } from '@affine/env/workspace'; import { LoadPriority, ReleaseType, WorkspaceFlavour, } from '@affine/env/workspace'; -import { - CRUD, - saveWorkspaceToLocalStorage, -} from '@affine/workspace/local/crud'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; -import { getCurrentStore } from '@toeverything/infra/atom'; -import { initEmptyPage } from '@toeverything/infra/blocksuite'; -import { buildShowcaseWorkspace } from '@toeverything/infra/blocksuite'; -import { nanoid } from 'nanoid'; -import { setPageModeAtom } from '../../atoms'; -import { NewWorkspaceSettingDetail, Provider } from '../shared'; - -const logger = new DebugLogger('use-create-first-workspace'); +import { Provider } from '../shared'; export const LocalAdapter: WorkspaceAdapter = { releaseType: ReleaseType.STABLE, flavour: WorkspaceFlavour.LOCAL, loadPriority: LoadPriority.LOW, - Events: { - 'app:access': async () => true, - 'app:init': () => { - const blockSuiteWorkspace = getOrCreateWorkspace( - nanoid(), - WorkspaceFlavour.LOCAL - ); - blockSuiteWorkspace.meta.setName(DEFAULT_WORKSPACE_NAME); - if (runtimeConfig.enablePreloading) { - buildShowcaseWorkspace(blockSuiteWorkspace, { - store: getCurrentStore(), - atoms: { - pageMode: setPageModeAtom, - }, - }).catch(err => { - logger.error('init page with preloading failed', err); - }); - } else { - const page = blockSuiteWorkspace.createPage(); - blockSuiteWorkspace.setPageMeta(page.id, { - jumpOnce: true, - }); - initEmptyPage(page).catch(error => { - logger.error('init page with empty failed', error); - }); - } - saveWorkspaceToLocalStorage(blockSuiteWorkspace.id); - logger.debug('create first workspace'); - return [blockSuiteWorkspace.id]; - }, - }, - CRUD, UI: { Provider, - NewSettingsDetail: ({ - currentWorkspaceId, - onTransformWorkspace, - onDeleteLocalWorkspace, - onDeleteCloudWorkspace, - onLeaveWorkspace, - }) => { - return ( - - ); - }, }, }; diff --git a/packages/frontend/core/src/adapters/public-cloud/ui.tsx b/packages/frontend/core/src/adapters/public-cloud/ui.tsx deleted file mode 100644 index 0e033dd58a..0000000000 --- a/packages/frontend/core/src/adapters/public-cloud/ui.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { WorkspaceFlavour } from '@affine/env/workspace'; -import { type WorkspaceUISchema } from '@affine/env/workspace'; - -import { Provider } from '../shared'; - -export const UI = { - Provider, - NewSettingsDetail: () => { - throw new Error('Not implemented'); - }, -} satisfies WorkspaceUISchema; diff --git a/packages/frontend/core/src/adapters/workspace.ts b/packages/frontend/core/src/adapters/workspace.ts index 30ca924c72..7ef8e67950 100644 --- a/packages/frontend/core/src/adapters/workspace.ts +++ b/packages/frontend/core/src/adapters/workspace.ts @@ -1,6 +1,5 @@ import { Unreachable } from '@affine/env/constant'; import type { - AppEvents, WorkspaceAdapter, WorkspaceUISchema, } from '@affine/env/workspace'; @@ -9,19 +8,9 @@ import { ReleaseType, WorkspaceFlavour, } from '@affine/env/workspace'; -import { CRUD as CloudCRUD } from '@affine/workspace/affine/crud'; import { UI as CloudUI } from './cloud/ui'; import { LocalAdapter } from './local'; -import { UI as PublicCloudUI } from './public-cloud/ui'; - -const unimplemented = () => { - throw new Error('Not implemented'); -}; - -const bypassList = async () => { - return []; -}; export const WorkspaceAdapters = { [WorkspaceFlavour.LOCAL]: LocalAdapter, @@ -29,43 +18,16 @@ export const WorkspaceAdapters = { releaseType: ReleaseType.UNRELEASED, flavour: WorkspaceFlavour.AFFINE_CLOUD, loadPriority: LoadPriority.HIGH, - Events: { - 'app:access': async () => { - try { - const { getSession } = await import('next-auth/react'); - const session = await getSession(); - return !!session; - } catch (e) { - console.error('failed to get session', e); - return false; - } - }, - } as Partial, - CRUD: CloudCRUD, UI: CloudUI, }, - [WorkspaceFlavour.AFFINE_PUBLIC]: { - releaseType: ReleaseType.UNRELEASED, - flavour: WorkspaceFlavour.AFFINE_PUBLIC, - loadPriority: LoadPriority.LOW, - Events: {} as Partial, - // todo: implement this - CRUD: { - get: unimplemented, - list: bypassList, - delete: unimplemented, - create: unimplemented, - }, - UI: PublicCloudUI, - }, } satisfies { [Key in WorkspaceFlavour]: WorkspaceAdapter; }; export function getUIAdapter( flavour: Flavour -): WorkspaceUISchema { - const ui = WorkspaceAdapters[flavour].UI as WorkspaceUISchema; +): WorkspaceUISchema { + const ui = WorkspaceAdapters[flavour].UI as WorkspaceUISchema; if (!ui) { throw new Unreachable(); } diff --git a/packages/frontend/core/src/app.tsx b/packages/frontend/core/src/app.tsx index d7db06564a..dfd19931f1 100644 --- a/packages/frontend/core/src/app.tsx +++ b/packages/frontend/core/src/app.tsx @@ -5,9 +5,9 @@ import { AffineContext } from '@affine/component/context'; import { GlobalLoading } from '@affine/component/global-loading'; import { NotificationCenter } from '@affine/component/notification-center'; import { WorkspaceFallback } from '@affine/component/workspace'; +import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; import { getCurrentStore } from '@toeverything/infra/atom'; -import { use } from 'foxact/use'; import type { PropsWithChildren, ReactElement } from 'react'; import { lazy, memo, Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; @@ -41,7 +41,6 @@ async function loadLanguage() { if (environment.isBrowser) { performanceI18nLogger.info('start'); - const { createI18n, setUpLanguage } = await import('@affine/i18n'); const i18n = createI18n(); document.documentElement.lang = i18n.language; @@ -51,12 +50,15 @@ async function loadLanguage() { } } -const languageLoadingPromise = loadLanguage().catch(console.error); +let languageLoadingPromise: Promise | null = null; export const App = memo(function App() { performanceRenderLogger.info('App'); - use(languageLoadingPromise); + if (!languageLoadingPromise) { + languageLoadingPromise = loadLanguage().catch(console.error); + } + return ( diff --git a/packages/frontend/core/src/atoms/collections.ts b/packages/frontend/core/src/atoms/collections.ts index 1091480228..af69f4fced 100644 --- a/packages/frontend/core/src/atoms/collections.ts +++ b/packages/frontend/core/src/atoms/collections.ts @@ -1,12 +1,18 @@ -import type { CollectionsCRUDAtom } from '@affine/component/page-list'; +import type { + CollectionsCRUD, + CollectionsCRUDAtom, +} from '@affine/component/page-list'; import type { Collection, DeprecatedCollection } from '@affine/env/filter'; +import { + currentWorkspaceAtom, + waitForCurrentWorkspaceAtom, +} from '@affine/workspace/atom'; import { DisposableGroup } from '@blocksuite/global/utils'; import type { Workspace } from '@blocksuite/store'; -import { currentWorkspaceAtom } from '@toeverything/infra/atom'; import { type DBSchema, openDB } from 'idb'; import { atom } from 'jotai'; import { atomWithObservable } from 'jotai/utils'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { getUserSetting } from '../utils/user-setting'; import { getWorkspaceSetting } from '../utils/workspace-setting'; @@ -95,7 +101,11 @@ type BaseCollectionsDataType = { export const pageCollectionBaseAtom = atomWithObservable( get => { - const currentWorkspacePromise = get(currentWorkspaceAtom); + 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 ( @@ -149,48 +159,44 @@ export const pageCollectionBaseAtom = return new Observable(subscriber => { const group = new DisposableGroup(); - currentWorkspacePromise - .then(async currentWorkspace => { - const workspaceSetting = getWorkspaceSetting(currentWorkspace); - migrateCollectionsFromIdbData(currentWorkspace) - .then(collections => { - if (collections.length) { - workspaceSetting.addCollection(...collections); - } - }) - .catch(error => { - console.error(error); - }); - migrateCollectionsFromUserData(currentWorkspace) - .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 workspaceSetting = getWorkspaceSetting( + currentWorkspace.blockSuiteWorkspace + ); + migrateCollectionsFromIdbData(currentWorkspace.blockSuiteWorkspace) + .then(collections => { + if (collections.length) { + workspaceSetting.addCollection(...collections); } - const fn = () => { - subscriber.next({ - loading: false, - collections: workspaceSetting.collections, - }); - }; - workspaceSetting.collectionsYArray.observe(fn); - group.add(() => { - workspaceSetting.collectionsYArray.unobserve(fn); - }); }) .catch(error => { - subscriber.error(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.collectionsYArray.observe(fn); + group.add(() => { + workspaceSetting.collectionsYArray.unobserve(fn); + }); return () => { group.dispose(); @@ -199,21 +205,27 @@ export const pageCollectionBaseAtom = }, { initialValue: { loading: true, collections: [] } } ); -export const collectionsCRUDAtom: CollectionsCRUDAtom = atom(get => { - const workspacePromise = get(currentWorkspaceAtom); + +export const collectionsCRUDAtom: CollectionsCRUDAtom = atom(async get => { + const workspace = await get(waitForCurrentWorkspaceAtom); return { - addCollection: async (...collections) => { - const workspace = await workspacePromise; - getWorkspaceSetting(workspace).addCollection(...collections); + addCollection: (...collections) => { + getWorkspaceSetting(workspace.blockSuiteWorkspace).addCollection( + ...collections + ); }, collections: get(pageCollectionBaseAtom).collections, - updateCollection: async (id, updater) => { - const workspace = await workspacePromise; - getWorkspaceSetting(workspace).updateCollection(id, updater); + updateCollection: (id, updater) => { + getWorkspaceSetting(workspace.blockSuiteWorkspace).updateCollection( + id, + updater + ); }, - deleteCollection: async (info, ...ids) => { - const workspace = await workspacePromise; - getWorkspaceSetting(workspace).deleteCollection(info, ...ids); + deleteCollection: (info, ...ids) => { + getWorkspaceSetting(workspace.blockSuiteWorkspace).deleteCollection( + info, + ...ids + ); }, - }; + } satisfies CollectionsCRUD; }); diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index c275bf321a..e512871f9e 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -14,13 +14,15 @@ export const openOnboardingModalAtom = atom(false); export const openSignOutModalAtom = atom(false); export const openPaymentDisableAtom = atom(false); -export type SettingAtom = Pick & { +export type SettingAtom = Pick< + SettingProps, + 'activeTab' | 'workspaceMetadata' +> & { open: boolean; }; export const openSettingModalAtom = atom({ activeTab: 'appearance', - workspaceId: null, open: false, }); diff --git a/packages/frontend/core/src/bootstrap/first-app-data.ts b/packages/frontend/core/src/bootstrap/first-app-data.ts new file mode 100644 index 0000000000..22a7339842 --- /dev/null +++ b/packages/frontend/core/src/bootstrap/first-app-data.ts @@ -0,0 +1,43 @@ +import { DebugLogger } from '@affine/debug'; +import { DEFAULT_WORKSPACE_NAME } from '@affine/env/constant'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { workspaceManager } from '@affine/workspace'; +import { getCurrentStore } from '@toeverything/infra/atom'; +import { + buildShowcaseWorkspace, + initEmptyPage, +} from '@toeverything/infra/blocksuite'; + +import { setPageModeAtom } from '../atoms'; + +const logger = new DebugLogger('affine:first-app-data'); + +export async function createFirstAppData() { + if (localStorage.getItem('is-first-open') !== null) { + return; + } + localStorage.setItem('is-first-open', 'false'); + const workspaceId = await workspaceManager.createWorkspace( + WorkspaceFlavour.LOCAL, + async workspace => { + workspace.meta.setName(DEFAULT_WORKSPACE_NAME); + if (runtimeConfig.enablePreloading) { + await buildShowcaseWorkspace(workspace, { + store: getCurrentStore(), + atoms: { + pageMode: setPageModeAtom, + }, + }); + } else { + const page = workspace.createPage(); + workspace.setPageMeta(page.id, { + jumpOnce: true, + }); + await initEmptyPage(page); + } + logger.debug('create first workspace'); + } + ); + console.info('create first workspace', workspaceId); + return workspaceId; +} diff --git a/packages/frontend/core/src/bootstrap/plugins/setup.ts b/packages/frontend/core/src/bootstrap/plugins/setup.ts index 64ce979364..eb848ce174 100644 --- a/packages/frontend/core/src/bootstrap/plugins/setup.ts +++ b/packages/frontend/core/src/bootstrap/plugins/setup.ts @@ -14,11 +14,7 @@ import { pluginSettingAtom, pluginWindowAtom, } from '@toeverything/infra/__internal__/plugin'; -import { - contentLayoutAtom, - currentPageIdAtom, - currentWorkspaceAtom, -} from '@toeverything/infra/atom'; +import { contentLayoutAtom, currentPageIdAtom } from '@toeverything/infra/atom'; import { atom } from 'jotai'; import { Provider } from 'jotai/react'; import type { createStore } from 'jotai/vanilla'; @@ -149,7 +145,6 @@ function createSetupImpl(rootStore: ReturnType) { '@blocksuite/inline': import('@blocksuite/inline'), '@affine/sdk/entry': { rootStore, - currentWorkspaceAtom: currentWorkspaceAtom, currentPageIdAtom: currentPageIdAtom, pushLayoutAtom: pushLayoutAtom, deleteLayoutAtom: deleteLayoutAtom, diff --git a/packages/frontend/core/src/bootstrap/setup.ts b/packages/frontend/core/src/bootstrap/setup.ts index caa79875a3..e7fa269453 100644 --- a/packages/frontend/core/src/bootstrap/setup.ts +++ b/packages/frontend/core/src/bootstrap/setup.ts @@ -1,15 +1,7 @@ import './register-blocksuite-components'; import { setupGlobal } from '@affine/env/global'; -import type { WorkspaceAdapter } from '@affine/env/workspace'; -import type { WorkspaceFlavour } from '@affine/env/workspace'; -import { - type RootWorkspaceMetadataV2, - rootWorkspacesMetadataAtom, - workspaceAdaptersAtom, -} from '@affine/workspace/atom'; import * as Sentry from '@sentry/react'; -import type { createStore } from 'jotai/vanilla'; import { useEffect } from 'react'; import { createRoutesFromChildren, @@ -18,45 +10,12 @@ import { useNavigationType, } from 'react-router-dom'; -import { WorkspaceAdapters } from '../adapters/workspace'; import { performanceLogger } from '../shared'; const performanceSetupLogger = performanceLogger.namespace('setup'); -export function createFirstAppData(store: ReturnType) { - const createFirst = (): RootWorkspaceMetadataV2[] => { - const Plugins = Object.values(WorkspaceAdapters).sort( - (a, b) => a.loadPriority - b.loadPriority - ); - - return Plugins.flatMap(Plugin => { - return Plugin.Events['app:init']?.().map( - id => - { - id, - flavour: Plugin.flavour, - } - ); - }).filter((ids): ids is RootWorkspaceMetadataV2 => !!ids); - }; - if (localStorage.getItem('is-first-open') !== null) { - return; - } - const result = createFirst(); - console.info('create first workspace', result); - localStorage.setItem('is-first-open', 'false'); - store.set(rootWorkspacesMetadataAtom, result); -} - -export async function setup(store: ReturnType) { +export function setup() { performanceSetupLogger.info('start'); - store.set( - workspaceAdaptersAtom, - WorkspaceAdapters as Record< - WorkspaceFlavour, - WorkspaceAdapter - > - ); performanceSetupLogger.info('setup global'); setupGlobal(); @@ -88,9 +47,5 @@ export async function setup(store: ReturnType) { }); } - performanceSetupLogger.info('get root workspace meta'); - // do not read `rootWorkspacesMetadataAtom` before migration - await store.get(rootWorkspacesMetadataAtom); - performanceSetupLogger.info('done'); } diff --git a/packages/frontend/core/src/commands/affine-help.tsx b/packages/frontend/core/src/commands/affine-help.tsx index 9b4c1289a6..5c0bbc0fd6 100644 --- a/packages/frontend/core/src/commands/affine-help.tsx +++ b/packages/frontend/core/src/commands/affine-help.tsx @@ -34,7 +34,7 @@ export function registerAffineHelpCommands({ store.set(openSettingModalAtom, { open: true, activeTab: 'about', - workspaceId: null, + workspaceMetadata: null, }); }, }) diff --git a/packages/frontend/core/src/commands/affine-navigation.tsx b/packages/frontend/core/src/commands/affine-navigation.tsx index 53ffb80447..2f8ac147c4 100644 --- a/packages/frontend/core/src/commands/affine-navigation.tsx +++ b/packages/frontend/core/src/commands/affine-navigation.tsx @@ -94,7 +94,6 @@ export function registerAffineNavigationCommands({ run() { store.set(openSettingModalAtom, { activeTab: 'appearance', - workspaceId: null, open: true, }); }, diff --git a/packages/frontend/core/src/components/adapter-worksapce-wrapper.tsx b/packages/frontend/core/src/components/adapter-worksapce-wrapper.tsx index 35e5c3ec30..fda0cdec77 100644 --- a/packages/frontend/core/src/components/adapter-worksapce-wrapper.tsx +++ b/packages/frontend/core/src/components/adapter-worksapce-wrapper.tsx @@ -1,11 +1,12 @@ +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; +import { useAtomValue } from 'jotai'; import type { FC, PropsWithChildren } from 'react'; import { WorkspaceAdapters } from '../adapters/workspace'; -import { useCurrentWorkspace } from '../hooks/current/use-current-workspace'; export const AdapterProviderWrapper: FC = ({ children }) => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const Provider = WorkspaceAdapters[currentWorkspace.flavour].UI.Provider; assertExists(Provider); 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 ae8004d591..d347a99b64 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,8 +1,8 @@ -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/infra/atom'; + currentWorkspaceAtom, + workspaceListAtom, +} from '@affine/workspace/atom'; +import { currentPageIdAtom } from '@toeverything/infra/atom'; import { useAtomValue } from 'jotai/react'; import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; @@ -13,8 +13,8 @@ export interface DumpInfoProps { export const DumpInfo = (_props: DumpInfoProps) => { const location = useLocation(); - const metadata = useAtomValue(rootWorkspacesMetadataAtom); - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const workspaceList = useAtomValue(workspaceListAtom); + const currentWorkspace = useAtomValue(currentWorkspaceAtom); const currentPageId = useAtomValue(currentPageIdAtom); const path = location.pathname; const query = useParams(); @@ -22,10 +22,10 @@ export const DumpInfo = (_props: DumpInfoProps) => { console.info('DumpInfo', { path, query, - currentWorkspaceId, + currentWorkspaceId: currentWorkspace?.id, currentPageId, - metadata, + workspaceList, }); - }, [path, query, currentWorkspaceId, currentPageId, metadata]); + }, [path, query, currentWorkspace, currentPageId, workspaceList]); return null; }; diff --git a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx index fdc007828b..45fc541d7f 100644 --- a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx +++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx @@ -20,7 +20,6 @@ const UserPlanButtonWithData = () => { setSettingModalAtom({ open: true, activeTab: 'plans', - workspaceId: null, }); }, [setSettingModalAtom] diff --git a/packages/frontend/core/src/components/affine/awareness/index.tsx b/packages/frontend/core/src/components/affine/awareness/index.tsx index db308bd264..6854640917 100644 --- a/packages/frontend/core/src/components/affine/awareness/index.tsx +++ b/packages/frontend/core/src/components/affine/awareness/index.tsx @@ -1,26 +1,33 @@ +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; +import { useAtomValue } from 'jotai'; import { Suspense, useEffect } from 'react'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; import { useCurrentUser } from '../../../hooks/affine/use-current-user'; -import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; const SyncAwarenessInnerLoggedIn = () => { const currentUser = useCurrentUser(); - const [{ blockSuiteWorkspace: workspace }] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); useEffect(() => { - if (currentUser && workspace) { - workspace.awarenessStore.awareness.setLocalStateField('user', { - name: currentUser.name, - // todo: add avatar? - }); + if (currentUser && currentWorkspace) { + currentWorkspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField( + 'user', + { + name: currentUser.name, + // todo: add avatar? + } + ); return () => { - workspace.awarenessStore.awareness.setLocalStateField('user', null); + currentWorkspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField( + 'user', + null + ); }; } return; - }, [currentUser, workspace]); + }, [currentUser, currentWorkspace]); return null; }; 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 89be6bb554..265ba388c1 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 @@ -1,27 +1,26 @@ import { Input, toast } from '@affine/component'; -import { Button } from '@affine/component/ui/button'; import { ConfirmModal, type ConfirmModalProps, Modal, } from '@affine/component/ui/modal'; -import { Tooltip } from '@affine/component/ui/tooltip'; import { DebugLogger } from '@affine/debug'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { HelpIcon } from '@blocksuite/icons'; +import { workspaceManagerAtom } from '@affine/workspace/atom'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; -import type { - LoadDBFileResult, - SelectDBFileLocationResult, -} from '@toeverything/infra/type'; -import { useSetAtom } from 'jotai'; +import { getCurrentStore } from '@toeverything/infra/atom'; +import { + buildShowcaseWorkspace, + initEmptyPage, +} from '@toeverything/infra/blocksuite'; +import type { LoadDBFileResult } from '@toeverything/infra/type'; +import { useAtomValue } from 'jotai'; import type { KeyboardEvent } from 'react'; -import { useEffect } from 'react'; import { useLayoutEffect } from 'react'; import { useCallback, useState } from 'react'; -import { openDisableCloudAlertModalAtom } from '../../../atoms'; -import { useAppHelper } from '../../../hooks/use-workspaces'; +import { setPageModeAtom } from '../../../atoms'; import * as style from './index.css'; type CreateWorkspaceStep = @@ -94,159 +93,14 @@ const NameWorkspaceContent = ({ ); }; -interface SetDBLocationContentProps { - onConfirmLocation: (dir?: string) => void; -} - -const useDefaultDBLocation = () => { - const [defaultDBLocation, setDefaultDBLocation] = useState(''); - - useEffect(() => { - window.apis?.db - .getDefaultStorageLocation() - .then(dir => { - setDefaultDBLocation(dir); - }) - .catch(err => { - console.error(err); - }); - }, []); - - return defaultDBLocation; -}; - -const SetDBLocationContent = ({ - onConfirmLocation, -}: SetDBLocationContentProps) => { - const t = useAFFiNEI18N(); - const defaultDBLocation = useDefaultDBLocation(); - const [opening, setOpening] = useState(false); - - const handleSelectDBFileLocation = useCallback(() => { - if (opening) { - return; - } - setOpening(true); - (async function () { - const result: SelectDBFileLocationResult = - await window.apis?.dialog.selectDBFileLocation(); - setOpening(false); - if (result?.filePath) { - onConfirmLocation(result.filePath); - } else if (result?.error) { - toast(t[result.error]()); - } - })().catch(err => { - logger.error(err); - }); - }, [onConfirmLocation, opening, t]); - - return ( -
-
- {t['com.affine.setDBLocation.title']()} -
-

{t['com.affine.setDBLocation.description']()}

-
- - - - -
-
- ); -}; - -interface SetSyncingModeContentProps { - mode: CreateWorkspaceMode; - onConfirmMode: (enableCloudSyncing: boolean) => void; -} - -const SetSyncingModeContent = ({ - mode, - onConfirmMode, -}: SetSyncingModeContentProps) => { - const t = useAFFiNEI18N(); - const [enableCloudSyncing, setEnableCloudSyncing] = useState(false); - return ( -
-
- {mode === 'new' - ? t['com.affine.setSyncingMode.title.created']() - : t['com.affine.setSyncingMode.title.added']()} -
- -
- - -
- -
- -
-
- ); -}; - export const CreateWorkspaceModal = ({ mode, onClose, onCreate, }: ModalProps) => { - const { createLocalWorkspace, addLocalWorkspace } = useAppHelper(); const [step, setStep] = useState(); - const [addedId, setAddedId] = useState(); - const [workspaceName, setWorkspaceName] = useState(); - const [dbFileLocation, setDBFileLocation] = useState(); - const setOpenDisableCloudAlertModal = useSetAtom( - openDisableCloudAlertModalAtom - ); const t = useAFFiNEI18N(); + const workspaceManager = useAtomValue(workspaceManagerAtom); // todo: maybe refactor using xstate? useLayoutEffect(() => { @@ -265,9 +119,8 @@ export const CreateWorkspaceModal = ({ setStep(undefined); const result: LoadDBFileResult = await window.apis.dialog.loadDBFile(); if (result.workspaceId && !canceled) { - setAddedId(result.workspaceId); - const newWorkspaceId = await addLocalWorkspace(result.workspaceId); - onCreate(newWorkspaceId); + workspaceManager._addLocalWorkspace(result.workspaceId); + onCreate(result.workspaceId); } else if (result.error || result.canceled) { if (result.error) { toast(t[result.error]()); @@ -285,77 +138,38 @@ export const CreateWorkspaceModal = ({ return () => { canceled = true; }; - }, [addLocalWorkspace, mode, onClose, onCreate, t]); - - const onConfirmEnableCloudSyncing = useCallback( - (enableCloudSyncing: boolean) => { - (async function () { - if (!runtimeConfig.enableCloud && enableCloudSyncing) { - setOpenDisableCloudAlertModal(true); - } else { - let id = addedId; - // syncing mode is also the last step - if (addedId && mode === 'add') { - await addLocalWorkspace(addedId); - } else if (mode === 'new' && workspaceName) { - id = await createLocalWorkspace(workspaceName); - // if dbFileLocation is set, move db file to that location - if (dbFileLocation) { - await window.apis?.dialog.moveDBFile(id, dbFileLocation); - } - } else { - logger.error('invalid state'); - return; - } - if (id) { - onCreate(id); - } - } - })().catch(e => { - logger.error(e); - }); - }, - [ - addLocalWorkspace, - addedId, - createLocalWorkspace, - dbFileLocation, - mode, - onCreate, - setOpenDisableCloudAlertModal, - workspaceName, - ] - ); + }, [mode, onClose, onCreate, t, workspaceManager]); const onConfirmName = useAsyncCallback( async (name: string) => { - setWorkspaceName(name); // this will be the last step for web for now // fix me later - const id = await createLocalWorkspace(name); + const id = await workspaceManager.createWorkspace( + WorkspaceFlavour.LOCAL, + async workspace => { + workspace.meta.setName(name); + if (runtimeConfig.enablePreloading) { + await buildShowcaseWorkspace(workspace, { + store: getCurrentStore(), + atoms: { + pageMode: setPageModeAtom, + }, + }); + } else { + const page = workspace.createPage(); + workspace.setPageMeta(page.id, { + jumpOnce: true, + }); + await initEmptyPage(page); + } + logger.debug('create first workspace'); + } + ); onCreate(id); }, - [createLocalWorkspace, onCreate] + [onCreate, workspaceManager] ); - const setDBLocationNode = - step === 'set-db-location' ? ( - { - setDBFileLocation(dir); - setStep('name-workspace'); - }} - /> - ) : null; - - const setSyncingModeNode = - step === 'set-syncing-mode' ? ( - - ) : null; - const onOpenChange = useCallback( (open: boolean) => { if (!open) { @@ -384,8 +198,6 @@ export const CreateWorkspaceModal = ({ }} >
- {setDBLocationNode} - {setSyncingModeNode} ); }; diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx index df8f267902..0a0239f71c 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx @@ -3,28 +3,28 @@ import { ConfirmModal, type ConfirmModalProps, } from '@affine/component/ui/modal'; -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; +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 { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; +import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info'; import { useCallback, useState } from 'react'; import * as styles from './style.css'; interface WorkspaceDeleteProps extends ConfirmModalProps { - workspace: AffineOfficialWorkspace; + workspaceMetadata: WorkspaceMetadata; } export const WorkspaceDeleteModal = ({ - workspace, + workspaceMetadata, ...props }: WorkspaceDeleteProps) => { const { onConfirm } = props; - const [workspaceName] = useBlockSuiteWorkspaceName( - workspace.blockSuiteWorkspace - ); const [deleteStr, setDeleteStr] = useState(''); + const info = useWorkspaceInfo(workspaceMetadata); + const workspaceName = info?.name ?? UNTITLED_WORKSPACE_NAME; const allowDelete = deleteStr === workspaceName; const t = useAFFiNEI18N(); @@ -46,7 +46,7 @@ export const WorkspaceDeleteModal = ({ }} {...props} > - {workspace.flavour === WorkspaceFlavour.LOCAL ? ( + {workspaceMetadata.flavour === WorkspaceFlavour.LOCAL ? ( Deleting ( diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/index.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/index.tsx index ad27044b7d..38d974b6b2 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/index.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/delete-leave-workspace/index.tsx @@ -1,29 +1,44 @@ +import { pushNotificationAtom } from '@affine/component/notification-center'; import { SettingRow } from '@affine/component/setting-components'; import { ConfirmModal } from '@affine/component/ui/modal'; -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; +import { WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + currentWorkspaceAtom, + workspaceListAtom, + workspaceManagerAtom, +} from '@affine/workspace/atom'; import { ArrowRightSmallIcon } from '@blocksuite/icons'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; +import { openSettingModalAtom } from '../../../../atoms'; +import { + RouteLogic, + useNavigateHelper, +} from '../../../../hooks/use-navigate-helper'; import type { WorkspaceSettingDetailProps } from '../types'; import { WorkspaceDeleteModal } from './delete'; -export interface DeleteLeaveWorkspaceProps extends WorkspaceSettingDetailProps { - workspace: AffineOfficialWorkspace; -} +export interface DeleteLeaveWorkspaceProps + extends WorkspaceSettingDetailProps {} export const DeleteLeaveWorkspace = ({ - workspace, - onDeleteCloudWorkspace, - onDeleteLocalWorkspace, - onLeaveWorkspace, + workspaceMetadata, isOwner, }: DeleteLeaveWorkspaceProps) => { const t = useAFFiNEI18N(); + const { jumpToSubPath, jumpToIndex } = useNavigateHelper(); // fixme: cloud regression const [showDelete, setShowDelete] = useState(false); const [showLeave, setShowLeave] = useState(false); + const setSettingModal = useSetAtom(openSettingModalAtom); + + const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceList = useAtomValue(workspaceListAtom); + const currentWorkspace = useAtomValue(currentWorkspaceAtom); + const pushNotification = useSetAtom(pushNotificationAtom); const onLeaveOrDelete = useCallback(() => { if (isOwner) { @@ -33,18 +48,41 @@ export const DeleteLeaveWorkspace = ({ } }, [isOwner]); - const onLeaveConfirm = useCallback(() => { - return onLeaveWorkspace(); - }, [onLeaveWorkspace]); + const onDeleteConfirm = useAsyncCallback(async () => { + setSettingModal(prev => ({ ...prev, open: false, workspaceId: null })); - const onDeleteConfirm = useCallback(() => { - if (workspace.flavour === WorkspaceFlavour.LOCAL) { - return onDeleteLocalWorkspace(); + if (currentWorkspace?.id === workspaceMetadata.id) { + const backWorkspace = workspaceList.find( + ws => ws.id !== workspaceMetadata.id + ); + // TODO: if there is no workspace, jump to a new page(wait for design) + if (backWorkspace) { + jumpToSubPath( + backWorkspace?.id || '', + WorkspaceSubPath.ALL, + RouteLogic.REPLACE + ); + } else { + jumpToIndex(RouteLogic.REPLACE); + } } - if (workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) { - return onDeleteCloudWorkspace(); - } - }, [onDeleteCloudWorkspace, onDeleteLocalWorkspace, workspace.flavour]); + + await workspaceManager.deleteWorkspace(workspaceMetadata); + pushNotification({ + title: t['Successfully deleted'](), + type: 'success', + }); + }, [ + currentWorkspace?.id, + jumpToIndex, + jumpToSubPath, + pushNotification, + setSettingModal, + t, + workspaceList, + workspaceManager, + workspaceMetadata, + ]); return ( <> @@ -68,13 +106,13 @@ export const DeleteLeaveWorkspace = ({ onConfirm={onDeleteConfirm} open={showDelete} onOpenChange={setShowDelete} - workspace={workspace} + workspaceMetadata={workspaceMetadata} /> ) : ( { + const t = useAFFiNEI18N(); + + const { openPage } = useNavigateHelper(); + + const workspaceManager = useAtomValue(workspaceManagerAtom); + const workspaceInfo = useWorkspaceInfo(workspaceMetadata); + const setSettingModal = useSetAtom(openSettingModalAtom); + + const [open, setOpen] = useState(false); + + const handleEnableCloud = useAsyncCallback(async () => { + if (!workspace) { + return; + } + const { id: newId } = + await workspaceManager.transformLocalToCloud(workspace); + openPage(newId, WorkspaceSubPath.ALL); + setOpen(false); + setSettingModal(settings => ({ + ...settings, + open: false, + })); + }, [openPage, setSettingModal, workspace, workspaceManager]); + + if (workspaceMetadata.flavour !== WorkspaceFlavour.LOCAL) { + return null; + } + + return ( + <> + + + + {runtimeConfig.enableCloud ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/export.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/export.tsx index a74908f90e..36ba065f2b 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/export.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/export.tsx @@ -1,69 +1,35 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; import { SettingRow } from '@affine/component/setting-components'; import { Button } from '@affine/component/ui/button'; -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { Workspace, WorkspaceMetadata } from '@affine/workspace'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import type { SaveDBFileResult } from '@toeverything/infra/type'; import { useSetAtom } from 'jotai'; import { useState } from 'react'; -import type { Doc } from 'yjs'; -import { encodeStateAsUpdate } from 'yjs'; - -async function syncBlobsToSqliteDb(workspace: AffineOfficialWorkspace) { - if (window.apis && environment.isDesktop) { - const bs = workspace.blockSuiteWorkspace.blob; - const blobsInDb = await window.apis.db.getBlobKeys(workspace.id); - const blobsInStorage = await bs.list(); - const blobsToSync = blobsInStorage.filter( - blob => !blobsInDb.includes(blob) - ); - - await Promise.all( - blobsToSync.map(async blobKey => { - const blob = await bs.get(blobKey); - if (blob) { - const bin = new Uint8Array(await blob.arrayBuffer()); - await window.apis.db.addBlob(workspace.id, blobKey, bin); - } - }) - ); - } -} - -async function syncDocsToSqliteDb(workspace: AffineOfficialWorkspace) { - if (window.apis && environment.isDesktop) { - const workspaceId = workspace.blockSuiteWorkspace.doc.guid; - const syncDoc = async (doc: Doc) => { - await window.apis.db.applyDocUpdate( - workspace.id, - encodeStateAsUpdate(doc), - doc.guid === workspaceId ? undefined : doc.guid - ); - await Promise.all([...doc.subdocs].map(subdoc => syncDoc(subdoc))); - }; - - return syncDoc(workspace.blockSuiteWorkspace.doc); - } -} interface ExportPanelProps { - workspace: AffineOfficialWorkspace; + workspaceMetadata: WorkspaceMetadata; + workspace: Workspace | null; } -export const ExportPanel = ({ workspace }: ExportPanelProps) => { - const workspaceId = workspace.id; +export const ExportPanel = ({ + workspaceMetadata, + workspace, +}: ExportPanelProps) => { + const workspaceId = workspaceMetadata.id; const t = useAFFiNEI18N(); - const [syncing, setSyncing] = useState(false); + const [saving, setSaving] = useState(false); + const pushNotification = useSetAtom(pushNotificationAtom); const onExport = useAsyncCallback(async () => { - if (syncing) { + if (saving || !workspace) { return; } - setSyncing(true); + setSaving(true); try { - await syncBlobsToSqliteDb(workspace); - await syncDocsToSqliteDb(workspace); + await workspace.engine.sync.waitForSynced(); + await workspace.engine.blob.sync(); const result: SaveDBFileResult = await window.apis?.dialog.saveDBFileAs(workspaceId); if (result?.error) { @@ -81,16 +47,16 @@ export const ExportPanel = ({ workspace }: ExportPanelProps) => { message: e.message, }); } finally { - setSyncing(false); + setSaving(false); } - }, [pushNotification, syncing, t, workspace, workspaceId]); + }, [pushNotification, saving, t, workspace, workspaceId]); return ( diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/index.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/index.tsx index 56af1e62ef..24ea8c997c 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/index.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/index.tsx @@ -3,47 +3,38 @@ import { SettingRow, SettingWrapper, } from '@affine/component/setting-components'; +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; -import { useMemo } from 'react'; +import { useWorkspace } from '@toeverything/hooks/use-workspace'; +import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info'; import { useSelfHosted } from '../../../hooks/affine/use-server-flavor'; -import { useWorkspace } from '../../../hooks/use-workspace'; import { DeleteLeaveWorkspace } from './delete-leave-workspace'; +import { EnableCloudPanel } from './enable-cloud'; import { ExportPanel } from './export'; import { LabelsPanel } from './labels'; import { MembersPanel } from './members'; import { ProfilePanel } from './profile'; -import { PublishPanel } from './publish'; import { StoragePanel } from './storage'; import type { WorkspaceSettingDetailProps } from './types'; export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => { - const { workspaceId } = props; const t = useAFFiNEI18N(); const isSelfHosted = useSelfHosted(); - const workspace = useWorkspace(workspaceId); - const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace); + const workspaceMetadata = props.workspaceMetadata; - const storageAndExportSetting = useMemo(() => { - if (environment.isDesktop) { - return ( - - {runtimeConfig.enableMoveDatabase ? ( - - ) : null} - - - ); - } else { - return null; - } - }, [t, workspace]); + // useWorkspace hook is a vary heavy operation here, but we need syncing name and avatar changes here, + // we don't have a better way to do this now + const workspace = useWorkspace(workspaceMetadata); + + const workspaceInfo = useWorkspaceInfo(workspaceMetadata); return ( <> @@ -53,20 +44,26 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => { spreadCol={false} > - + - - + + - {storageAndExportSetting} + {environment.isDesktop && ( + + {runtimeConfig.enableMoveDatabase ? ( + + ) : null} + + + )} - + ); diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/labels.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/labels.tsx index 285b525c61..e0242be214 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/labels.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/labels.tsx @@ -1,12 +1,9 @@ -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; import { useMemo } from 'react'; import * as style from './style.css'; import type { WorkspaceSettingDetailProps } from './types'; -export interface LabelsPanelProps extends WorkspaceSettingDetailProps { - workspace: AffineOfficialWorkspace; -} +export interface LabelsPanelProps extends WorkspaceSettingDetailProps {} type WorkspaceStatus = | 'local' @@ -38,7 +35,10 @@ const Label = ({ value, background }: LabelProps) => {
); }; -export const LabelsPanel = ({ workspace, isOwner }: LabelsPanelProps) => { +export const LabelsPanel = ({ + workspaceMetadata, + isOwner, +}: LabelsPanelProps) => { const labelMap: LabelMap = useMemo( () => ({ local: { @@ -74,11 +74,10 @@ export const LabelsPanel = ({ workspace, isOwner }: LabelsPanelProps) => { ); const labelConditions: labelConditionsProps[] = [ { condition: !isOwner, label: 'joinedWorkspace' }, - { condition: workspace.flavour === 'local', label: 'local' }, - { condition: workspace.flavour === 'affine-cloud', label: 'syncCloud' }, + { condition: workspaceMetadata.flavour === 'local', label: 'local' }, { - condition: workspace.flavour === 'affine-public', - label: 'publishedToWeb', + condition: workspaceMetadata.flavour === 'affine-cloud', + label: 'syncCloud', }, //TODO: add these labels // { status==="synced", label: 'availableOffline' } diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx index b29f39ae99..95f8081681 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx @@ -13,7 +13,6 @@ import { Button, IconButton } from '@affine/component/ui/button'; import { Loading } from '@affine/component/ui/loading'; import { Menu, MenuItem } from '@affine/component/ui/menu'; import { Tooltip } from '@affine/component/ui/tooltip'; -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Permission } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -45,7 +44,6 @@ import type { WorkspaceSettingDetailProps } from './types'; const COUNT_PER_PAGE = 8; export interface MembersPanelProps extends WorkspaceSettingDetailProps { upgradable: boolean; - workspace: AffineOfficialWorkspace; } type OnRevoke = (memberId: string) => void; const MembersPanelLocal = () => { @@ -62,11 +60,11 @@ const MembersPanelLocal = () => { }; export const CloudWorkspaceMembersPanel = ({ - workspace, isOwner, upgradable, + workspaceMetadata, }: MembersPanelProps) => { - const workspaceId = workspace.id; + const workspaceId = workspaceMetadata.id; const memberCount = useMemberCount(workspaceId); const t = useAFFiNEI18N(); @@ -138,7 +136,6 @@ export const CloudWorkspaceMembersPanel = ({ setSettingModalAtom({ open: true, activeTab: 'plans', - workspaceId: null, }); }, [setSettingModalAtom]); @@ -345,7 +342,7 @@ const MemberItem = ({ }; export const MembersPanel = (props: MembersPanelProps): ReactElement | null => { - if (props.workspace.flavour === WorkspaceFlavour.LOCAL) { + if (props.workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) { return ; } return ( diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/profile.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/profile.tsx index 59a06c6ed3..fa4943970c 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/profile.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/profile.tsx @@ -2,51 +2,120 @@ import { FlexWrapper, Input, Wrapper } from '@affine/component'; import { pushNotificationAtom } from '@affine/component/notification-center'; import { Avatar } from '@affine/component/ui/avatar'; import { Button } from '@affine/component/ui/button'; -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; +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 { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url'; -import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob'; +import { useWorkspaceStatus } from '@toeverything/hooks/use-workspace-status'; import { useSetAtom } from 'jotai'; import { type KeyboardEvent, type MouseEvent, startTransition, useCallback, + useEffect, useState, } from 'react'; +import { validateAndReduceImage } from '../../../utils/reduce-image'; import { Upload } from '../../pure/file-upload'; import * as style from './style.css'; import type { WorkspaceSettingDetailProps } from './types'; export interface ProfilePanelProps extends WorkspaceSettingDetailProps { - workspace: AffineOfficialWorkspace; + workspace: Workspace | null; } -export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => { +export const ProfilePanel = ({ isOwner, workspace }: ProfilePanelProps) => { const t = useAFFiNEI18N(); const pushNotification = useSetAtom(pushNotificationAtom); - const [workspaceAvatar, update] = useBlockSuiteWorkspaceAvatarUrl( - workspace.blockSuiteWorkspace + const workspaceIsLoading = + useWorkspaceStatus( + workspace, + status => + !status.engine.sync.local || + status.engine.sync.local?.step <= SyncPeerStep.LoadingRootDoc + ) ?? true; + + const [avatarBlob, setAvatarBlob] = useState(null); + const [name, setName] = useState(''); + + const avatarUrl = useWorkspaceBlobObjectUrl(workspace?.meta, avatarBlob); + + useEffect(() => { + if (workspace?.blockSuiteWorkspace) { + setAvatarBlob(workspace.blockSuiteWorkspace.meta.avatar ?? null); + setName( + workspace.blockSuiteWorkspace.meta.name ?? UNTITLED_WORKSPACE_NAME + ); + const dispose = workspace.blockSuiteWorkspace.meta.commonFieldsUpdated.on( + () => { + setAvatarBlob(workspace.blockSuiteWorkspace.meta.avatar ?? null); + setName( + workspace.blockSuiteWorkspace.meta.name ?? UNTITLED_WORKSPACE_NAME + ); + } + ); + return () => { + dispose.dispose(); + }; + } else { + setAvatarBlob(null); + setName(UNTITLED_WORKSPACE_NAME); + } + return; + }, [workspace]); + + const setWorkspaceAvatar = useCallback( + async (file: File | null) => { + if (!workspace) { + return; + } + if (!file) { + workspace.blockSuiteWorkspace.meta.setAvatar(''); + return; + } + try { + const reducedFile = await validateAndReduceImage(file); + const blobs = workspace.blockSuiteWorkspace.blob; + const blobId = await blobs.set(reducedFile); + workspace.blockSuiteWorkspace.meta.setAvatar(blobId); + } catch (error) { + console.error(error); + throw error; + } + }, + [workspace] ); - const [name, setName] = useBlockSuiteWorkspaceName( - workspace.blockSuiteWorkspace + const setWorkspaceName = useCallback( + (name: string) => { + if (!workspace) { + return; + } + workspace.blockSuiteWorkspace.meta.setName(name); + }, + [workspace] ); - const [input, setInput] = useState(name); + const [input, setInput] = useState(''); + useEffect(() => { + setInput(name); + }, [name]); const handleUpdateWorkspaceName = useCallback( (name: string) => { - setName(name); + setWorkspaceName(name); pushNotification({ title: t['Update workspace name success'](), type: 'success', }); }, - [pushNotification, setName, t] + [pushNotification, setWorkspaceName, t] ); const handleSetInput = useCallback((value: string) => { @@ -68,17 +137,17 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => { handleUpdateWorkspaceName(input); }, [handleUpdateWorkspaceName, input]); - const handleRemoveUserAvatar = useCallback( + const handleRemoveUserAvatar = useAsyncCallback( async (e: MouseEvent) => { e.stopPropagation(); - await update(null); + await setWorkspaceAvatar(null); }, - [update] + [setWorkspaceAvatar] ); const handleUploadAvatar = useCallback( (file: File) => { - update(file) + setWorkspaceAvatar(file) .then(() => { pushNotification({ title: 'Update workspace avatar success', @@ -93,10 +162,10 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => { }); }); }, - [pushNotification, update] + [pushNotification, setWorkspaceAvatar] ); - const canAdjustAvatar = workspaceAvatar && isOwner; + const canAdjustAvatar = !workspaceIsLoading && avatarUrl && isOwner; return (
@@ -108,7 +177,7 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => { > : undefined} @@ -132,10 +201,10 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
{t['Workspace Name']()}
{ onChange={handleSetInput} onKeyUp={handleKeyUp} /> - {input === workspace.blockSuiteWorkspace.meta.name ? null : ( + {input === name ? null : ( - - ) : null} -
- ); -}; - -interface FakePublishPanelAffineProps { - workspace: AffineOfficialWorkspace; -} - -const FakePublishPanelAffine = (_props: FakePublishPanelAffineProps) => { - const t = useAFFiNEI18N(); - - return ( - -
- - - -
-
- ); -}; - -const PublishPanelLocal = ({ - workspace, - onTransferWorkspace, -}: PublishPanelLocalProps) => { - const t = useAFFiNEI18N(); - const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace); - - const [open, setOpen] = useState(false); - - return ( - <> - - - - - {runtimeConfig.enableCloud ? ( - { - onTransferWorkspace( - WorkspaceFlavour.LOCAL, - WorkspaceFlavour.AFFINE_CLOUD, - workspace - ); - setOpen(false); - }} - /> - ) : ( - - )} - - ); -}; - -export const PublishPanel = (props: PublishPanelProps) => { - if ( - props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD || - props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC - ) { - return ; - } else if (props.workspace.flavour === WorkspaceFlavour.LOCAL) { - return ; - } - throw new Unreachable(); -}; diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/storage.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/storage.tsx index 9c6ac08873..5a6e45322a 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/storage.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/storage.tsx @@ -2,8 +2,8 @@ import { FlexWrapper, toast } from '@affine/component'; import { SettingRow } from '@affine/component/setting-components'; import { Button } from '@affine/component/ui/button'; import { Tooltip } from '@affine/component/ui/tooltip'; -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { WorkspaceMetadata } from '@affine/workspace/metadata'; import type { MoveDBFileResult } from '@toeverything/infra/type'; import { useMemo } from 'react'; import { useCallback, useEffect, useState } from 'react'; @@ -33,11 +33,11 @@ const useDBFileSecondaryPath = (workspaceId: string) => { }; interface StoragePanelProps { - workspace: AffineOfficialWorkspace; + workspaceMetadata: WorkspaceMetadata; } -export const StoragePanel = ({ workspace }: StoragePanelProps) => { - const workspaceId = workspace.id; +export const StoragePanel = ({ workspaceMetadata }: StoragePanelProps) => { + const workspaceId = workspaceMetadata.id; const t = useAFFiNEI18N(); const secondaryPath = useDBFileSecondaryPath(workspaceId); diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/types.ts b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/types.ts index a090bc756a..b07a28e27b 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/types.ts +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/types.ts @@ -1,20 +1,6 @@ -import type { - WorkspaceFlavour, - WorkspaceRegistry, -} from '@affine/env/workspace'; +import type { WorkspaceMetadata } from '@affine/workspace/metadata'; export interface WorkspaceSettingDetailProps { - workspaceId: string; isOwner: boolean; - onDeleteLocalWorkspace: () => void; - onDeleteCloudWorkspace: () => void; - onLeaveWorkspace: () => void; - onTransferWorkspace: < - From extends WorkspaceFlavour, - To extends WorkspaceFlavour, - >( - from: From, - to: To, - workspace: WorkspaceRegistry[From] - ) => void; + workspaceMetadata: WorkspaceMetadata; } 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 c3844ebd7c..1da1518ffe 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 @@ -5,13 +5,15 @@ import { listHistoryQuery, recoverDocMutation, } from '@affine/graphql'; +import { + createAffineCloudBlobStorage, + globalBlockSuiteSchema, +} from '@affine/workspace'; import { useMutateQueryResource, useMutation, useQueryInfinite, } from '@affine/workspace/affine/gql'; -import { createAffineCloudBlobEngine } from '@affine/workspace/blob'; -import { globalBlockSuiteSchema } from '@affine/workspace/manager'; import { assertEquals } from '@blocksuite/global/utils'; import { Workspace } from '@blocksuite/store'; import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; @@ -107,27 +109,13 @@ const workspaceMap = new Map(); const getOrCreateWorkspace = (workspaceId: string) => { let workspace = workspaceMap.get(workspaceId); if (!workspace) { - const blobEngine = createAffineCloudBlobEngine(workspaceId); + const blobStorage = createAffineCloudBlobStorage(workspaceId); workspace = new Workspace({ id: workspaceId, providerCreators: [], blobStorages: [ () => ({ - crud: { - async get(key) { - return (await blobEngine.get(key)) ?? null; - }, - async set(key, value) { - await blobEngine.set(key, value); - return key; - }, - async delete(key) { - return blobEngine.delete(key); - }, - async list() { - return blobEngine.list(); - }, - }, + crud: blobStorage, }), ], schema: globalBlockSuiteSchema, 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 c78d856020..2cda1b1e25 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,6 +7,7 @@ import { Button } from '@affine/component/ui/button'; import { ConfirmModal, Modal } from '@affine/component/ui/modal'; import type { PageMode } from '@affine/core/atoms'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import type { Workspace } from '@blocksuite/store'; import type { DialogContentProps } from '@radix-ui/react-dialog'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; @@ -22,7 +23,6 @@ import { import { currentModeAtom } from '../../../atoms/mode'; import { pageHistoryModalAtom } from '../../../atoms/page-history'; -import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style'; import { EdgelessSwitchItem, @@ -423,7 +423,7 @@ export const PageHistoryModal = ({ export const GlobalPageHistoryModal = () => { const [{ open, pageId }, setState] = useAtom(pageHistoryModalAtom); - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); const handleOpenChange = useCallback( (open: boolean) => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 651dc953d3..f26a470a61 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -17,7 +17,6 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useMutation, useQuery } from '@affine/workspace/affine/gql'; import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; -import { validateAndReduceImage } from '@toeverything/hooks/use-block-suite-workspace-avatar-url'; import bytes from 'bytes'; import { useSetAtom } from 'jotai'; import { @@ -37,6 +36,7 @@ import { import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; import { useSelfHosted } from '../../../../hooks/affine/use-server-flavor'; import { useUserSubscription } from '../../../../hooks/use-subscription'; +import { validateAndReduceImage } from '../../../../utils/reduce-image'; import { Upload } from '../../../pure/file-upload'; import * as style from './style.css'; @@ -187,7 +187,6 @@ const StoragePanel = () => { setSettingModalAtom({ open: true, activeTab: 'plans', - workspaceId: null, }); }, [setSettingModalAtom]); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index 8fe1fdda44..1bb702bb7b 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -119,7 +119,6 @@ const SubscriptionSettings = () => { setOpenSettingModalAtom({ open: true, activeTab: 'plans', - workspaceId: null, }); }, [setOpenSettingModalAtom]); 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 9651d5a0fd..8c33e1b645 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -1,6 +1,7 @@ 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 { debounce } from 'lodash-es'; import { Suspense, useCallback, useLayoutEffect, useRef } from 'react'; @@ -20,16 +21,16 @@ type ActiveTab = GeneralSettingKeys | 'workspace' | 'account'; export interface SettingProps extends ModalProps { activeTab: ActiveTab; - workspaceId: string | null; + workspaceMetadata?: WorkspaceMetadata | null; onSettingClick: (params: { activeTab: ActiveTab; - workspaceId: string | null; + workspaceMetadata: WorkspaceMetadata | null; }) => void; } export const SettingModal = ({ activeTab = 'appearance', - workspaceId = null, + workspaceMetadata = null, onSettingClick, ...modalProps }: SettingProps) => { @@ -75,22 +76,22 @@ export const SettingModal = ({ (key: GeneralSettingKeys) => { onSettingClick({ activeTab: key, - workspaceId: null, + workspaceMetadata: null, }); }, [onSettingClick] ); const onWorkspaceSettingClick = useCallback( - (workspaceId: string) => { + (workspaceMetadata: WorkspaceMetadata) => { onSettingClick({ activeTab: 'workspace', - workspaceId, + workspaceMetadata, }); }, [onSettingClick] ); const onAccountSettingClick = useCallback(() => { - onSettingClick({ activeTab: 'account', workspaceId: null }); + onSettingClick({ activeTab: 'account', workspaceMetadata: null }); }, [onSettingClick]); return ( @@ -114,7 +115,7 @@ export const SettingModal = ({ onGeneralSettingClick={onGeneralSettingClick} onWorkspaceSettingClick={onWorkspaceSettingClick} selectedGeneralKey={activeTab} - selectedWorkspaceId={workspaceId} + selectedWorkspaceId={workspaceMetadata?.id ?? null} onAccountSettingClick={onAccountSettingClick} /> @@ -125,9 +126,12 @@ export const SettingModal = ({ >
- {activeTab === 'workspace' && workspaceId ? ( + {activeTab === 'workspace' && workspaceMetadata ? ( }> - + ) : null} {generalSettingList.some(v => v.key === activeTab) ? ( 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 a39509437d..8c0c3a8735 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 @@ -4,22 +4,23 @@ import { } from '@affine/component/setting-components'; import { Avatar } from '@affine/component/ui/avatar'; import { Tooltip } from '@affine/component/ui/tooltip'; -import { WorkspaceFlavour } from '@affine/env/workspace'; +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace'; +import { + waitForCurrentWorkspaceAtom, + workspaceListAtom, +} from '@affine/workspace/atom'; import { Logo1Icon } from '@blocksuite/icons'; -import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url'; -import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; +import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob'; +import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info'; import clsx from 'clsx'; import { useAtom, useAtomValue } from 'jotai/react'; -import { type ReactElement, Suspense, useCallback, useMemo } from 'react'; +import { type ReactElement, Suspense, useCallback } from 'react'; import { authAtom } from '../../../../atoms'; import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status'; import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; -import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace'; import { UserPlanButton } from '../../auth/user-plan-button'; import type { GeneralSettingKeys, @@ -109,7 +110,7 @@ export const SettingSidebar = ({ }: { generalSettingList: GeneralSettingList; onGeneralSettingClick: (key: GeneralSettingKeys) => void; - onWorkspaceSettingClick: (workspaceId: string) => void; + onWorkspaceSettingClick: (workspaceMetadata: WorkspaceMetadata) => void; selectedWorkspaceId: string | null; selectedGeneralKey: string | null; onAccountSettingClick: () => void; @@ -182,25 +183,20 @@ export const WorkspaceList = ({ onWorkspaceSettingClick, selectedWorkspaceId, }: { - onWorkspaceSettingClick: (workspaceId: string) => void; + onWorkspaceSettingClick: (workspaceMetadata: WorkspaceMetadata) => void; selectedWorkspaceId: string | null; }) => { - const workspaces = useAtomValue(rootWorkspacesMetadataAtom); - const [currentWorkspace] = useCurrentWorkspace(); - const workspaceList = useMemo(() => { - return workspaces.filter( - ({ flavour }) => flavour !== WorkspaceFlavour.AFFINE_PUBLIC - ); - }, [workspaces]); + const workspaces = useAtomValue(workspaceListAtom); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); return ( <> - {workspaceList.map(workspace => { + {workspaces.map(workspace => { return ( }> { - onWorkspaceSettingClick(workspace.id); + onWorkspaceSettingClick(workspace); }} isCurrent={workspace.id === currentWorkspace.id} isActive={workspace.id === selectedWorkspaceId} @@ -218,33 +214,34 @@ const WorkspaceListItem = ({ isCurrent, isActive, }: { - meta: RootWorkspaceMetadata; + meta: WorkspaceMetadata; onClick: () => void; isCurrent: boolean; isActive: boolean; }) => { - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(meta.id); - const workspace = useAtomValue(workspaceAtom); - const [workspaceAvatar] = useBlockSuiteWorkspaceAvatarUrl(workspace); - const [workspaceName] = useBlockSuiteWorkspaceName(workspace); + const information = useWorkspaceInfo(meta); + + const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar); + + const name = information?.name ?? UNTITLED_WORKSPACE_NAME; return (
- {workspaceName} + {name} {isCurrent ? (
{ - const t = useAFFiNEI18N(); - - const { jumpToSubPath, jumpToIndex } = useNavigateHelper(); - const [currentWorkspace] = useCurrentWorkspace(); - - const workspace = useWorkspace(workspaceId); - const [workspaceName] = useBlockSuiteWorkspaceName( - workspace.blockSuiteWorkspace - ); - const workspaces = useAtomValue(rootWorkspacesMetadataAtom); - const pushNotification = useSetAtom(pushNotificationAtom); - - const leaveWorkspace = useLeaveWorkspace(); - const setSettingModal = useSetAtom(openSettingModalAtom); - const { deleteWorkspace } = useAppHelper(); - - const { NewSettingsDetail } = getUIAdapter(workspace.flavour); - - const closeAndJumpOut = useCallback(() => { - setSettingModal(prev => ({ ...prev, open: false, workspaceId: null })); - - if (currentWorkspace.id === workspaceId) { - const backWorkspace = workspaces.find(ws => ws.id !== workspaceId); - // TODO: if there is no workspace, jump to a new page(wait for design) - if (backWorkspace) { - jumpToSubPath( - backWorkspace?.id || '', - WorkspaceSubPath.ALL, - RouteLogic.REPLACE - ); - } else { - setTimeout(() => { - jumpToIndex(RouteLogic.REPLACE); - }, 100); - } - } - }, [ - currentWorkspace.id, - jumpToIndex, - jumpToSubPath, - setSettingModal, - workspaceId, - workspaces, - ]); - - const handleDeleteWorkspace = useAsyncCallback(async () => { - closeAndJumpOut(); - await deleteWorkspace(workspaceId); - - pushNotification({ - title: t['Successfully deleted'](), - type: 'success', - }); - }, [closeAndJumpOut, deleteWorkspace, pushNotification, t, workspaceId]); - - const handleLeaveWorkspace = useAsyncCallback(async () => { - closeAndJumpOut(); - await leaveWorkspace(workspaceId, workspaceName); - - pushNotification({ - title: 'Successfully leave', - type: 'success', - }); - }, [ - closeAndJumpOut, - leaveWorkspace, - pushNotification, - workspaceId, - workspaceName, - ]); - - const onTransformWorkspace = useOnTransformWorkspace(); - // const handleDelete = useCallback(async () => { - // await onDeleteWorkspace(); - // toast(t['Successfully deleted'](), { - // portal: document.body, - // }); - // onClose(); - // }, [onClose, onDeleteWorkspace, t, workspace.id]); +import { NewWorkspaceSettingDetail } from '../../../../adapters/shared'; +import { useIsWorkspaceOwner } from '../../../../hooks/affine/use-is-workspace-owner'; +export const WorkspaceSetting = ({ + workspaceMetadata, +}: { + workspaceMetadata: WorkspaceMetadata; +}) => { + const isOwner = useIsWorkspaceOwner(workspaceMetadata); return ( - ); }; 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 353f9cd7ca..f957f48567 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,39 +1,41 @@ -import { - type AffineOfficialWorkspace, - WorkspaceFlavour, -} from '@affine/env/workspace'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import type { Workspace } from '@affine/workspace'; +import { workspaceManagerAtom } from '@affine/workspace/atom'; import type { Page } from '@blocksuite/store'; -import { useCallback, useState } from 'react'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useAtomValue } from 'jotai'; +import { useState } from 'react'; -import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace'; +import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { EnableAffineCloudModal } from '../enable-affine-cloud-modal'; import { ShareMenu } from './share-menu'; type SharePageModalProps = { - workspace: AffineOfficialWorkspace; + workspace: Workspace; page: Page; }; export const SharePageButton = ({ workspace, page }: SharePageModalProps) => { - const onTransformWorkspace = useOnTransformWorkspace(); const [open, setOpen] = useState(false); - const handleConfirm = useCallback(() => { + const { openPage } = useNavigateHelper(); + + const workspaceManager = useAtomValue(workspaceManagerAtom); + + const handleConfirm = useAsyncCallback(async () => { if (workspace.flavour !== WorkspaceFlavour.LOCAL) { return; } - onTransformWorkspace( - WorkspaceFlavour.LOCAL, - WorkspaceFlavour.AFFINE_CLOUD, - workspace - ); + const { id: newId } = + await workspaceManager.transformLocalToCloud(workspace); + openPage(newId, page.id); setOpen(false); - }, [onTransformWorkspace, workspace]); + }, [openPage, page.id, workspace, workspaceManager]); return ( <> setOpen(true)} /> diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx index 2f5895e201..2819f1bf53 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx @@ -10,7 +10,10 @@ import * as styles from './index.css'; import type { ShareMenuProps } from './share-menu'; import { useSharingUrl } from './use-share-url'; -export const ShareExport = ({ workspace, currentPage }: ShareMenuProps) => { +export const ShareExport = ({ + workspaceMetadata: workspace, + currentPage, +}: ShareMenuProps) => { const t = useAFFiNEI18N(); const workspaceId = workspace.id; const pageId = currentPage.id; 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 a545341caf..2747fc8f0b 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 @@ -1,14 +1,9 @@ import { Button } from '@affine/component/ui/button'; import { Divider } from '@affine/component/ui/divider'; import { Menu } from '@affine/component/ui/menu'; -import { - type AffineCloudWorkspace, - type AffineOfficialWorkspace, - type AffinePublicWorkspace, - type LocalWorkspace, - WorkspaceFlavour, -} from '@affine/env/workspace'; +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'; @@ -17,13 +12,8 @@ import * as styles from './index.css'; import { ShareExport } from './share-export'; import { SharePage } from './share-page'; -export interface ShareMenuProps< - Workspace extends AffineOfficialWorkspace = - | AffineCloudWorkspace - | LocalWorkspace - | AffinePublicWorkspace, -> { - workspace: Workspace; +export interface ShareMenuProps { + workspaceMetadata: WorkspaceMetadata; currentPage: Page; onEnableAffineCloud: () => void; } @@ -70,7 +60,7 @@ const LocalShareMenu = (props: ShareMenuProps) => { const CloudShareMenu = (props: ShareMenuProps) => { const t = useAFFiNEI18N(); const { - workspace: { id: workspaceId }, + workspaceMetadata: { id: workspaceId }, currentPage, } = props; const { isSharedPage } = useIsSharedPage(workspaceId, currentPage.id); @@ -96,9 +86,9 @@ const CloudShareMenu = (props: ShareMenuProps) => { }; export const ShareMenu = (props: ShareMenuProps) => { - const { workspace } = props; + const { workspaceMetadata } = props; - if (workspace.flavour === WorkspaceFlavour.LOCAL) { + if (workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) { return ; } return ; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx index ad9994e294..165b304ee1 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx @@ -69,7 +69,7 @@ export const LocalSharePage = (props: ShareMenuProps) => { export const AffineSharePage = (props: ShareMenuProps) => { const { - workspace: { id: workspaceId }, + workspaceMetadata: { id: workspaceId }, currentPage, } = props; const pageId = currentPage.id; @@ -239,9 +239,11 @@ export const AffineSharePage = (props: ShareMenuProps) => { }; export const SharePage = (props: ShareMenuProps) => { - if (props.workspace.flavour === WorkspaceFlavour.LOCAL) { + if (props.workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) { return ; - } else if (props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) { + } else if ( + props.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD + ) { return ; } throw new Error('Unreachable'); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx index a48b307668..a356605640 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx @@ -1,4 +1,4 @@ -import type { AffineOfficialWorkspace } from '@affine/env/workspace'; +import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { useBlockSuitePageMeta, usePageMetaHelper, @@ -18,7 +18,7 @@ import { PageHeaderMenuButton } from './operation-menu'; import * as styles from './styles.css'; export interface BlockSuiteHeaderTitleProps { - workspace: AffineOfficialWorkspace; + blockSuiteWorkspace: BlockSuiteWorkspace; pageId: string; isPublic?: boolean; publicMode?: PageMode; @@ -53,7 +53,7 @@ const EditableTitle = ({ }; const StableTitle = ({ - workspace, + blockSuiteWorkspace: workspace, pageId, onRename, isPublic, @@ -61,8 +61,8 @@ const StableTitle = ({ }: BlockSuiteHeaderTitleProps & { onRename?: () => void; }) => { - const currentPage = workspace.blockSuiteWorkspace.getPage(pageId); - const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find( + const currentPage = workspace.getPage(pageId); + const pageMeta = useBlockSuitePageMeta(workspace).find( meta => meta.id === currentPage?.id ); @@ -77,7 +77,7 @@ const StableTitle = ({ return (
{ - const { workspace, pageId } = props; - const currentPage = workspace.blockSuiteWorkspace.getPage(pageId); - const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find( + const { blockSuiteWorkspace: workspace, pageId } = props; + const currentPage = workspace.getPage(pageId); + const pageMeta = useBlockSuitePageMeta(workspace).find( meta => meta.id === currentPage?.id ); - const pageTitleMeta = usePageMetaHelper(workspace.blockSuiteWorkspace); + const pageTitleMeta = usePageMetaHelper(workspace); const [isEditable, setIsEditable] = useState(false); const [title, setPageTitle] = useState(pageMeta?.title || 'Untitled'); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx index e22a35abec..4aa9080bb4 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx @@ -7,6 +7,7 @@ import { } from '@affine/component/ui/menu'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { DuplicateIcon, @@ -18,7 +19,6 @@ import { ImportIcon, PageIcon, } from '@blocksuite/icons'; -import type { PageMeta } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useAtomValue } from 'jotai'; import { useCallback, useState } from 'react'; @@ -27,7 +27,6 @@ import { currentModeAtom } from '../../../atoms/mode'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import { useExportPage } from '../../../hooks/affine/use-export-page'; import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper'; -import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { toast } from '../../../utils'; import { PageHistoryModal } from '../../affine/page-history-modal/history-modal'; import { HeaderDropDownButton } from '../../pure/header-drop-down-button'; @@ -42,16 +41,16 @@ export const PageHeaderMenuButton = ({ rename, pageId }: PageMenuProps) => { const t = useAFFiNEI18N(); // fixme(himself65): remove these hooks ASAP - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const currentPage = blockSuiteWorkspace.getPage(pageId); assertExists(currentPage); const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( meta => meta.id === pageId - ) as PageMeta; + ); const currentMode = useAtomValue(currentModeAtom); - const favorite = pageMeta.favorite ?? false; + const favorite = pageMeta?.favorite ?? false; const { togglePageMode, toggleFavorite, duplicate } = useBlockSuiteMetaHelper(blockSuiteWorkspace); @@ -65,12 +64,15 @@ export const PageHeaderMenuButton = ({ rename, pageId }: PageMenuProps) => { }, []); const handleOpenTrashModal = useCallback(() => { + if (!pageMeta) { + return; + } setTrashModal({ open: true, pageIds: [pageId], pageTitles: [pageMeta.title], }); - }, [pageId, pageMeta.title, setTrashModal]); + }, [pageId, pageMeta, setTrashModal]); const handleFavorite = useCallback(() => { toggleFavorite(pageId); @@ -205,7 +207,7 @@ export const PageHeaderMenuButton = ({ rename, pageId }: PageMenuProps) => { /> ); - if (pageMeta.trash) { + if (pageMeta?.trash) { return null; } return ( diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx index c522693ae6..255880670a 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx @@ -1,6 +1,5 @@ import { Tooltip } from '@affine/component/ui/tooltip'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useAtomValue } from 'jotai'; import type { CSSProperties } from 'react'; @@ -44,8 +43,7 @@ export const EditorModeSwitch = ({ const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( meta => meta.id === pageId ); - assertExists(pageMeta); - const { trash } = pageMeta; + const trash = pageMeta?.trash ?? false; const { togglePageMode, switchToEdgelessMode, switchToPageMode } = useBlockSuiteMetaHelper(blockSuiteWorkspace); diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index bff8c87f63..00a9a5538d 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -1,10 +1,8 @@ import './page-detail-editor.css'; -import { PageNotFoundError } from '@affine/env/constant'; import { assertExists, DisposableGroup } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; import type { Page, Workspace } from '@blocksuite/store'; -import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page'; import { pluginEditorAtom } from '@toeverything/infra/__internal__/plugin'; import { getCurrentStore } from '@toeverything/infra/atom'; @@ -51,10 +49,6 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ isPublic, publishMode, }: PageDetailEditorProps & { page: Page }) { - const meta = useBlockSuitePageMeta(workspace).find( - meta => meta.id === pageId - ); - const { switchToEdgelessMode, switchToPageMode } = useBlockSuiteMetaHelper(workspace); @@ -73,7 +67,6 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ const { appSettings } = useAppSettingHelper(); - assertExists(meta); const value = useMemo(() => { const fontStyle = fontStyleOptions.find( option => option.key === appSettings.fontStyle @@ -171,9 +164,8 @@ export const PageDetailEditor = (props: PageDetailEditorProps) => { const { workspace, pageId } = props; const page = useBlockSuiteWorkspacePage(workspace, pageId); if (!page) { - throw new PageNotFoundError(workspace, pageId); + return null; } - return ( diff --git a/packages/frontend/core/src/components/pure/cmdk/data.tsx b/packages/frontend/core/src/components/pure/cmdk/data.tsx index b32b2d67ee..98ccffe08e 100644 --- a/packages/frontend/core/src/components/pure/cmdk/data.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/data.tsx @@ -2,21 +2,17 @@ import { commandScore } from '@affine/cmdk'; import { useCollectionManager } from '@affine/component/page-list'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { + currentWorkspaceAtom, + waitForCurrentWorkspaceAtom, +} from '@affine/workspace/atom'; import { EdgelessIcon, PageIcon, ViewLayersIcon } from '@blocksuite/icons'; import type { Page, PageMeta } from '@blocksuite/store'; import { useBlockSuitePageMeta, usePageMetaHelper, } from '@toeverything/hooks/use-block-suite-page-meta'; -import { - getWorkspace, - waitForWorkspace, -} from '@toeverything/infra/__internal__/workspace'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, - getCurrentStore, -} from '@toeverything/infra/atom'; +import { currentPageIdAtom, getCurrentStore } from '@toeverything/infra/atom'; import { type AffineCommand, AffineCommandRegistry, @@ -33,7 +29,6 @@ import { recentPageIdsBaseAtom, } from '../../../atoms'; import { collectionsCRUDAtom } from '../../../atoms/collections'; -import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { WorkspaceSubPath } from '../../../shared'; import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils'; @@ -53,8 +48,8 @@ export const cmdkValueAtom = atom(''); // like currentWorkspaceAtom, but not throw error const safeCurrentPageAtom = atom>(async get => { - const currentWorkspaceId = get(currentWorkspaceIdAtom); - if (!currentWorkspaceId) { + const currentWorkspace = get(currentWorkspaceAtom); + if (!currentWorkspace) { return; } @@ -64,9 +59,7 @@ const safeCurrentPageAtom = atom>(async get => { return; } - const workspace = getWorkspace(currentWorkspaceId); - await waitForWorkspace(workspace); - const page = workspace.getPage(currentPageId); + const page = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId); if (!page) { return; @@ -132,7 +125,7 @@ export const filteredAffineCommands = atom(async get => { }); const useWorkspacePages = () => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const pages = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace); return pages; }; @@ -166,7 +159,7 @@ export const pageToCommand = ( blockId?: string ): CMDKCommand => { const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode; - const currentWorkspaceId = store.get(currentWorkspaceIdAtom); + const currentWorkspace = store.get(currentWorkspaceAtom); const title = page.title || t['Untitled'](); const commandLabel = label || { @@ -191,18 +184,18 @@ export const pageToCommand = ( originalValue: title, category: category, run: () => { - if (!currentWorkspaceId) { + if (!currentWorkspace) { console.error('current workspace not found'); return; } if (blockId) { return navigationHelper.jumpToPageBlock( - currentWorkspaceId, + currentWorkspace.id, page.id, blockId ); } - return navigationHelper.jumpToPage(currentWorkspaceId, page.id); + return navigationHelper.jumpToPage(currentWorkspace.id, page.id); }, icon: pageMode === 'edgeless' ? : , timestamp: page.updatedDate, @@ -217,7 +210,7 @@ export const usePageCommands = () => { const recentPages = useRecentPages(); const pages = useWorkspacePages(); const store = getCurrentStore(); - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); const pageHelper = usePageHelper(workspace.blockSuiteWorkspace); const pageMetaHelper = usePageMetaHelper(workspace.blockSuiteWorkspace); const query = useAtomValue(cmdkQueryAtom); @@ -359,7 +352,7 @@ export const collectionToCommand = ( selectCollection: (id: string) => void, t: ReturnType ): CMDKCommand => { - const currentWorkspaceId = store.get(currentWorkspaceIdAtom); + const currentWorkspace = store.get(currentWorkspaceAtom); const label = collection.name || t['Untitled'](); const category = 'affine:collections'; return { @@ -377,11 +370,11 @@ export const collectionToCommand = ( originalValue: label, category: category, run: () => { - if (!currentWorkspaceId) { + if (!currentWorkspace) { console.error('current workspace not found'); return; } - navigationHelper.jumpToSubPath(currentWorkspaceId, WorkspaceSubPath.ALL); + navigationHelper.jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL); selectCollection(collection.id); }, icon: , @@ -395,7 +388,7 @@ export const useCollectionsCommands = () => { const query = useAtomValue(cmdkQueryAtom); const navigationHelper = useNavigateHelper(); const t = useAFFiNEI18N(); - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); const selectCollection = useCallback( (id: string) => { navigationHelper.jumpToCollection(workspace.id, id); diff --git a/packages/frontend/core/src/components/pure/help-island/index.tsx b/packages/frontend/core/src/components/pure/help-island/index.tsx index 87c954c8e2..fa77c692ba 100644 --- a/packages/frontend/core/src/components/pure/help-island/index.tsx +++ b/packages/frontend/core/src/components/pure/help-island/index.tsx @@ -40,7 +40,6 @@ export const HelpIsland = () => { setOpenSettingModalAtom({ open: true, activeTab: tab, - workspaceId: null, }); }, [setOpenSettingModalAtom] 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 57ac8fcc0a..3828201064 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 @@ -3,21 +3,21 @@ import { ConfirmModal } from '@affine/component/ui/modal'; import { Tooltip } from '@affine/component/ui/tooltip'; import { WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon, ResetIcon } from '@blocksuite/icons'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useAtomValue } from 'jotai'; 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 { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { toast } from '../../../utils'; import * as styles from './styles.css'; export const TrashPageFooter = ({ pageId }: { pageId: string }) => { - // fixme(himself65): remove these hooks ASAP - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); 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 cdf3a56226..7e4d36607f 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 @@ -15,7 +15,6 @@ 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 { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useCallback, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; @@ -44,9 +43,9 @@ const CollectionRenderer = ({ const t = useAFFiNEI18N(); const dragItemId = getDropItemId('collections', collection.id); - const removeFromAllowList = useAsyncCallback( - async (id: string) => { - await setting.updateCollection({ + const removeFromAllowList = useCallback( + (id: string) => { + setting.updateCollection({ ...collection, allowList: collection.allowList?.filter(v => v !== id), }); @@ -66,9 +65,7 @@ const CollectionRenderer = ({ } else { toast(t['com.affine.collection.addPage.success']()); } - setting.addPage(collection.id, id).catch(err => { - console.error(err); - }); + setting.addPage(collection.id, id); }, }, }); @@ -90,9 +87,9 @@ const CollectionRenderer = ({ const currentPath = location.pathname.split('?')[0]; const path = `/workspace/${workspace.id}/collection/${collection.id}`; - const onRename = useAsyncCallback( - async (name: string) => { - await setting.updateCollection({ + const onRename = useCallback( + (name: string) => { + setting.updateCollection({ ...collection, name, }); 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 ebd70789c9..9d572cead3 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,12 +1,16 @@ import { Divider } from '@affine/component/ui/divider'; import { MenuItem } from '@affine/component/ui/menu'; +import { Unreachable } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import { + workspaceListAtom, + workspaceManagerAtom, +} from '@affine/workspace/atom'; import { Logo1Icon } from '@blocksuite/icons'; import { useAtomValue, useSetAtom } from 'jotai'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useSession } from 'next-auth/react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { authAtom, @@ -81,9 +85,16 @@ export const UserWithWorkspaceList = ({ onEventEnd?.(); }, [onEventEnd, setOpenCreateWorkspaceModal]); - const workspaces = useAtomValue(rootWorkspacesMetadataAtom, { - delay: 0, - }); + const workspaces = useAtomValue(workspaceListAtom); + + const workspaceManager = useAtomValue(workspaceManagerAtom); + + // revalidate workspace list when mounted + useEffect(() => { + workspaceManager.list.revalidate().catch(err => { + throw new Unreachable('revlidate should never throw, ' + err); + }); + }, [workspaceManager]); return (
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 4d4cd25ecc..a32d41321b 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 @@ -1,39 +1,29 @@ import { ScrollableContainer } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { WorkspaceList } from '@affine/component/workspace-list'; -import type { - AffineCloudWorkspace, - LocalWorkspace, -} from '@affine/env/workspace'; import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace'; +import { currentWorkspaceAtom } from '@affine/workspace/atom'; import type { DragEndEvent } from '@dnd-kit/core'; -import { arrayMove } from '@dnd-kit/sortable'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/infra/atom'; -import { useAtom, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useSession } from 'next-auth/react'; -import { startTransition, useCallback, useMemo, useTransition } from 'react'; +import { useCallback, useMemo } from 'react'; import { openCreateWorkspaceModalAtom, openSettingModalAtom, } from '../../../../../atoms'; -import type { AllWorkspace } from '../../../../../shared'; import { useIsWorkspaceOwner } from '../.././../../../hooks/affine/use-is-workspace-owner'; import { useNavigateHelper } from '../.././../../../hooks/use-navigate-helper'; import * as styles from './index.css'; interface WorkspaceModalProps { disabled?: boolean; - workspaces: (AffineCloudWorkspace | LocalWorkspace)[]; - currentWorkspaceId: AllWorkspace['id'] | null; - onClickWorkspace: (workspace: RootWorkspaceMetadata['id']) => void; - onClickWorkspaceSetting: (workspace: RootWorkspaceMetadata['id']) => void; + workspaces: WorkspaceMetadata[]; + currentWorkspaceId?: string | null; + onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void; + onClickWorkspaceSetting: (workspaceMetadata: WorkspaceMetadata) => void; onNewWorkspace: () => void; onAddWorkspace: () => void; onDragEnd: (event: DragEndEvent) => void; @@ -102,22 +92,14 @@ export const AFFiNEWorkspaceList = ({ workspaces, onEventEnd, }: { - workspaces: RootWorkspaceMetadata[]; + workspaces: WorkspaceMetadata[]; onEventEnd?: () => void; }) => { const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const { jumpToSubPath } = useNavigateHelper(); - const setWorkspaces = useSetAtom(rootWorkspacesMetadataAtom); - - const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom( - currentWorkspaceIdAtom - ); - - const setCurrentPageId = useSetAtom(currentPageIdAtom); - - const [, startCloseTransition] = useTransition(); + const currentWorkspace = useAtomValue(currentWorkspaceAtom); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); @@ -130,7 +112,7 @@ export const AFFiNEWorkspaceList = ({ () => workspaces.filter( ({ flavour }) => flavour === WorkspaceFlavour.AFFINE_CLOUD - ) as (AffineCloudWorkspace | LocalWorkspace)[], + ) as WorkspaceMetadata[], [workspaces] ); @@ -138,44 +120,37 @@ export const AFFiNEWorkspaceList = ({ () => workspaces.filter( ({ flavour }) => flavour === WorkspaceFlavour.LOCAL - ) as (AffineCloudWorkspace | LocalWorkspace)[], + ) as WorkspaceMetadata[], [workspaces] ); const onClickWorkspaceSetting = useCallback( - (workspaceId: string) => { + (workspaceMetadata: WorkspaceMetadata) => { setOpenSettingModalAtom({ open: true, activeTab: 'workspace', - workspaceId, + workspaceMetadata, }); onEventEnd?.(); }, [onEventEnd, setOpenSettingModalAtom] ); - const onMoveWorkspace = useCallback( - (activeId: string, overId: string) => { - const oldIndex = workspaces.findIndex(w => w.id === activeId); - - const newIndex = workspaces.findIndex(w => w.id === overId); - startTransition(() => { - setWorkspaces(workspaces => arrayMove(workspaces, oldIndex, newIndex)); - }); - }, - [setWorkspaces, workspaces] - ); + const onMoveWorkspace = useCallback((_activeId: string, _overId: string) => { + // TODO: order + // const oldIndex = workspaces.findIndex(w => w.id === activeId); + // const newIndex = workspaces.findIndex(w => w.id === overId); + // startTransition(() => { + // setWorkspaces(workspaces => arrayMove(workspaces, oldIndex, newIndex)); + // }); + }, []); const onClickWorkspace = useCallback( - (workspaceId: string) => { - startCloseTransition(() => { - setCurrentWorkspaceId(workspaceId); - setCurrentPageId(null); - jumpToSubPath(workspaceId, WorkspaceSubPath.ALL); - }); + (workspaceMetadata: WorkspaceMetadata) => { + jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL); onEventEnd?.(); }, - [jumpToSubPath, onEventEnd, setCurrentPageId, setCurrentWorkspaceId] + [jumpToSubPath, onEventEnd] ); const onDragEnd = useCallback( @@ -211,7 +186,7 @@ export const AFFiNEWorkspaceList = ({ onClickWorkspaceSetting={onClickWorkspaceSetting} onNewWorkspace={onNewWorkspace} onAddWorkspace={onAddWorkspace} - currentWorkspaceId={currentWorkspaceId} + currentWorkspaceId={currentWorkspace?.id} onDragEnd={onDragEnd} /> {localWorkspaces.length > 0 && cloudWorkspaces.length > 0 ? ( @@ -225,7 +200,7 @@ export const AFFiNEWorkspaceList = ({ onClickWorkspaceSetting={onClickWorkspaceSetting} onNewWorkspace={onNewWorkspace} onAddWorkspace={onAddWorkspace} - currentWorkspaceId={currentWorkspaceId} + currentWorkspaceId={currentWorkspace?.id} onDragEnd={onDragEnd} /> 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 ed06618827..ceff61e906 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 @@ -1,12 +1,10 @@ import { Avatar } from '@affine/component/ui/avatar'; import { Loading } from '@affine/component/ui/loading'; import { Tooltip } from '@affine/component/ui/tooltip'; -import { useCurrentSyncEngine } from '@affine/core/hooks/current/use-current-sync-engine'; +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { - type SyncEngineStatus, - SyncEngineStep, -} from '@affine/workspace/providers'; +import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { CloudWorkspaceIcon, InformationFillDuotoneIcon, @@ -14,8 +12,9 @@ import { NoNetworkIcon, UnsyncIcon, } from '@blocksuite/icons'; -import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url'; -import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; +import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob'; +import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info'; +import { useAtomValue } from 'jotai'; import { debounce } from 'lodash-es'; import { forwardRef, @@ -27,7 +26,6 @@ import { } from 'react'; import { useSystemOnline } from '../../../../hooks/use-system-online'; -import type { AllWorkspace } from '../../../../shared'; import { StyledSelectorContainer, StyledSelectorWrapper, @@ -87,21 +85,18 @@ const OfflineStatus = () => { ); }; -const WorkspaceStatus = ({ - currentWorkspace, -}: { - currentWorkspace: AllWorkspace; -}) => { +const WorkspaceStatus = () => { const isOnline = useSystemOnline(); const [syncEngineStatus, setSyncEngineStatus] = useState(null); - const syncEngine = useCurrentSyncEngine(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + // debounce sync engine status useEffect(() => { - setSyncEngineStatus(syncEngine?.status ?? null); - const disposable = syncEngine?.onStatusChange.on( + setSyncEngineStatus(currentWorkspace.engine.sync.status); + const disposable = currentWorkspace.engine.sync.onStatusChange.on( debounce(status => { setSyncEngineStatus(status); }, 500) @@ -109,7 +104,7 @@ const WorkspaceStatus = ({ return () => { disposable?.dispose(); }; - }, [syncEngine]); + }, [currentWorkspace]); const content = useMemo(() => { // TODO: add i18n @@ -162,17 +157,18 @@ const WorkspaceStatus = ({ export const WorkspaceCard = forwardRef< HTMLDivElement, - { - currentWorkspace: AllWorkspace; - } & HTMLAttributes ->(({ currentWorkspace, ...props }, ref) => { - const [name] = useBlockSuiteWorkspaceName( - currentWorkspace.blockSuiteWorkspace + HTMLAttributes +>(({ ...props }, ref) => { + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + + const information = useWorkspaceInfo(currentWorkspace.meta); + + const avatarUrl = useWorkspaceBlobObjectUrl( + currentWorkspace.meta, + information?.avatar ); - const [workspaceAvatar] = useBlockSuiteWorkspaceAvatarUrl( - currentWorkspace.blockSuiteWorkspace - ); + const name = information?.name ?? UNTITLED_WORKSPACE_NAME; return ( @@ -194,7 +190,7 @@ export const WorkspaceCard = forwardRef< {name} - + ); 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 76dd5b15cc..6162ec023a 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -22,6 +22,7 @@ import { Menu } from '@affine/component/ui/menu'; import { collectionsCRUDAtom } from '@affine/core/atoms/collections'; import { WorkspaceSubPath } from '@affine/env/workspace'; 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'; @@ -40,7 +41,6 @@ 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 type { AllWorkspace } from '../../shared'; import { CollectionsList } from '../pure/workspace-slider-bar/collections'; import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button'; import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button'; @@ -53,7 +53,7 @@ export type RootAppSidebarProps = { isPublicWorkspace: boolean; onOpenQuickSearchModal: () => void; onOpenSettingModal: () => void; - currentWorkspace: AllWorkspace; + currentWorkspace: Workspace; openPage: (pageId: string) => void; createPage: () => Page; currentPath: string; @@ -185,9 +185,9 @@ export const RootAppSidebar = ({ }); const handleCreateCollection = useCallback(() => { open('') - .then(async name => { + .then(name => { const id = nanoid(); - await setting.createCollection(createEmptyCollection(id, { name })); + setting.createCollection(createEmptyCollection(id, { name })); navigateHelper.jumpToCollection(blockSuiteWorkspace.id, id); }) .catch(err => { @@ -230,7 +230,6 @@ export const RootAppSidebar = ({ }} > { setOpenUserWorkspaceList(true); }, [setOpenUserWorkspaceList])} diff --git a/packages/frontend/core/src/components/top-tip.tsx b/packages/frontend/core/src/components/top-tip.tsx index 9a609380b7..daaa2ecdeb 100644 --- a/packages/frontend/core/src/components/top-tip.tsx +++ b/packages/frontend/core/src/components/top-tip.tsx @@ -1,17 +1,18 @@ import { BrowserWarning } from '@affine/component/affine-banner'; import { LocalDemoTips } from '@affine/component/affine-banner'; -import { - type AffineOfficialWorkspace, - WorkspaceFlavour, -} from '@affine/env/workspace'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useSetAtom } from 'jotai'; +import type { Workspace } from '@affine/workspace'; +import { workspaceManagerAtom } from '@affine/workspace/atom'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; import { authAtom } from '../atoms'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; -import { useOnTransformWorkspace } from '../hooks/root/use-on-transform-workspace'; +import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../shared'; import { EnableAffineCloudModal } from './affine/enable-affine-cloud-modal'; const minimumChromeVersion = 106; @@ -57,9 +58,11 @@ const OSWarningMessage = () => { }; export const TopTip = ({ + pageId, workspace, }: { - workspace: AffineOfficialWorkspace; + pageId?: string; + workspace: Workspace; }) => { const loginStatus = useCurrentLoginStatus(); const isLoggedIn = loginStatus === 'authenticated'; @@ -73,18 +76,18 @@ export const TopTip = ({ setAuthModal({ openModal: true, state: 'signIn' }); }, [setAuthModal]); - const onTransformWorkspace = useOnTransformWorkspace(); - const handleConfirm = useCallback(() => { + const { openPage } = useNavigateHelper(); + const workspaceManager = useAtomValue(workspaceManagerAtom); + const handleConfirm = useAsyncCallback(async () => { if (workspace.flavour !== WorkspaceFlavour.LOCAL) { return; } - onTransformWorkspace( - WorkspaceFlavour.LOCAL, - WorkspaceFlavour.AFFINE_CLOUD, - workspace - ); + // TODO: we need to transform local to cloud + const { id: newId } = + await workspaceManager.transformLocalToCloud(workspace); + openPage(newId, pageId || WorkspaceSubPath.ALL); setOpen(false); - }, [onTransformWorkspace, workspace]); + }, [openPage, pageId, workspace, workspaceManager]); if ( showLocalDemoTips && diff --git a/packages/frontend/core/src/components/workspace-upgrade/upgrade-hooks.ts b/packages/frontend/core/src/components/workspace-upgrade/upgrade-hooks.ts deleted file mode 100644 index 47d3976b5a..0000000000 --- a/packages/frontend/core/src/components/workspace-upgrade/upgrade-hooks.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; -import type { Workspace } from '@blocksuite/store'; -import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; -import { getCurrentStore } from '@toeverything/infra/atom'; -import type { MigrationPoint } from '@toeverything/infra/blocksuite'; -import { - migrateLocalBlobStorage, - migrateWorkspace, -} from '@toeverything/infra/blocksuite'; -import { nanoid } from 'nanoid'; -import { useState } from 'react'; -import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; - -import { WorkspaceAdapters } from '../../adapters/workspace'; -import { useCurrentSyncEngine } from '../../hooks/current/use-current-sync-engine'; -import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; - -export type UpgradeState = 'pending' | 'upgrading' | 'done' | 'error'; - -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); - } - } -} - -export function useUpgradeWorkspace(migration: MigrationPoint) { - const [state, setState] = useState('pending'); - const [error, setError] = useState(null); - const [newWorkspaceId, setNewWorkspaceId] = useState(null); - - const [workspace] = useCurrentWorkspace(); - const syncEngine = useCurrentSyncEngine(); - const rootStore = getCurrentStore(); - - const upgradeWorkspace = useAsyncCallback(async () => { - setState('upgrading'); - setError(null); - try { - // Migration need to wait for root doc and all subdocs loaded. - await syncEngine?.waitForSynced(); - - // Clone a new doc to prevent change events. - const clonedDoc = new YDoc({ - guid: workspace.blockSuiteWorkspace.doc.guid, - }); - applyDoc(clonedDoc, workspace.blockSuiteWorkspace.doc); - const schema = workspace.blockSuiteWorkspace.schema; - let newWorkspace: Workspace | null = null; - - const resultDoc = await migrateWorkspace(migration, { - doc: clonedDoc, - schema, - createWorkspace: () => { - // Migrate to subdoc version need to create a new workspace. - // It will only happened for old local workspace. - newWorkspace = getOrCreateWorkspace(nanoid(), WorkspaceFlavour.LOCAL); - return Promise.resolve(newWorkspace); - }, - }); - - if (newWorkspace) { - const localMetaString = - localStorage.getItem('jotai-workspaces') ?? '[]'; - const localMetadataList = JSON.parse( - localMetaString - ) as RootWorkspaceMetadata[]; - const currentLocalMetadata = localMetadataList.find( - item => item.id === workspace.id - ); - const flavour = currentLocalMetadata?.flavour ?? WorkspaceFlavour.LOCAL; - - // Legacy logic moved from `setup.ts`. - // It works well before, should be refactor or remove in the future. - const adapter = WorkspaceAdapters[flavour]; - const newId = await adapter.CRUD.create(newWorkspace); - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(newId); - await rootStore.get(workspaceAtom); // Trigger provider sync to persist data. - - await adapter.CRUD.delete(workspace.blockSuiteWorkspace); - await migrateLocalBlobStorage(workspace.id, newId); - setNewWorkspaceId(newId); - - const index = localMetadataList.findIndex( - meta => meta.id === workspace.id - ); - localMetadataList[index] = { - ...currentLocalMetadata, - id: newId, - flavour, - }; - localStorage.setItem( - 'jotai-workspaces', - JSON.stringify(localMetadataList) - ); - localStorage.setItem('last_workspace_id', newId); - localStorage.removeItem('last_page_id'); - } else { - applyDoc(workspace.blockSuiteWorkspace.doc, resultDoc); - } - - await syncEngine?.waitForSynced(); - - setState('done'); - } catch (e: any) { - console.error(e); - setError(e); - setState('error'); - } - }, [rootStore, workspace, syncEngine, migration]); - - return [state, error, upgradeWorkspace, newWorkspaceId] as const; -} diff --git a/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx b/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx index 62fd5dcf98..d37051501d 100644 --- a/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx +++ b/packages/frontend/core/src/components/workspace-upgrade/upgrade.tsx @@ -1,90 +1,84 @@ import { AffineShapeIcon } from '@affine/component/page-list'; // TODO: import from page-list temporarily, need to defined common svg icon/images management. import { Button } from '@affine/component/ui/button'; +import { WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { MigrationPoint } from '@toeverything/infra/blocksuite'; -import { useCallback, useMemo } from 'react'; +import { + waitForCurrentWorkspaceAtom, + workspaceManagerAtom, +} from '@affine/workspace/atom'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useWorkspaceStatus } from '@toeverything/hooks/use-workspace-status'; +import { useAtomValue } from 'jotai'; +import { useState } from 'react'; -import { pathGenerator } from '../../shared'; +import { useNavigateHelper } from '../../hooks/use-navigate-helper'; import * as styles from './upgrade.css'; -import { type UpgradeState, useUpgradeWorkspace } from './upgrade-hooks'; import { ArrowCircleIcon, HeartBreakIcon } from './upgrade-icon'; -const UPGRADE_TIPS_KEYS = { - pending: 'com.affine.upgrade.tips.normal', - upgrading: 'com.affine.upgrade.tips.normal', - done: 'com.affine.upgrade.tips.done', - error: 'com.affine.upgrade.tips.error', -} as const; - -const BUTTON_TEXT_KEYS = { - pending: 'com.affine.upgrade.button-text.pending', - upgrading: 'com.affine.upgrade.button-text.upgrading', - done: 'com.affine.upgrade.button-text.done', - error: 'com.affine.upgrade.button-text.error', -} as const; - -function UpgradeIcon({ upgradeState }: { upgradeState: UpgradeState }) { - if (upgradeState === 'error') { - return ; - } - return ( - - ); -} - -interface WorkspaceUpgradeProps { - migration: MigrationPoint; -} - /** * TODO: Help info is not implemented yet. */ -export const WorkspaceUpgrade = function WorkspaceUpgrade( - props: WorkspaceUpgradeProps -) { - const [upgradeState, error, upgradeWorkspace, newWorkspaceId] = - useUpgradeWorkspace(props.migration); +export const WorkspaceUpgrade = function WorkspaceUpgrade() { + const [error, setError] = useState(null); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const workspaceManager = useAtomValue(workspaceManagerAtom); + const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade); + const { openPage } = useNavigateHelper(); const t = useAFFiNEI18N(); - const refreshPage = useCallback(() => { - window.location.reload(); - }, []); + const onButtonClick = useAsyncCallback(async () => { + if (upgradeStatus?.upgrading) { + return; + } - const onButtonClick = useMemo(() => { - if (upgradeState === 'done') { + try { + const newWorkspaceId = + await currentWorkspace.upgrade.upgrade(workspaceManager); if (newWorkspaceId) { - return () => { - window.location.replace(pathGenerator.all(newWorkspaceId)); - }; + openPage(newWorkspaceId, WorkspaceSubPath.ALL); + } else { + // blocksuite may enter an incorrect state, reload to reset it. + location.reload(); } - - return refreshPage; + } catch (error) { + setError(error instanceof Error ? error.message : '' + error); } - - if (upgradeState === 'pending') { - return upgradeWorkspace; - } - - return undefined; - }, [upgradeState, upgradeWorkspace, refreshPage, newWorkspaceId]); + }, [ + upgradeStatus?.upgrading, + currentWorkspace.upgrade, + workspaceManager, + openPage, + ]); return (

- {error ? error.message : t[UPGRADE_TIPS_KEYS[upgradeState]]()} + {error ? error : t['com.affine.upgrade.tips.normal']()}

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 3d5e9ea92d..e869cb9a16 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,16 +4,17 @@ import { FavoriteTag, } from '@affine/component/page-list'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import type { PageMeta } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useAtomValue } from 'jotai'; import { useCallback, useMemo } from 'react'; import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; -import { useCurrentWorkspace } from '../current/use-current-workspace'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; export const useAllPageListConfig = () => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const workspace = currentWorkspace.blockSuiteWorkspace; const pageMetas = useBlockSuitePageMeta(workspace); const { isPreferredEdgeless } = usePageHelper(workspace); 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 3470a61dbb..1b09ee3a54 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,13 +1,23 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; import { getIsOwnerQuery } from '@affine/graphql'; import { useQueryImmutable } from '@affine/workspace/affine/gql'; +import type { WorkspaceMetadata } from '@affine/workspace/metadata'; -export function useIsWorkspaceOwner(workspaceId: string) { - const { data } = useQueryImmutable({ - query: getIsOwnerQuery, - variables: { - workspaceId, - }, - }); +export function useIsWorkspaceOwner(workspaceMetadata: WorkspaceMetadata) { + const { data } = useQueryImmutable( + workspaceMetadata.flavour !== WorkspaceFlavour.LOCAL + ? { + query: getIsOwnerQuery, + variables: { + workspaceId: workspaceMetadata.id, + }, + } + : undefined + ); + + if (workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) { + return true; + } return data.isOwner; } diff --git a/packages/frontend/core/src/hooks/affine/use-leave-workspace.ts b/packages/frontend/core/src/hooks/affine/use-leave-workspace.ts deleted file mode 100644 index b630512ea5..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-leave-workspace.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { leaveWorkspaceMutation } from '@affine/graphql'; -import { useMutation } from '@affine/workspace/affine/gql'; -import { useCallback } from 'react'; - -import { useAppHelper } from '../use-workspaces'; - -export function useLeaveWorkspace() { - const { deleteWorkspaceMeta } = useAppHelper(); - - const { trigger: leaveWorkspace } = useMutation({ - mutation: leaveWorkspaceMutation, - }); - - return useCallback( - async (workspaceId: string, workspaceName: string) => { - deleteWorkspaceMeta(workspaceId); - - await leaveWorkspace({ - workspaceId, - workspaceName, - sendLeaveMail: true, - }); - }, - [deleteWorkspaceMeta, leaveWorkspace] - ); -} 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 73a8b53e62..4667436032 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,6 +1,7 @@ import { toast } from '@affine/component'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons'; import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; @@ -8,11 +9,10 @@ import { PreconditionStrategy, registerAffineCommand, } from '@toeverything/infra/command'; -import { useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; import { pageHistoryModalAtom } from '../../atoms/page-history'; -import { useCurrentWorkspace } from '../current/use-current-workspace'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; import { useExportPage } from './use-export-page'; import { useTrashModalHelper } from './use-trash-modal-helper'; @@ -22,7 +22,7 @@ export function useRegisterBlocksuiteEditorCommands( mode: 'page' | 'edgeless' ) { const t = useAFFiNEI18N(); - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); 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 367354e0cf..89b1d0eadf 100644 --- a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts +++ b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts @@ -1,11 +1,12 @@ import { toast } from '@affine/component'; import type { DraggableTitleCellData } from '@affine/component/page-list'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core'; import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useAtomValue } from 'jotai'; import { useCallback } from 'react'; -import { useCurrentWorkspace } from '../current/use-current-workspace'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; import { useTrashModalHelper } from './use-trash-modal-helper'; @@ -68,7 +69,7 @@ export function getDragItemId( export const useSidebarDrag = () => { const t = useAFFiNEI18N(); - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); 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 0e5a35ada8..8b16a611c3 100644 --- a/packages/frontend/core/src/hooks/current/use-current-page.ts +++ b/packages/frontend/core/src/hooks/current/use-current-page.ts @@ -1,12 +1,14 @@ -import { - currentPageIdAtom, - currentWorkspaceAtom, -} from '@toeverything/infra/atom'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; +import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page'; +import { currentPageIdAtom } from '@toeverything/infra/atom'; import { useAtomValue } from 'jotai'; export const useCurrentPage = () => { const currentPageId = useAtomValue(currentPageIdAtom); - const currentWorkspace = useAtomValue(currentWorkspaceAtom); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); - return currentPageId ? currentWorkspace.getPage(currentPageId) : null; + return useBlockSuiteWorkspacePage( + currentWorkspace?.blockSuiteWorkspace, + currentPageId + ); }; diff --git a/packages/frontend/core/src/hooks/current/use-current-sync-engine.ts b/packages/frontend/core/src/hooks/current/use-current-sync-engine.ts deleted file mode 100644 index 2fd2fcdac0..0000000000 --- a/packages/frontend/core/src/hooks/current/use-current-sync-engine.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { SyncEngine, SyncEngineStatus } from '@affine/workspace/providers'; -import { useEffect, useState } from 'react'; - -import { useCurrentWorkspace } from './use-current-workspace'; - -export function useCurrentSyncEngine(): SyncEngine | undefined { - const [workspace] = useCurrentWorkspace(); - // FIXME: This is a hack to get the sync engine, we need refactor this in the future. - const syncEngine = ( - workspace.blockSuiteWorkspace.providers[0] as { engine?: SyncEngine } - )?.engine; - - return syncEngine; -} - -export function useCurrentSyncEngineStatus(): SyncEngineStatus | undefined { - const syncEngine = useCurrentSyncEngine(); - const [status, setStatus] = useState(); - - useEffect(() => { - if (syncEngine) { - setStatus(syncEngine.status); - return syncEngine.onStatusChange.on(status => { - setStatus(status); - }).dispose; - } else { - setStatus(undefined); - } - return; - }, [syncEngine]); - - return status; -} diff --git a/packages/frontend/core/src/hooks/current/use-current-workspace.ts b/packages/frontend/core/src/hooks/current/use-current-workspace.ts deleted file mode 100644 index 00a85ef948..0000000000 --- a/packages/frontend/core/src/hooks/current/use-current-workspace.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { assertExists } from '@blocksuite/global/utils'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/infra/atom'; -import { useAtom, useSetAtom } from 'jotai'; -import { useCallback, useEffect } from 'react'; - -import type { AllWorkspace } from '../../shared'; -import { useWorkspace, useWorkspaceEffect } from '../use-workspace'; - -declare global { - /** - * @internal debug only - */ - // eslint-disable-next-line no-var - var currentWorkspace: AllWorkspace | undefined; - interface WindowEventMap { - 'affine:workspace:change': CustomEvent<{ id: string }>; - } -} - -export function useCurrentWorkspace(): [ - AllWorkspace, - (id: string | null) => void, -] { - const [id, setId] = useAtom(currentWorkspaceIdAtom); - assertExists(id); - const currentWorkspace = useWorkspace(id); - // when you call current workspace, effect is always called - useWorkspaceEffect(currentWorkspace.id); - useEffect(() => { - globalThis.currentWorkspace = currentWorkspace; - globalThis.dispatchEvent( - new CustomEvent('affine:workspace:change', { - detail: { id: currentWorkspace.id }, - }) - ); - }, [currentWorkspace]); - const setPageId = useSetAtom(currentPageIdAtom); - return [ - currentWorkspace, - useCallback( - (id: string | null) => { - if (environment.isBrowser && id) { - localStorage.setItem('last_workspace_id', id); - } - setPageId(null); - setId(id); - }, - [setId, setPageId] - ), - ]; -} diff --git a/packages/frontend/core/src/hooks/root/use-on-transform-workspace.ts b/packages/frontend/core/src/hooks/root/use-on-transform-workspace.ts deleted file mode 100644 index 496b66759a..0000000000 --- a/packages/frontend/core/src/hooks/root/use-on-transform-workspace.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { pushNotificationAtom } from '@affine/component/notification-center'; -import type { WorkspaceRegistry } from '@affine/env/workspace'; -import type { WorkspaceFlavour } from '@affine/env/workspace'; -import { WorkspaceSubPath } from '@affine/env/workspace'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - rootWorkspacesMetadataAtom, - workspaceAdaptersAtom, -} from '@affine/workspace/atom'; -import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; -import { currentPageIdAtom } from '@toeverything/infra/atom'; -import { WorkspaceVersion } from '@toeverything/infra/blocksuite'; -import { useAtomValue, useSetAtom } from 'jotai'; - -import { openSettingModalAtom } from '../../atoms'; -import { useNavigateHelper } from '../use-navigate-helper'; - -export function useOnTransformWorkspace() { - const t = useAFFiNEI18N(); - const setSettingModal = useSetAtom(openSettingModalAtom); - const WorkspaceAdapters = useAtomValue(workspaceAdaptersAtom); - const setMetadata = useSetAtom(rootWorkspacesMetadataAtom); - const { openPage } = useNavigateHelper(); - const currentPageId = useAtomValue(currentPageIdAtom); - const pushNotification = useSetAtom(pushNotificationAtom); - - return useAsyncCallback( - async ( - from: From, - to: To, - workspace: WorkspaceRegistry[From] - ): Promise => { - // create first, then delete, in case of failure - const newId = await WorkspaceAdapters[to].CRUD.create( - workspace.blockSuiteWorkspace - ); - await WorkspaceAdapters[from].CRUD.delete(workspace.blockSuiteWorkspace); - setMetadata(workspaces => { - const idx = workspaces.findIndex(ws => ws.id === workspace.id); - workspaces.splice(idx, 1, { - id: newId, - flavour: to, - version: WorkspaceVersion.SubDoc, - }); - return [...workspaces]; - }, newId); - // fixme(himself65): setting modal could still open and open the non-exist workspace - setSettingModal(settings => ({ - ...settings, - open: false, - })); - window.dispatchEvent( - new CustomEvent('affine-workspace:transform', { - detail: { - from, - to, - oldId: workspace.id, - newId: newId, - }, - }) - ); - openPage(newId, currentPageId ?? WorkspaceSubPath.ALL); - pushNotification({ - title: t['Successfully enabled AFFiNE Cloud'](), - type: 'success', - }); - }, - [ - WorkspaceAdapters, - setMetadata, - setSettingModal, - openPage, - currentPageId, - pushNotification, - t, - ] - ); -} - -declare global { - // global Events - interface WindowEventMap { - 'affine-workspace:transform': CustomEvent<{ - from: WorkspaceFlavour; - to: WorkspaceFlavour; - oldId: string; - newId: string; - }>; - } -} 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 d28eaa7860..b10fe3bf65 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -1,5 +1,6 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { useAtom, useStore } from 'jotai'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; +import { useAtom, useAtomValue, useStore } from 'jotai'; import { useTheme } from 'next-themes'; import { useEffect } from 'react'; @@ -14,14 +15,13 @@ import { } from '../commands'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; import { useLanguageHelper } from './affine/use-language-helper'; -import { useCurrentWorkspace } from './current/use-current-workspace'; import { useNavigateHelper } from './use-navigate-helper'; export function useRegisterWorkspaceCommands() { const store = useStore(); const t = useAFFiNEI18N(); const theme = useTheme(); - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); 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 deleted file mode 100644 index 780776b9da..0000000000 --- a/packages/frontend/core/src/hooks/use-workspace-blob.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { BlobManager } from '@blocksuite/store'; -import { useEffect, useMemo, useRef, useState } from 'react'; - -import type { BlockSuiteWorkspace } from '../shared'; - -const logger = new DebugLogger('useWorkspaceBlob'); - -export function useWorkspaceBlob( - blockSuiteWorkspace: BlockSuiteWorkspace -): BlobManager { - return useMemo(() => blockSuiteWorkspace.blob, [blockSuiteWorkspace.blob]); -} - -export function useWorkspaceBlobImage( - key: string | null, - blockSuiteWorkspace: BlockSuiteWorkspace -) { - const blobManager = useWorkspaceBlob(blockSuiteWorkspace); - const [blob, setBlob] = useState(null); - useEffect(() => { - const controller = new AbortController(); - if (key === null) { - setBlob(null); - return; - } - blobManager - ?.get(key) - .then(blob => { - if (controller.signal.aborted) { - return; - } - if (blob) { - setBlob(blob); - } - }) - .catch(err => { - logger.error('Failed to get blob', err); - }); - return () => { - controller.abort(); - }; - }, [blobManager, key]); - const [url, setUrl] = useState(null); - const ref = useRef(null); - - useEffect(() => { - if (ref.current) { - URL.revokeObjectURL(ref.current); - } - if (blob) { - const url = URL.createObjectURL(blob); - setUrl(url); - ref.current = url; - } - }, [blob]); - return url; -} diff --git a/packages/frontend/core/src/hooks/use-workspace.ts b/packages/frontend/core/src/hooks/use-workspace.ts deleted file mode 100644 index dc3937ad06..0000000000 --- a/packages/frontend/core/src/hooks/use-workspace.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - type AffineOfficialWorkspace, - WorkspaceFlavour, -} from '@affine/env/workspace'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -import type { Workspace } from '@blocksuite/store'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; -import type { Atom } from 'jotai'; -import { atom, useAtomValue } from 'jotai'; - -const workspaceWeakMap = new WeakMap< - Workspace, - Atom> ->(); - -// workspace effect is the side effect like connect to the server/indexeddb, -// this will save the workspace updates permanently. -export function useWorkspaceEffect(workspaceId: string): void { - const [, effectAtom] = getBlockSuiteWorkspaceAtom(workspaceId); - useAtomValue(effectAtom); -} - -// todo(himself65): remove this hook -export function useWorkspace(workspaceId: string): AffineOfficialWorkspace { - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId); - const workspace = useAtomValue(workspaceAtom); - if (!workspaceWeakMap.has(workspace)) { - const baseAtom = atom(async get => { - const metadataList = await get(rootWorkspacesMetadataAtom); - const flavour = metadataList.find(({ id }) => id === workspaceId) - ?.flavour; - - if (!flavour) { - // when last workspace is removed, we may encounter this warning. it should be fine - console.warn( - 'workspace not found in rootWorkspacesMetadataAtom, maybe it is removed', - workspaceId - ); - } - - return { - id: workspaceId, - flavour: flavour ?? WorkspaceFlavour.LOCAL, - blockSuiteWorkspace: workspace, - }; - }); - workspaceWeakMap.set(workspace, baseAtom); - } - - return useAtomValue( - workspaceWeakMap.get(workspace) as Atom> - ); -} diff --git a/packages/frontend/core/src/hooks/use-workspaces.ts b/packages/frontend/core/src/hooks/use-workspaces.ts deleted file mode 100644 index 1d6cf40092..0000000000 --- a/packages/frontend/core/src/hooks/use-workspaces.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -import { saveWorkspaceToLocalStorage } from '@affine/workspace/local/crud'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; -import { getWorkspace } from '@toeverything/infra/__internal__/workspace'; -import { getCurrentStore } from '@toeverything/infra/atom'; -import { - buildShowcaseWorkspace, - WorkspaceVersion, -} from '@toeverything/infra/blocksuite'; -import { useAtomValue, useSetAtom } from 'jotai'; -import { nanoid } from 'nanoid'; -import { useCallback } from 'react'; - -import { LocalAdapter } from '../adapters/local'; -import { WorkspaceAdapters } from '../adapters/workspace'; -import { setPageModeAtom } from '../atoms'; - -const logger = new DebugLogger('use-workspaces'); - -/** - * This hook has the permission to all workspaces. Be careful when using it. - */ -export function useAppHelper() { - const jotaiWorkspaces = useAtomValue(rootWorkspacesMetadataAtom); - const set = useSetAtom(rootWorkspacesMetadataAtom); - return { - addLocalWorkspace: useCallback( - async (workspaceId: string): Promise => { - getOrCreateWorkspace(workspaceId, WorkspaceFlavour.LOCAL); - saveWorkspaceToLocalStorage(workspaceId); - set(workspaces => [ - ...workspaces, - { - id: workspaceId, - flavour: WorkspaceFlavour.LOCAL, - version: WorkspaceVersion.DatabaseV3, - }, - ]); - logger.debug('imported local workspace', workspaceId); - return workspaceId; - }, - [set] - ), - addCloudWorkspace: useCallback( - (workspaceId: string) => { - getOrCreateWorkspace(workspaceId, WorkspaceFlavour.AFFINE_CLOUD); - set(workspaces => [ - ...workspaces, - { - id: workspaceId, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - version: WorkspaceVersion.DatabaseV3, - }, - ]); - logger.debug('imported cloud workspace', workspaceId); - }, - [set] - ), - createLocalWorkspace: useCallback( - async (name: string): Promise => { - const blockSuiteWorkspace = getOrCreateWorkspace( - nanoid(), - WorkspaceFlavour.LOCAL - ); - blockSuiteWorkspace.meta.setName(name); - const id = await LocalAdapter.CRUD.create(blockSuiteWorkspace); - { - // this is hack, because CRUD doesn't return the workspace - const blockSuiteWorkspace = getOrCreateWorkspace( - id, - WorkspaceFlavour.LOCAL - ); - await buildShowcaseWorkspace(blockSuiteWorkspace, { - store: getCurrentStore(), - atoms: { - pageMode: setPageModeAtom, - }, - }); - } - set(workspaces => [ - ...workspaces, - { - id, - flavour: WorkspaceFlavour.LOCAL, - version: WorkspaceVersion.DatabaseV3, - }, - ]); - logger.debug('created local workspace', id); - return id; - }, - [set] - ), - deleteWorkspace: useCallback( - async (workspaceId: string) => { - const targetJotaiWorkspace = jotaiWorkspaces.find( - ws => ws.id === workspaceId - ); - if (!targetJotaiWorkspace) { - throw new Error('page cannot be found'); - } - - const targetWorkspace = getWorkspace(targetJotaiWorkspace.id); - - // delete workspace from plugin - await WorkspaceAdapters[targetJotaiWorkspace.flavour].CRUD.delete( - targetWorkspace - ); - // delete workspace from jotai storage - set(workspaces => workspaces.filter(ws => ws.id !== workspaceId)); - }, - [jotaiWorkspaces, set] - ), - deleteWorkspaceMeta: useCallback( - (workspaceId: string) => { - set(workspaces => workspaces.filter(ws => ws.id !== workspaceId)); - }, - [set] - ), - }; -} diff --git a/packages/frontend/core/src/index.tsx b/packages/frontend/core/src/index.tsx index b7cef0c072..f6397dcc61 100644 --- a/packages/frontend/core/src/index.tsx +++ b/packages/frontend/core/src/index.tsx @@ -2,23 +2,23 @@ import './polyfill/ses-lockdown'; import './polyfill/intl-segmenter'; import './polyfill/request-idle-callback'; -import { WorkspaceFallback } from '@affine/component/workspace'; import { assertExists } from '@blocksuite/global/utils'; import { getCurrentStore } from '@toeverything/infra/atom'; -import { StrictMode, Suspense } from 'react'; +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { App } from './app'; import { bootstrapPluginSystem } from './bootstrap/register-plugins'; +import { setup } from './bootstrap/setup'; import { performanceLogger } from './shared'; const performanceMainLogger = performanceLogger.namespace('main'); -async function main() { +function main() { performanceMainLogger.info('start'); - const { setup } = await import('./bootstrap/setup'); const rootStore = getCurrentStore(); performanceMainLogger.info('setup start'); - await setup(rootStore); + setup(); performanceMainLogger.info('setup done'); bootstrapPluginSystem(rootStore).catch(err => { @@ -26,20 +26,19 @@ async function main() { }); performanceMainLogger.info('import app'); - const { App } = await import('./app'); const root = document.getElementById('app'); assertExists(root); performanceMainLogger.info('render app'); createRoot(root).render( - }> - - + ); } -main().catch(err => { +try { + main(); +} catch (err) { console.error('Failed to bootstrap app', err); -}); +} diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index c578613e34..e903416643 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -7,8 +7,7 @@ import { PageListDragOverlay, } from '@affine/component/page-list'; import { MainContainer, WorkspaceFallback } from '@affine/component/workspace'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -import { getBlobEngine } from '@affine/workspace/manager'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { DndContext, @@ -20,8 +19,7 @@ import { useSensors, } from '@dnd-kit/core'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; -import { currentWorkspaceIdAtom } from '@toeverything/infra/atom'; -import type { MigrationPoint } from '@toeverything/infra/blocksuite'; +import { useWorkspaceStatus } from '@toeverything/hooks/use-workspace-status'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { PropsWithChildren, ReactNode } from 'react'; import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; @@ -37,7 +35,6 @@ import { RootAppSidebar } from '../components/root-app-sidebar'; import { WorkspaceUpgrade } from '../components/workspace-upgrade'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; import { useSidebarDrag } from '../hooks/affine/use-sidebar-drag'; -import { useCurrentWorkspace } from '../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands'; import { @@ -57,11 +54,11 @@ export const QuickSearch = () => { openQuickSearchModalAtom ); - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const { pageId } = useParams(); - const blockSuiteWorkspace = currentWorkspace?.blockSuiteWorkspace; + const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const pageMeta = useBlockSuitePageMeta( - currentWorkspace?.blockSuiteWorkspace + currentWorkspace.blockSuiteWorkspace ).find(meta => meta.id === pageId); if (!blockSuiteWorkspace) { @@ -77,89 +74,25 @@ export const QuickSearch = () => { ); }; -export const CurrentWorkspaceContext = ({ +export const WorkspaceLayout = function WorkspaceLayout({ children, -}: PropsWithChildren): ReactNode => { - const workspaceId = useAtomValue(currentWorkspaceIdAtom); - const metadata = useAtomValue(rootWorkspacesMetadataAtom); - const exist = metadata.find(m => m.id === workspaceId); - if (metadata.length === 0) { - return ; - } - if (!workspaceId) { - return ; - } - if (!exist) { - return ; - } - return children; -}; - -type WorkspaceLayoutProps = { - migration?: MigrationPoint; -}; - -const useSyncWorkspaceBlob = () => { - // temporary solution for sync blob - - const [currentWorkspace] = useCurrentWorkspace(); - - useEffect(() => { - const blobEngine = getBlobEngine(currentWorkspace.blockSuiteWorkspace); - let stopped = false; - function sync() { - if (stopped) { - return; - } - - blobEngine - ?.sync() - .catch(error => { - console.error('sync blob error', error); - }) - .finally(() => { - // sync every 1 minute - setTimeout(sync, 60000); - }); - } - - // after currentWorkspace changed, wait 1 second to start sync - setTimeout(sync, 1000); - - return () => { - stopped = true; - }; - }, [currentWorkspace]); -}; - -export const WorkspaceLayout = function WorkspacesSuspense({ - children, - migration, -}: PropsWithChildren) { - useSyncWorkspaceBlob(); +}: PropsWithChildren) { return ( - - {/* load all workspaces is costly, do not block the whole UI */} - - - - - }> - - {children} - - - + {/* load all workspaces is costly, do not block the whole UI */} + + + + + }> + {children} + ); }; -export const WorkspaceLayoutInner = ({ - children, - migration, -}: PropsWithChildren) => { - const [currentWorkspace] = useCurrentWorkspace(); +export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const { openPage } = useNavigateHelper(); const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); @@ -200,7 +133,6 @@ export const WorkspaceLayoutInner = ({ const handleOpenSettingModal = useCallback(() => { setOpenSettingModalAtom({ activeTab: 'appearance', - workspaceId: null, open: true, }); }, [setOpenSettingModalAtom]); @@ -224,6 +156,8 @@ export const WorkspaceLayoutInner = ({ // todo: refactor this that the root layout do not need to check route state const isInPageDetail = !!pageId; + const upgradeStatus = useWorkspaceStatus(currentWorkspace, s => s.upgrade); + return ( <> {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */} @@ -256,8 +190,8 @@ export const WorkspaceLayoutInner = ({ padding={appSettings.clientBorder} > - {migration ? ( - + {upgradeStatus?.needUpgrade || upgradeStatus?.upgrading ? ( + ) : ( children )} diff --git a/packages/frontend/core/src/pages/404.tsx b/packages/frontend/core/src/pages/404.tsx index e569f1a09e..f6f980ad12 100644 --- a/packages/frontend/core/src/pages/404.tsx +++ b/packages/frontend/core/src/pages/404.tsx @@ -9,7 +9,7 @@ import { SignOutModal } from '../components/affine/sign-out-modal'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; import { signOutCloud } from '../utils/cloud-utils'; -export const Component = (): ReactElement => { +export const PageNotFound = (): ReactElement => { const { data: session } = useSession(); const { jumpToIndex } = useNavigateHelper(); const [open, setOpen] = useState(false); @@ -52,3 +52,5 @@ export const Component = (): ReactElement => { ); }; + +export const Component = PageNotFound; diff --git a/packages/frontend/core/src/pages/index.tsx b/packages/frontend/core/src/pages/index.tsx index 0d98c9e85c..eb873e866e 100644 --- a/packages/frontend/core/src/pages/index.tsx +++ b/packages/frontend/core/src/pages/index.tsx @@ -1,13 +1,12 @@ import { Menu } from '@affine/component/ui/menu'; -import { DebugLogger } from '@affine/debug'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -import { getWorkspace } from '@toeverything/infra/__internal__/workspace'; -import { getCurrentStore } from '@toeverything/infra/atom'; -import { lazy } from 'react'; -import type { LoaderFunction } from 'react-router-dom'; -import { redirect } from 'react-router-dom'; +import { workspaceListAtom } from '@affine/workspace/atom'; +import { useAtomValue } from 'jotai'; +import { lazy, useEffect } from 'react'; +import { createFirstAppData } from '../bootstrap/first-app-data'; import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list'; +import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { WorkspaceSubPath } from '../shared'; const AllWorkspaceModals = lazy(() => import('../providers/modal-provider').then(({ AllWorkspaceModals }) => ({ @@ -15,44 +14,27 @@ const AllWorkspaceModals = lazy(() => })) ); -const logger = new DebugLogger('index-page'); - -export const loader: LoaderFunction = async () => { - const rootStore = getCurrentStore(); - const { createFirstAppData } = await import('../bootstrap/setup'); - createFirstAppData(rootStore); - const meta = await rootStore.get(rootWorkspacesMetadataAtom); - const lastId = localStorage.getItem('last_workspace_id'); - const lastPageId = localStorage.getItem('last_page_id'); - const target = (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0); - if (target) { - const targetWorkspace = getWorkspace(target.id); - - const nonTrashPages = targetWorkspace.meta.pageMetas.filter( - ({ trash }) => !trash - ); - const helloWorldPage = nonTrashPages.find(({ jumpOnce }) => jumpOnce)?.id; - const pageId = - nonTrashPages.find(({ id }) => id === lastPageId)?.id ?? - nonTrashPages.at(0)?.id; - if (helloWorldPage) { - logger.debug( - 'Found target workspace. Jump to hello world page', - helloWorldPage - ); - return redirect(`/workspace/${targetWorkspace.id}/${helloWorldPage}`); - } else if (pageId) { - logger.debug('Found target workspace. Jump to page', pageId); - return redirect(`/workspace/${targetWorkspace.id}/${pageId}`); - } else { - logger.debug('Found target workspace. Jump to all page'); - return redirect(`/workspace/${targetWorkspace.id}/all`); - } - } - return null; -}; - export const Component = () => { + const list = useAtomValue(workspaceListAtom); + const { openPage } = useNavigateHelper(); + + useEffect(() => { + if (list.length === 0) { + return; + } + + // open last workspace + const lastId = localStorage.getItem('last_workspace_id'); + const openWorkspace = list.find(w => w.id === lastId) ?? list[0]; + openPage(openWorkspace.id, WorkspaceSubPath.ALL); + }, [list, openPage]); + + useEffect(() => { + createFirstAppData().catch(err => { + console.error('Failed to create first app data', err); + }); + }, []); + // TODO: We need a no workspace page return ( <> diff --git a/packages/frontend/core/src/pages/invite.tsx b/packages/frontend/core/src/pages/invite.tsx index 59e3223a25..e74995e056 100644 --- a/packages/frontend/core/src/pages/invite.tsx +++ b/packages/frontend/core/src/pages/invite.tsx @@ -14,7 +14,6 @@ import { authAtom } from '../atoms'; import { setOnceSignedInEventAtom } from '../atoms/event'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; -import { useAppHelper } from '../hooks/use-workspaces'; export const loader: LoaderFunction = async args => { const inviteId = args.params.inviteId || ''; @@ -49,7 +48,6 @@ export const loader: LoaderFunction = async args => { export const Component = () => { const loginStatus = useCurrentLoginStatus(); const { jumpToSignIn } = useNavigateHelper(); - const { addCloudWorkspace } = useAppHelper(); const { jumpToSubPath } = useNavigateHelper(); const setOnceSignedInEvent = useSetAtom(setOnceSignedInEventAtom); @@ -61,13 +59,12 @@ export const Component = () => { }; const openWorkspace = useCallback(() => { - addCloudWorkspace(inviteInfo.workspace.id); jumpToSubPath( inviteInfo.workspace.id, WorkspaceSubPath.ALL, RouteLogic.REPLACE ); - }, [addCloudWorkspace, inviteInfo.workspace.id, jumpToSubPath]); + }, [inviteInfo.workspace.id, jumpToSubPath]); useEffect(() => { if (loginStatus === 'unauthenticated') { 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 e255212a63..29b6b5526e 100644 --- a/packages/frontend/core/src/pages/share/share-detail-page.tsx +++ b/packages/frontend/core/src/pages/share/share-detail-page.tsx @@ -1,11 +1,13 @@ import { MainContainer } from '@affine/component/workspace'; import { DebugLogger } from '@affine/debug'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { CloudDoc } from '@affine/workspace/affine/download'; -import { downloadBinaryFromCloud } from '@affine/workspace/affine/download'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; +import { fetchWithTraceReport } from '@affine/graphql'; +import { + createAffineCloudBlobStorage, + createStaticBlobStorage, + globalBlockSuiteSchema, +} from '@affine/workspace'; import { assertExists } from '@blocksuite/global/utils'; -import type { Page } from '@blocksuite/store'; +import { type Page, Workspace } from '@blocksuite/store'; import { noop } from 'foxact/noop'; import type { ReactElement } from 'react'; import { useCallback } from 'react'; @@ -24,6 +26,36 @@ import { PageDetailEditor } from '../../components/page-detail-editor'; import { SharePageNotFoundError } from '../../components/share-page-not-found-error'; import { ShareHeader } from './share-header'; +type DocPublishMode = 'edgeless' | 'page'; + +export type CloudDoc = { + arrayBuffer: ArrayBuffer; + publishMode: DocPublishMode; +}; + +export async function downloadBinaryFromCloud( + rootGuid: string, + pageGuid: string +): Promise { + const response = await fetchWithTraceReport( + runtimeConfig.serverUrlPrefix + + `/api/workspaces/${rootGuid}/docs/${pageGuid}`, + { + priority: 'high', + } + ); + if (response.ok) { + const publishMode = (response.headers.get('publish-mode') || + 'page') as DocPublishMode; + const arrayBuffer = await response.arrayBuffer(); + + // return both arrayBuffer and publish mode + return { arrayBuffer, publishMode }; + } + + return null; +} + type LoaderData = { page: Page; publishMode: PageMode; @@ -49,10 +81,18 @@ export const loader: LoaderFunction = async ({ params }) => { if (!workspaceId || !pageId) { return redirect('/404'); } - const workspace = getOrCreateWorkspace( - workspaceId, - WorkspaceFlavour.AFFINE_PUBLIC - ); + const workspace = new Workspace({ + id: workspaceId, + blobStorages: [ + () => ({ + crud: createAffineCloudBlobStorage(workspaceId), + }), + () => ({ + crud: createStaticBlobStorage(), + }), + ], + schema: globalBlockSuiteSchema, + }); // download root workspace { const response = await downloadBinaryFromCloud(workspaceId, workspaceId); @@ -84,9 +124,9 @@ export const Component = (): ReactElement => { } center={ 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 2c88292722..7b77ff9281 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 @@ -4,32 +4,32 @@ import { useCollectionManager, } from '@affine/component/page-list'; import type { Collection, Filter } from '@affine/env/filter'; -import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; +import { useAtomValue } from 'jotai'; import { useCallback } from 'react'; import { collectionsCRUDAtom } from '../../../atoms/collections'; import { filterContainerStyle } from '../../../components/filter-container.css'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import { useWorkspace } from '../../../hooks/use-workspace'; -export const FilterContainer = ({ workspaceId }: { workspaceId: string }) => { - const currentWorkspace = useWorkspace(workspaceId); +export const FilterContainer = () => { + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const navigateHelper = useNavigateHelper(); const setting = useCollectionManager(collectionsCRUDAtom); const saveToCollection = useCallback( - async (collection: Collection) => { - await setting.createCollection({ + (collection: Collection) => { + setting.createCollection({ ...collection, filterList: setting.currentCollection.filterList, }); - navigateHelper.jumpToCollection(workspaceId, collection.id); + navigateHelper.jumpToCollection(currentWorkspace.id, collection.id); }, - [setting, navigateHelper, workspaceId] + [setting, navigateHelper, currentWorkspace.id] ); - const onFilterChange = useAsyncCallback( - async (filterList: Filter[]) => { - await setting.updateCollection({ + const onFilterChange = useCallback( + (filterList: Filter[]) => { + setting.updateCollection({ ...setting.currentCollection, filterList, }); 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 d07a8fb2d3..628b68ea03 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 @@ -9,10 +9,9 @@ import { useCollectionManager, VirtualizedPageList, } from '@affine/component/page-list'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { assertExists } from '@blocksuite/global/utils'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { CloseIcon, DeleteIcon, @@ -21,18 +20,16 @@ import { } from '@blocksuite/icons'; import type { PageMeta, Workspace } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; -import { getCurrentStore } from '@toeverything/infra/atom'; import clsx from 'clsx'; +import { useAtomValue, useSetAtom } from 'jotai'; import { type PropsWithChildren, useCallback, + useEffect, useMemo, useRef, useState, } from 'react'; -import type { LoaderFunction } from 'react-router-dom'; -import { redirect } from 'react-router-dom'; import { NIL } from 'uuid'; import { collectionsCRUDAtom } from '../../../atoms/collections'; @@ -45,32 +42,13 @@ import { useAllPageListConfig } from '../../../hooks/affine/use-all-page-list-co import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import { useDeleteCollectionInfo } from '../../../hooks/affine/use-delete-collection-info'; import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper'; -import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; +import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { performanceRenderLogger } from '../../../shared'; import { EmptyPageList } from '../page-list-empty'; import { useFilteredPageMetas } from '../pages'; import * as styles from './all-page.css'; import { FilterContainer } from './all-page-filter'; -export const loader: LoaderFunction = async args => { - const rootStore = getCurrentStore(); - const workspaceId = args.params.workspaceId; - assertExists(workspaceId); - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId); - const workspace = await rootStore.get(workspaceAtom); - for (const pageId of workspace.pages.keys()) { - const page = workspace.getPage(pageId); - if (page && page.meta.jumpOnce) { - workspace.meta.setPageMeta(page.id, { - jumpOnce: false, - }); - return redirect(`/workspace/${workspace.id}/${page.id}`); - } - } - rootStore.set(currentCollectionAtom, NIL); - return null; -}; - const PageListHeader = () => { const t = useAFFiNEI18N(); const setting = useCollectionManager(collectionsCRUDAtom); @@ -100,7 +78,7 @@ const PageListHeader = () => { }; const usePageOperationsRenderer = () => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const { setTrashModal } = useTrashModalHelper( currentWorkspace.blockSuiteWorkspace ); @@ -155,7 +133,7 @@ const PageListFloatingToolbar = ({ selectedIds: string[]; onClose: () => void; }) => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const { setTrashModal } = useTrashModalHelper( currentWorkspace.blockSuiteWorkspace ); @@ -206,7 +184,7 @@ const NewPageButton = ({ className?: string; size?: 'small' | 'default'; }>) => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const { importFile, createEdgeless, createPage } = usePageHelper( currentWorkspace.blockSuiteWorkspace ); @@ -263,14 +241,14 @@ const AllPageHeader = ({ } center={} /> - + ); }; // even though it is called all page, it is also being used for collection route as well export const AllPage = () => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const { isPreferredEdgeless } = usePageHelper( currentWorkspace.blockSuiteWorkspace ); @@ -300,12 +278,10 @@ export const AllPage = () => { return (
- {currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? ( - - ) : null} + {filteredPageMetas.length > 0 ? ( <> { export const Component = () => { performanceRenderLogger.info('AllPage'); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentCollection = useSetAtom(currentCollectionAtom); + const navigateHelper = useNavigateHelper(); + + useEffect(() => { + function checkJumpOnce() { + for (const [pageId] of currentWorkspace.blockSuiteWorkspace.pages) { + const page = currentWorkspace.blockSuiteWorkspace.getPage(pageId); + if (page && page.meta.jumpOnce) { + currentWorkspace.blockSuiteWorkspace.meta.setPageMeta(page.id, { + jumpOnce: false, + }); + navigateHelper.jumpToPage(currentWorkspace.id, pageId); + } + } + } + checkJumpOnce(); + return currentWorkspace.blockSuiteWorkspace.slots.pagesUpdated.on( + checkJumpOnce + ).dispose; + }, [ + currentWorkspace.blockSuiteWorkspace, + currentWorkspace.id, + navigateHelper, + ]); + + useEffect(() => { + currentCollection(NIL); + }, [currentCollection]); + return ; }; diff --git a/packages/frontend/core/src/pages/workspace/collection.tsx b/packages/frontend/core/src/pages/workspace/collection.tsx index 9d6585fb60..28d55d3337 100644 --- a/packages/frontend/core/src/pages/workspace/collection.tsx +++ b/packages/frontend/core/src/pages/workspace/collection.tsx @@ -13,6 +13,7 @@ import { WindowsAppControls } from '@affine/core/components/pure/header/windows- import type { Collection } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { CloseIcon, FilterIcon, @@ -31,7 +32,6 @@ import { pageCollectionBaseAtom, } from '../../atoms/collections'; import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config'; -import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; import { WorkspaceSubPath } from '../../shared'; import { getWorkspaceSetting } from '../../utils/workspace-setting'; @@ -51,7 +51,7 @@ export const Component = function CollectionPage() { const { collections, loading } = useAtomValue(pageCollectionBaseAtom); const navigate = useNavigateHelper(); const params = useParams(); - const [workspace] = useCurrentWorkspace(); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); const collection = collections.find(v => v.id === params.collectionId); const pushNotification = useSetAtom(pushNotificationAtom); useEffect(() => { @@ -102,11 +102,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => { const { node, open } = useEditCollection(useAllPageListConfig()); const openPageEdit = useAsyncCallback(async () => { const ret = await open({ ...collection }, 'page'); - await updateCollection(ret); + updateCollection(ret); }, [open, collection, updateCollection]); const openRuleEdit = useAsyncCallback(async () => { const ret = await open({ ...collection }, 'rule'); - await updateCollection(ret); + updateCollection(ret); }, [collection, open, updateCollection]); const [showTips, setShowTips] = useState(false); useEffect(() => { diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx index 6628da77e8..a34a373612 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx @@ -4,7 +4,7 @@ import { appSidebarOpenAtom, SidebarSwitch, } from '@affine/component/app-sidebar'; -import type { AllWorkspace } from '@affine/core/shared'; +import type { Workspace } from '@affine/workspace'; import { RightSidebarIcon } from '@blocksuite/icons'; import type { Page } from '@blocksuite/store'; import { useAtomValue, useSetAtom } from 'jotai'; @@ -106,7 +106,7 @@ export function DetailPageHeader({ showSidebarSwitch = true, }: { page: Page; - workspace: AllWorkspace; + workspace: Workspace; showSidebarSwitch?: boolean; }) { const leftSidebarOpen = useAtomValue(appSidebarOpenAtom); @@ -117,7 +117,10 @@ export function DetailPageHeader({
{!leftSidebarOpen ? : null} - +
{page ? : null} diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 6e2e15ade0..1911cedb26 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -5,17 +5,13 @@ import { } from '@affine/component/page-list'; import { ResizePanel } from '@affine/component/resize-panel'; import { WorkspaceSubPath } from '@affine/env/workspace'; -import { globalBlockSuiteSchema } from '@affine/workspace/manager'; -import { SyncEngineStep } from '@affine/workspace/providers'; -import { assertExists } from '@blocksuite/global/utils'; +import { globalBlockSuiteSchema, SyncEngineStep } from '@affine/workspace'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import type { AffineEditorContainer } from '@blocksuite/presets'; import type { Page, Workspace } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; -import { - appSettingAtom, - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/infra/atom'; +import { useWorkspaceStatus } from '@toeverything/hooks/use-workspace-status'; +import { appSettingAtom, currentPageIdAtom } from '@toeverything/infra/atom'; import { useAtomValue, useSetAtom } from 'jotai'; import { type ReactElement, @@ -24,7 +20,7 @@ import { useEffect, useState, } from 'react'; -import { type LoaderFunction, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import type { Map as YMap } from 'yjs'; import { setPageModeAtom } from '../../../atoms'; @@ -37,13 +33,9 @@ import { PageDetailEditor } from '../../../components/page-detail-editor'; import { TrashPageFooter } from '../../../components/pure/trash-page-footer'; import { TopTip } from '../../../components/top-tip'; import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands'; -import { - useCurrentSyncEngine, - useCurrentSyncEngineStatus, -} from '../../../hooks/current/use-current-sync-engine'; -import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { performanceRenderLogger } from '../../../shared'; +import { PageNotFound } from '../../404'; import * as styles from './detail-page.css'; import { DetailPageHeader, RightSidebarHeader } from './detail-page-header'; import { @@ -112,11 +104,7 @@ const DetailPageLayout = ({ const DetailPageImpl = ({ page }: { page: Page }) => { const currentPageId = page.id; const { openPage, jumpToSubPath } = useNavigateHelper(); - const [currentWorkspace] = useCurrentWorkspace(); - assertExists( - currentWorkspace, - 'current workspace is null when rendering detail' - ); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( @@ -186,7 +174,7 @@ const DetailPageImpl = ({ page }: { page: Page }) => { workspace={currentWorkspace} showSidebarSwitch={!isInTrash} /> - + } main={ @@ -231,31 +219,23 @@ const useSafePage = (workspace: Workspace, pageId: string) => { }; export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { - const [currentWorkspace] = useCurrentWorkspace(); - const currentSyncEngineStatus = useCurrentSyncEngineStatus(); - const currentSyncEngine = useCurrentSyncEngine(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const currentSyncEngineStep = useWorkspaceStatus( + currentWorkspace, + s => s.engine.sync.step + ); // set sync engine priority target useEffect(() => { - currentSyncEngine?.setPriorityRule(id => id.endsWith(pageId)); - }, [pageId, currentSyncEngine, currentWorkspace]); + currentWorkspace.setPriorityRule(id => id.endsWith(pageId)); + }, [pageId, currentWorkspace]); const page = useSafePage(currentWorkspace?.blockSuiteWorkspace, pageId); - const navigate = useNavigateHelper(); - - // if sync engine has been synced and the page is null, wait 1s and jump to 404 page. - useEffect(() => { - if (currentSyncEngineStatus?.step === SyncEngineStep.Synced && !page) { - const timeout = setTimeout(() => { - navigate.jumpTo404(); - }, 1000); - return () => { - clearTimeout(timeout); - }; - } - return; - }, [currentSyncEngineStatus, navigate, page]); + // if sync engine has been synced and the page is null, show 404 page. + if (currentSyncEngineStep === SyncEngineStep.Synced && !page) { + return ; + } if (!page) { return ; @@ -270,27 +250,18 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { return ; }; -export const loader: LoaderFunction = async () => { - return null; -}; - export const Component = () => { performanceRenderLogger.info('DetailPage'); - const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom); const setCurrentPageId = useSetAtom(currentPageIdAtom); const params = useParams(); useEffect(() => { - if (params.workspaceId) { - localStorage.setItem('last_workspace_id', params.workspaceId); - setCurrentWorkspaceId(params.workspaceId); - } if (params.pageId) { localStorage.setItem('last_page_id', params.pageId); setCurrentPageId(params.pageId); } - }, [params, setCurrentPageId, setCurrentWorkspaceId]); + }, [params, setCurrentPageId]); const pageId = params.pageId; diff --git a/packages/frontend/core/src/pages/workspace/index.tsx b/packages/frontend/core/src/pages/workspace/index.tsx index bd70b0e2b3..249ac3f88b 100644 --- a/packages/frontend/core/src/pages/workspace/index.tsx +++ b/packages/frontend/core/src/pages/workspace/index.tsx @@ -1,88 +1,88 @@ -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace'; +import { WorkspaceFallback } from '@affine/component/workspace'; +import { type Workspace } from '@affine/workspace'; import { - currentPageIdAtom, - currentWorkspaceIdAtom, - getCurrentStore, -} from '@toeverything/infra/atom'; -import type { MigrationPoint } from '@toeverything/infra/blocksuite'; -import { - checkWorkspaceCompatibility, - fixWorkspaceVersion, - guidCompatibilityFix, -} from '@toeverything/infra/blocksuite'; -import { useSetAtom } from 'jotai'; -import { type ReactElement, useEffect } from 'react'; -import { - type LoaderFunction, - Outlet, - redirect, - useLoaderData, - useParams, -} from 'react-router-dom'; + currentWorkspaceAtom, + workspaceListAtom, + workspaceListLoadingStatusAtom, + workspaceManagerAtom, +} from '@affine/workspace/atom'; +import { useWorkspace } from '@toeverything/hooks/use-workspace'; +import { useAtom, useAtomValue } from 'jotai'; +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 { performanceLogger, performanceRenderLogger } from '../../shared'; +import { performanceRenderLogger } from '../../shared'; +import { PageNotFound } from '../404'; -const workspaceLoaderLogger = performanceLogger.namespace('workspace_loader'); - -export const loader: LoaderFunction = async args => { - workspaceLoaderLogger.info('start'); - - const rootStore = getCurrentStore(); - - if (args.params.workspaceId) { - localStorage.setItem('last_workspace_id', args.params.workspaceId); - rootStore.set(currentWorkspaceIdAtom, args.params.workspaceId); +declare global { + /** + * @internal debug only + */ + // eslint-disable-next-line no-var + var currentWorkspace: Workspace | undefined; + interface WindowEventMap { + 'affine:workspace:change': CustomEvent<{ id: string }>; } - - const meta = await rootStore.get(rootWorkspacesMetadataAtom); - workspaceLoaderLogger.info('meta loaded'); - - const currentMetadata = meta.find(({ id }) => id === args.params.workspaceId); - if (!currentMetadata) { - return redirect('/404'); - } - - if (args.params.pageId) { - localStorage.setItem('last_page_id', args.params.pageId); - rootStore.set(currentPageIdAtom, args.params.pageId); - } else { - rootStore.set(currentPageIdAtom, null); - } - - const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentMetadata.id); - workspaceLoaderLogger.info('get cloud workspace atom'); - - const workspace = await rootStore.get(workspaceAtom); - workspaceLoaderLogger.info('workspace loaded'); - - guidCompatibilityFix(workspace.doc); - fixWorkspaceVersion(workspace.doc); - return checkWorkspaceCompatibility(workspace); -}; +} export const Component = (): ReactElement => { performanceRenderLogger.info('WorkspaceLayout'); - const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom); + const [ + _ /* read this atom here to make sure children refresh when currentWorkspace changed */, + setCurrentWorkspace, + ] = useAtom(currentWorkspaceAtom); const params = useParams(); - useEffect(() => { - if (params.workspaceId) { - localStorage.setItem('last_workspace_id', params.workspaceId); - setCurrentWorkspaceId(params.workspaceId); - } - }, [params, setCurrentWorkspaceId]); + const list = useAtomValue(workspaceListAtom); + const listLoading = useAtomValue(workspaceListLoadingStatusAtom); + const workspaceManager = useAtomValue(workspaceManagerAtom); + + const meta = useMemo(() => { + return list.find(({ id }) => id === params.workspaceId); + }, [list, params.workspaceId]); + + const workspace = useWorkspace(meta); + + useEffect(() => { + if (!workspace) { + setCurrentWorkspace(null); + return undefined; + } + setCurrentWorkspace(workspace ?? null); + + // for debug purpose + window.currentWorkspace = workspace; + window.dispatchEvent( + new CustomEvent('affine:workspace:change', { + detail: { + id: workspace.id, + }, + }) + ); + + localStorage.setItem('last_workspace_id', workspace.id); + }, [setCurrentWorkspace, meta, workspaceManager, workspace]); + + // if listLoading is false, we can show 404 page, otherwise we should show loading page. + if (listLoading === false && meta === undefined) { + return ; + } + + if (!workspace) { + return ; + } - const migration = useLoaderData() as MigrationPoint | undefined; return ( - - - - - + }> + + + + + + ); }; diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index a4b4d5b044..637fda9215 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -5,11 +5,13 @@ import { VirtualizedPageList, } from '@affine/component/page-list'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons'; import type { PageMeta } from '@blocksuite/store'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { getCurrentStore } from '@toeverything/infra/atom'; +import { useAtomValue } from 'jotai'; import { useCallback } from 'react'; import { type LoaderFunction } from 'react-router-dom'; import { NIL } from 'uuid'; @@ -18,7 +20,6 @@ import { usePageHelper } from '../../components/blocksuite/block-suite-page-list import { Header } from '../../components/pure/header'; import { WindowsAppControls } from '../../components/pure/header/windows-app-controls'; import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper'; -import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { EmptyPageList } from './page-list-empty'; import { useFilteredPageMetas } from './pages'; import * as styles from './trash-page.css'; @@ -56,7 +57,7 @@ export const loader: LoaderFunction = async () => { }; export const TrashPage = () => { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); // todo(himself65): refactor to plugin 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 3c805bae9c..1e361aeaa7 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -1,7 +1,12 @@ -import { WorkspaceSubPath } from '@affine/env/workspace'; +import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace'; +import { + currentWorkspaceAtom, + waitForCurrentWorkspaceAtom, + workspaceListAtom, +} from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; -import { useAtom } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import type { ReactElement } from 'react'; import { lazy, Suspense, useCallback } from 'react'; @@ -14,7 +19,6 @@ import { openSignOutModalAtom, } from '../atoms'; import { PaymentDisableModal } from '../components/affine/payment-disable'; -import { useCurrentWorkspace } from '../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { signOutCloud } from '../utils/cloud-utils'; @@ -56,33 +60,43 @@ const SignOutModal = lazy(() => ); export const Setting = () => { - const [currentWorkspace] = useCurrentWorkspace(); - const [{ open, workspaceId, activeTab }, setOpenSettingModalAtom] = + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); + const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] = useAtom(openSettingModalAtom); assertExists(currentWorkspace); const onSettingClick = useCallback( ({ activeTab, - workspaceId, - }: Pick) => { - setOpenSettingModalAtom(prev => ({ ...prev, activeTab, workspaceId })); + workspaceMetadata, + }: Pick) => { + setOpenSettingModalAtom(prev => ({ + ...prev, + activeTab, + workspaceMetadata, + })); }, [setOpenSettingModalAtom] ); + const onOpenChange = useCallback( + (open: boolean) => { + setOpenSettingModalAtom(prev => ({ ...prev, open })); + }, + [setOpenSettingModalAtom] + ); + + if (!open) { + return null; + } + return ( { - setOpenSettingModalAtom(prev => ({ ...prev, open })); - }, - [setOpenSettingModalAtom] - )} + onOpenChange={onOpenChange} /> ); }; @@ -128,7 +142,7 @@ export const AuthModal = (): ReactElement => { }; export function CurrentWorkspaceModals() { - const [currentWorkspace] = useCurrentWorkspace(); + const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom( openDisableCloudAlertModalAtom ); @@ -152,14 +166,25 @@ export function CurrentWorkspaceModals() { } export const SignOutConfirmModal = () => { - const { jumpToIndex } = useNavigateHelper(); + const { openPage } = useNavigateHelper(); const [open, setOpen] = useAtom(openSignOutModalAtom); + const currentWorkspace = useAtomValue(currentWorkspaceAtom); + const workspaceList = useAtomValue(workspaceListAtom); const onConfirm = useAsyncCallback(async () => { setOpen(false); await signOutCloud(); - jumpToIndex(); - }, [jumpToIndex, setOpen]); + + // if current workspace is affine cloud, switch to local workspace + if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { + const localWorkspace = workspaceList.find( + w => w.flavour === WorkspaceFlavour.LOCAL + ); + if (localWorkspace) { + openPage(localWorkspace.id, WorkspaceSubPath.ALL); + } + } + }, [currentWorkspace?.flavour, openPage, setOpen, workspaceList]); return ( diff --git a/packages/frontend/core/src/providers/session-provider.tsx b/packages/frontend/core/src/providers/session-provider.tsx index 1135fa6468..3980fbe1ac 100644 --- a/packages/frontend/core/src/providers/session-provider.tsx +++ b/packages/frontend/core/src/providers/session-provider.tsx @@ -2,7 +2,7 @@ import '@toeverything/hooks/use-affine-ipc-renderer'; import { pushNotificationAtom } from '@affine/component/notification-center'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { refreshRootMetadataAtom } from '@affine/workspace/atom'; +import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import { useAtom, useSetAtom } from 'jotai'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports @@ -22,14 +22,15 @@ const SessionDefence = (props: PropsWithChildren) => { const prevSession = useRef>(); const [sessionInAtom, setSession] = useAtom(sessionAtom); const pushNotification = useSetAtom(pushNotificationAtom); - const refreshMetadata = useSetAtom(refreshRootMetadataAtom); const onceSignedInEvents = useOnceSignedInEvents(); const t = useAFFiNEI18N(); const refreshAfterSignedInEvents = useAsyncCallback(async () => { await onceSignedInEvents(); - refreshMetadata(); - }, [onceSignedInEvents, refreshMetadata]); + new BroadcastChannel( + CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY + ).postMessage(1); + }, [onceSignedInEvents]); useEffect(() => { if (sessionInAtom !== session && session.status === 'authenticated') { diff --git a/packages/frontend/core/src/shared/index.ts b/packages/frontend/core/src/shared/index.ts index b7c908ab62..98f956e2ae 100644 --- a/packages/frontend/core/src/shared/index.ts +++ b/packages/frontend/core/src/shared/index.ts @@ -1,11 +1,8 @@ import { DebugLogger } from '@affine/debug'; -import type { WorkspaceRegistry } from '@affine/env/workspace'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; export { BlockSuiteWorkspace }; -export type AllWorkspace = WorkspaceRegistry[keyof WorkspaceRegistry]; - export enum WorkspaceSubPath { ALL = 'all', TRASH = 'trash', diff --git a/packages/frontend/core/src/utils/cloud-utils.tsx b/packages/frontend/core/src/utils/cloud-utils.tsx index 19a0b67f4a..7d18b3eddc 100644 --- a/packages/frontend/core/src/utils/cloud-utils.tsx +++ b/packages/frontend/core/src/utils/cloud-utils.tsx @@ -4,11 +4,9 @@ import { TRACE_ID_BYTES, traceReporter, } from '@affine/graphql'; -import { refreshRootMetadataAtom } from '@affine/workspace/atom'; -import { getCurrentStore } from '@toeverything/infra/atom'; +import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { signIn, signOut } from 'next-auth/react'; -import { startTransition } from 'react'; type TraceParams = { startTime: string; @@ -91,10 +89,9 @@ export const signOutCloud: typeof signOut = async options => { }) .then(result => { if (result) { - startTransition(() => { - localStorage.removeItem('last_workspace_id'); - getCurrentStore().set(refreshRootMetadataAtom); - }); + new BroadcastChannel( + CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY + ).postMessage(1); } return onResolveHandleTrace(result, traceParams); }) diff --git a/packages/frontend/core/src/utils/reduce-image.ts b/packages/frontend/core/src/utils/reduce-image.ts new file mode 100644 index 0000000000..c255131355 --- /dev/null +++ b/packages/frontend/core/src/utils/reduce-image.ts @@ -0,0 +1,42 @@ +import reduce from 'image-blob-reduce'; + +// validate and reduce image size and return as file +export const validateAndReduceImage = async (file: File): Promise => { + // Declare a new async function that wraps the decode logic + const decodeAndReduceImage = async (): Promise => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.src = url; + + await img.decode().catch(() => { + URL.revokeObjectURL(url); + throw new Error('Image could not be decoded'); + }); + + img.onload = img.onerror = () => { + URL.revokeObjectURL(url); + }; + + const sizeInMB = file.size / (1024 * 1024); + if (sizeInMB > 10 || img.width > 4000 || img.height > 4000) { + // Compress the file to less than 10MB + const compressedImg = await reduce().toBlob(file, { + max: 4000, + unsharpAmount: 80, + unsharpRadius: 0.6, + unsharpThreshold: 2, + }); + return compressedImg; + } + + return file; + }; + + try { + const reducedBlob = await decodeAndReduceImage(); + + return new File([reducedBlob], file.name, { type: file.type }); + } catch (error) { + throw new Error('Image could not be reduce :' + error); + } +}; diff --git a/packages/frontend/electron/src/helper/db/migration.ts b/packages/frontend/electron/src/helper/db/migration.ts index 393b0f76b4..7459fbae06 100644 --- a/packages/frontend/electron/src/helper/db/migration.ts +++ b/packages/frontend/electron/src/helper/db/migration.ts @@ -5,7 +5,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; import { Schema } from '@blocksuite/store'; import { forceUpgradePages, - guidCompatibilityFix, + migrateGuidCompatibility, migrateToSubdoc, WorkspaceVersion, } from '@toeverything/infra/blocksuite'; @@ -127,8 +127,8 @@ export const applyGuidCompatibilityFix = async (db: SqliteConnection) => { const rootDoc = new YDoc(); oldRows.forEach(row => applyUpdate(rootDoc, row.data)); - // see comments of guidCompatibilityFix - guidCompatibilityFix(rootDoc); + // see comments of migrateGuidCompatibility + migrateGuidCompatibility(rootDoc); // todo: backup? await db.replaceUpdates(undefined, [ diff --git a/packages/frontend/hooks/package.json b/packages/frontend/hooks/package.json index fed403260d..e729cc4c38 100644 --- a/packages/frontend/hooks/package.json +++ b/packages/frontend/hooks/package.json @@ -7,7 +7,6 @@ "private": true, "dependencies": { "foxact": "^0.2.20", - "image-blob-reduce": "^4.1.0", "jotai": "^2.5.1", "jotai-effect": "^0.2.3", "lodash.debounce": "^4.0.8", @@ -20,6 +19,7 @@ "devDependencies": { "@affine/debug": "workspace:*", "@affine/env": "workspace:*", + "@affine/workspace": "workspace:*", "@blocksuite/block-std": "0.11.0-nightly-202312150424-f13b992", "@blocksuite/blocks": "0.11.0-nightly-202312150424-f13b992", "@blocksuite/global": "0.11.0-nightly-202312150424-f13b992", diff --git a/packages/frontend/hooks/src/__tests__/index.spec.ts b/packages/frontend/hooks/src/__tests__/index.spec.ts index 2e1218908a..5f623101ac 100644 --- a/packages/frontend/hooks/src/__tests__/index.spec.ts +++ b/packages/frontend/hooks/src/__tests__/index.spec.ts @@ -13,7 +13,6 @@ import { describe, expect, test, vi } from 'vitest'; import { beforeEach } from 'vitest'; import { useBlockSuitePagePreview } from '../use-block-suite-page-preview'; -import { useBlockSuiteWorkspaceName } from '../use-block-suite-workspace-name'; import { useBlockSuiteWorkspacePageTitle } from '../use-block-suite-workspace-page-title'; let blockSuiteWorkspace: BlockSuiteWorkspace; @@ -39,21 +38,6 @@ beforeEach(async () => { await initPage(blockSuiteWorkspace.createPage({ id: 'page2' })); }); -describe('useBlockSuiteWorkspaceName', () => { - test('basic', async () => { - blockSuiteWorkspace.meta.setName('test 1'); - const workspaceNameHook = renderHook(() => - useBlockSuiteWorkspaceName(blockSuiteWorkspace) - ); - expect(workspaceNameHook.result.current[0]).toBe('test 1'); - blockSuiteWorkspace.meta.setName('test 2'); - workspaceNameHook.rerender(); - expect(workspaceNameHook.result.current[0]).toBe('test 2'); - workspaceNameHook.result.current[1]('test 3'); - expect(blockSuiteWorkspace.meta.name).toBe('test 3'); - }); -}); - describe('useBlockSuiteWorkspacePageTitle', () => { test('basic', async () => { const pageTitleHook = renderHook(() => diff --git a/packages/frontend/hooks/src/use-block-suite-page-meta.ts b/packages/frontend/hooks/src/use-block-suite-page-meta.ts index 3e37f557ff..3ea236a54d 100644 --- a/packages/frontend/hooks/src/use-block-suite-page-meta.ts +++ b/packages/frontend/hooks/src/use-block-suite-page-meta.ts @@ -14,6 +14,7 @@ export function useBlockSuitePageMeta( const baseAtom = atom(blockSuiteWorkspace.meta.pageMetas); weakMap.set(blockSuiteWorkspace, baseAtom); baseAtom.onMount = set => { + set(blockSuiteWorkspace.meta.pageMetas); const dispose = blockSuiteWorkspace.meta.pageMetasUpdated.on(() => { set(blockSuiteWorkspace.meta.pageMetas); }); diff --git a/packages/frontend/hooks/src/use-block-suite-workspace-avatar-url.ts b/packages/frontend/hooks/src/use-block-suite-workspace-avatar-url.ts deleted file mode 100644 index 6da97d7ec5..0000000000 --- a/packages/frontend/hooks/src/use-block-suite-workspace-avatar-url.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { assertExists } from '@blocksuite/global/utils'; -import type { Workspace } from '@blocksuite/store'; -import reduce from 'image-blob-reduce'; -import { useCallback, useEffect, useState } from 'react'; -import useSWRImmutable from 'swr/immutable'; - -// validate and reduce image size and return as file -export const validateAndReduceImage = async (file: File): Promise => { - // Declare a new async function that wraps the decode logic - const decodeAndReduceImage = async (): Promise => { - const img = new Image(); - const url = URL.createObjectURL(file); - img.src = url; - - await img.decode().catch(() => { - URL.revokeObjectURL(url); - throw new Error('Image could not be decoded'); - }); - - img.onload = img.onerror = () => { - URL.revokeObjectURL(url); - }; - - const sizeInMB = file.size / (1024 * 1024); - if (sizeInMB > 10 || img.width > 4000 || img.height > 4000) { - // Compress the file to less than 10MB - const compressedImg = await reduce().toBlob(file, { - max: 4000, - unsharpAmount: 80, - unsharpRadius: 0.6, - unsharpThreshold: 2, - }); - return compressedImg; - } - - return file; - }; - - try { - const reducedBlob = await decodeAndReduceImage(); - - return new File([reducedBlob], file.name, { type: file.type }); - } catch (error) { - throw new Error('Image could not be reduce :' + error); - } -}; - -export function useBlockSuiteWorkspaceAvatarUrl( - blockSuiteWorkspace: Workspace -) { - const [url, set] = useState(() => blockSuiteWorkspace.meta.avatar); - if (url !== blockSuiteWorkspace.meta.avatar) { - set(blockSuiteWorkspace.meta.avatar); - } - const { data: avatar, mutate } = useSWRImmutable(url, { - fetcher: async avatar => { - assertExists(blockSuiteWorkspace); - const blobs = blockSuiteWorkspace.blob; - const blob = await blobs.get(avatar); - if (blob) { - return URL.createObjectURL(blob); - } - return null; - }, - suspense: false, - }); - - const setAvatar = useCallback( - async (file: File | null): Promise => { - assertExists(blockSuiteWorkspace); - if (!file) { - blockSuiteWorkspace.meta.setAvatar(''); - return false; - } - try { - const reducedFile = await validateAndReduceImage(file); - const blobs = blockSuiteWorkspace.blob; - const blobId = await blobs.set(reducedFile); - blockSuiteWorkspace.meta.setAvatar(blobId); - await mutate(blobId); - return true; - } catch (error) { - console.error(error); - throw error; - } - }, - [blockSuiteWorkspace, mutate] - ); - - useEffect(() => { - if (blockSuiteWorkspace) { - const dispose = blockSuiteWorkspace.meta.commonFieldsUpdated.on(() => { - set(blockSuiteWorkspace.meta.avatar); - }); - return () => { - dispose.dispose(); - }; - } - return; - }, [blockSuiteWorkspace]); - return [avatar ?? null, setAvatar] as const; -} diff --git a/packages/frontend/hooks/src/use-block-suite-workspace-name.ts b/packages/frontend/hooks/src/use-block-suite-workspace-name.ts deleted file mode 100644 index f7df247d47..0000000000 --- a/packages/frontend/hooks/src/use-block-suite-workspace-name.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; -import type { Workspace } from '@blocksuite/store'; -import type { Atom, WritableAtom } from 'jotai'; -import { atom, useAtom } from 'jotai'; - -type StringAtom = WritableAtom & Atom; - -const weakMap = new WeakMap(); - -export function useBlockSuiteWorkspaceName(blockSuiteWorkspace: Workspace) { - let nameAtom: StringAtom; - if (!weakMap.has(blockSuiteWorkspace)) { - const baseAtom = atom( - blockSuiteWorkspace.meta.name ?? UNTITLED_WORKSPACE_NAME - ); - const writableAtom = atom( - get => get(baseAtom), - (_, set, name: string) => { - blockSuiteWorkspace.meta.setName(name); - set(baseAtom, name); - } - ); - baseAtom.onMount = set => { - const dispose = blockSuiteWorkspace.meta.commonFieldsUpdated.on(() => { - set(blockSuiteWorkspace.meta.name ?? ''); - }); - return () => { - dispose.dispose(); - }; - }; - weakMap.set(blockSuiteWorkspace, writableAtom); - nameAtom = writableAtom; - } else { - nameAtom = weakMap.get(blockSuiteWorkspace) as StringAtom; - } - return useAtom(nameAtom); -} diff --git a/packages/frontend/hooks/src/use-workspace-blob.ts b/packages/frontend/hooks/src/use-workspace-blob.ts new file mode 100644 index 0000000000..9fe8fb5c0b --- /dev/null +++ b/packages/frontend/hooks/src/use-workspace-blob.ts @@ -0,0 +1,40 @@ +import { workspaceManagerAtom } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import { useAtomValue } from 'jotai'; +import { useEffect, useState } from 'react'; + +export function useWorkspaceBlobObjectUrl( + meta?: WorkspaceMetadata, + blobKey?: string | null +) { + const workspaceManager = useAtomValue(workspaceManagerAtom); + + const [blob, setBlob] = useState(undefined); + + useEffect(() => { + setBlob(undefined); + if (!blobKey || !meta) { + return; + } + let canceled = false; + let objectUrl: string = ''; + workspaceManager + .getWorkspaceBlob(meta, blobKey) + .then(blob => { + if (blob && !canceled) { + objectUrl = URL.createObjectURL(blob); + setBlob(objectUrl); + } + }) + .catch(err => { + console.error('get workspace blob error: ' + err); + }); + + return () => { + canceled = true; + URL.revokeObjectURL(objectUrl); + }; + }, [meta, blobKey, workspaceManager]); + + return blob; +} diff --git a/packages/frontend/hooks/src/use-workspace-info.ts b/packages/frontend/hooks/src/use-workspace-info.ts new file mode 100644 index 0000000000..73a5eba04d --- /dev/null +++ b/packages/frontend/hooks/src/use-workspace-info.ts @@ -0,0 +1,26 @@ +import type { Workspace, WorkspaceMetadata } from '@affine/workspace'; +import { workspaceManagerAtom } from '@affine/workspace/atom'; +import { useAtomValue } from 'jotai'; +import { useEffect, useState } from 'react'; + +export function useWorkspaceInfo( + meta: WorkspaceMetadata, + workspace?: Workspace +) { + const workspaceManager = useAtomValue(workspaceManagerAtom); + + const [information, setInformation] = useState( + () => workspaceManager.list.getInformation(meta).info + ); + + useEffect(() => { + const information = workspaceManager.list.getInformation(meta); + + setInformation(information.info); + return information.onUpdated.on(info => { + setInformation(info); + }).dispose; + }, [meta, workspace, workspaceManager]); + + return information; +} diff --git a/packages/frontend/hooks/src/use-workspace-status.ts b/packages/frontend/hooks/src/use-workspace-status.ts new file mode 100644 index 0000000000..96e8cf0e18 --- /dev/null +++ b/packages/frontend/hooks/src/use-workspace-status.ts @@ -0,0 +1,34 @@ +import type { Workspace, WorkspaceStatus } from '@affine/workspace'; +import { useEffect, useState } from 'react'; + +export function useWorkspaceStatus< + Selector extends ((status: WorkspaceStatus) => any) | undefined | null, + Status = Selector extends (status: WorkspaceStatus) => any + ? ReturnType + : WorkspaceStatus, +>(workspace?: Workspace | null, selector?: Selector): Status | null { + // avoid re-render when selector is changed + const [cachedSelector] = useState(() => selector); + + const [status, setStatus] = useState(() => { + if (!workspace) { + return null; + } + return cachedSelector ? cachedSelector(workspace.status) : workspace.status; + }); + + useEffect(() => { + if (!workspace) { + setStatus(null); + return; + } + setStatus( + cachedSelector ? cachedSelector(workspace.status) : workspace.status + ); + return workspace.onStatusChange.on(status => + setStatus(cachedSelector ? cachedSelector(status) : status) + ).dispose; + }, [cachedSelector, workspace]); + + return status; +} diff --git a/packages/frontend/hooks/src/use-workspace.ts b/packages/frontend/hooks/src/use-workspace.ts new file mode 100644 index 0000000000..5c6617b7ae --- /dev/null +++ b/packages/frontend/hooks/src/use-workspace.ts @@ -0,0 +1,28 @@ +import type { Workspace } from '@affine/workspace'; +import { workspaceManagerAtom } from '@affine/workspace/atom'; +import type { WorkspaceMetadata } from '@affine/workspace/metadata'; +import { useAtomValue } from 'jotai'; +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 [workspace, setWorkspace] = useState(null); + + useEffect(() => { + if (!meta) { + setWorkspace(null); // set to null if meta is null or undefined + return; + } + const ref = workspaceManager.use(meta); + setWorkspace(ref.workspace); + return () => { + ref.release(); + }; + }, [meta, workspaceManager]); + + return workspace; +} diff --git a/packages/frontend/hooks/tsconfig.json b/packages/frontend/hooks/tsconfig.json index 9b142bb210..6351cba59f 100644 --- a/packages/frontend/hooks/tsconfig.json +++ b/packages/frontend/hooks/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../../common/env" }, { "path": "../../common/y-indexeddb" }, { "path": "../../common/debug" }, - { "path": "../../common/infra" } + { "path": "../../common/infra" }, + { "path": "../workspace" } ] } diff --git a/packages/frontend/workspace/package.json b/packages/frontend/workspace/package.json index 2d1fabe467..93ce84afa0 100644 --- a/packages/frontend/workspace/package.json +++ b/packages/frontend/workspace/package.json @@ -2,12 +2,9 @@ "name": "@affine/workspace", "private": true, "exports": { + ".": "./src/index.ts", "./atom": "./src/atom.ts", - "./manager": "./src/manager/index.ts", - "./blob": "./src/blob/index.ts", - "./local/crud": "./src/local/crud.ts", - "./affine/*": "./src/affine/*.ts", - "./providers": "./src/providers/index.ts" + "./affine/*": "./src/affine/*.ts" }, "peerDependencies": { "@blocksuite/blocks": "*", @@ -19,8 +16,7 @@ "@affine/debug": "workspace:*", "@affine/env": "workspace:*", "@affine/graphql": "workspace:*", - "@toeverything/hooks": "workspace:*", - "@toeverything/y-indexeddb": "workspace:*", + "@toeverything/infra": "workspace:*", "async-call-rpc": "^6.3.1", "idb": "^8.0.0", "idb-keyval": "^6.2.1", @@ -34,6 +30,7 @@ "next-auth": "^4.24.5", "react": "18.2.0", "react-dom": "18.2.0", + "rxjs": "^7.8.1", "socket.io-client": "^4.7.2", "swr": "2.2.4", "valtio": "^1.11.2", diff --git a/packages/frontend/workspace/src/affine/crud.ts b/packages/frontend/workspace/src/affine/crud.ts deleted file mode 100644 index 434cd729b2..0000000000 --- a/packages/frontend/workspace/src/affine/crud.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { - AffineCloudWorkspace, - WorkspaceCRUD, -} from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { - createWorkspaceMutation, - deleteWorkspaceMutation, - getWorkspaceQuery, - getWorkspacesQuery, -} from '@affine/graphql'; -import { createIndexeddbStorage, Workspace } from '@blocksuite/store'; -import { migrateLocalBlobStorage } from '@toeverything/infra/blocksuite'; -import { getSession } from 'next-auth/react'; -import { proxy } from 'valtio/vanilla'; - -import { getOrCreateWorkspace } from '../manager'; -import { fetcher } from './gql'; - -const Y = Workspace.Y; - -async function deleteLocalBlobStorage(id: string) { - const storage = createIndexeddbStorage(id); - const keys = await storage.crud.list(); - for (const key of keys) { - await storage.crud.delete(key); - } -} - -// we don't need to persistence the state into local storage -// because if a user clicks create multiple time and nothing happened -// because of the server delay or something, he/she will wait. -// and also the user journey of creating workspace is long. -const createdWorkspaces = proxy([]); - -export const CRUD: WorkspaceCRUD = { - create: async upstreamWorkspace => { - if (createdWorkspaces.some(id => id === upstreamWorkspace.id)) { - throw new Error('workspace already created'); - } - const { createWorkspace } = await fetcher({ - query: createWorkspaceMutation, - }); - - createdWorkspaces.push(upstreamWorkspace.id); - const newBlockSuiteWorkspace = getOrCreateWorkspace( - createWorkspace.id, - WorkspaceFlavour.AFFINE_CLOUD - ); - - if (environment.isDesktop) { - // this will clone all data from existing db to new db file, including docs and blobs - await window.apis.workspace.clone( - upstreamWorkspace.id, - createWorkspace.id - ); - - // skip apply updates in memory and we will use providers to sync data from db - } else { - Y.applyUpdate( - newBlockSuiteWorkspace.doc, - Y.encodeStateAsUpdate(upstreamWorkspace.doc) - ); - - await Promise.all( - [...upstreamWorkspace.doc.subdocs].map(async subdoc => { - subdoc.load(); - return subdoc.whenLoaded.then(() => { - newBlockSuiteWorkspace.doc.subdocs.forEach(newSubdoc => { - if (newSubdoc.guid === subdoc.guid) { - Y.applyUpdate(newSubdoc, Y.encodeStateAsUpdate(subdoc)); - } - }); - }); - }) - ); - - migrateLocalBlobStorage(upstreamWorkspace.id, createWorkspace.id) - .then(() => deleteLocalBlobStorage(upstreamWorkspace.id)) - .catch(e => { - console.error('error when moving blob storage:', e); - }); - // todo(himself65): delete old workspace in the future - } - - return createWorkspace.id; - }, - delete: async workspace => { - await fetcher({ - query: deleteWorkspaceMutation, - variables: { - id: workspace.id, - }, - }); - }, - get: async id => { - if (!environment.isServer && !navigator.onLine) { - // no network - return null; - } - if ( - !(await getSession() - .then(() => true) - .catch(() => false)) - ) { - return null; - } - try { - await fetcher({ - query: getWorkspaceQuery, - variables: { - id, - }, - }); - return { - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - blockSuiteWorkspace: getOrCreateWorkspace( - id, - WorkspaceFlavour.AFFINE_CLOUD - ), - } satisfies AffineCloudWorkspace; - } catch (e) { - console.error('error when fetching cloud workspace:', e); - return null; - } - }, - list: async () => { - if (!environment.isServer && !navigator.onLine) { - // no network - return []; - } - if ( - !(await getSession() - .then(() => true) - .catch(() => false)) - ) { - return []; - } - try { - const { workspaces } = await fetcher({ - query: getWorkspacesQuery, - }); - const ids = workspaces.map(({ id }) => id); - - return ids.map( - id => - ({ - id, - flavour: WorkspaceFlavour.AFFINE_CLOUD, - blockSuiteWorkspace: getOrCreateWorkspace( - id, - WorkspaceFlavour.AFFINE_CLOUD - ), - }) satisfies AffineCloudWorkspace - ); - } catch (e) { - console.error('error when fetching cloud workspaces:', e); - return []; - } - }, -}; diff --git a/packages/frontend/workspace/src/affine/download.ts b/packages/frontend/workspace/src/affine/download.ts deleted file mode 100644 index b397c742f9..0000000000 --- a/packages/frontend/workspace/src/affine/download.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { fetchWithTraceReport } from '@affine/graphql'; - -const hashMap = new Map(); -type DocPublishMode = 'edgeless' | 'page'; - -export type CloudDoc = { - arrayBuffer: ArrayBuffer; - publishMode: DocPublishMode; -}; - -export async function downloadBinaryFromCloud( - rootGuid: string, - pageGuid: string -): Promise { - const cached = hashMap.get(`${rootGuid}/${pageGuid}`); - if (cached) { - return cached; - } - const response = await fetchWithTraceReport( - runtimeConfig.serverUrlPrefix + - `/api/workspaces/${rootGuid}/docs/${pageGuid}`, - { - priority: 'high', - } - ); - if (response.ok) { - const publishMode = (response.headers.get('publish-mode') || - 'page') as DocPublishMode; - const arrayBuffer = await response.arrayBuffer(); - hashMap.set(`${rootGuid}/${pageGuid}`, { arrayBuffer, publishMode }); - - // return both arrayBuffer and publish mode - return { arrayBuffer, publishMode }; - } - - return null; -} diff --git a/packages/frontend/workspace/src/affine/gql.ts b/packages/frontend/workspace/src/affine/gql.ts index 2b6778d3ff..a58b3f5085 100644 --- a/packages/frontend/workspace/src/affine/gql.ts +++ b/packages/frontend/workspace/src/affine/gql.ts @@ -8,9 +8,8 @@ import type { RecursiveMaybeFields, } from '@affine/graphql'; import { gqlFetcherFactory } from '@affine/graphql'; -import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import type { GraphQLError } from 'graphql'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import type { Key, SWRConfiguration, SWRResponse } from 'swr'; import useSWR, { useSWRConfig } from 'swr'; import useSWRImutable from 'swr/immutable'; @@ -46,7 +45,7 @@ export const fetcher = gqlFetcherFactory( * ``` */ type useQueryFn = ( - options: QueryOptions, + options?: QueryOptions, config?: Omit< SWRConfiguration< QueryResponse, @@ -128,11 +127,13 @@ export function useQueryInfinite( const loadingMore = size > 0 && data && !data[size - 1]; // todo: find a generic way to know whether or not there are more items to load - const loadMore = useAsyncCallback(async () => { + const loadMore = useCallback(() => { if (loadingMore) { return; } - await setSize(size => size + 1); + setSize(size => size + 1).catch(err => { + console.error(err); + }); }, [loadingMore, setSize]); return { data, diff --git a/packages/frontend/workspace/src/atom.ts b/packages/frontend/workspace/src/atom.ts index 881726723d..b5f967a4f4 100644 --- a/packages/frontend/workspace/src/atom.ts +++ b/packages/frontend/workspace/src/atom.ts @@ -1,261 +1,57 @@ import { DebugLogger } from '@affine/debug'; -import type { WorkspaceAdapter } from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { assertEquals, assertExists } from '@blocksuite/global/utils'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/infra/atom'; -import { WorkspaceVersion } from '@toeverything/infra/blocksuite'; -import { type Atom, atom } from 'jotai/vanilla'; -import { z } from 'zod'; +import { atom } from 'jotai'; +import { atomWithObservable } from 'jotai/utils'; +import { Observable } from 'rxjs'; -import { getOrCreateWorkspace } from './manager'; +import { type Workspace, workspaceManager, type WorkspaceMetadata } from '.'; -const performanceJotaiLogger = new DebugLogger('performance:jotai'); +const logger = new DebugLogger('affine:workspace:atom'); -const rootWorkspaceMetadataV1Schema = z.object({ - id: z.string(), - flavour: z.nativeEnum(WorkspaceFlavour), -}); +// readonly atom for workspace manager, currently only one workspace manager is supported +export const workspaceManagerAtom = atom(() => workspaceManager); -const rootWorkspaceMetadataV2Schema = rootWorkspaceMetadataV1Schema.extend({ - version: z.nativeEnum(WorkspaceVersion), -}); - -const rootWorkspaceMetadataArraySchema = z.array( - z.union([rootWorkspaceMetadataV1Schema, rootWorkspaceMetadataV2Schema]) -); - -export type RootWorkspaceMetadataV2 = z.infer< - typeof rootWorkspaceMetadataV2Schema ->; - -export type RootWorkspaceMetadataV1 = z.infer< - typeof rootWorkspaceMetadataV1Schema ->; - -export type RootWorkspaceMetadata = - | RootWorkspaceMetadataV1 - | RootWorkspaceMetadataV2; - -export const workspaceAdaptersAtom = atom< - Record< - WorkspaceFlavour, - Pick< - WorkspaceAdapter, - 'CRUD' | 'Events' | 'flavour' | 'loadPriority' - > - > ->( - null as unknown as Record< - WorkspaceFlavour, - Pick< - WorkspaceAdapter, - 'CRUD' | 'Events' | 'flavour' | 'loadPriority' - > - > -); - -// #region root atoms -// root primitive atom that stores the necessary data for the whole app -// be careful when you use this atom, -// it should be used only in the root component -/** - * root workspaces atom - * this atom stores the metadata of all workspaces, - * which is `id` and `flavor,` that is enough to load the real workspace data - */ -const METADATA_STORAGE_KEY = 'jotai-workspaces'; -const rootWorkspacesMetadataPrimitiveAtom = atom | null>(null); - -type Getter = (atom: Atom) => Value; - -type FetchMetadata = (get: Getter) => Promise; - -/** - * @internal - */ -const fetchMetadata: FetchMetadata = async get => { - performanceJotaiLogger.info('fetch metadata start'); - - const WorkspaceAdapters = get(workspaceAdaptersAtom); - assertExists(WorkspaceAdapters, 'workspace adapter should be defined'); - const metadata: RootWorkspaceMetadata[] = []; - - // step 1: try load metadata from localStorage. - // - // we need this step because workspaces have the order. - { - const loadFromLocalStorage = (): RootWorkspaceMetadata[] => { - // don't change this key, - // otherwise it will cause the data loss in the production - const primitiveMetadata = localStorage.getItem(METADATA_STORAGE_KEY); - if (primitiveMetadata) { - try { - const items = JSON.parse(primitiveMetadata) as z.infer< - typeof rootWorkspaceMetadataArraySchema - >; - rootWorkspaceMetadataArraySchema.parse(items); - return [...items]; - } catch (e) { - console.error('cannot parse worksapce', e); - } - return []; - } - return []; - }; - metadata.push(...loadFromLocalStorage()); - } - // step 2: fetch from adapters - { - const Adapters = Object.values(WorkspaceAdapters).sort( - (a, b) => a.loadPriority - b.loadPriority - ); - - for (const Adapter of Adapters) { - performanceJotaiLogger.info('%s adapter', Adapter.flavour); - - const { CRUD, flavour: currentFlavour } = Adapter; - const appAccessFn = Adapter.Events['app:access']; - const canAccess = appAccessFn && !(await appAccessFn()); - performanceJotaiLogger.info('%s app:access', Adapter.flavour); - if (canAccess) { - // skip the adapter if the user doesn't have access to it - const removed = metadata.filter( - meta => meta.flavour === currentFlavour - ); - removed.forEach(meta => { - metadata.splice(metadata.indexOf(meta), 1); - }); - Adapter.Events['service:stop']?.(); - continue; - } - try { - const item = await CRUD.list(); - performanceJotaiLogger.info('%s CRUD list', Adapter.flavour); - // remove the metadata that is not in the list - // because we treat the workspace adapter as the source of truth - { - const removed = metadata.filter( - meta => - meta.flavour === currentFlavour && - !item.some(x => x.id === meta.id) - ); - removed.forEach(meta => { - metadata.splice(metadata.indexOf(meta), 1); - }); - } - // sort the metadata by the order of the list - if (metadata.length) { - item.sort((a, b) => { - return ( - metadata.findIndex(x => x.id === a.id) - - metadata.findIndex(x => x.id === b.id) - ); - }); - } - metadata.push( - ...item.map(x => ({ - id: x.id, - flavour: x.flavour, - version: WorkspaceVersion.DatabaseV3, - })) - ); - } catch (e) { - console.error('list data error:', e); - } - performanceJotaiLogger.info('%s service:start', Adapter.flavour); - Adapter.Events['service:start']?.(); - } - } - const metadataMap = new Map(metadata.map(x => [x.id, x])); - // init workspace data - metadataMap.forEach((meta, id) => { - if ( - meta.flavour === WorkspaceFlavour.AFFINE_CLOUD || - meta.flavour === WorkspaceFlavour.LOCAL - ) { - getOrCreateWorkspace(id, meta.flavour); - } else { - throw new Error(`unknown flavour ${meta.flavour}`); - } - }); - const result = Array.from(metadataMap.values()); - performanceJotaiLogger.info('fetch metadata done', result); - return result; -}; - -const rootWorkspacesMetadataPromiseAtom = atom< - Promise ->(async get => { - const primitiveMetadata = get(rootWorkspacesMetadataPrimitiveAtom); - assertEquals( - primitiveMetadata, - null, - 'rootWorkspacesMetadataPrimitiveAtom should be null' - ); - return fetchMetadata(get); -}); - -type SetStateAction = Value | ((prev: Value) => Value); - -export const rootWorkspacesMetadataAtom = atom< - Promise, - [ - setStateAction: SetStateAction, - newWorkspaceId?: string, - ], - void ->( - async get => { - const maybeMetadata = get(rootWorkspacesMetadataPrimitiveAtom); - if (maybeMetadata !== null) { - return maybeMetadata; - } - return get(rootWorkspacesMetadataPromiseAtom); - }, - async (get, set, action, newWorkspaceId) => { - const metadataPromise = get(rootWorkspacesMetadataPromiseAtom); - const oldWorkspaceId = get(currentWorkspaceIdAtom); - const oldPageId = get(currentPageIdAtom); - - // get metadata - set(rootWorkspacesMetadataPrimitiveAtom, async maybeMetadataPromise => { - let metadata: RootWorkspaceMetadata[] = - (await maybeMetadataPromise) ?? (await metadataPromise); - - // update metadata - if (typeof action === 'function') { - metadata = action(metadata); - } else { - metadata = action; - } - - const metadataMap = new Map(metadata.map(x => [x.id, x])); - metadata = Array.from(metadataMap.values()); - // write back to localStorage - rootWorkspaceMetadataArraySchema.parse(metadata); - localStorage.setItem(METADATA_STORAGE_KEY, JSON.stringify(metadata)); - - // if the current workspace is deleted, reset the current workspace - if (oldWorkspaceId && metadata.some(x => x.id === oldWorkspaceId)) { - set(currentWorkspaceIdAtom, oldWorkspaceId); - set(currentPageIdAtom, oldPageId); - } - - if (newWorkspaceId) { - set(currentWorkspaceIdAtom, newWorkspaceId); - } - return metadata; +// 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: [], } ); -export const refreshRootMetadataAtom = atom(null, (get, set) => { - set(rootWorkspacesMetadataPrimitiveAtom, fetchMetadata(get)); -}); +// 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, + } +); -//#endregion +// 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/workspace/src/blob/index.ts b/packages/frontend/workspace/src/blob/index.ts deleted file mode 100644 index e6fc140b24..0000000000 --- a/packages/frontend/workspace/src/blob/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BlobEngine } from './engine'; -import { - createAffineCloudBlobStorage, - createIndexeddbBlobStorage, - createSQLiteBlobStorage, - createStaticBlobStorage, -} from './storage'; - -export * from './engine'; -export * from './storage'; - -export function createLocalBlobStorage(workspaceId: string) { - if (environment.isDesktop) { - return createSQLiteBlobStorage(workspaceId); - } else { - return createIndexeddbBlobStorage(workspaceId); - } -} - -export function createLocalBlobEngine(workspaceId: string) { - return new BlobEngine(createLocalBlobStorage(workspaceId), [ - createStaticBlobStorage(), - ]); -} - -export function createAffineCloudBlobEngine(workspaceId: string) { - return new BlobEngine(createLocalBlobStorage(workspaceId), [ - createStaticBlobStorage(), - createAffineCloudBlobStorage(workspaceId), - ]); -} - -export function createAffinePublicBlobEngine(workspaceId: string) { - return new BlobEngine(createAffineCloudBlobStorage(workspaceId), [ - createStaticBlobStorage(), - ]); -} diff --git a/packages/frontend/workspace/src/blob/storage/index.ts b/packages/frontend/workspace/src/blob/storage/index.ts deleted file mode 100644 index a8d9bd6e86..0000000000 --- a/packages/frontend/workspace/src/blob/storage/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './affine-cloud'; -export * from './indexeddb'; -export * from './sqlite'; -export * from './static'; diff --git a/packages/frontend/workspace/src/providers/awareness/index.ts b/packages/frontend/workspace/src/engine/awareness.ts similarity index 55% rename from packages/frontend/workspace/src/providers/awareness/index.ts rename to packages/frontend/workspace/src/engine/awareness.ts index 2fc09f42e9..2d365f72ed 100644 --- a/packages/frontend/workspace/src/providers/awareness/index.ts +++ b/packages/frontend/workspace/src/engine/awareness.ts @@ -2,6 +2,3 @@ export interface AwarenessProvider { connect(): void; disconnect(): void; } - -export * from './affine'; -export * from './broadcast-channel'; diff --git a/packages/frontend/workspace/src/blob/engine.ts b/packages/frontend/workspace/src/engine/blob.ts similarity index 65% rename from packages/frontend/workspace/src/blob/engine.ts rename to packages/frontend/workspace/src/engine/blob.ts index 0c5adb241d..8f4b5e596d 100644 --- a/packages/frontend/workspace/src/blob/engine.ts +++ b/packages/frontend/workspace/src/engine/blob.ts @@ -3,12 +3,51 @@ import { difference } from 'lodash-es'; const logger = new DebugLogger('affine:blob-engine'); +/** + * # BlobEngine + * + * sync blobs between storages in background. + * + * all operations priority use local, then use remote. + */ export class BlobEngine { + private abort: AbortController | null = null; + 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]; } @@ -19,17 +58,18 @@ export class BlobEngine { } logger.debug('start syncing blob...'); for (const remote of this.remotes) { - let localList; - let remoteList; - try { - localList = await this.local.list(); - remoteList = await remote.list(); - } catch (err) { - logger.error(`error when sync`, err); - continue; - } + 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 { @@ -74,7 +114,7 @@ export class BlobEngine { return data; } } - return undefined; + return null; } async set(key: string, value: Blob) { @@ -107,6 +147,8 @@ export class BlobEngine { .catch(() => { // Promise.allSettled never reject }); + + return key; } async delete(_key: string) { @@ -132,8 +174,29 @@ export class BlobEngine { export interface BlobStorage { name: string; readonly: boolean; - get: (key: string) => Promise; - set: (key: string, value: Blob) => Promise; + 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/frontend/workspace/src/engine/index.ts b/packages/frontend/workspace/src/engine/index.ts new file mode 100644 index 0000000000..6f2d3f18f6 --- /dev/null +++ b/packages/frontend/workspace/src/engine/index.ts @@ -0,0 +1,74 @@ +import { Slot } from '@blocksuite/global/utils'; + +import { throwIfAborted } from '../utils/throw-if-aborted'; +import type { AwarenessProvider } from './awareness'; +import type { BlobEngine } from './blob'; +import type { SyncEngine, SyncEngineStatus } from './sync'; + +export interface WorkspaceEngineStatus { + sync: SyncEngineStatus; +} + +/** + * # 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, + }; + sync.onStatusChange.on(status => { + this.status = { + sync: 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 './sync'; diff --git a/packages/frontend/workspace/src/providers/sync/__tests__/engine.spec.ts b/packages/frontend/workspace/src/engine/sync/__tests__/engine.spec.ts similarity index 96% rename from packages/frontend/workspace/src/providers/sync/__tests__/engine.spec.ts rename to packages/frontend/workspace/src/engine/sync/__tests__/engine.spec.ts index 3436d8e896..1b7a1d6f9a 100644 --- a/packages/frontend/workspace/src/providers/sync/__tests__/engine.spec.ts +++ b/packages/frontend/workspace/src/engine/sync/__tests__/engine.spec.ts @@ -7,7 +7,7 @@ import { Schema, Workspace } from '@blocksuite/store'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Doc } from 'yjs'; -import { createIndexedDBStorage } from '../../storage'; +import { createIndexedDBStorage } from '../../../impl/local/sync-indexeddb'; import { SyncEngine, SyncEngineStep, SyncPeerStep } from '../'; import { createTestStorage } from './test-storage'; @@ -50,7 +50,7 @@ describe('SyncEngine', () => { const frameId = page.addBlock('affine:note', {}, pageBlockId); page.addBlock('affine:paragraph', {}, frameId); await syncEngine.waitForSynced(); - syncEngine.stop(); + syncEngine.forceStop(); prev = workspace.doc.toJSON(); } @@ -70,7 +70,7 @@ describe('SyncEngine', () => { expect(workspace.doc.toJSON()).toEqual({ ...prev, }); - syncEngine.stop(); + syncEngine.forceStop(); } { @@ -89,7 +89,7 @@ describe('SyncEngine', () => { expect(workspace.doc.toJSON()).toEqual({ ...prev, }); - syncEngine.stop(); + syncEngine.forceStop(); } { @@ -108,7 +108,7 @@ describe('SyncEngine', () => { expect(workspace.doc.toJSON()).toEqual({ ...prev, }); - syncEngine.stop(); + syncEngine.forceStop(); } }); diff --git a/packages/frontend/workspace/src/providers/sync/__tests__/peer.spec.ts b/packages/frontend/workspace/src/engine/sync/__tests__/peer.spec.ts similarity index 96% rename from packages/frontend/workspace/src/providers/sync/__tests__/peer.spec.ts rename to packages/frontend/workspace/src/engine/sync/__tests__/peer.spec.ts index 7711657c23..d1fce352c0 100644 --- a/packages/frontend/workspace/src/providers/sync/__tests__/peer.spec.ts +++ b/packages/frontend/workspace/src/engine/sync/__tests__/peer.spec.ts @@ -4,7 +4,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; import { Schema, Workspace } from '@blocksuite/store'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { createIndexedDBStorage } from '../../storage'; +import { createIndexedDBStorage } from '../../../impl/local/sync-indexeddb'; import { SyncPeer, SyncPeerStep } from '../'; const schema = new Schema(); diff --git a/packages/frontend/workspace/src/providers/sync/__tests__/test-storage.ts b/packages/frontend/workspace/src/engine/sync/__tests__/test-storage.ts similarity index 91% rename from packages/frontend/workspace/src/providers/sync/__tests__/test-storage.ts rename to packages/frontend/workspace/src/engine/sync/__tests__/test-storage.ts index ab5390e0e8..07cd557166 100644 --- a/packages/frontend/workspace/src/providers/sync/__tests__/test-storage.ts +++ b/packages/frontend/workspace/src/engine/sync/__tests__/test-storage.ts @@ -1,6 +1,6 @@ -import type { Storage } from '../../storage'; +import type { SyncStorage } from '..'; -export function createTestStorage(origin: Storage) { +export function createTestStorage(origin: SyncStorage) { const controler = { pausedPull: Promise.resolve(), resumePull: () => {}, diff --git a/packages/frontend/workspace/src/engine/sync/consts.ts b/packages/frontend/workspace/src/engine/sync/consts.ts new file mode 100644 index 0000000000..e5fd2e8718 --- /dev/null +++ b/packages/frontend/workspace/src/engine/sync/consts.ts @@ -0,0 +1,15 @@ +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/frontend/workspace/src/providers/sync/engine.ts b/packages/frontend/workspace/src/engine/sync/engine.ts similarity index 85% rename from packages/frontend/workspace/src/providers/sync/engine.ts rename to packages/frontend/workspace/src/engine/sync/engine.ts index 6acfee83f0..f1a9ab297c 100644 --- a/packages/frontend/workspace/src/providers/sync/engine.ts +++ b/packages/frontend/workspace/src/engine/sync/engine.ts @@ -2,10 +2,11 @@ import { DebugLogger } from '@affine/debug'; import { Slot } from '@blocksuite/global/utils'; import type { Doc } from 'yjs'; -import type { Storage } from '../storage'; -import { SharedPriorityTarget } from '../utils/async-queue'; -import { MANUALLY_STOP, SyncEngineStep } from './consts'; -import { SyncPeer, type SyncPeerStatus, SyncPeerStep } from './peer'; +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; @@ -52,7 +53,7 @@ export class SyncEngine { private _status: SyncEngineStatus; onStatusChange = new Slot(); private set status(s: SyncEngineStatus) { - this.logger.info('status change', SyncEngineStep[s.step]); + this.logger.debug('status change', s); this._status = s; this.onStatusChange.emit(s); } @@ -67,8 +68,8 @@ export class SyncEngine { constructor( private readonly rootDoc: Doc, - private readonly local: Storage, - private readonly remotes: Storage[] + private readonly local: SyncStorage, + private readonly remotes: SyncStorage[] ) { this._status = { step: SyncEngineStep.Stopped, @@ -80,7 +81,7 @@ export class SyncEngine { start() { if (this.status.step !== SyncEngineStep.Stopped) { - this.stop(); + this.forceStop(); } this.abort = new AbortController(); @@ -90,7 +91,33 @@ export class SyncEngine { }); } - stop() { + 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, @@ -157,7 +184,7 @@ export class SyncEngine { }); }); } catch (error) { - if (error === MANUALLY_STOP) { + if (error === MANUALLY_STOP || signal.aborted) { return; } throw error; diff --git a/packages/frontend/workspace/src/providers/sync/index.ts b/packages/frontend/workspace/src/engine/sync/index.ts similarity index 93% rename from packages/frontend/workspace/src/providers/sync/index.ts rename to packages/frontend/workspace/src/engine/sync/index.ts index b741931973..0e3d766d79 100644 --- a/packages/frontend/workspace/src/providers/sync/index.ts +++ b/packages/frontend/workspace/src/engine/sync/index.ts @@ -17,3 +17,4 @@ export * from './consts'; export * from './engine'; export * from './peer'; +export * from './storage'; diff --git a/packages/frontend/workspace/src/providers/sync/peer.ts b/packages/frontend/workspace/src/engine/sync/peer.ts similarity index 95% rename from packages/frontend/workspace/src/providers/sync/peer.ts rename to packages/frontend/workspace/src/engine/sync/peer.ts index 6a1955c4d7..3c58a3f218 100644 --- a/packages/frontend/workspace/src/providers/sync/peer.ts +++ b/packages/frontend/workspace/src/engine/sync/peer.ts @@ -4,20 +4,14 @@ import { isEqual } from '@blocksuite/global/utils'; import type { Doc } from 'yjs'; import { applyUpdate, encodeStateAsUpdate, encodeStateVector } from 'yjs'; -import { mergeUpdates, type Storage } from '../storage'; -import { PriorityAsyncQueue, SharedPriorityTarget } from '../utils/async-queue'; -import { throwIfAborted } from '../utils/throw-if-aborted'; -import { MANUALLY_STOP } from './consts'; - -export enum SyncPeerStep { - Stopped = 0, - Retrying = 1, - LoadingRootDoc = 2, - LoadingSubDoc = 3, - Loaded = 4.5, - Syncing = 5, - Synced = 6, -} +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; @@ -62,7 +56,7 @@ export class SyncPeer { pendingPushUpdates: 0, }; onStatusChange = new Slot(); - abort = new AbortController(); + readonly abort = new AbortController(); get name() { return this.storage.name; } @@ -70,7 +64,7 @@ export class SyncPeer { constructor( private readonly rootDoc: Doc, - private readonly storage: Storage, + private readonly storage: SyncStorage, private readonly priorityTarget = new SharedPriorityTarget() ) { this.logger.debug('peer start'); @@ -111,7 +105,7 @@ export class SyncPeer { try { await this.sync(abort); } catch (err) { - if (err === MANUALLY_STOP) { + if (err === MANUALLY_STOP || abort.aborted) { return; } @@ -141,7 +135,7 @@ export class SyncPeer { }), ]); } catch (err) { - if (err === MANUALLY_STOP) { + if (err === MANUALLY_STOP || abort.aborted) { return; } diff --git a/packages/frontend/workspace/src/providers/storage/index.ts b/packages/frontend/workspace/src/engine/sync/storage.ts similarity index 84% rename from packages/frontend/workspace/src/providers/storage/index.ts rename to packages/frontend/workspace/src/engine/sync/storage.ts index f9847f195a..34784f1d40 100644 --- a/packages/frontend/workspace/src/providers/storage/index.ts +++ b/packages/frontend/workspace/src/engine/sync/storage.ts @@ -1,4 +1,4 @@ -export interface Storage { +export interface SyncStorage { /** * for debug */ @@ -23,7 +23,3 @@ export interface Storage { disconnect: (reason: string) => void ): Promise<() => void>; } - -export * from './affine'; -export * from './indexeddb'; -export * from './sqlite'; diff --git a/packages/frontend/workspace/src/factory.ts b/packages/frontend/workspace/src/factory.ts new file mode 100644 index 0000000000..da72aa62b5 --- /dev/null +++ b/packages/frontend/workspace/src/factory.ts @@ -0,0 +1,13 @@ +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/frontend/workspace/src/global-schema.ts b/packages/frontend/workspace/src/global-schema.ts new file mode 100644 index 0000000000..e03dc9a7c2 --- /dev/null +++ b/packages/frontend/workspace/src/global-schema.ts @@ -0,0 +1,6 @@ +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/frontend/workspace/src/providers/awareness/affine/index.ts b/packages/frontend/workspace/src/impl/cloud/awareness.ts similarity index 89% rename from packages/frontend/workspace/src/providers/awareness/affine/index.ts rename to packages/frontend/workspace/src/impl/cloud/awareness.ts index d6d0b9d393..cfb91ab3a9 100644 --- a/packages/frontend/workspace/src/providers/awareness/affine/index.ts +++ b/packages/frontend/workspace/src/impl/cloud/awareness.ts @@ -6,18 +6,15 @@ import { removeAwarenessStates, } from 'y-protocols/awareness'; +import type { AwarenessProvider } from '../../engine/awareness'; import { getIoManager } from '../../utils/affine-io'; import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64'; -import type { AwarenessProvider } from '..'; const logger = new DebugLogger('affine:awareness:socketio'); -export type AwarenessChanges = Record< - 'added' | 'updated' | 'removed', - number[] ->; +type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>; -export function createAffineAwarenessProvider( +export function createCloudAwarenessProvider( workspaceId: string, awareness: Awareness ): AwarenessProvider { @@ -87,11 +84,14 @@ export function createAffineAwarenessProvider( window.addEventListener('beforeunload', windowBeforeUnloadHandler); - socket.emit('awareness-init', workspaceId); socket.connect(); + + socket.emit('client-handshake-awareness', workspaceId); + socket.emit('awareness-init', workspaceId); }, disconnect: () => { awareness.off('update', awarenessUpdate); + socket.emit('client-leave-awareness', workspaceId); socket.off('server-awareness-broadcast', awarenessBroadcast); socket.off('new-client-awareness-init', newClientAwarenessInitHandler); window.removeEventListener('unload', windowBeforeUnloadHandler); diff --git a/packages/frontend/workspace/src/blob/storage/affine-cloud.ts b/packages/frontend/workspace/src/impl/cloud/blob.ts similarity index 88% rename from packages/frontend/workspace/src/blob/storage/affine-cloud.ts rename to packages/frontend/workspace/src/impl/cloud/blob.ts index a1c23f54bd..ccfae1e025 100644 --- a/packages/frontend/workspace/src/blob/storage/affine-cloud.ts +++ b/packages/frontend/workspace/src/impl/cloud/blob.ts @@ -5,10 +5,10 @@ import { listBlobsQuery, setBlobMutation, } from '@affine/graphql'; -import { fetcher } from '@affine/workspace/affine/gql'; -import type { BlobStorage } from '../engine'; -import { bufferToBlob } from '../util'; +import { fetcher } from '../../affine/gql'; +import type { BlobStorage } from '../../engine/blob'; +import { bufferToBlob } from '../../utils/buffer-to-blob'; export const createAffineCloudBlobStorage = ( workspaceId: string @@ -25,7 +25,7 @@ export const createAffineCloudBlobStorage = ( async res => { if (!res.ok) { // status not in the range 200-299 - return undefined; + return null; } return bufferToBlob(await res.arrayBuffer()); } @@ -54,6 +54,7 @@ export const createAffineCloudBlobStorage = ( }, }); console.assert(result.setBlob === key, 'Blob hash mismatch'); + return result.setBlob; }, list: async () => { const result = await fetcher({ diff --git a/packages/frontend/workspace/src/impl/cloud/consts.ts b/packages/frontend/workspace/src/impl/cloud/consts.ts new file mode 100644 index 0000000000..47e9b8a7a6 --- /dev/null +++ b/packages/frontend/workspace/src/impl/cloud/consts.ts @@ -0,0 +1,2 @@ +export const CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY = + 'affine-cloud-workspace-changed'; diff --git a/packages/frontend/workspace/src/impl/cloud/index.ts b/packages/frontend/workspace/src/impl/cloud/index.ts new file mode 100644 index 0000000000..049887d3ab --- /dev/null +++ b/packages/frontend/workspace/src/impl/cloud/index.ts @@ -0,0 +1,6 @@ +export * from './awareness'; +export * from './blob'; +export * from './consts'; +export * from './list'; +export * from './sync'; +export * from './workspace-factory'; diff --git a/packages/frontend/workspace/src/impl/cloud/list.ts b/packages/frontend/workspace/src/impl/cloud/list.ts new file mode 100644 index 0000000000..a4fd93f54f --- /dev/null +++ b/packages/frontend/workspace/src/impl/cloud/list.ts @@ -0,0 +1,155 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { + createWorkspaceMutation, + deleteWorkspaceMutation, + getWorkspacesQuery, +} from '@affine/graphql'; +import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { difference } from 'lodash-es'; +import { nanoid } from 'nanoid'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import { fetcher } from '../../affine/gql'; +import { globalBlockSuiteSchema } from '../../global-schema'; +import type { WorkspaceListProvider } from '../../list'; +import { createLocalBlobStorage } from '../local/blob'; +import { createLocalStorage } from '../local/sync'; +import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts'; +import { createAffineStaticStorage } from './sync'; + +async function getCloudWorkspaceList() { + try { + const { workspaces } = await fetcher({ + query: getWorkspacesQuery, + }); + const ids = workspaces.map(({ id }) => id); + return ids.map(id => ({ + id, + flavour: WorkspaceFlavour.AFFINE_CLOUD, + })); + } catch (err) { + if (err instanceof Array && err[0]?.message === 'Forbidden resource') { + // user not logged in + return []; + } + throw err; + } +} + +export function createCloudWorkspaceListProvider(): WorkspaceListProvider { + const notifyChannel = new BroadcastChannel( + CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY + ); + + return { + name: WorkspaceFlavour.AFFINE_CLOUD, + async getList() { + return getCloudWorkspaceList(); + }, + async create(initial) { + const tempId = nanoid(); + + 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, + }); + + // save the initial state to local storage, then sync to cloud + const blobStorage = createLocalBlobStorage(workspaceId); + const syncStorage = createLocalStorage(workspaceId); + + // 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)); + } + + // notify all browser tabs, so they can update their workspace list + 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[] = []; + + 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); + }); + } + + 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, + }); + + 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/src/providers/storage/affine/batch-sync-sender.ts b/packages/frontend/workspace/src/impl/cloud/sync/batch-sync-sender.ts similarity index 100% rename from packages/frontend/workspace/src/providers/storage/affine/batch-sync-sender.ts rename to packages/frontend/workspace/src/impl/cloud/sync/batch-sync-sender.ts diff --git a/packages/frontend/workspace/src/providers/storage/affine/index.ts b/packages/frontend/workspace/src/impl/cloud/sync/index.ts similarity index 77% rename from packages/frontend/workspace/src/providers/storage/affine/index.ts rename to packages/frontend/workspace/src/impl/cloud/sync/index.ts index 622ef8b6b4..187b39d83b 100644 --- a/packages/frontend/workspace/src/providers/storage/affine/index.ts +++ b/packages/frontend/workspace/src/impl/cloud/sync/index.ts @@ -1,15 +1,16 @@ import { DebugLogger } from '@affine/debug'; +import { fetchWithTraceReport } from '@affine/graphql'; -import { getIoManager } from '../../utils/affine-io'; -import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64'; -import type { Storage } from '..'; +import type { SyncStorage } from '../../../engine/sync'; +import { getIoManager } from '../../../utils/affine-io'; +import { base64ToUint8Array, uint8ArrayToBase64 } from '../../../utils/base64'; import { MultipleBatchSyncSender } from './batch-sync-sender'; const logger = new DebugLogger('affine:storage:socketio'); export function createAffineStorage( workspaceId: string -): Storage & { disconnect: () => void } { +): SyncStorage & { disconnect: () => void } { logger.debug('createAffineStorage', workspaceId); const socket = getIoManager().socket('/'); @@ -53,7 +54,7 @@ export function createAffineStorage( // TODO: handle error socket.on('connect', () => { socket.emit( - 'client-handshake', + 'client-handshake-sync', workspaceId, (response: { error?: any }) => { if (!response.error) { @@ -66,7 +67,7 @@ export function createAffineStorage( socket.connect(); return { - name: 'socketio', + name: 'affine-cloud', async pull(docId, state) { const stateVector = state ? await uint8ArrayToBase64(state) : undefined; @@ -125,7 +126,7 @@ export function createAffineStorage( async subscribe(cb, disconnect) { const response: { error?: any } = await socket .timeout(10000) - .emitWithAck('client-handshake', workspaceId); + .emitWithAck('client-handshake-sync', workspaceId); if (response.error) { throw new Error('client-handshake error, ' + response.error); @@ -155,8 +156,38 @@ export function createAffineStorage( }, disconnect() { syncSender.stop(); - socket.emit('client-leave', workspaceId); + socket.emit('client-leave-sync', workspaceId); socket.disconnect(); }, }; } + +export function createAffineStaticStorage(workspaceId: string): SyncStorage { + logger.debug('createAffineStaticStorage', workspaceId); + + return { + name: 'affine-cloud-static', + async pull(docId) { + const response = await fetchWithTraceReport( + runtimeConfig.serverUrlPrefix + + `/api/workspaces/${workspaceId}/docs/${docId}`, + { + priority: 'high', + } + ); + if (response.ok) { + const arrayBuffer = await response.arrayBuffer(); + + return { data: new Uint8Array(arrayBuffer) }; + } + + return null; + }, + async push() { + throw new Error('Not implemented'); + }, + async subscribe() { + throw new Error('Not implemented'); + }, + }; +} diff --git a/packages/frontend/workspace/src/impl/cloud/workspace-factory.ts b/packages/frontend/workspace/src/impl/cloud/workspace-factory.ts new file mode 100644 index 0000000000..1877817f61 --- /dev/null +++ b/packages/frontend/workspace/src/impl/cloud/workspace-factory.ts @@ -0,0 +1,77 @@ +import { setupEditorFlags } from '@affine/env/global'; +import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { nanoid } from 'nanoid'; + +import { BlobEngine, SyncEngine, WorkspaceEngine } from '../../engine'; +import type { WorkspaceFactory } from '../../factory'; +import { globalBlockSuiteSchema } from '../../global-schema'; +import { Workspace } from '../../workspace'; +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'; + +export const cloudWorkspaceFactory: WorkspaceFactory = { + name: 'affine-cloud', + openWorkspace(metadata) { + const blobEngine = new BlobEngine(createLocalBlobStorage(metadata.id), [ + createAffineCloudBlobStorage(metadata.id), + createStaticBlobStorage(), + ]); + + // 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; + }, + async getWorkspaceBlob(id: string, blobKey: string): Promise { + // try to get blob from local storage first + const localBlobStorage = createLocalBlobStorage(id); + const localBlob = await localBlobStorage.get(blobKey); + if (localBlob) { + return localBlob; + } + + const blobStorage = createAffineCloudBlobStorage(id); + return await blobStorage.get(blobKey); + }, +}; diff --git a/packages/frontend/workspace/src/impl/index.ts b/packages/frontend/workspace/src/impl/index.ts new file mode 100644 index 0000000000..a2d3a7baa9 --- /dev/null +++ b/packages/frontend/workspace/src/impl/index.ts @@ -0,0 +1,2 @@ +export * from './cloud'; +export * from './local'; diff --git a/packages/frontend/workspace/src/providers/awareness/broadcast-channel/index.ts b/packages/frontend/workspace/src/impl/local/awareness.ts similarity index 92% rename from packages/frontend/workspace/src/providers/awareness/broadcast-channel/index.ts rename to packages/frontend/workspace/src/impl/local/awareness.ts index 3fa9f7cb26..b012333bd4 100644 --- a/packages/frontend/workspace/src/providers/awareness/broadcast-channel/index.ts +++ b/packages/frontend/workspace/src/impl/local/awareness.ts @@ -4,8 +4,9 @@ import { encodeAwarenessUpdate, } from 'y-protocols/awareness.js'; -import type { AwarenessProvider } from '..'; -import type { AwarenessChanges } from '../affine'; +import type { AwarenessProvider } from '../../engine/awareness'; + +type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>; type ChannelMessage = | { type: 'connect' } diff --git a/packages/frontend/workspace/src/blob/storage/indexeddb.ts b/packages/frontend/workspace/src/impl/local/blob-indexeddb.ts similarity index 84% rename from packages/frontend/workspace/src/blob/storage/indexeddb.ts rename to packages/frontend/workspace/src/impl/local/blob-indexeddb.ts index ce5fb35bf6..8117151306 100644 --- a/packages/frontend/workspace/src/blob/storage/indexeddb.ts +++ b/packages/frontend/workspace/src/impl/local/blob-indexeddb.ts @@ -1,7 +1,7 @@ import { createStore, del, get, keys, set } from 'idb-keyval'; -import type { BlobStorage } from '../engine'; -import { bufferToBlob } from '../util'; +import type { BlobStorage } from '../../engine/blob'; +import { bufferToBlob } from '../../utils/buffer-to-blob'; export const createIndexeddbBlobStorage = ( workspaceId: string @@ -16,11 +16,12 @@ export const createIndexeddbBlobStorage = ( if (res) { return bufferToBlob(res); } - return undefined; + 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); diff --git a/packages/frontend/workspace/src/blob/storage/sqlite.ts b/packages/frontend/workspace/src/impl/local/blob-sqlite.ts similarity index 83% rename from packages/frontend/workspace/src/blob/storage/sqlite.ts rename to packages/frontend/workspace/src/impl/local/blob-sqlite.ts index 3f3a0c080c..1188936133 100644 --- a/packages/frontend/workspace/src/blob/storage/sqlite.ts +++ b/packages/frontend/workspace/src/impl/local/blob-sqlite.ts @@ -1,7 +1,7 @@ import { assertExists } from '@blocksuite/global/utils'; -import type { BlobStorage } from '../engine'; -import { bufferToBlob } from '../util'; +import type { BlobStorage } from '../../engine/blob'; +import { bufferToBlob } from '../../utils/buffer-to-blob'; export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => { const apis = window.apis; @@ -14,7 +14,7 @@ export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => { if (buffer) { return bufferToBlob(buffer); } - return undefined; + return null; }, set: async (key: string, value: Blob) => { await apis.db.addBlob( @@ -22,6 +22,7 @@ export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => { key, new Uint8Array(await value.arrayBuffer()) ); + return key; }, delete: async (key: string) => { return apis.db.deleteBlob(workspaceId, key); diff --git a/packages/frontend/workspace/src/blob/storage/static.ts b/packages/frontend/workspace/src/impl/local/blob-static.ts similarity index 94% rename from packages/frontend/workspace/src/blob/storage/static.ts rename to packages/frontend/workspace/src/impl/local/blob-static.ts index 56578543c3..0ac9fcaeff 100644 --- a/packages/frontend/workspace/src/blob/storage/static.ts +++ b/packages/frontend/workspace/src/impl/local/blob-static.ts @@ -1,4 +1,4 @@ -import type { BlobStorage } from '../engine'; +import type { BlobStorage } from '../../engine/blob'; export const predefinedStaticFiles = [ '029uztLz2CzJezK7UUhrbGiWUdZ0J7NVs_qR6RDsvb8=', @@ -45,7 +45,7 @@ export const createStaticBlobStorage = (): BlobStorage => { predefinedStaticFiles.includes(key) || key.startsWith('/static/'); if (!isStaticResource) { - return undefined; + return null; } const path = key.startsWith('/static/') ? key : `/static/${key}`; @@ -55,10 +55,11 @@ export const createStaticBlobStorage = (): BlobStorage => { return await response.blob(); } - return undefined; + return null; }, - set: async () => { + set: async key => { // ignore + return key; }, delete: async () => { // ignore diff --git a/packages/frontend/workspace/src/impl/local/blob.ts b/packages/frontend/workspace/src/impl/local/blob.ts new file mode 100644 index 0000000000..746f119b60 --- /dev/null +++ b/packages/frontend/workspace/src/impl/local/blob.ts @@ -0,0 +1,10 @@ +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/src/impl/local/consts.ts b/packages/frontend/workspace/src/impl/local/consts.ts new file mode 100644 index 0000000000..2855fcf80b --- /dev/null +++ b/packages/frontend/workspace/src/impl/local/consts.ts @@ -0,0 +1,3 @@ +export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace'; +export const LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY = + 'affine-local-workspace-created'; diff --git a/packages/frontend/workspace/src/impl/local/index.ts b/packages/frontend/workspace/src/impl/local/index.ts new file mode 100644 index 0000000000..0fbc7662e3 --- /dev/null +++ b/packages/frontend/workspace/src/impl/local/index.ts @@ -0,0 +1,11 @@ +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/src/impl/local/list.ts b/packages/frontend/workspace/src/impl/local/list.ts new file mode 100644 index 0000000000..afe3088bc2 --- /dev/null +++ b/packages/frontend/workspace/src/impl/local/list.ts @@ -0,0 +1,129 @@ +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { difference } from 'lodash-es'; +import { nanoid } from 'nanoid'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import { globalBlockSuiteSchema } from '../../global-schema'; +import type { WorkspaceListProvider } from '../../list'; +import { createLocalBlobStorage } from './blob'; +import { + LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY, + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, +} from './consts'; +import { createLocalStorage } from './sync'; + +export function createLocalWorkspaceListProvider(): WorkspaceListProvider { + const 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[] = []; + + 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 })), + }); + } + + scan(); + + // rescan if other tabs notify us + notifyChannel.addEventListener('message', scan); + return () => { + notifyChannel.removeEventListener('message', scan); + }; + }, + async create(initial) { + const id = nanoid(); + + const blobStorage = createLocalBlobStorage(id); + const syncStorage = createLocalStorage(id); + + const workspace = new BlockSuiteWorkspace({ + id: id, + idGenerator: () => nanoid(), + schema: globalBlockSuiteSchema, + }); + + // apply initial state + await initial(workspace, blobStorage); + + // 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 + 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 (window.apis && environment.isDesktop) { + await window.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, + }); + + applyUpdate(bs.doc, data.data); + + return { + name: bs.meta.name, + avatar: bs.meta.avatar, + }; + }, + }; +} diff --git a/packages/frontend/workspace/src/providers/storage/indexeddb/index.ts b/packages/frontend/workspace/src/impl/local/sync-indexeddb.ts similarity index 84% rename from packages/frontend/workspace/src/providers/storage/indexeddb/index.ts rename to packages/frontend/workspace/src/impl/local/sync-indexeddb.ts index 51a47d352f..c6cedffa5c 100644 --- a/packages/frontend/workspace/src/providers/storage/indexeddb/index.ts +++ b/packages/frontend/workspace/src/impl/local/sync-indexeddb.ts @@ -1,33 +1,12 @@ import { type DBSchema, type IDBPDatabase, openDB } from 'idb'; -import { - applyUpdate, - diffUpdate, - Doc, - encodeStateAsUpdate, - encodeStateVectorFromUpdate, -} from 'yjs'; +import { diffUpdate, encodeStateVectorFromUpdate } from 'yjs'; -import type { Storage } from '..'; +import type { SyncStorage } from '../../engine/sync'; +import { mergeUpdates } from '../../utils/merge-updates'; export const dbVersion = 1; export const DEFAULT_DB_NAME = 'affine-local'; -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); -} - type UpdateMessage = { timestamp: number; update: Uint8Array; @@ -63,7 +42,7 @@ export function createIndexedDBStorage( workspaceId: string, dbName = DEFAULT_DB_NAME, mergeCount = 1 -): Storage { +): SyncStorage { let dbPromise: Promise> | null = null; const getDb = async () => { if (dbPromise === null) { @@ -93,7 +72,7 @@ export function createIndexedDBStorage( const { updates } = data; const update = mergeUpdates(updates.map(({ update }) => update)); - const diff = state ? diffUpdate(update, state) : update; + const diff = state.length ? diffUpdate(update, state) : update; return { data: diff, state: encodeStateVectorFromUpdate(update) }; }, diff --git a/packages/frontend/workspace/src/providers/storage/sqlite/index.ts b/packages/frontend/workspace/src/impl/local/sync-sqlite.ts similarity index 85% rename from packages/frontend/workspace/src/providers/storage/sqlite/index.ts rename to packages/frontend/workspace/src/impl/local/sync-sqlite.ts index be7dafd45d..1f103e5778 100644 --- a/packages/frontend/workspace/src/providers/storage/sqlite/index.ts +++ b/packages/frontend/workspace/src/impl/local/sync-sqlite.ts @@ -1,8 +1,8 @@ import { encodeStateVectorFromUpdate } from 'yjs'; -import type { Storage } from '..'; +import type { SyncStorage } from '../../engine/sync'; -export function createSQLiteStorage(workspaceId: string): Storage { +export function createSQLiteStorage(workspaceId: string): SyncStorage { if (!window.apis?.db) { throw new Error('sqlite datasource is not available'); } diff --git a/packages/frontend/workspace/src/impl/local/sync.ts b/packages/frontend/workspace/src/impl/local/sync.ts new file mode 100644 index 0000000000..4b58c4e45b --- /dev/null +++ b/packages/frontend/workspace/src/impl/local/sync.ts @@ -0,0 +1,7 @@ +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/src/impl/local/workspace-factory.ts b/packages/frontend/workspace/src/impl/local/workspace-factory.ts new file mode 100644 index 0000000000..08ed43a187 --- /dev/null +++ b/packages/frontend/workspace/src/impl/local/workspace-factory.ts @@ -0,0 +1,54 @@ +import { setupEditorFlags } from '@affine/env/global'; +import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { nanoid } from 'nanoid'; + +import { WorkspaceEngine } from '../../engine'; +import { BlobEngine } from '../../engine/blob'; +import { SyncEngine } from '../../engine/sync'; +import type { WorkspaceFactory } from '../../factory'; +import { globalBlockSuiteSchema } from '../../global-schema'; +import { Workspace } from '../../workspace'; +import { createBroadcastChannelAwarenessProvider } from './awareness'; +import { createLocalBlobStorage } from './blob'; +import { createStaticBlobStorage } from './blob-static'; +import { createLocalStorage } from './sync'; + +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, + ]); + + setupEditorFlags(bs); + + return new Workspace(metadata, engine, bs); + }, + async getWorkspaceBlob(id, blobKey) { + const blobStorage = createLocalBlobStorage(id); + + return await blobStorage.get(blobKey); + }, +}; diff --git a/packages/frontend/workspace/src/index.ts b/packages/frontend/workspace/src/index.ts new file mode 100644 index 0000000000..84c8aa193b --- /dev/null +++ b/packages/frontend/workspace/src/index.ts @@ -0,0 +1,29 @@ +import { + cloudWorkspaceFactory, + createCloudWorkspaceListProvider, + createLocalWorkspaceListProvider, + localWorkspaceFactory, +} from './impl'; +import { WorkspaceList } from './list'; +import { WorkspaceManager } from './manager'; + +const list = new WorkspaceList([ + createLocalWorkspaceListProvider(), + createCloudWorkspaceListProvider(), +]); + +export const workspaceManager = new WorkspaceManager(list, [ + localWorkspaceFactory, + cloudWorkspaceFactory, +]); + +(window as any).workspaceManager = workspaceManager; + +export * from './engine'; +export * from './factory'; +export * from './global-schema'; +export * from './impl'; +export * from './list'; +export * from './manager'; +export * from './metadata'; +export * from './workspace'; diff --git a/packages/frontend/workspace/src/list/cache.ts b/packages/frontend/workspace/src/list/cache.ts new file mode 100644 index 0000000000..0b154e7d75 --- /dev/null +++ b/packages/frontend/workspace/src/list/cache.ts @@ -0,0 +1,21 @@ +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/frontend/workspace/src/list/index.ts b/packages/frontend/workspace/src/list/index.ts new file mode 100644 index 0000000000..002f569a35 --- /dev/null +++ b/packages/frontend/workspace/src/list/index.ts @@ -0,0 +1,300 @@ +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/frontend/workspace/src/list/information.ts b/packages/frontend/workspace/src/list/information.ts new file mode 100644 index 0000000000..44fae94f27 --- /dev/null +++ b/packages/frontend/workspace/src/list/information.ts @@ -0,0 +1,97 @@ +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 '.'; + +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/frontend/workspace/src/local/__tests__/crud.spec.ts b/packages/frontend/workspace/src/local/__tests__/crud.spec.ts deleted file mode 100644 index 51339eed59..0000000000 --- a/packages/frontend/workspace/src/local/__tests__/crud.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import 'fake-indexeddb/auto'; - -import type { WorkspaceCRUD } from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; -import { assertExists } from '@blocksuite/global/utils'; -import { Schema, Workspace } from '@blocksuite/store'; -import { afterEach, assertType, describe, expect, test } from 'vitest'; - -import { CRUD } from '../crud'; - -const schema = new Schema(); - -schema.register(AffineSchemas).register(__unstableSchemas); - -afterEach(() => { - localStorage.clear(); -}); - -describe('crud', () => { - test('type', () => { - assertType>(CRUD); - }); - - test('basic', async () => { - const workspace = await CRUD.get('not_exist'); - expect(workspace).toBeNull(); - - expect(await CRUD.list()).toEqual([]); - }); - - test('delete not exist', async () => { - await expect(async () => - CRUD.delete(new Workspace({ id: 'test', schema })) - ).rejects.toThrowError(); - }); - - test('create & delete', async () => { - const workspace = new Workspace({ id: 'test', schema }); - const page = workspace.createPage({ id: 'page0' }); - await page.waitForLoaded(); - const pageBlockId = page.addBlock('affine:page', { - title: new page.Text(''), - }); - page.addBlock('affine:surface', {}, pageBlockId); - const frameId = page.addBlock('affine:note', {}, pageBlockId); - page.addBlock('affine:paragraph', {}, frameId); - - const id = await CRUD.create(workspace); - const list = await CRUD.list(); - expect(list.length).toBe(1); - expect(list[0].id).toBe(id); - const localWorkspace = list.at(0); - assertExists(localWorkspace); - expect(localWorkspace.id).toBe(id); - expect(localWorkspace.flavour).toBe(WorkspaceFlavour.LOCAL); - expect(localWorkspace.blockSuiteWorkspace.doc.toJSON()).toEqual({ - meta: expect.anything(), - spaces: expect.objectContaining({ - page0: expect.anything(), - }), - }); - - await CRUD.delete(localWorkspace.blockSuiteWorkspace); - expect(await CRUD.get(id)).toBeNull(); - expect(await CRUD.list()).toEqual([]); - }); -}); diff --git a/packages/frontend/workspace/src/local/crud.ts b/packages/frontend/workspace/src/local/crud.ts deleted file mode 100644 index da40919386..0000000000 --- a/packages/frontend/workspace/src/local/crud.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { LocalWorkspace, WorkspaceCRUD } from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; -import { createJSONStorage } from 'jotai/utils'; -import { nanoid } from 'nanoid'; -import { z } from 'zod'; - -import { getOrCreateWorkspace } from '../manager'; - -const getStorage = () => createJSONStorage(() => localStorage); - -const kStoreKey = 'affine-local-workspace'; -const schema = z.array(z.string()); - -const logger = new DebugLogger('affine:workspace:local:crud'); - -/** - * @internal - */ -export function saveWorkspaceToLocalStorage(workspaceId: string) { - const storage = getStorage(); - !Array.isArray(storage.getItem(kStoreKey, [])) && - storage.setItem(kStoreKey, []); - const data = storage.getItem(kStoreKey, []) as z.infer; - const id = data.find(id => id === workspaceId); - if (!id) { - logger.debug('saveWorkspaceToLocalStorage', workspaceId); - storage.setItem(kStoreKey, [...data, workspaceId]); - } -} - -export const CRUD: WorkspaceCRUD = { - get: async workspaceId => { - logger.debug('get', workspaceId); - const storage = getStorage(); - !Array.isArray(storage.getItem(kStoreKey, [])) && - storage.setItem(kStoreKey, []); - const data = storage.getItem(kStoreKey, []) as z.infer; - const id = data.find(id => id === workspaceId); - if (!id) { - return null; - } - const blockSuiteWorkspace = getOrCreateWorkspace( - id, - WorkspaceFlavour.LOCAL - ); - const workspace: LocalWorkspace = { - id, - flavour: WorkspaceFlavour.LOCAL, - blockSuiteWorkspace: blockSuiteWorkspace, - }; - return workspace; - }, - create: async ({ doc }) => { - logger.debug('create', doc); - const storage = getStorage(); - !Array.isArray(storage.getItem(kStoreKey, [])) && - storage.setItem(kStoreKey, []); - const binary = BlockSuiteWorkspace.Y.encodeStateAsUpdate(doc); - const id = nanoid(); - const blockSuiteWorkspace = getOrCreateWorkspace( - id, - WorkspaceFlavour.LOCAL - ); - BlockSuiteWorkspace.Y.applyUpdate(blockSuiteWorkspace.doc, binary); - - doc.getSubdocs().forEach(subdoc => { - blockSuiteWorkspace.doc.getSubdocs().forEach(newDoc => { - if (subdoc.guid === newDoc.guid) { - BlockSuiteWorkspace.Y.applyUpdate( - newDoc, - BlockSuiteWorkspace.Y.encodeStateAsUpdate(subdoc) - ); - } - }); - }); - // todo: do we need to persist doc to persistence datasource? - saveWorkspaceToLocalStorage(id); - return id; - }, - delete: async workspace => { - logger.debug('delete', workspace); - const storage = getStorage(); - !Array.isArray(storage.getItem(kStoreKey, [])) && - storage.setItem(kStoreKey, []); - const data = storage.getItem(kStoreKey, []) as z.infer; - const idx = data.findIndex(id => id === workspace.id); - if (idx === -1) { - throw new Error('workspace not found'); - } - data.splice(idx, 1); - storage.setItem(kStoreKey, [...data]); - // flywire - if (window.apis && environment.isDesktop) { - await window.apis.workspace.delete(workspace.id); - } - }, - list: async () => { - logger.debug('list'); - const storage = getStorage(); - const allWorkspaceIDs: string[] = storage.getItem(kStoreKey, []) as z.infer< - typeof schema - >; - - const workspaces = ( - await Promise.all(allWorkspaceIDs.map(id => CRUD.get(id))) - ).filter(item => item !== null) as LocalWorkspace[]; - - return workspaces; - }, -}; diff --git a/packages/frontend/workspace/src/manager.ts b/packages/frontend/workspace/src/manager.ts new file mode 100644 index 0000000000..9b32ebff92 --- /dev/null +++ b/packages/frontend/workspace/src/manager.ts @@ -0,0 +1,185 @@ +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 { BlobStorage } from '.'; +import type { WorkspaceFactory } from './factory'; +import { LOCAL_WORKSPACE_LOCAL_STORAGE_KEY } from './impl/local/consts'; +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); + } + + /** + * a hack for directly add local workspace to workspace list + * Used after copying sqlite database file to appdata folder + */ + _addLocalWorkspace(id: string) { + 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) + ); + } + + 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/frontend/workspace/src/manager/index.ts b/packages/frontend/workspace/src/manager/index.ts deleted file mode 100644 index bd1266e439..0000000000 --- a/packages/frontend/workspace/src/manager/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { BlockSuiteFeatureFlags } from '@affine/env/global'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { createAffinePublicProviders } from '@affine/workspace/providers'; -import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; -import type { DocProviderCreator } from '@blocksuite/store'; -import { Schema, Workspace } from '@blocksuite/store'; -import { INTERNAL_BLOCKSUITE_HASH_MAP } from '@toeverything/infra/__internal__/workspace'; -import { nanoid } from 'nanoid'; -import type { Doc } from 'yjs'; -import type { Transaction } from 'yjs'; - -import type { BlobEngine } from '../blob'; -import { - createAffineCloudBlobEngine, - createAffinePublicBlobEngine, - createLocalBlobEngine, -} from '../blob'; -import { createAffineProviders, createLocalProviders } from '../providers'; - -function setEditorFlags(workspace: Workspace) { - Object.entries(runtimeConfig.editorFlags).forEach(([key, value]) => { - workspace.awarenessStore.setFlag( - key as keyof BlockSuiteFeatureFlags, - value - ); - }); -} - -type UpdateCallback = ( - update: Uint8Array, - origin: string | number | null, - doc: Doc, - transaction: Transaction -) => void; - -type SubdocEvent = { - loaded: Set; - removed: Set; - added: Set; -}; - -const docUpdateCallbackWeakMap = new WeakMap(); - -export const globalBlockSuiteSchema = new Schema(); - -globalBlockSuiteSchema.register(AffineSchemas).register(__unstableSchemas); - -const createMonitor = (doc: Doc) => { - const onUpdate: UpdateCallback = (_, origin) => { - if (process.env.NODE_ENV === 'development') { - if (typeof origin !== 'string' && typeof origin !== 'number') { - console.warn( - 'origin is not a string or number, this will cause problems in the future', - origin - ); - } - } else { - // todo: add monitor in the future - } - }; - docUpdateCallbackWeakMap.set(doc, onUpdate); - doc.on('update', onUpdate); - const onSubdocs = (event: SubdocEvent) => { - event.added.forEach(subdoc => { - if (!docUpdateCallbackWeakMap.has(subdoc)) { - createMonitor(subdoc); - } - }); - event.removed.forEach(subdoc => { - if (docUpdateCallbackWeakMap.has(subdoc)) { - docUpdateCallbackWeakMap.delete(subdoc); - } - }); - }; - doc.on('subdocs', onSubdocs); - doc.on('destroy', () => { - docUpdateCallbackWeakMap.delete(doc); - doc.off('update', onSubdocs); - }); -}; - -const workspaceBlobEngineWeakMap = new WeakMap(); -export function getBlobEngine(workspace: Workspace) { - // temporary solution to get blob engine from workspace - return workspaceBlobEngineWeakMap.get(workspace); -} - -// if not exist, create a new workspace -export function getOrCreateWorkspace( - id: string, - flavour: WorkspaceFlavour -): Workspace { - const providerCreators: DocProviderCreator[] = []; - if (INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) { - return INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace; - } - - let blobEngine: BlobEngine; - if (flavour === WorkspaceFlavour.AFFINE_CLOUD) { - blobEngine = createAffineCloudBlobEngine(id); - providerCreators.push(...createAffineProviders()); - } else if (flavour === WorkspaceFlavour.LOCAL) { - blobEngine = createLocalBlobEngine(id); - providerCreators.push(...createLocalProviders()); - } else if (flavour === WorkspaceFlavour.AFFINE_PUBLIC) { - blobEngine = createAffinePublicBlobEngine(id); - providerCreators.push(...createAffinePublicProviders()); - } else { - throw new Error('unsupported flavour'); - } - - const workspace = new Workspace({ - id, - providerCreators: typeof window === 'undefined' ? [] : providerCreators, - blobStorages: [ - () => ({ - crud: { - async get(key) { - return (await blobEngine.get(key)) ?? null; - }, - async set(key, value) { - await blobEngine.set(key, value); - return key; - }, - async delete(key) { - return blobEngine.delete(key); - }, - async list() { - return blobEngine.list(); - }, - }, - }), - ], - idGenerator: () => nanoid(), - schema: globalBlockSuiteSchema, - }); - workspaceBlobEngineWeakMap.set(workspace, blobEngine); - createMonitor(workspace.doc); - setEditorFlags(workspace); - INTERNAL_BLOCKSUITE_HASH_MAP.set(id, workspace); - return workspace; -} diff --git a/packages/frontend/workspace/src/metadata.ts b/packages/frontend/workspace/src/metadata.ts new file mode 100644 index 0000000000..d73b79f8a6 --- /dev/null +++ b/packages/frontend/workspace/src/metadata.ts @@ -0,0 +1,3 @@ +import type { WorkspaceFlavour } from '@affine/env/workspace'; + +export type WorkspaceMetadata = { id: string; flavour: WorkspaceFlavour }; diff --git a/packages/frontend/workspace/src/pool.ts b/packages/frontend/workspace/src/pool.ts new file mode 100644 index 0000000000..e271c5cdc9 --- /dev/null +++ b/packages/frontend/workspace/src/pool.ts @@ -0,0 +1,86 @@ +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/frontend/workspace/src/providers/index.ts b/packages/frontend/workspace/src/providers/index.ts deleted file mode 100644 index 199521b4bf..0000000000 --- a/packages/frontend/workspace/src/providers/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * The `Provider` is responsible for sync `Y.Doc` with the local database and the Affine Cloud, serving as the source of - * Affine's local-first collaborative magic. - * - * When Affine boot, the `Provider` is tasked with reading content from the local database and loading it into the - * workspace, continuously storing any changes made by the user into the local database. - * - * When using Affine Cloud, the `Provider` also handles sync content with the Cloud. - * - * Additionally, the `Provider` is responsible for implementing a local-first capability, allowing users to edit offline - * with changes stored in the local database and sync with the Cloud when the network is restored. - */ - -import type { DocProviderCreator } from '@blocksuite/store'; - -import { - createAffineAwarenessProvider, - createBroadcastChannelAwarenessProvider, -} from './awareness'; -import { createAffineStorage } from './storage/affine'; -import { createIndexedDBStorage } from './storage/indexeddb'; -import { createSQLiteStorage } from './storage/sqlite'; -import { SyncEngine } from './sync'; - -export * from './sync'; - -export const createLocalProviders = (): DocProviderCreator[] => { - return [ - (_, doc, { awareness }) => { - const engine = new SyncEngine( - doc, - environment.isDesktop - ? createSQLiteStorage(doc.guid) - : createIndexedDBStorage(doc.guid), - [] - ); - - const awarenessProviders = [ - createBroadcastChannelAwarenessProvider(doc.guid, awareness), - ]; - - let connected = false; - - return { - flavour: '_', - passive: true, - active: true, - sync() { - if (!connected) { - engine.start(); - - for (const provider of awarenessProviders) { - provider.connect(); - } - connected = true; - } - }, - get whenReady() { - return engine.waitForLoadedRootDoc(); - }, - connect() { - if (!connected) { - engine.start(); - - for (const provider of awarenessProviders) { - provider.connect(); - } - connected = true; - } - }, - disconnect() { - // TODO: actually disconnect - }, - get connected() { - return connected; - }, - engine, - }; - }, - ]; -}; - -export const createAffineProviders = (): DocProviderCreator[] => { - return [ - (_, doc, { awareness }) => { - const engine = new SyncEngine( - doc, - environment.isDesktop - ? createSQLiteStorage(doc.guid) - : createIndexedDBStorage(doc.guid), - [createAffineStorage(doc.guid)] - ); - - const awarenessProviders = [ - createBroadcastChannelAwarenessProvider(doc.guid, awareness), - createAffineAwarenessProvider(doc.guid, awareness), - ]; - - let connected = false; - - return { - flavour: '_', - passive: true, - active: true, - sync() { - if (!connected) { - engine.start(); - - for (const provider of awarenessProviders) { - provider.connect(); - } - connected = true; - } - }, - get whenReady() { - return engine.waitForLoadedRootDoc(); - }, - connect() { - if (!connected) { - engine.start(); - - for (const provider of awarenessProviders) { - provider.connect(); - } - connected = true; - } - }, - disconnect() { - // TODO: actually disconnect - }, - get connected() { - return connected; - }, - engine, - }; - }, - ]; -}; - -export const createAffinePublicProviders = (): DocProviderCreator[] => { - return []; -}; diff --git a/packages/frontend/workspace/src/providers/sync/consts.ts b/packages/frontend/workspace/src/providers/sync/consts.ts deleted file mode 100644 index 4ddda3db06..0000000000 --- a/packages/frontend/workspace/src/providers/sync/consts.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const MANUALLY_STOP = 'manually-stop'; - -export enum SyncEngineStep { - Stopped = 0, - Syncing = 1, - Synced = 2, -} diff --git a/packages/frontend/workspace/src/upgrade/index.ts b/packages/frontend/workspace/src/upgrade/index.ts new file mode 100644 index 0000000000..53e06a5dc7 --- /dev/null +++ b/packages/frontend/workspace/src/upgrade/index.ts @@ -0,0 +1,146 @@ +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.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 + ); + + 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/frontend/workspace/src/providers/utils/__tests__/async-queue.spec.ts b/packages/frontend/workspace/src/utils/__tests__/async-queue.spec.ts similarity index 100% rename from packages/frontend/workspace/src/providers/utils/__tests__/async-queue.spec.ts rename to packages/frontend/workspace/src/utils/__tests__/async-queue.spec.ts diff --git a/packages/frontend/workspace/src/blob/__tests__/util.spec.ts b/packages/frontend/workspace/src/utils/__tests__/buffer-to-blob.spec.ts similarity index 87% rename from packages/frontend/workspace/src/blob/__tests__/util.spec.ts rename to packages/frontend/workspace/src/utils/__tests__/buffer-to-blob.spec.ts index 1b3e0fe20a..1aa471b055 100644 --- a/packages/frontend/workspace/src/blob/__tests__/util.spec.ts +++ b/packages/frontend/workspace/src/utils/__tests__/buffer-to-blob.spec.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import { describe, expect, test } from 'vitest'; -import { isSvgBuffer } from '../util'; +import { isSvgBuffer } from '../buffer-to-blob'; describe('isSvgBuffer', () => { test('basic', async () => { diff --git a/packages/frontend/workspace/src/providers/utils/__tests__/throw-if-aborted.spec.ts b/packages/frontend/workspace/src/utils/__tests__/throw-if-aborted.spec.ts similarity index 100% rename from packages/frontend/workspace/src/providers/utils/__tests__/throw-if-aborted.spec.ts rename to packages/frontend/workspace/src/utils/__tests__/throw-if-aborted.spec.ts diff --git a/packages/frontend/workspace/src/providers/utils/affine-io.ts b/packages/frontend/workspace/src/utils/affine-io.ts similarity index 100% rename from packages/frontend/workspace/src/providers/utils/affine-io.ts rename to packages/frontend/workspace/src/utils/affine-io.ts diff --git a/packages/frontend/workspace/src/providers/utils/async-queue.ts b/packages/frontend/workspace/src/utils/async-queue.ts similarity index 100% rename from packages/frontend/workspace/src/providers/utils/async-queue.ts rename to packages/frontend/workspace/src/utils/async-queue.ts diff --git a/packages/frontend/workspace/src/providers/utils/base64.ts b/packages/frontend/workspace/src/utils/base64.ts similarity index 100% rename from packages/frontend/workspace/src/providers/utils/base64.ts rename to packages/frontend/workspace/src/utils/base64.ts diff --git a/packages/frontend/workspace/src/blob/util.ts b/packages/frontend/workspace/src/utils/buffer-to-blob.ts similarity index 100% rename from packages/frontend/workspace/src/blob/util.ts rename to packages/frontend/workspace/src/utils/buffer-to-blob.ts diff --git a/packages/frontend/workspace/src/utils/merge-updates.ts b/packages/frontend/workspace/src/utils/merge-updates.ts new file mode 100644 index 0000000000..e3c8a4a06a --- /dev/null +++ b/packages/frontend/workspace/src/utils/merge-updates.ts @@ -0,0 +1,17 @@ +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/frontend/workspace/src/providers/utils/throw-if-aborted.ts b/packages/frontend/workspace/src/utils/throw-if-aborted.ts similarity index 82% rename from packages/frontend/workspace/src/providers/utils/throw-if-aborted.ts rename to packages/frontend/workspace/src/utils/throw-if-aborted.ts index 4646b0fbd9..54e2c81ac9 100644 --- a/packages/frontend/workspace/src/providers/utils/throw-if-aborted.ts +++ b/packages/frontend/workspace/src/utils/throw-if-aborted.ts @@ -5,3 +5,5 @@ export function throwIfAborted(abort?: AbortSignal) { } return true; } + +export const MANUALLY_STOP = 'manually-stop'; diff --git a/packages/frontend/workspace/src/workspace.ts b/packages/frontend/workspace/src/workspace.ts new file mode 100644 index 0000000000..c0ecaf1ec5 --- /dev/null +++ b/packages/frontend/workspace/src/workspace.ts @@ -0,0 +1,137 @@ +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/frontend/workspace/tsconfig.json b/packages/frontend/workspace/tsconfig.json index 32459ccb91..dc07b10505 100644 --- a/packages/frontend/workspace/tsconfig.json +++ b/packages/frontend/workspace/tsconfig.json @@ -7,12 +7,9 @@ }, "references": [ { "path": "../../../tests/fixtures" }, - { "path": "../../common/y-indexeddb" }, - { "path": "../../common/y-provider" }, { "path": "../../common/env" }, { "path": "../../common/debug" }, { "path": "../../common/infra" }, - { "path": "../../frontend/hooks" }, { "path": "../../frontend/graphql" } ] } diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index 349efeec2f..280776f99b 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -151,10 +151,10 @@ test('can collaborate with other user and name should display when editing', asy { const title = getBlockSuiteEditorTitle(page2); expect(await title.innerText()).toBe('TEST TITLE'); - const typingPromise = Promise.all([ - page.keyboard.press('Enter', { delay: 50 }), - page.keyboard.type('TEST CONTENT', { delay: 50 }), - ]); + const typingPromise = (async () => { + await page.keyboard.press('Enter', { delay: 50 }); + await page.keyboard.type('TEST CONTENT', { delay: 50 }); + })(); // username should be visible when editing await expect(page2.getByText(user.name)).toBeVisible(); await typingPromise; diff --git a/tests/affine-desktop/e2e/basic.spec.ts b/tests/affine-desktop/e2e/basic.spec.ts index c9808d9dfc..5f690483db 100644 --- a/tests/affine-desktop/e2e/basic.spec.ts +++ b/tests/affine-desktop/e2e/basic.spec.ts @@ -18,7 +18,7 @@ test('new page', async ({ page, workspace }) => { delay: 100, }); await page.waitForSelector('v-line'); - const flavour = (await workspace.current()).flavour; + const flavour = (await workspace.current()).meta.flavour; expect(flavour).toBe('local'); }); @@ -169,14 +169,8 @@ test('windows only check', async ({ page }) => { test('delete workspace', async ({ page }) => { await clickSideBarCurrentWorkspaceBanner(page); await page.getByTestId('new-workspace').click(); - await page - .getByTestId('create-workspace-input') - .pressSequentially('Delete Me', { - delay: 100, - }); - await page.getByTestId('create-workspace-create-button').click({ - delay: 100, - }); + await page.getByTestId('create-workspace-input').fill('Delete Me'); + await page.getByTestId('create-workspace-create-button').click(); // await page.getByTestId('create-workspace-continue-button').click({ // delay: 100, // }); @@ -197,9 +191,7 @@ test('delete workspace', async ({ page }) => { ); await page.mouse.wheel(0, 500); await page.getByTestId('delete-workspace-button').click(); - await page - .getByTestId('delete-workspace-input') - .pressSequentially('Delete Me'); + await page.getByTestId('delete-workspace-input').fill('Delete Me'); await page.getByTestId('delete-workspace-confirm-button').click(); await page.waitForTimeout(1000); expect(await page.getByTestId('workspace-name').textContent()).toBe( diff --git a/tests/affine-desktop/e2e/workspace.spec.ts b/tests/affine-desktop/e2e/workspace.spec.ts index 51d09fed89..920e600b78 100644 --- a/tests/affine-desktop/e2e/workspace.spec.ts +++ b/tests/affine-desktop/e2e/workspace.spec.ts @@ -10,7 +10,7 @@ test('check workspace has a DB file', async ({ appInfo, workspace }) => { const dbPath = path.join( appInfo.sessionData, 'workspaces', - w.id, + w.meta.id, 'storage.db' ); // check if db file exists @@ -25,7 +25,7 @@ test.skip('move workspace db file', async ({ page, appInfo, workspace }) => { // goto workspace setting await page.getByTestId('workspace-list-item').click(); - const tmpPath = path.join(appInfo.sessionData, w.id + '-tmp-dir'); + const tmpPath = path.join(appInfo.sessionData, w.meta.id + '-tmp-dir'); // move db file to tmp folder await page.evaluate(tmpPath => { @@ -53,7 +53,7 @@ test.fixme('export then add', async ({ page, appInfo, workspace }) => { await page.getByTestId('slider-bar-workspace-setting-button').click(); await expect(page.getByTestId('setting-modal')).toBeVisible(); - const originalId = w.id; + const originalId = w.meta.id; const newWorkspaceName = 'new-test-name'; @@ -67,7 +67,7 @@ test.fixme('export then add', async ({ page, appInfo, workspace }) => { await page.getByTestId('save-workspace-name').click(); await page.waitForSelector('text="Update workspace name success"'); - const tmpPath = path.join(appInfo.sessionData, w.id + '-tmp.db'); + const tmpPath = path.join(appInfo.sessionData, w.meta.id + '-tmp.db'); // export db file to tmp folder await page.evaluate(tmpPath => { @@ -105,7 +105,7 @@ test.fixme('export then add', async ({ page, appInfo, workspace }) => { // sleep for a while to wait for the workspace to be added :D await page.waitForTimeout(2000); const newWorkspace = await workspace.current(); - expect(newWorkspace.id).not.toBe(originalId); + expect(newWorkspace.meta.id).not.toBe(originalId); // check its name is correct await expect(page.getByTestId('workspace-name')).toHaveText(newWorkspaceName); diff --git a/tests/affine-local/e2e/local-first-avatar.spec.ts b/tests/affine-local/e2e/local-first-avatar.spec.ts index deb1445a84..a42aae065c 100644 --- a/tests/affine-local/e2e/local-first-avatar.spec.ts +++ b/tests/affine-local/e2e/local-first-avatar.spec.ts @@ -57,5 +57,5 @@ test('should create a page with a local first avatar and remove it', async ({ const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-delete-page.spec.ts b/tests/affine-local/e2e/local-first-delete-page.spec.ts index 5e51692e71..64318b12f8 100644 --- a/tests/affine-local/e2e/local-first-delete-page.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-page.spec.ts @@ -41,7 +41,7 @@ test('page delete -> refresh page -> it should be disappear', async ({ const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('page delete -> create new page -> refresh page -> new page should be appear -> old page should be disappear', async ({ @@ -95,7 +95,7 @@ test('page delete -> create new page -> refresh page -> new page should be appea const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('delete multiple pages -> create multiple pages -> refresh', async ({ @@ -155,5 +155,5 @@ test('delete multiple pages -> create multiple pages -> refresh', async ({ const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts index e36bb5ec01..faeb92bbe5 100644 --- a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts @@ -45,7 +45,7 @@ test('Create new workspace, then delete it', async ({ page, workspace }) => { ); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('Delete last workspace', async ({ page }) => { diff --git a/tests/affine-local/e2e/local-first-export-page.spec.ts b/tests/affine-local/e2e/local-first-export-page.spec.ts index d592860392..f359f959ca 100644 --- a/tests/affine-local/e2e/local-first-export-page.spec.ts +++ b/tests/affine-local/e2e/local-first-export-page.spec.ts @@ -41,7 +41,7 @@ test.skip('New a page ,then open it and export html', async ({ ); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test.skip('New a page ,then open it and export markdown', async ({ @@ -74,5 +74,5 @@ test.skip('New a page ,then open it and export markdown', async ({ ); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-favorite-page.spec.ts b/tests/affine-local/e2e/local-first-favorite-page.spec.ts index eb03abf881..fca397cb26 100644 --- a/tests/affine-local/e2e/local-first-favorite-page.spec.ts +++ b/tests/affine-local/e2e/local-first-favorite-page.spec.ts @@ -31,7 +31,7 @@ test('New a page and open it ,then favorite it', async ({ await favoriteBtn.click(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('Export to html, markdown and png', async ({ page }) => { @@ -121,5 +121,5 @@ test('Cancel favorite', async ({ page, workspace }) => { ).not.toBeUndefined(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-favorites-items.spec.ts b/tests/affine-local/e2e/local-first-favorites-items.spec.ts index 64e31bfd3e..52d9024f0f 100644 --- a/tests/affine-local/e2e/local-first-favorites-items.spec.ts +++ b/tests/affine-local/e2e/local-first-favorites-items.spec.ts @@ -33,7 +33,7 @@ test('Show favorite items in sidebar', async ({ page, workspace }) => { ); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('Show favorite reference in sidebar', async ({ page, workspace }) => { @@ -73,7 +73,7 @@ test('Show favorite reference in sidebar', async ({ page, workspace }) => { ).toBeVisible(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test("Deleted page's reference will not be shown in sidebar", async ({ @@ -124,7 +124,7 @@ test("Deleted page's reference will not be shown in sidebar", async ({ expect(collapseButton).toHaveAttribute('data-disabled', 'true'); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('Add new favorite page via sidebar', async ({ page }) => { diff --git a/tests/affine-local/e2e/local-first-new-page.spec.ts b/tests/affine-local/e2e/local-first-new-page.spec.ts index 44cbca9f18..b43d0bb947 100644 --- a/tests/affine-local/e2e/local-first-new-page.spec.ts +++ b/tests/affine-local/e2e/local-first-new-page.spec.ts @@ -16,7 +16,7 @@ test('click btn new page', async ({ page, workspace }) => { expect(newPageId).not.toBe(originPageId); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('click btn bew page and find it in all pages', async ({ @@ -33,5 +33,5 @@ test('click btn bew page and find it in all pages', async ({ expect(cell).not.toBeUndefined(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts b/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts index 177eeec166..8be69b903f 100644 --- a/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts +++ b/tests/affine-local/e2e/local-first-openpage-newtab.spec.ts @@ -28,5 +28,5 @@ test('click btn bew page and open in tab', async ({ page, workspace }) => { expect(newTabPage.url()).toBe(newPageUrl); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-restore-page.spec.ts b/tests/affine-local/e2e/local-first-restore-page.spec.ts index 50da9fe814..95a57e2268 100644 --- a/tests/affine-local/e2e/local-first-restore-page.spec.ts +++ b/tests/affine-local/e2e/local-first-restore-page.spec.ts @@ -47,5 +47,5 @@ test('New a page , then delete it in all pages, restore it', async ({ expect(restoreCell).not.toBeUndefined(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts b/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts index 5444504744..92ccd9f5ab 100644 --- a/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts +++ b/tests/affine-local/e2e/local-first-show-delete-modal.spec.ts @@ -32,7 +32,7 @@ test('New a page ,then open it and show delete modal', async ({ expect(confirmTip).not.toBeUndefined(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('New a page ,then go to all pages and show delete modal', async ({ @@ -58,5 +58,5 @@ test('New a page ,then go to all pages and show delete modal', async ({ expect(confirmTip).not.toBeUndefined(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-trash-page.spec.ts b/tests/affine-local/e2e/local-first-trash-page.spec.ts index 6e8797b222..c35739264c 100644 --- a/tests/affine-local/e2e/local-first-trash-page.spec.ts +++ b/tests/affine-local/e2e/local-first-trash-page.spec.ts @@ -38,5 +38,5 @@ test('New a page , then delete it in all pages, finally find it in trash', async ).not.toBeUndefined(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/local-first-workspace-list.spec.ts b/tests/affine-local/e2e/local-first-workspace-list.spec.ts index e0eaea59d3..d4c9ee7616 100644 --- a/tests/affine-local/e2e/local-first-workspace-list.spec.ts +++ b/tests/affine-local/e2e/local-first-workspace-list.spec.ts @@ -25,7 +25,7 @@ test('just one item in the workspace list at first', async ({ ).not.toBeNull(); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('create one workspace in the workspace list', async ({ @@ -57,7 +57,7 @@ test('create one workspace in the workspace list', async ({ expect(result1).toBe(11); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); test('create multi workspace in the workspace list', async ({ @@ -88,7 +88,7 @@ test('create multi workspace in the workspace list', async ({ const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); await openWorkspaceListModal(page); await page.waitForTimeout(1000); @@ -142,5 +142,5 @@ test('create multi workspace in the workspace list', async ({ const nextWorkspace = await workspace.current(); - expect(currentWorkspace.id).toBe(nextWorkspace.id); + expect(currentWorkspace.meta.id).toBe(nextWorkspace.meta.id); }); diff --git a/tests/affine-local/e2e/local-first-workspace.spec.ts b/tests/affine-local/e2e/local-first-workspace.spec.ts index 875b156690..b3cf123b8a 100644 --- a/tests/affine-local/e2e/local-first-workspace.spec.ts +++ b/tests/affine-local/e2e/local-first-workspace.spec.ts @@ -11,5 +11,5 @@ test('preset workspace name', async ({ page, workspace }) => { expect(await workspaceName.textContent()).toBe('Demo Workspace'); const currentWorkspace = await workspace.current(); - expect(currentWorkspace.flavour).toContain('local'); + expect(currentWorkspace.meta.flavour).toContain('local'); }); diff --git a/tests/affine-local/e2e/router.spec.ts b/tests/affine-local/e2e/router.spec.ts index 09ab7e6264..9505d87dd0 100644 --- a/tests/affine-local/e2e/router.spec.ts +++ b/tests/affine-local/e2e/router.spec.ts @@ -20,6 +20,7 @@ test('goto not found workspace', async ({ page }) => { // if doesn't wait for timeout, data won't be saved into indexedDB await page.waitForTimeout(1000); await page.goto(new URL('/workspace/invalid/all', coreUrl).toString()); - await page.waitForTimeout(3000); - expect(page.url()).toBe(new URL('/404', coreUrl).toString()); + await expect(page.getByTestId('not-found')).toBeVisible({ + timeout: 10000, + }); }); diff --git a/tests/affine-migration/e2e/basic.spec.ts b/tests/affine-migration/e2e/basic.spec.ts index 9e26655cd5..5faee08998 100644 --- a/tests/affine-migration/e2e/basic.spec.ts +++ b/tests/affine-migration/e2e/basic.spec.ts @@ -38,9 +38,6 @@ test('v1 to v4', async ({ page }) => { await expect(page.getByTestId('upgrade-workspace-button')).toBeVisible(); await page.getByTestId('upgrade-workspace-button').click(); - await expect(page.getByText('Refresh Current Page')).toBeVisible(); - await page.getByTestId('upgrade-workspace-button').click(); - await expect(page.getByTestId('page-list-item')).toHaveCount(2); await page .getByTestId('page-list-item-title-text') @@ -63,10 +60,6 @@ test('v2 to v4, database migration', async ({ page }) => { await expect(page.getByTestId('upgrade-workspace-button')).toBeVisible(); await page.getByTestId('upgrade-workspace-button').click(); - await expect(page.getByText('Refresh Current Page')).toBeVisible(); - await page.getByTestId('upgrade-workspace-button').click(); - await waitForEditorLoad(page); - // check page mode is correct await expect(page.locator('v-line').nth(0)).toHaveText('hello'); await expect(page.locator('affine-database')).toBeVisible(); @@ -84,9 +77,6 @@ test('v3 to v4, surface migration', async ({ page }) => { await expect(page.getByTestId('upgrade-workspace-button')).toBeVisible(); await page.getByTestId('upgrade-workspace-button').click(); - - await expect(page.getByText('Refresh Current Page')).toBeVisible(); - await page.getByTestId('upgrade-workspace-button').click(); await waitForEditorLoad(page); await page.waitForTimeout(500); @@ -106,9 +96,6 @@ test('v0 to v4, subdoc migration', async ({ page }) => { await expect(page.getByTestId('upgrade-workspace-button')).toBeVisible(); await page.getByTestId('upgrade-workspace-button').click(); - await expect(page.getByText('Refresh Current Page')).toBeVisible(); - await page.getByTestId('upgrade-workspace-button').click(); - await expect(page.getByTestId('page-list-item')).toHaveCount(2); await page .getByTestId('page-list-item-title-text') diff --git a/tests/kit/playwright.ts b/tests/kit/playwright.ts index f27b31a2b8..552dcb38db 100644 --- a/tests/kit/playwright.ts +++ b/tests/kit/playwright.ts @@ -28,8 +28,7 @@ function generateUUID() { export const enableCoverage = !!process.env.CI || !!process.env.COVERAGE; type CurrentWorkspace = { - id: string; - flavour: string; + meta: { id: string; flavour: string }; blockSuiteWorkspace: Workspace; }; diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index c1a4295409..0fe780780a 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -1,6 +1,7 @@ import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, + waitForAllPagesLoad, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { @@ -191,7 +192,7 @@ export async function enableCloudWorkspace(page: Page) { await page.getByTestId('confirm-enable-affine-cloud-button').click(); // wait for upload and delete local workspace await page.waitForTimeout(2000); - await waitForEditorLoad(page); + await waitForAllPagesLoad(page); await clickNewPageButton(page); } diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx index 82f655b9ac..b57b50da35 100644 --- a/tests/storybook/.storybook/preview.tsx +++ b/tests/storybook/.storybook/preview.tsx @@ -9,14 +9,19 @@ 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'; import useSWR from 'swr'; import type { Decorator } from '@storybook/react'; import { createStore } from 'jotai/vanilla'; import { _setCurrentStore } from '@toeverything/infra/atom'; -import { setupGlobal } from '@affine/env/global'; +import { setupGlobal, type Environment } from '@affine/env/global'; import type { Preview } from '@storybook/react'; import { useLayoutEffect, useRef } from 'react'; +import { setup } from '@affine/core/bootstrap/setup'; +import { bootstrapPluginSystem } from '@affine/core/bootstrap/register-plugins'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { currentWorkspaceAtom } from '@affine/workspace/atom'; setupGlobal(); export const parameters = { @@ -101,37 +106,25 @@ const ThemeChange = () => { return null; }; -const storeMap = new Map>(); - -const bootstrapPluginSystemPromise = import( - '@affine/core/bootstrap/register-plugins' -).then(({ bootstrapPluginSystem }) => bootstrapPluginSystem); - -const setupPromise = import('@affine/core/bootstrap/setup').then( - ({ setup }) => setup -); +localStorage.clear(); +const store = createStore(); +_setCurrentStore(store); +setup(); +bootstrapPluginSystem(store).catch(err => { + console.error('Failed to bootstrap plugin system', err); +}); +workspaceManager + .createWorkspace(WorkspaceFlavour.LOCAL, async w => { + w.meta.setName('test-workspace'); + }) + .then(id => { + store.set( + currentWorkspaceAtom, + workspaceManager.use({ flavour: WorkspaceFlavour.LOCAL, id }).workspace + ); + }); const withContextDecorator: Decorator = (Story, context) => { - const { data: store } = useSWR( - context.id, - async () => { - if (storeMap.has(context.id)) { - return storeMap.get(context.id); - } - localStorage.clear(); - const store = createStore(); - _setCurrentStore(store); - const setup = await setupPromise; - await setup(store); - const bootstrapPluginSystem = await bootstrapPluginSystemPromise; - await bootstrapPluginSystem(store); - storeMap.set(context.id, store); - return store; - }, - { - suspense: true, - } - ); return ( @@ -152,14 +145,22 @@ const withPlatformSelectionDecorator: Decorator = (Story, context) => { } switch (context.globals.platform) { case 'desktop-macos': - environment.isDesktop = true; - environment.isMacOs = true; - environment.isWindows = false; + environment = { + ...environment, + isBrowser: true, + isDesktop: true, + isMacOs: true, + isWindows: false, + } as Environment; break; case 'desktop-windows': - environment.isDesktop = true; - environment.isMacOs = false; - environment.isWindows = true; + environment = { + ...environment, + isBrowser: true, + isDesktop: true, + isMacOs: false, + isWindows: true, + } as Environment; break; default: globalThis.$AFFINE_SETUP = false; diff --git a/tests/storybook/package.json b/tests/storybook/package.json index 95535515a3..a94b77f69b 100644 --- a/tests/storybook/package.json +++ b/tests/storybook/package.json @@ -24,6 +24,7 @@ "@vitejs/plugin-react": "^4.2.0", "concurrently": "^8.2.2", "jest-mock": "^29.7.0", + "nanoid": "^5.0.3", "serve": "^14.2.1", "ses": "^0.18.8", "storybook": "^7.5.3", diff --git a/tests/storybook/src/stories/card.stories.tsx b/tests/storybook/src/stories/card.stories.tsx index 296383629d..85928ae7cb 100644 --- a/tests/storybook/src/stories/card.stories.tsx +++ b/tests/storybook/src/stories/card.stories.tsx @@ -3,7 +3,6 @@ import { BlockCard } from '@affine/component/card/block-card'; import { WorkspaceCard } from '@affine/component/card/workspace-card'; import { Tooltip } from '@affine/component/ui/tooltip'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; import { EdgelessIcon, ExportToHtmlIcon, @@ -20,13 +19,6 @@ export default { }, } satisfies Meta; -const blockSuiteWorkspace = getOrCreateWorkspace( - 'blocksuite-local', - WorkspaceFlavour.LOCAL -); - -blockSuiteWorkspace.meta.setName('Hello World'); - export const AffineWorkspaceCard = () => { return ( res.arrayBuffer()) - .then(async buffer => { - const id = await workspace.blob.set( - new Blob([buffer], { type: 'image/png' }) - ); - const frameId = page.getBlockByFlavour('affine:note')[0].id; - page.addBlock( - 'affine:paragraph', - { - text: new page.Text('Please double click the image to preview it.'), - }, - frameId - ); - page.addBlock( - 'affine:image', - { - sourceId: id, - }, - frameId - ); - }) - .catch(err => { - console.error('Failed to load large-image.png', err); - }); - export const Default = () => { + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + + const [page, setPage] = useState(null); + + useEffect(() => { + const page = workspace.blockSuiteWorkspace.createPage('page0'); + initEmptyPage(page); + fetch(new URL('@affine-test/fixtures/large-image.png', import.meta.url)) + .then(res => res.arrayBuffer()) + .then(async buffer => { + const id = await workspace.blockSuiteWorkspace.blob.set( + new Blob([buffer], { type: 'image/png' }) + ); + const frameId = page.getBlockByFlavour('affine:note')[0].id; + page.addBlock( + 'affine:paragraph', + { + text: new page.Text('Please double click the image to preview it.'), + }, + frameId + ); + page.addBlock( + 'affine:image', + { + sourceId: id, + }, + frameId + ); + }) + .catch(err => { + console.error('Failed to load large-image.png', err); + }); + setPage(page); + }, [workspace]); + + if (!page) { + return null; + } + return (
{ - const workspaceId = 'test-workspace'; - getOrCreateWorkspace(workspaceId, WorkspaceFlavour.LOCAL); - store.set(rootWorkspacesMetadataAtom, [ - { - id: workspaceId, - flavour: WorkspaceFlavour.LOCAL, - version: 4, - }, - ]); - store.set(currentWorkspaceIdAtom, workspaceId); - }, [store]); + store.set(currentWorkspaceAtom, workspace); + }, [store, workspace]); } export const CMDKStoryWithCommands: StoryFn = () => { diff --git a/tests/storybook/src/stories/share-menu.stories.tsx b/tests/storybook/src/stories/share-menu.stories.tsx index 7bafe3c318..96181dacfe 100644 --- a/tests/storybook/src/stories/share-menu.stories.tsx +++ b/tests/storybook/src/stories/share-menu.stories.tsx @@ -1,17 +1,15 @@ 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 type { - AffineCloudWorkspace, - LocalWorkspace, -} from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; -import type { Page } from '@blocksuite/store'; +import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom'; +import { type Page } from '@blocksuite/store'; import { expect } from '@storybook/jest'; import type { Meta, StoryFn } from '@storybook/react'; -import { use } from 'foxact/use'; -import { useState } from 'react'; +import { initEmptyPage } from '@toeverything/infra/blocksuite'; +import { useAtomValue } from 'jotai'; +import { nanoid } from 'nanoid'; +import { useEffect, useState } from 'react'; export default { title: 'AFFiNE/ShareMenu', @@ -21,57 +19,30 @@ export default { }, } satisfies Meta; -async function initPage(page: Page) { - await page.waitForLoaded(); - // Add page block and surface block at root level - const pageBlockId = page.addBlock('affine:page', { - title: new page.Text('Hello, world!'), - }); - page.addBlock('affine:surface', {}, pageBlockId); - const frameId = page.addBlock('affine:note', {}, pageBlockId); - page.addBlock( - 'affine:paragraph', - { - text: new page.Text('This is a paragraph.'), - }, - frameId - ); - page.resetHistory(); -} - -const blockSuiteWorkspace = getOrCreateWorkspace( - 'test-workspace', - WorkspaceFlavour.LOCAL -); - -const promise = Promise.all([ - initPage(blockSuiteWorkspace.createPage({ id: 'page0' })), - initPage(blockSuiteWorkspace.createPage({ id: 'page1' })), - initPage(blockSuiteWorkspace.createPage({ id: 'page2' })), -]); - -const localWorkspace: LocalWorkspace = { - id: 'test-workspace', - flavour: WorkspaceFlavour.LOCAL, - blockSuiteWorkspace, -}; - -const affineWorkspace: AffineCloudWorkspace = { - id: 'test-workspace', - flavour: WorkspaceFlavour.AFFINE_CLOUD, - blockSuiteWorkspace, -}; - async function unimplemented() { toast('work in progress'); } export const Basic: StoryFn = () => { - use(promise); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + + const [page, setPage] = useState(null); + + useEffect(() => { + const page = workspace.blockSuiteWorkspace.createPage(nanoid()); + initEmptyPage(page); + + setPage(page); + }, [workspace]); + + if (!page) { + return
; + } + return ( ); @@ -95,11 +66,28 @@ Basic.play = async ({ canvasElement }) => { }; export const AffineBasic: StoryFn = () => { - use(promise); + const workspace = useAtomValue(waitForCurrentWorkspaceAtom); + + const [page, setPage] = useState(null); + + useEffect(() => { + const page = workspace.blockSuiteWorkspace.createPage(nanoid()); + initEmptyPage(page); + + setPage(page); + }, [workspace]); + + if (!page) { + return
; + } + return ( ); @@ -107,7 +95,6 @@ export const AffineBasic: StoryFn = () => { export const DisableModal: StoryFn = () => { const [open, setOpen] = useState(false); - use(promise); return ( <>
setOpen(!open)}>Disable Public Link
diff --git a/tests/storybook/src/stories/workspace-list.stories.tsx b/tests/storybook/src/stories/workspace-list.stories.tsx index 270e4ae453..b06e4e0e71 100644 --- a/tests/storybook/src/stories/workspace-list.stories.tsx +++ b/tests/storybook/src/stories/workspace-list.stories.tsx @@ -1,10 +1,8 @@ import type { WorkspaceListProps } from '@affine/component/workspace-list'; import { WorkspaceList } from '@affine/component/workspace-list'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; -import { arrayMove } from '@dnd-kit/sortable'; +import { workspaceListAtom } from '@affine/workspace/atom'; import type { Meta } from '@storybook/react'; -import { useState } from 'react'; +import { useAtomValue } from 'jotai'; export default { title: 'AFFiNE/WorkspaceList', @@ -15,49 +13,14 @@ export default { } satisfies Meta; export const Default = () => { - const [items, setItems] = useState(() => { - const items = [ - { - id: '1', - flavour: WorkspaceFlavour.LOCAL, - blockSuiteWorkspace: getOrCreateWorkspace('1', WorkspaceFlavour.LOCAL), - }, - { - id: '2', - flavour: WorkspaceFlavour.LOCAL, - blockSuiteWorkspace: getOrCreateWorkspace('2', WorkspaceFlavour.LOCAL), - }, - { - id: '3', - flavour: WorkspaceFlavour.LOCAL, - blockSuiteWorkspace: getOrCreateWorkspace('3', WorkspaceFlavour.LOCAL), - }, - ] satisfies WorkspaceListProps['items']; - - items.forEach(item => { - item.blockSuiteWorkspace.meta.setName(item.id); - }); - - return items; - }); + const list = useAtomValue(workspaceListAtom); return ( {}} onSettingClick={() => {}} - onDragEnd={event => { - const { active, over } = event; - - if (active.id !== over?.id) { - setItems(items => { - const oldIndex = items.findIndex(item => item.id === active.id); - const newIndex = items.findIndex(item => item.id === over?.id); - - return arrayMove(items, oldIndex, newIndex); - }); - } - }} + onDragEnd={_ => {}} /> ); }; diff --git a/tests/storybook/tsconfig.json b/tests/storybook/tsconfig.json index 928df14aad..9bb1be5abf 100644 --- a/tests/storybook/tsconfig.json +++ b/tests/storybook/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.json", - "include": ["./src"], + "include": ["./src/**/*", "./.storybook/**/*"], "compilerOptions": { // Workaround for storybook build "baseUrl": "../..", @@ -18,6 +18,9 @@ { "path": "../../packages/common/env" }, + { + "path": "../../packages/common/infra" + }, { "path": "../../packages/frontend/workspace" }, diff --git a/yarn.lock b/yarn.lock index 916f620c64..4b2c108781 100644 --- a/yarn.lock +++ b/yarn.lock @@ -380,6 +380,7 @@ __metadata: "@testing-library/react": "npm:^14.0.0" "@toeverything/theme": "npm:^0.7.20" "@types/bytes": "npm:^3.1.3" + "@types/image-blob-reduce": "npm:^4.1.3" "@types/lodash-es": "npm:^4.17.9" "@types/uuid": "npm:^9.0.5" "@types/webpack-env": "npm:^1.18.2" @@ -399,6 +400,7 @@ __metadata: graphql: "npm:^16.8.1" html-webpack-plugin: "npm:^5.5.3" idb: "npm:^8.0.0" + image-blob-reduce: "npm:^4.1.0" intl-segmenter-polyfill-rs: "npm:^0.1.6" jotai: "npm:^2.5.1" jotai-devtools: "npm:^0.7.0" @@ -864,6 +866,7 @@ __metadata: foxact: "npm:^0.2.26" jest-mock: "npm:^29.7.0" jotai: "npm:^2.5.1" + nanoid: "npm:^5.0.3" react: "npm:18.2.0" react-dom: "npm:18.2.0" react-router-dom: "npm:^6.19.0" @@ -917,8 +920,7 @@ __metadata: "@affine/env": "workspace:*" "@affine/graphql": "workspace:*" "@testing-library/react": "npm:^14.0.0" - "@toeverything/hooks": "workspace:*" - "@toeverything/y-indexeddb": "workspace:*" + "@toeverything/infra": "workspace:*" "@types/ws": "npm:^8.5.7" async-call-rpc: "npm:^6.3.1" fake-indexeddb: "npm:^5.0.0" @@ -934,6 +936,7 @@ __metadata: next-auth: "npm:^4.24.5" react: "npm:18.2.0" react-dom: "npm:18.2.0" + rxjs: "npm:^7.8.1" socket.io-client: "npm:^4.7.2" swr: "npm:2.2.4" valtio: "npm:^1.11.2" @@ -14109,6 +14112,7 @@ __metadata: dependencies: "@affine/debug": "workspace:*" "@affine/env": "workspace:*" + "@affine/workspace": "workspace:*" "@blocksuite/block-std": "npm:0.11.0-nightly-202312150424-f13b992" "@blocksuite/blocks": "npm:0.11.0-nightly-202312150424-f13b992" "@blocksuite/global": "npm:0.11.0-nightly-202312150424-f13b992" @@ -14121,7 +14125,6 @@ __metadata: "@types/lodash.debounce": "npm:^4.0.7" fake-indexeddb: "npm:^5.0.0" foxact: "npm:^0.2.20" - image-blob-reduce: "npm:^4.1.0" jotai: "npm:^2.5.1" jotai-effect: "npm:^0.2.3" lodash.debounce: "npm:^4.0.8" @@ -14215,7 +14218,7 @@ __metadata: languageName: node linkType: hard -"@toeverything/y-indexeddb@workspace:*, @toeverything/y-indexeddb@workspace:packages/common/y-indexeddb": +"@toeverything/y-indexeddb@workspace:packages/common/y-indexeddb": version: 0.0.0-use.local resolution: "@toeverything/y-indexeddb@workspace:packages/common/y-indexeddb" dependencies: