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

View File

@@ -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>
</>
);
};

View File

@@ -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',
});

View File

@@ -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;
}

View File

@@ -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]);
}

View File

@@ -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();
}
}

View File

@@ -8,6 +8,7 @@ export type SettingTab =
| 'about'
| 'plans'
| 'billing'
| 'backup' // electron only
| 'experimental-features'
| 'editor'
| 'account'