feat(electron): backup panel (#9738)

fix PD-2071, PD-2059, PD-2069, PD-2068
This commit is contained in:
pengx17
2025-01-22 03:11:28 +00:00
committed by Peng Xiao
parent 862a9d0bc4
commit 088ae0ac0a
22 changed files with 754 additions and 30 deletions
@@ -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>
</>
);
};
@@ -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'
+52 -1
View File
@@ -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",
-10
View File
@@ -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();
});
+1 -1
View File
@@ -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;
}
-1
View File
@@ -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();
-2
View File
@@ -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);