mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 12:32:00 +08:00
feat(electron): backup panel (#9738)
fix PD-2071, PD-2059, PD-2069, PD-2068
This commit is contained in:
@@ -4,6 +4,7 @@ import { AppContainer } from '@affine/core/desktop/components/app-container';
|
|||||||
import { router } from '@affine/core/desktop/router';
|
import { router } from '@affine/core/desktop/router';
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
import { configureCommonModules } from '@affine/core/modules';
|
||||||
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||||
|
import { configureDesktopBackupModule } from '@affine/core/modules/backup';
|
||||||
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
||||||
import {
|
import {
|
||||||
configureDesktopApiModule,
|
configureDesktopApiModule,
|
||||||
@@ -77,6 +78,7 @@ configureAppTabsHeaderModule(framework);
|
|||||||
configureFindInPageModule(framework);
|
configureFindInPageModule(framework);
|
||||||
configureDesktopApiModule(framework);
|
configureDesktopApiModule(framework);
|
||||||
configureSpellCheckSettingModule(framework);
|
configureSpellCheckSettingModule(framework);
|
||||||
|
configureDesktopBackupModule(framework);
|
||||||
framework.impl(NbstoreProvider, {
|
framework.impl(NbstoreProvider, {
|
||||||
openStore(key, options) {
|
openStore(key, options) {
|
||||||
const { port1: portForOpClient, port2: portForWorker } =
|
const { port1: portForOpClient, port2: portForWorker } =
|
||||||
|
|||||||
@@ -176,10 +176,21 @@ export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult
|
|||||||
* update the local workspace id list and then connect to it.
|
* update the local workspace id list and then connect to it.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export async function loadDBFile(): Promise<LoadDBFileResult> {
|
export async function loadDBFile(
|
||||||
|
dbFilePath?: string
|
||||||
|
): Promise<LoadDBFileResult> {
|
||||||
try {
|
try {
|
||||||
const ret =
|
const provided =
|
||||||
getFakedResult() ??
|
getFakedResult() ??
|
||||||
|
(dbFilePath
|
||||||
|
? {
|
||||||
|
filePath: dbFilePath,
|
||||||
|
filePaths: [dbFilePath],
|
||||||
|
canceled: false,
|
||||||
|
}
|
||||||
|
: undefined);
|
||||||
|
const ret =
|
||||||
|
provided ??
|
||||||
(await mainRPC.showOpenDialog({
|
(await mainRPC.showOpenDialog({
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
title: 'Load Workspace',
|
title: 'Load Workspace',
|
||||||
@@ -249,6 +260,12 @@ async function cpV1DBFile(
|
|||||||
return { error: 'DB_FILE_INVALID' }; // invalid db file
|
return { error: 'DB_FILE_INVALID' }; // invalid db file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkout to make sure wal is flushed
|
||||||
|
const connection = new SqliteConnection(originalPath);
|
||||||
|
await connection.connect();
|
||||||
|
await connection.checkpoint();
|
||||||
|
await connection.close();
|
||||||
|
|
||||||
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
|
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
|
||||||
|
|
||||||
await fs.ensureDir(await getWorkspacesBasePath());
|
await fs.ensureDir(await getWorkspacesBasePath());
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
} from './dialog';
|
} from './dialog';
|
||||||
|
|
||||||
export const dialogHandlers = {
|
export const dialogHandlers = {
|
||||||
loadDBFile: async () => {
|
loadDBFile: async (dbFilePath?: string) => {
|
||||||
return loadDBFile();
|
return loadDBFile(dbFilePath);
|
||||||
},
|
},
|
||||||
saveDBFileAs: async (universalId: string, name: string) => {
|
saveDBFileAs: async (universalId: string, name: string) => {
|
||||||
return saveDBFileAs(universalId, name);
|
return saveDBFileAs(universalId, name);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export async function ensureSQLiteDisconnected(
|
|||||||
const db = await ensureSQLiteDB(spaceType, id);
|
const db = await ensureSQLiteDB(spaceType, id);
|
||||||
|
|
||||||
if (db) {
|
if (db) {
|
||||||
|
await db.checkpoint();
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { mergeUpdate } from './merge-update';
|
|||||||
|
|
||||||
const TRIM_SIZE = 1;
|
const TRIM_SIZE = 1;
|
||||||
|
|
||||||
export class WorkspaceSQLiteDB {
|
export class WorkspaceSQLiteDB implements AsyncDisposable {
|
||||||
lock = new AsyncLock();
|
lock = new AsyncLock();
|
||||||
update$ = new Subject<void>();
|
update$ = new Subject<void>();
|
||||||
adapter = new SQLiteAdapter(this.path);
|
adapter = new SQLiteAdapter(this.path);
|
||||||
@@ -32,17 +32,32 @@ export class WorkspaceSQLiteDB {
|
|||||||
this.update$.complete();
|
this.update$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncDispose] = async () => {
|
||||||
|
await this.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
private readonly toDBDocId = (docId: string) => {
|
private readonly toDBDocId = (docId: string) => {
|
||||||
return this.workspaceId === docId ? undefined : docId;
|
return this.workspaceId === docId ? undefined : docId;
|
||||||
};
|
};
|
||||||
|
|
||||||
getWorkspaceName = async () => {
|
getWorkspaceMeta = async () => {
|
||||||
const ydoc = new YDoc();
|
const ydoc = new YDoc();
|
||||||
const updates = await this.adapter.getUpdates();
|
const updates = await this.adapter.getUpdates();
|
||||||
updates.forEach(update => {
|
updates.forEach(update => {
|
||||||
applyUpdate(ydoc, update.data);
|
applyUpdate(ydoc, update.data);
|
||||||
});
|
});
|
||||||
return ydoc.getMap('meta').get('name') as string;
|
logger.log(
|
||||||
|
`ydoc.getMap('meta').get('name')`,
|
||||||
|
ydoc.getMap('meta').get('name'),
|
||||||
|
this.path,
|
||||||
|
updates.length
|
||||||
|
);
|
||||||
|
return ydoc.getMap('meta').toJSON();
|
||||||
|
};
|
||||||
|
|
||||||
|
getWorkspaceName = async () => {
|
||||||
|
const meta = await this.getWorkspaceMeta();
|
||||||
|
return meta.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { parseUniversalId } from '@affine/nbstore';
|
import { DocStorage } from '@affine/native';
|
||||||
|
import {
|
||||||
|
parseUniversalId,
|
||||||
|
universalId as generateUniversalId,
|
||||||
|
} from '@affine/nbstore';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
|
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import { isWindows } from '../../shared/utils';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import { getDocStoragePool } from '../nbstore';
|
import { getDocStoragePool } from '../nbstore';
|
||||||
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
|
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
|
||||||
|
import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
|
||||||
import type { WorkspaceMeta } from '../type';
|
import type { WorkspaceMeta } from '../type';
|
||||||
import {
|
import {
|
||||||
getDeletedWorkspacesBasePath,
|
getDeletedWorkspacesBasePath,
|
||||||
@@ -50,12 +57,28 @@ export async function trashWorkspace(universalId: string) {
|
|||||||
await deleteWorkspaceV1(id);
|
await deleteWorkspaceV1(id);
|
||||||
|
|
||||||
const dbPath = await getSpaceDBPath(peer, type, id);
|
const dbPath = await getSpaceDBPath(peer, type, id);
|
||||||
const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
|
const basePath = await getDeletedWorkspacesBasePath();
|
||||||
|
const movedPath = path.join(basePath, `${id}`);
|
||||||
try {
|
try {
|
||||||
await getDocStoragePool().disconnect(universalId);
|
const storage = new DocStorage(dbPath);
|
||||||
return await fs.move(path.dirname(dbPath), movedPath, {
|
if (await storage.validate()) {
|
||||||
overwrite: true,
|
const pool = getDocStoragePool();
|
||||||
});
|
await pool.checkpoint(universalId);
|
||||||
|
await pool.disconnect(universalId);
|
||||||
|
}
|
||||||
|
await fs.ensureDir(movedPath);
|
||||||
|
// todo(@pengx17): it seems the db file is still being used at the point
|
||||||
|
// on windows so that it cannot be moved. we will fallback to copy the dir instead.
|
||||||
|
if (isWindows()) {
|
||||||
|
await fs.copy(path.dirname(dbPath), movedPath, {
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
await fs.rmdir(path.dirname(dbPath), { recursive: true });
|
||||||
|
} else {
|
||||||
|
return await fs.move(path.dirname(dbPath), movedPath, {
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('trashWorkspace', error);
|
logger.error('trashWorkspace', error);
|
||||||
}
|
}
|
||||||
@@ -79,3 +102,139 @@ export async function storeWorkspaceMeta(
|
|||||||
logger.error('storeWorkspaceMeta failed', err);
|
logger.error('storeWorkspaceMeta failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceDocMeta = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: Uint8Array | null;
|
||||||
|
fileSize: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
docCount: number;
|
||||||
|
dbPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getWorkspaceDocMetaV1(
|
||||||
|
workspaceId: string,
|
||||||
|
dbPath: string
|
||||||
|
): Promise<WorkspaceDocMeta | null> {
|
||||||
|
try {
|
||||||
|
await using db = new WorkspaceSQLiteDB(dbPath, workspaceId);
|
||||||
|
await db.init();
|
||||||
|
await db.checkpoint();
|
||||||
|
const meta = await db.getWorkspaceMeta();
|
||||||
|
const dbFileSize = await fs.stat(dbPath);
|
||||||
|
return {
|
||||||
|
id: workspaceId,
|
||||||
|
name: meta.name,
|
||||||
|
avatar: await db.getBlob(meta.avatar),
|
||||||
|
fileSize: dbFileSize.size,
|
||||||
|
updatedAt: dbFileSize.mtime,
|
||||||
|
createdAt: dbFileSize.birthtime,
|
||||||
|
docCount: meta.pages.length,
|
||||||
|
dbPath,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkspaceDocMeta(
|
||||||
|
workspaceId: string,
|
||||||
|
dbPath: string
|
||||||
|
): Promise<WorkspaceDocMeta | null> {
|
||||||
|
const pool = getDocStoragePool();
|
||||||
|
const universalId = generateUniversalId({
|
||||||
|
peer: 'deleted-local',
|
||||||
|
type: 'workspace',
|
||||||
|
id: workspaceId,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await pool.connect(universalId, dbPath);
|
||||||
|
await pool.checkpoint(universalId);
|
||||||
|
const snapshot = await pool.getDocSnapshot(universalId, workspaceId);
|
||||||
|
const pendingUpdates = await pool.getDocUpdates(universalId, workspaceId);
|
||||||
|
if (snapshot) {
|
||||||
|
const updates = snapshot.bin;
|
||||||
|
const ydoc = new YDoc();
|
||||||
|
applyUpdate(ydoc, updates);
|
||||||
|
pendingUpdates.forEach(update => {
|
||||||
|
applyUpdate(ydoc, update.bin);
|
||||||
|
});
|
||||||
|
const meta = ydoc.getMap('meta').toJSON();
|
||||||
|
const dbFileStat = await fs.stat(dbPath);
|
||||||
|
const blob = meta.avatar
|
||||||
|
? await pool.getBlob(universalId, meta.avatar)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
id: workspaceId,
|
||||||
|
name: meta.name,
|
||||||
|
avatar: blob ? blob.data : null,
|
||||||
|
fileSize: dbFileStat.size,
|
||||||
|
updatedAt: dbFileStat.mtime,
|
||||||
|
createdAt: dbFileStat.birthtime,
|
||||||
|
docCount: meta.pages.length,
|
||||||
|
dbPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try using v1
|
||||||
|
return await getWorkspaceDocMetaV1(workspaceId, dbPath);
|
||||||
|
} finally {
|
||||||
|
await pool.disconnect(universalId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeletedWorkspaces() {
|
||||||
|
const basePath = await getDeletedWorkspacesBasePath();
|
||||||
|
const directories = await fs.readdir(basePath);
|
||||||
|
const workspaceEntries = await Promise.all(
|
||||||
|
directories.map(async dir => {
|
||||||
|
const stats = await fs.stat(path.join(basePath, dir));
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dbfileStats = await fs.stat(path.join(basePath, dir, 'storage.db'));
|
||||||
|
return {
|
||||||
|
id: dir,
|
||||||
|
mtime: new Date(dbfileStats.mtime),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceIds = workspaceEntries
|
||||||
|
.filter(v => v !== null)
|
||||||
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
||||||
|
.map(entry => entry.id);
|
||||||
|
|
||||||
|
const items: WorkspaceDocMeta[] = [];
|
||||||
|
|
||||||
|
// todo(@pengx17): add cursor based pagination
|
||||||
|
for (const id of workspaceIds) {
|
||||||
|
const meta = await getWorkspaceDocMeta(
|
||||||
|
id,
|
||||||
|
path.join(basePath, id, 'storage.db')
|
||||||
|
);
|
||||||
|
if (meta) {
|
||||||
|
items.push(meta);
|
||||||
|
} else {
|
||||||
|
logger.warn('getDeletedWorkspaces', `No meta found for ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupWorkspace(id: string) {
|
||||||
|
const basePath = await getDeletedWorkspacesBasePath();
|
||||||
|
const workspacePath = path.join(basePath, id);
|
||||||
|
await fs.rmdir(workspacePath, { recursive: true });
|
||||||
|
logger.info(
|
||||||
|
'deleteBackupWorkspace',
|
||||||
|
`Deleted backup workspace: ${workspacePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { MainEventRegister } from '../type';
|
import type { MainEventRegister } from '../type';
|
||||||
import { deleteWorkspace, trashWorkspace } from './handlers';
|
import {
|
||||||
|
deleteBackupWorkspace,
|
||||||
|
deleteWorkspace,
|
||||||
|
getDeletedWorkspaces,
|
||||||
|
trashWorkspace,
|
||||||
|
} from './handlers';
|
||||||
|
|
||||||
export * from './handlers';
|
export * from './handlers';
|
||||||
export * from './subjects';
|
export * from './subjects';
|
||||||
@@ -9,4 +14,8 @@ export const workspaceEvents = {} as Record<string, MainEventRegister>;
|
|||||||
export const workspaceHandlers = {
|
export const workspaceHandlers = {
|
||||||
delete: deleteWorkspace,
|
delete: deleteWorkspace,
|
||||||
moveToTrash: trashWorkspace,
|
moveToTrash: trashWorkspace,
|
||||||
|
getBackupWorkspaces: async () => {
|
||||||
|
return getDeletedWorkspaces();
|
||||||
|
},
|
||||||
|
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class HelperProcessManager {
|
|||||||
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], {
|
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], {
|
||||||
// todo: port number should not being used
|
// todo: port number should not being used
|
||||||
execArgv: isDev ? ['--inspect=40894'] : [],
|
execArgv: isDev ? ['--inspect=40894'] : [],
|
||||||
|
serviceName: 'affine-helper',
|
||||||
});
|
});
|
||||||
this.#process = helperProcess;
|
this.#process = helperProcess;
|
||||||
this.ready = new Promise((resolve, reject) => {
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
Loading,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
notify,
|
||||||
|
Skeleton,
|
||||||
|
useConfirmModal,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { Pagination } from '@affine/component/member-components';
|
||||||
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
|
import { Avatar } from '@affine/component/ui/avatar';
|
||||||
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
|
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||||
|
import { BackupService } from '@affine/core/modules/backup/services';
|
||||||
|
import { i18nTime, useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
DeleteIcon,
|
||||||
|
LocalWorkspaceIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import bytes from 'bytes';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
const Empty = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{t['com.affine.settings.workspace.backup.empty']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlobAvatar = ({
|
||||||
|
blob,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
blob: Uint8Array | null;
|
||||||
|
name: string;
|
||||||
|
}) => {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(new Blob([blob]));
|
||||||
|
setUrl(url);
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [blob]);
|
||||||
|
return (
|
||||||
|
<Avatar colorfulFallback name={name} rounded={4} size={32} url={url} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackupWorkspaceItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileSize: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
avatar: Uint8Array | null;
|
||||||
|
dbPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BackupWorkspaceItem = ({ item }: { item: BackupWorkspaceItem }) => {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const backupService = useService(BackupService);
|
||||||
|
const t = useI18n();
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
const { jumpToPage } = useNavigateHelper();
|
||||||
|
|
||||||
|
const handleImport = useAsyncCallback(async () => {
|
||||||
|
setImporting(true);
|
||||||
|
const workspaceId = await backupService.recoverBackupWorkspace(item.dbPath);
|
||||||
|
if (!workspaceId) {
|
||||||
|
setImporting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify.success({
|
||||||
|
title: t['com.affine.settings.workspace.backup.import.success'](),
|
||||||
|
action: {
|
||||||
|
label:
|
||||||
|
t['com.affine.settings.workspace.backup.import.success.action'](),
|
||||||
|
onClick: () => {
|
||||||
|
jumpToPage(workspaceId, 'all');
|
||||||
|
},
|
||||||
|
autoClose: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setMenuOpen(false);
|
||||||
|
setImporting(false);
|
||||||
|
}, [backupService, item.dbPath, jumpToPage, t]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(backupWorkspaceId: string) => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t['com.affine.workspaceDelete.title'](),
|
||||||
|
children: t['com.affine.settings.workspace.backup.delete.warning'](),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await backupService.deleteBackupWorkspace(backupWorkspaceId);
|
||||||
|
notify.success({
|
||||||
|
title: t['com.affine.settings.workspace.backup.delete.success'](),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
confirmText: t['Confirm'](),
|
||||||
|
cancelText: t['Cancel'](),
|
||||||
|
confirmButtonOptions: {
|
||||||
|
variant: 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[backupService, openConfirmModal, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="backup-workspace-item"
|
||||||
|
className={styles.listItem}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setMenuOpen(v => !v)}
|
||||||
|
>
|
||||||
|
<BlobAvatar blob={item.avatar} name={item.name} />
|
||||||
|
<div className={styles.listItemLeftLabel}>
|
||||||
|
<div className={styles.listItemLeftLabelTitle}>{item.name}</div>
|
||||||
|
<div className={styles.listItemLeftLabelDesc}>
|
||||||
|
{bytes(item.fileSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.listItemRightLabel}>
|
||||||
|
{t['com.affine.settings.workspace.backup.delete-at']({
|
||||||
|
date: i18nTime(item.updatedAt, {
|
||||||
|
absolute: {
|
||||||
|
accuracy: 'day',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
time: i18nTime(item.updatedAt, {
|
||||||
|
absolute: {
|
||||||
|
accuracy: 'minute',
|
||||||
|
noDate: true,
|
||||||
|
noYear: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
<Menu
|
||||||
|
rootOptions={{
|
||||||
|
open: menuOpen && !importing,
|
||||||
|
onOpenChange: setMenuOpen,
|
||||||
|
modal: true,
|
||||||
|
}}
|
||||||
|
items={
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<LocalWorkspaceIcon />}
|
||||||
|
onClick={handleImport}
|
||||||
|
>
|
||||||
|
{t['com.affine.settings.workspace.backup.import']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<DeleteIcon />}
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
type="danger"
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
contentOptions={{ align: 'end' }}
|
||||||
|
>
|
||||||
|
<IconButton disabled={importing} size="20">
|
||||||
|
{importing ? <Loading /> : <MoreVerticalIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
|
export const BackupSettingPanel = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const backupService = useService(BackupService);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(() => {
|
||||||
|
backupService.revalidate();
|
||||||
|
}, [backupService]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
backupService.revalidate();
|
||||||
|
}, [backupService, handlePageChange]);
|
||||||
|
|
||||||
|
const isLoading = useLiveData(backupService.isLoading$);
|
||||||
|
const backupWorkspaces = useLiveData(backupService.pageBackupWorkspaces$);
|
||||||
|
|
||||||
|
const [pageNum, setPageNum] = useState(0);
|
||||||
|
|
||||||
|
const innerElement = useMemo(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
style={{ margin: '2px', width: 'calc(100% - 4px)' }}
|
||||||
|
height={60}
|
||||||
|
animation="wave"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (backupWorkspaces?.items.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{backupWorkspaces?.items
|
||||||
|
.slice(pageNum * PAGE_SIZE, (pageNum + 1) * PAGE_SIZE)
|
||||||
|
.map(item => <BackupWorkspaceItem key={item.id} item={item} />)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Pagination
|
||||||
|
totalCount={backupWorkspaces?.items.length ?? 0}
|
||||||
|
countPerPage={PAGE_SIZE}
|
||||||
|
pageNum={pageNum}
|
||||||
|
onPageChange={(_, pageNum) => {
|
||||||
|
setPageNum(pageNum);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [isLoading, backupWorkspaces, pageNum]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingHeader
|
||||||
|
title={t['com.affine.settings.workspace.backup']()}
|
||||||
|
subtitle={t['com.affine.settings.workspace.backup.subtitle']()}
|
||||||
|
data-testid="backup-title"
|
||||||
|
/>
|
||||||
|
<div className={styles.listContainer}>{innerElement}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const listContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: cssVarV2('layer/background/primary'),
|
||||||
|
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const list = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
padding: '8px 8px 8px 16px',
|
||||||
|
height: '60px',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
':hover': {
|
||||||
|
background: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemLeftLabel = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemLeftLabelTitle = style({
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: '250px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemLeftLabelDesc = style({
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemRightLabel = style({
|
||||||
|
flex: 1,
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
gap: 10,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const empty = style({
|
||||||
|
padding: '8px 16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pagination = style({
|
||||||
|
paddingBottom: '4px',
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { useI18n } from '@affine/i18n';
|
|||||||
import {
|
import {
|
||||||
AppearanceIcon,
|
AppearanceIcon,
|
||||||
ExperimentIcon,
|
ExperimentIcon,
|
||||||
|
FolderIcon,
|
||||||
InformationIcon,
|
InformationIcon,
|
||||||
KeyboardIcon,
|
KeyboardIcon,
|
||||||
PenIcon,
|
PenIcon,
|
||||||
@@ -16,6 +17,7 @@ import { AuthService, ServerService } from '../../../../modules/cloud';
|
|||||||
import type { SettingSidebarItem, SettingState } from '../types';
|
import type { SettingSidebarItem, SettingState } from '../types';
|
||||||
import { AboutAffine } from './about';
|
import { AboutAffine } from './about';
|
||||||
import { AppearanceSettings } from './appearance';
|
import { AppearanceSettings } from './appearance';
|
||||||
|
import { BackupSettingPanel } from './backup';
|
||||||
import { BillingSettings } from './billing';
|
import { BillingSettings } from './billing';
|
||||||
import { EditorSettings } from './editor';
|
import { EditorSettings } from './editor';
|
||||||
import { ExperimentalFeatures } from './experimental-features';
|
import { ExperimentalFeatures } from './experimental-features';
|
||||||
@@ -93,6 +95,15 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (BUILD_CONFIG.isElectron) {
|
||||||
|
settings.push({
|
||||||
|
key: 'backup',
|
||||||
|
title: t['com.affine.settings.workspace.backup'](),
|
||||||
|
icon: <FolderIcon />,
|
||||||
|
testId: 'backup-panel-trigger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
settings.push({
|
settings.push({
|
||||||
key: 'experimental-features',
|
key: 'experimental-features',
|
||||||
title: t['com.affine.settings.workspace.experimental-features'](),
|
title: t['com.affine.settings.workspace.experimental-features'](),
|
||||||
@@ -129,6 +140,8 @@ export const GeneralSetting = ({
|
|||||||
return <BillingSettings onChangeSettingState={onChangeSettingState} />;
|
return <BillingSettings onChangeSettingState={onChangeSettingState} />;
|
||||||
case 'experimental-features':
|
case 'experimental-features':
|
||||||
return <ExperimentalFeatures />;
|
return <ExperimentalFeatures />;
|
||||||
|
case 'backup':
|
||||||
|
return <BackupSettingPanel />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { DesktopApiService } from '../desktop-api';
|
||||||
|
import { WorkspacesService } from '../workspace';
|
||||||
|
import { BackupService } from './services';
|
||||||
|
|
||||||
|
export function configureDesktopBackupModule(framework: Framework) {
|
||||||
|
framework.service(BackupService, [DesktopApiService, WorkspacesService]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
catchErrorInto,
|
||||||
|
effect,
|
||||||
|
fromPromise,
|
||||||
|
LiveData,
|
||||||
|
onComplete,
|
||||||
|
onStart,
|
||||||
|
Service,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import type { DesktopApiService } from '../../desktop-api';
|
||||||
|
import type { WorkspacesService } from '../../workspace';
|
||||||
|
import { _addLocalWorkspace } from '../../workspace-engine';
|
||||||
|
|
||||||
|
type BackupWorkspaceResult = Awaited<
|
||||||
|
ReturnType<DesktopApiService['handler']['workspace']['getBackupWorkspaces']>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class BackupService extends Service {
|
||||||
|
constructor(
|
||||||
|
private readonly desktopApiService: DesktopApiService,
|
||||||
|
private readonly workspacesService: WorkspacesService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading$ = new LiveData(false);
|
||||||
|
error$ = new LiveData<any>(null);
|
||||||
|
|
||||||
|
pageBackupWorkspaces$ = new LiveData<BackupWorkspaceResult | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly revalidate = effect(
|
||||||
|
switchMap(() =>
|
||||||
|
fromPromise(async () => {
|
||||||
|
return this.desktopApiService.handler.workspace.getBackupWorkspaces();
|
||||||
|
}).pipe(
|
||||||
|
mergeMap(data => {
|
||||||
|
this.pageBackupWorkspaces$.setValue(data);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
catchErrorInto(this.error$),
|
||||||
|
onStart(() => this.isLoading$.setValue(true)),
|
||||||
|
onComplete(() => this.isLoading$.setValue(false))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async recoverBackupWorkspace(dbPath: string) {
|
||||||
|
const result =
|
||||||
|
await this.desktopApiService.handler.dialog.loadDBFile(dbPath);
|
||||||
|
if (result.workspaceId) {
|
||||||
|
_addLocalWorkspace(result.workspaceId);
|
||||||
|
this.workspacesService.list.revalidate();
|
||||||
|
}
|
||||||
|
return result.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackupWorkspace(backupWorkspaceId: string) {
|
||||||
|
await this.desktopApiService.handler.workspace.deleteBackupWorkspace(
|
||||||
|
backupWorkspaceId
|
||||||
|
);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.revalidate.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export type SettingTab =
|
|||||||
| 'about'
|
| 'about'
|
||||||
| 'plans'
|
| 'plans'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
|
| 'backup' // electron only
|
||||||
| 'experimental-features'
|
| 'experimental-features'
|
||||||
| 'editor'
|
| 'editor'
|
||||||
| 'account'
|
| 'account'
|
||||||
|
|||||||
@@ -4231,6 +4231,14 @@ export function useAFFiNEI18N(): {
|
|||||||
["com.affine.quicksearch.group.searchfor"](options: {
|
["com.affine.quicksearch.group.searchfor"](options: {
|
||||||
readonly query: string;
|
readonly query: string;
|
||||||
}): string;
|
}): string;
|
||||||
|
/**
|
||||||
|
* `Reset sync`
|
||||||
|
*/
|
||||||
|
["com.affine.resetSyncStatus.button"](): string;
|
||||||
|
/**
|
||||||
|
* `This operation may fix some synchronization issues.`
|
||||||
|
*/
|
||||||
|
["com.affine.resetSyncStatus.description"](): string;
|
||||||
/**
|
/**
|
||||||
* `Collections`
|
* `Collections`
|
||||||
*/
|
*/
|
||||||
@@ -5362,7 +5370,7 @@ export function useAFFiNEI18N(): {
|
|||||||
*/
|
*/
|
||||||
["com.affine.settings.workspace.preferences"](): string;
|
["com.affine.settings.workspace.preferences"](): string;
|
||||||
/**
|
/**
|
||||||
* `Team's Billing`
|
* `Team's Team's Billing`
|
||||||
*/
|
*/
|
||||||
["com.affine.settings.workspace.billing"](): string;
|
["com.affine.settings.workspace.billing"](): string;
|
||||||
/**
|
/**
|
||||||
@@ -5505,6 +5513,49 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.`
|
* `Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.`
|
||||||
*/
|
*/
|
||||||
["com.affine.settings.workspace.affine-ai.description"](): string;
|
["com.affine.settings.workspace.affine-ai.description"](): string;
|
||||||
|
/**
|
||||||
|
* `Backup`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup"](): string;
|
||||||
|
/**
|
||||||
|
* `Management in local workspace backup files`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.subtitle"](): string;
|
||||||
|
/**
|
||||||
|
* `No backup files found`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.empty"](): string;
|
||||||
|
/**
|
||||||
|
* `Delete backup workspace`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.delete"](): string;
|
||||||
|
/**
|
||||||
|
* `Are you sure you want to delete this workspace. This action cannot be undone. Make sure you no longer need them before proceeding.`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.delete.warning"](): string;
|
||||||
|
/**
|
||||||
|
* `Workspace backup deleted successfully`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.delete.success"](): string;
|
||||||
|
/**
|
||||||
|
* `Workspace enabled successfully`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.import.success"](): string;
|
||||||
|
/**
|
||||||
|
* `Enable local workspace`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.import"](): string;
|
||||||
|
/**
|
||||||
|
* `Open`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.import.success.action"](): string;
|
||||||
|
/**
|
||||||
|
* `Deleted {{date}} at {{time}}`
|
||||||
|
*/
|
||||||
|
["com.affine.settings.workspace.backup.delete-at"](options: Readonly<{
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
}>): string;
|
||||||
/**
|
/**
|
||||||
* `Sharing doc requires AFFiNE Cloud.`
|
* `Sharing doc requires AFFiNE Cloud.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1049,6 +1049,8 @@
|
|||||||
"com.affine.peek-view-controls.open-doc-in-center-peek": "Open in center peek",
|
"com.affine.peek-view-controls.open-doc-in-center-peek": "Open in center peek",
|
||||||
"com.affine.quicksearch.group.creation": "New",
|
"com.affine.quicksearch.group.creation": "New",
|
||||||
"com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"",
|
"com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"",
|
||||||
|
"com.affine.resetSyncStatus.button": "Reset sync",
|
||||||
|
"com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.",
|
||||||
"com.affine.rootAppSidebar.collections": "Collections",
|
"com.affine.rootAppSidebar.collections": "Collections",
|
||||||
"com.affine.rootAppSidebar.doc.link-doc-only": "Only doc can be placed on here",
|
"com.affine.rootAppSidebar.doc.link-doc-only": "Only doc can be placed on here",
|
||||||
"com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs",
|
"com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs",
|
||||||
@@ -1376,6 +1378,16 @@
|
|||||||
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
|
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
|
||||||
"com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant",
|
"com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant",
|
||||||
"com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.",
|
"com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.",
|
||||||
|
"com.affine.settings.workspace.backup": "Backup",
|
||||||
|
"com.affine.settings.workspace.backup.subtitle": "Management in local workspace backup files",
|
||||||
|
"com.affine.settings.workspace.backup.empty": "No backup files found",
|
||||||
|
"com.affine.settings.workspace.backup.delete": "Delete backup workspace",
|
||||||
|
"com.affine.settings.workspace.backup.delete.warning": "Are you sure you want to delete this workspace. This action cannot be undone. Make sure you no longer need them before proceeding.",
|
||||||
|
"com.affine.settings.workspace.backup.delete.success": "Workspace backup deleted successfully",
|
||||||
|
"com.affine.settings.workspace.backup.import.success": "Workspace enabled successfully",
|
||||||
|
"com.affine.settings.workspace.backup.import": "Enable local workspace",
|
||||||
|
"com.affine.settings.workspace.backup.import.success.action": "Open",
|
||||||
|
"com.affine.settings.workspace.backup.delete-at": "Deleted on {{date}} at {{time}}",
|
||||||
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
|
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
|
||||||
"com.affine.share-menu.ShareMode": "Share mode",
|
"com.affine.share-menu.ShareMode": "Share mode",
|
||||||
"com.affine.share-menu.SharePage": "Share doc",
|
"com.affine.share-menu.SharePage": "Share doc",
|
||||||
|
|||||||
@@ -125,16 +125,6 @@ test('delete workspace', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('workspace-name-input')).toHaveValue(
|
await expect(page.getByTestId('workspace-name-input')).toHaveValue(
|
||||||
'Delete Me'
|
'Delete Me'
|
||||||
);
|
);
|
||||||
const contentElement = page.getByTestId('setting-modal-content');
|
|
||||||
const boundingBox = await contentElement.boundingBox();
|
|
||||||
if (!boundingBox) {
|
|
||||||
throw new Error('boundingBox is null');
|
|
||||||
}
|
|
||||||
await page.mouse.move(
|
|
||||||
boundingBox.x + boundingBox.width / 2,
|
|
||||||
boundingBox.y + boundingBox.height / 2
|
|
||||||
);
|
|
||||||
await page.mouse.wheel(0, 500);
|
|
||||||
await page.getByTestId('delete-workspace-button').click();
|
await page.getByTestId('delete-workspace-button').click();
|
||||||
await page.getByTestId('delete-workspace-input').fill('Delete Me');
|
await page.getByTestId('delete-workspace-input').fill('Delete Me');
|
||||||
await page.getByTestId('delete-workspace-confirm-button').click();
|
await page.getByTestId('delete-workspace-confirm-button').click();
|
||||||
|
|||||||
@@ -101,3 +101,64 @@ test('export then add', async ({ page, appInfo, workspace }) => {
|
|||||||
const title = page.locator('[data-block-is-title] >> text="test1"');
|
const title = page.locator('[data-block-is-title] >> text="test1"');
|
||||||
await expect(title).toBeVisible();
|
await expect(title).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('delete workspace and then restore it from backup', async ({ page }) => {
|
||||||
|
//#region 1. create a new workspace
|
||||||
|
await clickSideBarCurrentWorkspaceBanner(page);
|
||||||
|
const newWorkspaceName = 'new-test-name';
|
||||||
|
|
||||||
|
await page.getByTestId('new-workspace').click();
|
||||||
|
await page.getByTestId('create-workspace-input').fill(newWorkspaceName);
|
||||||
|
await page.getByTestId('create-workspace-create-button').click();
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region 2. create a page in the new workspace (will verify later if it is successfully recovered)
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
|
||||||
|
await getBlockSuiteEditorTitle(page).fill('test1');
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region 3. delete the workspace
|
||||||
|
await page.getByTestId('slider-bar-workspace-setting-button').click();
|
||||||
|
await expect(page.getByTestId('setting-modal')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('workspace-setting:preference').click();
|
||||||
|
await page.getByTestId('delete-workspace-button').click();
|
||||||
|
await page.getByTestId('delete-workspace-input').fill(newWorkspaceName);
|
||||||
|
|
||||||
|
await page.getByTestId('delete-workspace-confirm-button').click();
|
||||||
|
|
||||||
|
// we are back to the original workspace
|
||||||
|
await expect(page.getByTestId('workspace-name')).toContainText(
|
||||||
|
'Demo Workspace'
|
||||||
|
);
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region 4. restore the workspace from backup
|
||||||
|
await page.getByTestId('slider-bar-workspace-setting-button').click();
|
||||||
|
await expect(page.getByTestId('setting-modal')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('backup-panel-trigger').click();
|
||||||
|
await expect(page.getByTestId('backup-workspace-item')).toHaveCount(1);
|
||||||
|
await page.getByTestId('backup-workspace-item').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Enable local workspace' }).click();
|
||||||
|
const toast = page.locator(
|
||||||
|
'[data-sonner-toast]:has-text("Workspace enabled successfully")'
|
||||||
|
);
|
||||||
|
await expect(toast).toBeVisible();
|
||||||
|
await toast.getByRole('button', { name: 'Open' }).click();
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// verify the workspace name & page title
|
||||||
|
await expect(page.getByTestId('workspace-name')).toContainText(
|
||||||
|
newWorkspaceName
|
||||||
|
);
|
||||||
|
// find button which has the title "test1"
|
||||||
|
const test1PageButton = await page.waitForSelector(`text="test1"`);
|
||||||
|
await test1PageButton.click();
|
||||||
|
|
||||||
|
const title = page.locator('[data-block-is-title] >> text="test1"');
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"e2e": "DEBUG=pw:browser yarn playwright test"
|
"e2e": "yarn playwright test --reporter=list"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-test/kit": "workspace:*",
|
"@affine-test/kit": "workspace:*",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const config: PlaywrightTestConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
// todo: fix the flaky tests
|
|
||||||
config.retries = 5;
|
config.retries = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export const test = base.extend<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
await use(electronApp);
|
await use(electronApp);
|
||||||
console.log('Cleaning up...');
|
|
||||||
const pages = electronApp.windows();
|
const pages = electronApp.windows();
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ export async function removeWithRetry(
|
|||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
try {
|
try {
|
||||||
await fs.remove(filePath);
|
await fs.remove(filePath);
|
||||||
console.log(`File ${filePath} successfully deleted.`);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === 'EBUSY' || err.code === 'EPERM') {
|
if (err.code === 'EBUSY' || err.code === 'EPERM') {
|
||||||
console.log(`File ${filePath} is busy or locked, retrying...`);
|
|
||||||
await setTimeout(delay);
|
await setTimeout(delay);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Failed to delete file ${filePath}:`, err);
|
console.error(`Failed to delete file ${filePath}:`, err);
|
||||||
|
|||||||
Reference in New Issue
Block a user