mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
refactor: workspace manager (#5060)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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<WorkspaceMetadata[]>;
|
||||
|
||||
/**
|
||||
* delete workspace by id
|
||||
*/
|
||||
delete(workspaceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* create workspace
|
||||
* @param initial callback to put initial data to workspace
|
||||
*/
|
||||
create(
|
||||
initial: (
|
||||
workspace: BlockSuiteWorkspace,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* 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<WorkspaceInfo | undefined>;
|
||||
}
|
||||
|
||||
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<WorkspaceListStatus>();
|
||||
private _status: Readonly<WorkspaceListStatus> = {
|
||||
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<void>
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceInfo>();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user