mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 15:16:28 +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 { configureCommonModules } from '@affine/core/modules';
|
||||
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||
import { configureDesktopBackupModule } from '@affine/core/modules/backup';
|
||||
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
configureDesktopApiModule,
|
||||
@@ -77,6 +78,7 @@ configureAppTabsHeaderModule(framework);
|
||||
configureFindInPageModule(framework);
|
||||
configureDesktopApiModule(framework);
|
||||
configureSpellCheckSettingModule(framework);
|
||||
configureDesktopBackupModule(framework);
|
||||
framework.impl(NbstoreProvider, {
|
||||
openStore(key, options) {
|
||||
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.
|
||||
*
|
||||
*/
|
||||
export async function loadDBFile(): Promise<LoadDBFileResult> {
|
||||
export async function loadDBFile(
|
||||
dbFilePath?: string
|
||||
): Promise<LoadDBFileResult> {
|
||||
try {
|
||||
const ret =
|
||||
const provided =
|
||||
getFakedResult() ??
|
||||
(dbFilePath
|
||||
? {
|
||||
filePath: dbFilePath,
|
||||
filePaths: [dbFilePath],
|
||||
canceled: false,
|
||||
}
|
||||
: undefined);
|
||||
const ret =
|
||||
provided ??
|
||||
(await mainRPC.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
title: 'Load Workspace',
|
||||
@@ -249,6 +260,12 @@ async function cpV1DBFile(
|
||||
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);
|
||||
|
||||
await fs.ensureDir(await getWorkspacesBasePath());
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from './dialog';
|
||||
|
||||
export const dialogHandlers = {
|
||||
loadDBFile: async () => {
|
||||
return loadDBFile();
|
||||
loadDBFile: async (dbFilePath?: string) => {
|
||||
return loadDBFile(dbFilePath);
|
||||
},
|
||||
saveDBFileAs: async (universalId: string, name: string) => {
|
||||
return saveDBFileAs(universalId, name);
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function ensureSQLiteDisconnected(
|
||||
const db = await ensureSQLiteDB(spaceType, id);
|
||||
|
||||
if (db) {
|
||||
await db.checkpoint();
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { mergeUpdate } from './merge-update';
|
||||
|
||||
const TRIM_SIZE = 1;
|
||||
|
||||
export class WorkspaceSQLiteDB {
|
||||
export class WorkspaceSQLiteDB implements AsyncDisposable {
|
||||
lock = new AsyncLock();
|
||||
update$ = new Subject<void>();
|
||||
adapter = new SQLiteAdapter(this.path);
|
||||
@@ -32,17 +32,32 @@ export class WorkspaceSQLiteDB {
|
||||
this.update$.complete();
|
||||
}
|
||||
|
||||
[Symbol.asyncDispose] = async () => {
|
||||
await this.destroy();
|
||||
};
|
||||
|
||||
private readonly toDBDocId = (docId: string) => {
|
||||
return this.workspaceId === docId ? undefined : docId;
|
||||
};
|
||||
|
||||
getWorkspaceName = async () => {
|
||||
getWorkspaceMeta = async () => {
|
||||
const ydoc = new YDoc();
|
||||
const updates = await this.adapter.getUpdates();
|
||||
updates.forEach(update => {
|
||||
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() {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
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 { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import { isWindows } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import { getDocStoragePool } from '../nbstore';
|
||||
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
|
||||
import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
import {
|
||||
getDeletedWorkspacesBasePath,
|
||||
@@ -50,12 +57,28 @@ export async function trashWorkspace(universalId: string) {
|
||||
await deleteWorkspaceV1(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 {
|
||||
await getDocStoragePool().disconnect(universalId);
|
||||
return await fs.move(path.dirname(dbPath), movedPath, {
|
||||
overwrite: true,
|
||||
});
|
||||
const storage = new DocStorage(dbPath);
|
||||
if (await storage.validate()) {
|
||||
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) {
|
||||
logger.error('trashWorkspace', error);
|
||||
}
|
||||
@@ -79,3 +102,139 @@ export async function storeWorkspaceMeta(
|
||||
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 { deleteWorkspace, trashWorkspace } from './handlers';
|
||||
import {
|
||||
deleteBackupWorkspace,
|
||||
deleteWorkspace,
|
||||
getDeletedWorkspaces,
|
||||
trashWorkspace,
|
||||
} from './handlers';
|
||||
|
||||
export * from './handlers';
|
||||
export * from './subjects';
|
||||
@@ -9,4 +14,8 @@ export const workspaceEvents = {} as Record<string, MainEventRegister>;
|
||||
export const workspaceHandlers = {
|
||||
delete: deleteWorkspace,
|
||||
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, [], {
|
||||
// todo: port number should not being used
|
||||
execArgv: isDev ? ['--inspect=40894'] : [],
|
||||
serviceName: 'affine-helper',
|
||||
});
|
||||
this.#process = helperProcess;
|
||||
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 {
|
||||
AppearanceIcon,
|
||||
ExperimentIcon,
|
||||
FolderIcon,
|
||||
InformationIcon,
|
||||
KeyboardIcon,
|
||||
PenIcon,
|
||||
@@ -16,6 +17,7 @@ import { AuthService, ServerService } from '../../../../modules/cloud';
|
||||
import type { SettingSidebarItem, SettingState } from '../types';
|
||||
import { AboutAffine } from './about';
|
||||
import { AppearanceSettings } from './appearance';
|
||||
import { BackupSettingPanel } from './backup';
|
||||
import { BillingSettings } from './billing';
|
||||
import { EditorSettings } from './editor';
|
||||
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({
|
||||
key: 'experimental-features',
|
||||
title: t['com.affine.settings.workspace.experimental-features'](),
|
||||
@@ -129,6 +140,8 @@ export const GeneralSetting = ({
|
||||
return <BillingSettings onChangeSettingState={onChangeSettingState} />;
|
||||
case 'experimental-features':
|
||||
return <ExperimentalFeatures />;
|
||||
case 'backup':
|
||||
return <BackupSettingPanel />;
|
||||
default:
|
||||
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'
|
||||
| 'plans'
|
||||
| 'billing'
|
||||
| 'backup' // electron only
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
|
||||
@@ -4231,6 +4231,14 @@ export function useAFFiNEI18N(): {
|
||||
["com.affine.quicksearch.group.searchfor"](options: {
|
||||
readonly query: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Reset sync`
|
||||
*/
|
||||
["com.affine.resetSyncStatus.button"](): string;
|
||||
/**
|
||||
* `This operation may fix some synchronization issues.`
|
||||
*/
|
||||
["com.affine.resetSyncStatus.description"](): string;
|
||||
/**
|
||||
* `Collections`
|
||||
*/
|
||||
@@ -5362,7 +5370,7 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["com.affine.settings.workspace.preferences"](): string;
|
||||
/**
|
||||
* `Team's Billing`
|
||||
* `Team's Team's Billing`
|
||||
*/
|
||||
["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.`
|
||||
*/
|
||||
["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.`
|
||||
*/
|
||||
|
||||
@@ -1049,6 +1049,8 @@
|
||||
"com.affine.peek-view-controls.open-doc-in-center-peek": "Open in center peek",
|
||||
"com.affine.quicksearch.group.creation": "New",
|
||||
"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.doc.link-doc-only": "Only doc can be placed on here",
|
||||
"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.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.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.ShareMode": "Share mode",
|
||||
"com.affine.share-menu.SharePage": "Share doc",
|
||||
|
||||
@@ -125,16 +125,6 @@ test('delete workspace', async ({ page }) => {
|
||||
await expect(page.getByTestId('workspace-name-input')).toHaveValue(
|
||||
'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-input').fill('Delete Me');
|
||||
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"');
|
||||
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,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"e2e": "DEBUG=pw:browser yarn playwright test"
|
||||
"e2e": "yarn playwright test --reporter=list"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
|
||||
@@ -29,7 +29,6 @@ const config: PlaywrightTestConfig = {
|
||||
};
|
||||
|
||||
if (process.env.CI) {
|
||||
// todo: fix the flaky tests
|
||||
config.retries = 5;
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ export const test = base.extend<{
|
||||
});
|
||||
|
||||
await use(electronApp);
|
||||
console.log('Cleaning up...');
|
||||
const pages = electronApp.windows();
|
||||
for (const page of pages) {
|
||||
await page.close();
|
||||
|
||||
@@ -24,11 +24,9 @@ export async function removeWithRetry(
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await fs.remove(filePath);
|
||||
console.log(`File ${filePath} successfully deleted.`);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EBUSY' || err.code === 'EPERM') {
|
||||
console.log(`File ${filePath} is busy or locked, retrying...`);
|
||||
await setTimeout(delay);
|
||||
} else {
|
||||
console.error(`Failed to delete file ${filePath}:`, err);
|
||||
|
||||
Reference in New Issue
Block a user