From 34d575078c1edb9debefa904ba69ea8812dd1c6b Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Mon, 27 Nov 2023 02:41:19 +0000 Subject: [PATCH] feat(core): simple recovery history ui poc (#5033) Simple recovery history UI poc. What's missing - [x] e2e All biz logic should be done, excluding complete ui details. - [ ] offline prompt - [ ] history timeline - [ ] page ui https://github.com/toeverything/AFFiNE/assets/584378/fc3f6a48-ff7f-4265-b9f5-9c0087cb2635 --- .../backend/server/src/modules/doc/history.ts | 2 +- packages/common/env/src/global.ts | 1 + .../frontend/core/.webpack/runtime-config.ts | 8 + .../frontend/core/src/atoms/page-history.ts | 7 + .../affine/page-history-modal/data.ts | 251 ++++++++++ .../empty-history-shape.tsx | 119 +++++ .../page-history-modal/history-modal.tsx | 446 ++++++++++++++++++ .../affine/page-history-modal/index.tsx | 1 + .../affine/page-history-modal/styles.css.ts | 185 ++++++++ .../operation-menu.tsx | 36 +- .../src/components/root-app-sidebar/index.tsx | 4 +- .../core/src/hooks/affine/use-mutate-cloud.ts | 1 + ...se-register-blocksuite-editor-commands.tsx | 40 +- .../src/hooks/affine/use-server-flavor.ts | 7 +- ...nds.ts => use-browser-history-commands.ts} | 2 +- .../core/src/pages/workspace/detail-page.tsx | 5 +- .../graphql/src/graphql/histories.gql | 13 + .../frontend/graphql/src/graphql/index.ts | 27 ++ .../graphql/src/graphql/recover-doc.gql | 7 + packages/frontend/graphql/src/schema.ts | 40 ++ packages/frontend/i18n/src/resources/en.json | 11 +- packages/frontend/workspace/package.json | 1 + packages/frontend/workspace/src/affine/gql.ts | 91 +++- 23 files changed, 1291 insertions(+), 14 deletions(-) create mode 100644 packages/frontend/core/src/atoms/page-history.ts create mode 100644 packages/frontend/core/src/components/affine/page-history-modal/data.ts create mode 100644 packages/frontend/core/src/components/affine/page-history-modal/empty-history-shape.tsx create mode 100644 packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx create mode 100644 packages/frontend/core/src/components/affine/page-history-modal/index.tsx create mode 100644 packages/frontend/core/src/components/affine/page-history-modal/styles.css.ts rename packages/frontend/core/src/hooks/{use-shortcut-commands.ts => use-browser-history-commands.ts} (95%) create mode 100644 packages/frontend/graphql/src/graphql/histories.gql create mode 100644 packages/frontend/graphql/src/graphql/recover-doc.gql diff --git a/packages/backend/server/src/modules/doc/history.ts b/packages/backend/server/src/modules/doc/history.ts index ddb869d18e..2a6dc47554 100644 --- a/packages/backend/server/src/modules/doc/history.ts +++ b/packages/backend/server/src/modules/doc/history.ts @@ -89,7 +89,7 @@ export class DocHistoryManager { workspaceId, id, timestamp: { - lte: before, + lt: before, }, // only include the ones has not expired expiredAt: { diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 5699cc6e19..7e2125a38e 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -32,6 +32,7 @@ export const runtimeFlagsSchema = z.object({ enableCaptcha: z.boolean(), enableEnhanceShareMode: z.boolean(), enablePayment: z.boolean(), + enablePageHistory: z.boolean(), // this is for the electron app serverUrlPrefix: z.string(), enableMoveDatabase: z.boolean(), diff --git a/packages/frontend/core/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts index 52eead3d66..e54ac2410d 100644 --- a/packages/frontend/core/.webpack/runtime-config.ts +++ b/packages/frontend/core/.webpack/runtime-config.ts @@ -33,6 +33,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableCaptcha: true, enableEnhanceShareMode: false, enablePayment: true, + enablePageHistory: false, serverUrlPrefix: 'https://insider.affine.pro', // Let insider be stable environment temporarily. editorFlags, appVersion: packageJson.version, @@ -41,6 +42,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { get beta() { return { ...this.stable, + enablePageHistory: false, serverUrlPrefix: 'https://insider.affine.pro', }; }, @@ -75,6 +77,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableCaptcha: true, enableEnhanceShareMode: false, enablePayment: true, + enablePageHistory: true, serverUrlPrefix: 'https://affine.fail', editorFlags, appVersion: packageJson.version, @@ -142,6 +145,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { : buildFlags.mode === 'development' ? true : currentBuildPreset.enablePayment, + enablePageHistory: process.env.ENABLE_PAGE_HISTORY + ? process.env.ENABLE_PAGE_HISTORY === 'true' + : buildFlags.mode === 'development' + ? true + : currentBuildPreset.enablePageHistory, }; if (buildFlags.mode === 'development') { diff --git a/packages/frontend/core/src/atoms/page-history.ts b/packages/frontend/core/src/atoms/page-history.ts new file mode 100644 index 0000000000..aef79debb7 --- /dev/null +++ b/packages/frontend/core/src/atoms/page-history.ts @@ -0,0 +1,7 @@ +import { atom } from 'jotai'; + +// make page history controllable by atom to make it easier to use in CMDK +export const pageHistoryModalAtom = atom({ + open: false, + pageId: '', +}); diff --git a/packages/frontend/core/src/components/affine/page-history-modal/data.ts b/packages/frontend/core/src/components/affine/page-history-modal/data.ts new file mode 100644 index 0000000000..c3844ebd7c --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-history-modal/data.ts @@ -0,0 +1,251 @@ +import { DebugLogger } from '@affine/debug'; +import { + fetchWithTraceReport, + type ListHistoryQuery, + listHistoryQuery, + recoverDocMutation, +} from '@affine/graphql'; +import { + useMutateQueryResource, + useMutation, + useQueryInfinite, +} from '@affine/workspace/affine/gql'; +import { createAffineCloudBlobEngine } from '@affine/workspace/blob'; +import { globalBlockSuiteSchema } from '@affine/workspace/manager'; +import { assertEquals } from '@blocksuite/global/utils'; +import { Workspace } from '@blocksuite/store'; +import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; +import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page'; +import { revertUpdate } from '@toeverything/y-indexeddb'; +import { useMemo } from 'react'; +import useSWRImmutable from 'swr/immutable'; +import { applyUpdate } from 'yjs'; + +const logger = new DebugLogger('page-history'); + +type DocHistory = ListHistoryQuery['workspace']['histories'][number]; + +export const usePageSnapshotList = (workspaceId: string, pageDocId: string) => { + const pageSize = 10; + const { data, loadingMore, loadMore } = useQueryInfinite({ + query: listHistoryQuery, + getVariables: (_, previousPageData) => { + // use the timestamp of the last history as the cursor + const before = previousPageData?.workspace.histories.at(-1)?.timestamp; + const vars = { + pageDocId: pageDocId, + workspaceId: workspaceId, + before: before, + take: pageSize, + }; + + return vars; + }, + }); + + const shouldLoadMore = useMemo(() => { + if (!data) { + return false; + } + const lastPage = data.at(-1); + if (!lastPage) { + return false; + } + return lastPage.workspace.histories.length === pageSize; + }, [data]); + + const histories = useMemo(() => { + if (!data) { + return []; + } + return data.flatMap(page => page.workspace.histories); + }, [data]); + + return [ + histories, + shouldLoadMore ? loadMore : undefined, + loadingMore, + ] as const; +}; + +const snapshotFetcher = async ( + [workspaceId, pageDocId, ts]: [ + workspaceId: string, + pageDocId: string, + ts: string, + ] // timestamp is the key to the history/snapshot +) => { + if (!ts) { + return null; + } + const res = await fetchWithTraceReport( + runtimeConfig.serverUrlPrefix + + `/api/workspaces/${workspaceId}/docs/${pageDocId}/histories/${ts}`, + { + priority: 'high', + } + ); + + if (!res.ok) { + throw new Error('Failed to fetch snapshot'); + } + + const snapshot = await res.arrayBuffer(); + if (!snapshot) { + throw new Error('Invalid snapshot'); + } + return snapshot; +}; + +// attach the Page shown in the modal to a temporary workspace +// so that we do not need to worry about providers etc +// todo: fix references to the page (the referenced page will shown as deleted) +// if we simply clone the current workspace, it maybe time consuming right? +const workspaceMap = new Map(); + +// assume the workspace is a cloud workspace since the history feature is only enabled for cloud workspace +const getOrCreateWorkspace = (workspaceId: string) => { + let workspace = workspaceMap.get(workspaceId); + if (!workspace) { + const blobEngine = createAffineCloudBlobEngine(workspaceId); + workspace = new Workspace({ + id: workspaceId, + providerCreators: [], + blobStorages: [ + () => ({ + crud: { + async get(key) { + return (await blobEngine.get(key)) ?? null; + }, + async set(key, value) { + await blobEngine.set(key, value); + return key; + }, + async delete(key) { + return blobEngine.delete(key); + }, + async list() { + return blobEngine.list(); + }, + }, + }), + ], + schema: globalBlockSuiteSchema, + }); + workspaceMap.set(workspaceId, workspace); + } + return workspace; +}; + +// workspace id + page id + timestamp -> snapshot (update binary) +export const usePageHistory = ( + workspaceId: string, + pageDocId: string, + ts?: string +) => { + // snapshot should be immutable. so we use swr immutable to disable revalidation + const { data } = useSWRImmutable( + [workspaceId, pageDocId, ts], + { + fetcher: snapshotFetcher, + suspense: false, + } + ); + return data ?? undefined; +}; + +// workspace id + page id + timestamp + snapshot -> Page (to be used for rendering in blocksuite editor) +export const useSnapshotPage = ( + workspaceId: string, + pageDocId: string, + ts?: string, + snapshot?: ArrayBuffer +) => { + const page = useMemo(() => { + if (!ts) { + return null; + } + const pageId = pageDocId + '-' + ts; + const historyShellWorkspace = getOrCreateWorkspace(workspaceId); + let page = historyShellWorkspace.getPage(pageId); + if (!page && snapshot) { + page = historyShellWorkspace.createPage({ + id: pageId, + }); + page.awarenessStore.setReadonly(page, true); + const spaceDoc = page.spaceDoc; + page + .load(() => applyUpdate(spaceDoc, new Uint8Array(snapshot))) + .catch(console.error); // must load before applyUpdate + } + return page; + }, [pageDocId, snapshot, ts, workspaceId]); + + return page; +}; + +export const historyListGroupByDay = (histories: DocHistory[]) => { + const map = new Map(); + for (const history of histories) { + const day = new Date(history.timestamp).toLocaleDateString(); + const list = map.get(day) ?? []; + list.push(history); + map.set(day, list); + } + return [...map.entries()]; +}; + +export const useRestorePage = (workspace: Workspace, pageId: string) => { + const page = useBlockSuiteWorkspacePage(workspace, pageId); + const mutateQueryResource = useMutateQueryResource(); + const { trigger: recover, isMutating } = useMutation({ + mutation: recoverDocMutation, + }); + const { getPageMeta, setPageTitle } = usePageMetaHelper(workspace); + + const onRestore = useMemo(() => { + return async (version: string, update: Uint8Array) => { + if (!page) { + return; + } + const pageDocId = page.spaceDoc.guid; + revertUpdate(page.spaceDoc, update, key => { + assertEquals(key, 'blocks'); // only expect this value is 'blocks' + return 'Map'; + }); + + // should also update the page title, since it may be changed in the history + const title = page.meta.title; + + if (getPageMeta(pageDocId)?.title !== title) { + setPageTitle(pageDocId, title); + } + + await recover({ + docId: pageDocId, + timestamp: version, + workspaceId: workspace.id, + }); + + await mutateQueryResource(listHistoryQuery, vars => { + return ( + vars.pageDocId === pageDocId && vars.workspaceId === workspace.id + ); + }); + + logger.info('Page restored', pageDocId, version); + }; + }, [ + getPageMeta, + mutateQueryResource, + page, + recover, + setPageTitle, + workspace.id, + ]); + + return { + onRestore, + isMutating, + }; +}; diff --git a/packages/frontend/core/src/components/affine/page-history-modal/empty-history-shape.tsx b/packages/frontend/core/src/components/affine/page-history-modal/empty-history-shape.tsx new file mode 100644 index 0000000000..d36d99de89 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-history-modal/empty-history-shape.tsx @@ -0,0 +1,119 @@ +export const EmptyHistoryShape = () => ( + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx new file mode 100644 index 0000000000..220fe3ef2f --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -0,0 +1,446 @@ +import { Scrollable } from '@affine/component'; +import { + BlockSuiteEditor, + BlockSuiteFallback, +} from '@affine/component/block-suite-editor'; +import type { PageMode } from '@affine/core/atoms'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { Workspace } from '@blocksuite/store'; +import type { DialogContentProps } from '@radix-ui/react-dialog'; +import { Button } from '@toeverything/components/button'; +import { ConfirmModal, Modal } from '@toeverything/components/modal'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useAtom, useAtomValue } from 'jotai'; +import { + type PropsWithChildren, + Suspense, + useCallback, + useLayoutEffect, + useMemo, + useState, +} from 'react'; + +import { currentModeAtom } from '../../../atoms/mode'; +import { pageHistoryModalAtom } from '../../../atoms/page-history'; +import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; +import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style'; +import { + EdgelessSwitchItem, + PageSwitchItem, +} from '../../blocksuite/block-suite-mode-switch/switch-items'; +import { AffineErrorBoundary } from '../affine-error-boundary'; +import { + historyListGroupByDay, + usePageHistory, + usePageSnapshotList, + useRestorePage, + useSnapshotPage, +} from './data'; +import { EmptyHistoryShape } from './empty-history-shape'; +import * as styles from './styles.css'; + +export interface PageHistoryModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspace: Workspace; + pageId: string; +} + +const contentOptions: DialogContentProps = { + ['data-testid' as string]: 'page-history-modal', + onPointerDownOutside: e => { + e.preventDefault(); + }, + style: { + padding: 0, + maxWidth: 944, + backgroundColor: 'var(--affine-background-primary-color)', + overflow: 'hidden', + }, +}; + +const ModalContainer = ({ + onOpenChange, + open, + children, +}: PropsWithChildren<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}>) => { + return ( + + {children} + + ); +}; + +const localTimeFormatter = new Intl.DateTimeFormat('en', { + timeStyle: 'short', +}); + +const timestampToLocalTime = (ts: string) => { + return localTimeFormatter.format(new Date(ts)); +}; + +interface HistoryEditorPreviewProps { + workspaceId: string; + pageDocId: string; + ts?: string; + snapshot?: ArrayBuffer; + mode: PageMode; + onModeChange: (mode: PageMode) => void; + title: string; +} + +const HistoryEditorPreview = ({ + ts, + snapshot, + onModeChange, + mode, + workspaceId, + pageDocId, + title, +}: HistoryEditorPreviewProps) => { + const onSwitchToPageMode = useCallback(() => { + onModeChange('page'); + }, [onModeChange]); + const onSwitchToEdgelessMode = useCallback(() => { + onModeChange('edgeless'); + }, [onModeChange]); + const page = useSnapshotPage(workspaceId, pageDocId, ts, snapshot); + + return ( +
+
+ + + + + +
{title}
+ +
+ {ts ? timestampToLocalTime(ts) : null} +
+
+ + {page ? ( + + ) : ( + + )} +
+ ); +}; + +const PageHistoryList = ({ + pageDocId, + workspaceId, + activeVersion, + onVersionChange, +}: { + workspaceId: string; + pageDocId: string; + activeVersion?: string; + onVersionChange: (version: string) => void; +}) => { + const [historyList, loadMore, loadingMore] = usePageSnapshotList( + workspaceId, + pageDocId + ); + const historyListByDay = useMemo(() => { + return historyListGroupByDay(historyList); + }, [historyList]); + + const t = useAFFiNEI18N(); + + useLayoutEffect(() => { + if (historyList.length > 0 && !activeVersion) { + onVersionChange(historyList[0].timestamp); + } + }, [activeVersion, historyList, onVersionChange]); + + return ( +
+
+ {t['com.affine.history.version-history']()} +
+ + + {historyListByDay.map(([day, list]) => { + return ( +
+
{day}
+ {list.map(history => ( +
{ + e.stopPropagation(); + onVersionChange(history.timestamp); + }} + data-active={activeVersion === history.timestamp} + > + +
+ ))} +
+ ); + })} + {loadMore ? ( + + ) : null} +
+ +
+
+ ); +}; + +interface ConfirmRestoreModalProps { + open: boolean; + onConfirm: (res: boolean) => void; + isMutating: boolean; +} + +const ConfirmRestoreModal = ({ + isMutating, + open, + onConfirm, +}: ConfirmRestoreModalProps) => { + const t = useAFFiNEI18N(); + + const handleConfirm = useCallback(() => { + onConfirm(true); + }, [onConfirm]); + + const handleCancel = useCallback(() => { + onConfirm(false); + }, [onConfirm]); + + return ( + + ); +}; + +const EmptyHistoryPrompt = () => { + const t = useAFFiNEI18N(); + + return ( +
+ +
+ {t['com.affine.history.empty-prompt.title']()} +
+
+ {t['com.affine.history.empty-prompt.description']()} +
+
+ ); +}; + +const PageHistoryManager = ({ + workspace, + pageId, + onClose, +}: { + workspace: Workspace; + pageId: string; + onClose: () => void; +}) => { + const workspaceId = workspace.id; + const [activeVersion, setActiveVersion] = useState(); + + const pageDocId = useMemo(() => { + return workspace.getPage(pageId)?.spaceDoc.guid ?? pageId; + }, [pageId, workspace]); + + const snapshot = usePageHistory(workspaceId, pageDocId, activeVersion); + + const t = useAFFiNEI18N(); + + const { onRestore, isMutating } = useRestorePage(workspace, pageId); + + const handleRestore = useMemo( + () => async () => { + if (!activeVersion || !snapshot) { + return; + } + await onRestore(activeVersion, new Uint8Array(snapshot)); + // close the modal after restore + onClose(); + }, + [activeVersion, onClose, onRestore, snapshot] + ); + + const defaultPreviewPageMode = useAtomValue(currentModeAtom); + const [mode, setMode] = useState(defaultPreviewPageMode); + + const title = useMemo( + () => workspace.getPage(pageId)?.meta.title || t['Untitled'](), + [pageId, t, workspace] + ); + + const [showRestoreConfirmModal, setShowRestoreConfirmModal] = useState(false); + + const showRestoreConfirm = useCallback(() => { + setShowRestoreConfirmModal(true); + }, []); + + const onConfirmRestore = useAsyncCallback( + async res => { + if (res) { + await handleRestore(); + } + setShowRestoreConfirmModal(false); + }, + [handleRestore] + ); + + return ( +
+
+ + + +
+ + {!activeVersion ? ( +
+ +
+ ) : null} + +
+ +
+ +
+ + +
+ ); +}; + +export const PageHistoryModal = ({ + onOpenChange, + open, + pageId, + workspace, +}: PageHistoryModalProps) => { + const onClose = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + return ( + + }> + + + + ); +}; + +export const GlobalPageHistoryModal = () => { + const [{ open, pageId }, setState] = useAtom(pageHistoryModalAtom); + const [workspace] = useCurrentWorkspace(); + + const handleOpenChange = useCallback( + (open: boolean) => { + setState(prev => ({ + ...prev, + open, + })); + }, + [setState] + ); + + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/affine/page-history-modal/index.tsx b/packages/frontend/core/src/components/affine/page-history-modal/index.tsx new file mode 100644 index 0000000000..3df0bbb51e --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-history-modal/index.tsx @@ -0,0 +1 @@ +export * from './history-modal'; diff --git a/packages/frontend/core/src/components/affine/page-history-modal/styles.css.ts b/packages/frontend/core/src/components/affine/page-history-modal/styles.css.ts new file mode 100644 index 0000000000..8694b73337 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-history-modal/styles.css.ts @@ -0,0 +1,185 @@ +import { createVar, globalStyle, style } from '@vanilla-extract/css'; + +const headerHeight = createVar('header-height'); +const footerHeight = createVar('footer-height'); +const historyListWidth = createVar('history-list-width'); + +export const root = style({ + height: '100%', + width: '100%', + vars: { + [headerHeight]: '52px', + [footerHeight]: '68px', + [historyListWidth]: '160px', + }, +}); + +export const modalContent = style({ + display: 'flex', + height: `calc(100% - ${footerHeight})`, + width: '100%', + position: 'absolute', + selectors: { + '&[data-empty="true"]': { + opacity: 0, + pointerEvents: 'none', + }, + }, +}); + +export const previewWrapper = style({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + height: '100%', + width: `calc(100% - ${historyListWidth})`, + backgroundColor: 'var(--affine-background-secondary-color)', +}); + +export const previewHeader = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: headerHeight, + borderBottom: '1px solid var(--affine-border-color)', + backgroundColor: 'var(--affine-background-primary-color)', + padding: '0 12px', + flexShrink: 0, + gap: 12, +}); + +export const previewHeaderTitle = style({ + fontSize: 'var(--affine-font-xs)', + fontWeight: 600, + maxWidth: 400, // better responsiveness +}); + +export const previewHeaderTimestamp = style({ + color: 'var(--affine-text-secondary-color)', + backgroundColor: 'var(--affine-background-secondary-color)', + padding: '0 10px', + borderRadius: 4, + fontSize: 'var(--affine-font-xs)', +}); + +export const editor = style({ + height: '100%', + flexGrow: 1, + overflow: 'hidden', +}); + +export const historyList = style({ + overflow: 'hidden', + height: '100%', + width: historyListWidth, + flexShrink: 0, + borderLeft: '1px solid var(--affine-border-color)', +}); + +export const historyListScrollable = style({ + height: `calc(100% - ${headerHeight})`, +}); + +export const historyListScrollableInner = style({ + display: 'flex', + gap: 16, + flexDirection: 'column', +}); + +export const historyListHeader = style({ + display: 'flex', + alignItems: 'center', + height: 52, + borderBottom: '1px solid var(--affine-border-color)', + fontWeight: 'bold', + flexShrink: 0, + padding: '0 12px', +}); + +export const historyItemGroup = style({ + display: 'flex', + flexDirection: 'column', + rowGap: 6, +}); + +export const historyItemGroupTitle = style({ + display: 'flex', + alignItems: 'center', + padding: '12px', + fontWeight: 'bold', + backgroundColor: 'var(--affine-background-primary-color)', + position: 'sticky', + top: 0, +}); + +export const historyItem = style({ + display: 'flex', + alignItems: 'center', + padding: '0 12px', + height: 32, + cursor: 'pointer', + selectors: { + '&:hover, &[data-active=true]': { + backgroundColor: 'var(--affine-hover-color)', + }, + }, +}); + +export const historyItemLoadMore = style([ + historyItem, + { + cursor: 'pointer', + color: 'var(--affine-text-secondary-color)', + flexShrink: 0, + borderRadius: 0, + selectors: { + '&:hover': { + backgroundColor: 'var(--affine-hover-color)', + }, + }, + }, +]); + +globalStyle(`${historyItem} button`, { + color: 'inherit', +}); + +export const historyFooter = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 68, + borderTop: '1px solid var(--affine-border-color)', + padding: '0 24px', + position: 'absolute', + bottom: 0, + left: 0, + right: 0, +}); + +export const spacer = style({ + flexGrow: 1, +}); + +export const emptyHistoryPrompt = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + width: '100%', + zIndex: 1, + gap: 20, +}); + +export const emptyHistoryPromptTitle = style({ + fontWeight: 600, + fontSize: 'var(--affine-font-h-5)', +}); + +export const emptyHistoryPromptDescription = style({ + width: 320, + textAlign: 'center', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-secondary-color)', +}); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx index 42c702a40c..5d19c820da 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/operation-menu.tsx @@ -1,5 +1,6 @@ import { FlexWrapper } from '@affine/component'; import { Export, MoveToTrash } from '@affine/component/page-list'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; import { @@ -8,6 +9,7 @@ import { EditIcon, FavoritedIcon, FavoriteIcon, + HistoryIcon, ImportIcon, PageIcon, } from '@blocksuite/icons'; @@ -25,7 +27,7 @@ import { } from '@toeverything/hooks/use-block-suite-page-meta'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; import { useAtomValue, useSetAtom } from 'jotai'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { setPageModeAtom } from '../../../atoms'; @@ -36,6 +38,7 @@ import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helpe import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { toast } from '../../../utils'; +import { PageHistoryModal } from '../../affine/page-history-modal/history-modal'; import { HeaderDropDownButton } from '../../pure/header-drop-down-button'; import { usePageHelper } from '../block-suite-page-list/utils'; @@ -68,6 +71,12 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => { const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace); + const [historyModalOpen, setHistoryModalOpen] = useState(false); + + const openHistoryModal = useCallback(() => { + setHistoryModalOpen(true); + }, []); + const handleOpenTrashModal = useCallback(() => { setTrashModal({ open: true, @@ -207,6 +216,23 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => { > {t['Import']()} + + {workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD && + runtimeConfig.enablePageHistory ? ( + + + + } + data-testid="editor-option-menu-history" + onSelect={openHistoryModal} + style={menuItemStyle} + > + {t['com.affine.history.view-history-version']()} + + ) : null} + { > + {workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? ( + + ) : null} ); }; diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index c5367a1530..3b82e54e26 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -30,8 +30,8 @@ import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper'; import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info'; import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts'; import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper'; +import { useRegisterBrowserHistoryCommands } from '../../hooks/use-browser-history-commands'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { useRegisterBlocksuiteEditorCommands } from '../../hooks/use-shortcut-commands'; import type { AllWorkspace } from '../../shared'; import { CollectionsList } from '../pure/workspace-slider-bar/collections'; import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button'; @@ -169,7 +169,7 @@ export const RootAppSidebar = ({ const closeUserWorkspaceList = useCallback(() => { setOpenUserWorkspaceList(false); }, [setOpenUserWorkspaceList]); - useRegisterBlocksuiteEditorCommands(router.back, router.forward); + useRegisterBrowserHistoryCommands(router.back, router.forward); const userInfo = useDeleteCollectionInfo(); return ( { + // todo: should not mutate all graphql cache return mutate(key => { if (Array.isArray(key)) { return key[0] === 'cloud'; diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx index d7f6cd5134..62ddb8b48b 100644 --- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx +++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx @@ -1,25 +1,29 @@ import { toast } from '@affine/component'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { assertExists } from '@blocksuite/global/utils'; -import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; -import type { Workspace } from '@blocksuite/store'; +import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons'; import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta'; import { PreconditionStrategy, registerAffineCommand, } from '@toeverything/infra/command'; +import { useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; +import { pageHistoryModalAtom } from '../../atoms/page-history'; +import { useCurrentWorkspace } from '../current/use-current-workspace'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; import { useExportPage } from './use-export-page'; import { useTrashModalHelper } from './use-trash-modal-helper'; export function useRegisterBlocksuiteEditorCommands( - blockSuiteWorkspace: Workspace, pageId: string, mode: 'page' | 'edgeless' ) { const t = useAFFiNEI18N(); + const [workspace] = useCurrentWorkspace(); + const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace); const currentPage = blockSuiteWorkspace.getPage(pageId); assertExists(currentPage); @@ -28,6 +32,15 @@ export function useRegisterBlocksuiteEditorCommands( const favorite = pageMeta.favorite ?? false; const trash = pageMeta.trash ?? false; + const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom); + + const openHistoryModal = useCallback(() => { + setPageHistoryModalState(() => ({ + pageId, + open: true, + })); + }, [pageId, setPageHistoryModalState]); + const { togglePageMode, toggleFavorite, restoreFromTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace); const exportHandler = useExportPage(currentPage); @@ -40,12 +53,14 @@ export function useRegisterBlocksuiteEditorCommands( }); }, [pageId, pageMeta.title, setTrashModal]); + const isCloudWorkspace = workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; + useEffect(() => { const unsubs: Array<() => void> = []; const preconditionStrategy = () => PreconditionStrategy.InPaperOrEdgeless && !trash; - //TODO: add back when edgeless presentation is ready + // TODO: add back when edgeless presentation is ready // this is pretty hack and easy to break. need a better way to communicate with blocksuite editor // unsubs.push( @@ -189,6 +204,20 @@ export function useRegisterBlocksuiteEditorCommands( }) ); + if (runtimeConfig.enablePageHistory && isCloudWorkspace) { + unsubs.push( + registerAffineCommand({ + id: `editor:${mode}-page-history`, + category: `editor:${mode}`, + icon: , + label: t['com.affine.cmdk.affine.editor.reveal-page-history-modal'](), + run() { + openHistoryModal(); + }, + }) + ); + } + return () => { unsubs.forEach(unsub => unsub()); }; @@ -198,11 +227,12 @@ export function useRegisterBlocksuiteEditorCommands( onClickDelete, exportHandler, pageId, - pageMeta.title, restoreFromTrash, t, toggleFavorite, togglePageMode, trash, + isCloudWorkspace, + openHistoryModal, ]); } diff --git a/packages/frontend/core/src/hooks/affine/use-server-flavor.ts b/packages/frontend/core/src/hooks/affine/use-server-flavor.ts index 6ffb71257c..b7335d2318 100644 --- a/packages/frontend/core/src/hooks/affine/use-server-flavor.ts +++ b/packages/frontend/core/src/hooks/affine/use-server-flavor.ts @@ -12,7 +12,12 @@ const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => { export const useServerFlavor = () => { const { data: config, error } = useQuery( { query: serverConfigQuery }, - { use: [errorHandler] } + { + use: [errorHandler], + revalidateOnFocus: false, + revalidateOnMount: false, + revalidateIfStale: false, + } ); if (error || !config) { diff --git a/packages/frontend/core/src/hooks/use-shortcut-commands.ts b/packages/frontend/core/src/hooks/use-browser-history-commands.ts similarity index 95% rename from packages/frontend/core/src/hooks/use-shortcut-commands.ts rename to packages/frontend/core/src/hooks/use-browser-history-commands.ts index e8003ddac8..4c3a6e810d 100644 --- a/packages/frontend/core/src/hooks/use-shortcut-commands.ts +++ b/packages/frontend/core/src/hooks/use-browser-history-commands.ts @@ -4,7 +4,7 @@ import { } from '@toeverything/infra/command'; import { useEffect } from 'react'; -export function useRegisterBlocksuiteEditorCommands( +export function useRegisterBrowserHistoryCommands( back: () => unknown, forward: () => unknown ) { diff --git a/packages/frontend/core/src/pages/workspace/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page.tsx index e9111cc833..58e45519ed 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page.tsx @@ -24,6 +24,7 @@ import { setPageModeAtom } from '../../atoms'; import { collectionsCRUDAtom } from '../../atoms/collections'; import { currentModeAtom } from '../../atoms/mode'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; +import { GlobalPageHistoryModal } from '../../components/affine/page-history-modal'; import { WorkspaceHeader } from '../../components/workspace-header'; import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands'; import { useCurrentSyncEngineStatus } from '../../hooks/current/use-current-sync-engine'; @@ -41,7 +42,7 @@ const DetailPageImpl = (): ReactElement => { const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom); const mode = useAtomValue(currentModeAtom); const setPageMode = useSetAtom(setPageModeAtom); - useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode); + useRegisterBlocksuiteEditorCommands(currentPageId, mode); const onLoad = useCallback( (page: Page, editor: EditorContainer) => { try { @@ -101,6 +102,8 @@ const DetailPageImpl = (): ReactElement => { currentPageId={currentPageId} onLoadEditor={onLoad} /> + + ); }; diff --git a/packages/frontend/graphql/src/graphql/histories.gql b/packages/frontend/graphql/src/graphql/histories.gql new file mode 100644 index 0000000000..b6aade3e0c --- /dev/null +++ b/packages/frontend/graphql/src/graphql/histories.gql @@ -0,0 +1,13 @@ +query listHistory( + $workspaceId: String! + $pageDocId: String! + $take: Int + $before: DateTime +) { + workspace(id: $workspaceId) { + histories(guid: $pageDocId, take: $take, before: $before) { + id + timestamp + } + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 86ef26cfd1..9f543e0569 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -362,6 +362,22 @@ query getWorkspaces { }`, }; +export const listHistoryQuery = { + id: 'listHistoryQuery' as const, + operationName: 'listHistory', + definitionName: 'workspace', + containsFile: false, + query: ` +query listHistory($workspaceId: String!, $pageDocId: String!, $take: Int, $before: DateTime) { + workspace(id: $workspaceId) { + histories(guid: $pageDocId, take: $take, before: $before) { + id + timestamp + } + } +}`, +}; + export const getInvoicesCountQuery = { id: 'getInvoicesCountQuery' as const, operationName: 'getInvoicesCount', @@ -445,6 +461,17 @@ mutation publishPage($workspaceId: String!, $pageId: String!, $mode: PublicPageM }`, }; +export const recoverDocMutation = { + id: 'recoverDocMutation' as const, + operationName: 'recoverDoc', + definitionName: 'recoverDoc', + containsFile: false, + query: ` +mutation recoverDoc($workspaceId: String!, $docId: String!, $timestamp: DateTime!) { + recoverDoc(workspaceId: $workspaceId, guid: $docId, timestamp: $timestamp) +}`, +}; + export const removeAvatarMutation = { id: 'removeAvatarMutation' as const, operationName: 'removeAvatar', diff --git a/packages/frontend/graphql/src/graphql/recover-doc.gql b/packages/frontend/graphql/src/graphql/recover-doc.gql new file mode 100644 index 0000000000..d7a1dfcada --- /dev/null +++ b/packages/frontend/graphql/src/graphql/recover-doc.gql @@ -0,0 +1,7 @@ +mutation recoverDoc( + $workspaceId: String! + $docId: String! + $timestamp: DateTime! +) { + recoverDoc(workspaceId: $workspaceId, guid: $docId, timestamp: $timestamp) +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 61b4b4020c..b0243ca909 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -373,6 +373,25 @@ export type GetWorkspacesQuery = { workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>; }; +export type ListHistoryQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + pageDocId: Scalars['String']['input']; + take: InputMaybe; + before: InputMaybe; +}>; + +export type ListHistoryQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + histories: Array<{ + __typename?: 'DocHistoryType'; + id: string; + timestamp: string; + }>; + }; +}; + export type GetInvoicesCountQueryVariables = Exact<{ [key: string]: never }>; export type GetInvoicesCountQuery = { @@ -445,6 +464,17 @@ export type PublishPageMutation = { }; }; +export type RecoverDocMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; + timestamp: Scalars['DateTime']['input']; +}>; + +export type RecoverDocMutation = { + __typename?: 'Mutation'; + recoverDoc: string; +}; + export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>; export type RemoveAvatarMutation = { @@ -729,6 +759,11 @@ export type Queries = variables: GetWorkspacesQueryVariables; response: GetWorkspacesQuery; } + | { + name: 'listHistoryQuery'; + variables: ListHistoryQueryVariables; + response: ListHistoryQuery; + } | { name: 'getInvoicesCountQuery'; variables: GetInvoicesCountQueryVariables; @@ -816,6 +851,11 @@ export type Mutations = variables: PublishPageMutationVariables; response: PublishPageMutation; } + | { + name: 'recoverDocMutation'; + variables: RecoverDocMutationVariables; + response: RecoverDocMutation; + } | { name: 'removeAvatarMutation'; variables: RemoveAvatarMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8bfeed413e..440de930e6 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -518,6 +518,7 @@ "com.affine.cmdk.affine.switch-state.on": "ON", "com.affine.cmdk.affine.translucent-ui-on-the-sidebar.to": "Change Translucent UI On The Sidebar to", "com.affine.cmdk.affine.whats-new": "What's New", + "com.affine.cmdk.affine.editor.reveal-page-history-modal": "Reveal Page History Modal", "com.affine.cmdk.placeholder": "Type a command or search anything...", "com.affine.collection-bar.action.tooltip.delete": "Delete", "com.affine.collection-bar.action.tooltip.edit": "Edit", @@ -972,5 +973,13 @@ "system": "System", "upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.", "will be moved to Trash": "{{title}} will be moved to Trash", - "will delete member": "will delete member" + "will delete member": "will delete member", + "com.affine.history.version-history": "Version History", + "com.affine.history.view-history-version": "View History Version", + "com.affine.history.restore-current-version": "Restore current version", + "com.affine.history.back-to-page": "Back to page", + "com.affine.history.empty-prompt.title": "Empty", + "com.affine.history.empty-prompt.description": "This document is such a spring chicken, it hasn't sprouted a single historical sprig yet!", + "com.affine.history.confirm-restore-modal.restore": "Restore", + "com.affine.history.confirm-restore-modal.hint": "You are about to restore the current version of the page to the latest version available. This action will overwrite any changes made prior to the latest version." } diff --git a/packages/frontend/workspace/package.json b/packages/frontend/workspace/package.json index 57d5f88b5c..0662a725a9 100644 --- a/packages/frontend/workspace/package.json +++ b/packages/frontend/workspace/package.json @@ -4,6 +4,7 @@ "exports": { "./atom": "./src/atom.ts", "./manager": "./src/manager/index.ts", + "./blob": "./src/blob/index.ts", "./local/crud": "./src/local/crud.ts", "./affine/*": "./src/affine/*.ts", "./providers": "./src/providers/index.ts" diff --git a/packages/frontend/workspace/src/affine/gql.ts b/packages/frontend/workspace/src/affine/gql.ts index 102e5c7d31..b7988042a0 100644 --- a/packages/frontend/workspace/src/affine/gql.ts +++ b/packages/frontend/workspace/src/affine/gql.ts @@ -5,12 +5,15 @@ import type { QueryOptions, QueryResponse, QueryVariables, + RecursiveMaybeFields, } from '@affine/graphql'; import { gqlFetcherFactory } from '@affine/graphql'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import type { GraphQLError } from 'graphql'; import { useMemo } from 'react'; import type { Key, SWRConfiguration, SWRResponse } from 'swr'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; +import useSWRInfinite from 'swr/infinite'; import type { SWRMutationConfiguration, SWRMutationResponse, @@ -86,6 +89,63 @@ export function useQuery( ); } +export function useQueryInfinite( + options: Omit, 'variables'> & { + getVariables: ( + pageIndex: number, + previousPageData: QueryResponse + ) => QueryOptions['variables']; + }, + config?: Omit< + SWRConfiguration< + QueryResponse, + GraphQLError | GraphQLError[], + typeof fetcher + >, + 'fetcher' + > +) { + const configWithSuspense: SWRConfiguration = useMemo( + () => ({ + suspense: true, + ...config, + }), + [config] + ); + + const { data, setSize, size, error } = useSWRInfinite< + QueryResponse, + GraphQLError | GraphQLError[] + >( + (pageIndex: number, previousPageData: QueryResponse) => [ + 'cloud', + options.query.id, + options.getVariables(pageIndex, previousPageData), + ], + async ([_, __, variables]) => { + const params = { ...options, variables } as QueryOptions; + return fetcher(params); + }, + configWithSuspense + ); + + const loadingMore = size > 0 && data && !data[size - 1]; + + // todo: find a generic way to know whether or not there are more items to load + const loadMore = useAsyncCallback(async () => { + if (loadingMore) { + return; + } + await setSize(size => size + 1); + }, [loadingMore, setSize]); + return { + data, + error, + loadingMore, + loadMore, + }; +} + /** * A useSWRMutation wrapper for sending graphql mutations * @@ -138,3 +198,32 @@ export function useMutation( } export const gql = fetcher; + +// use this to revalidate all queries that match the filter +export const useMutateQueryResource = () => { + const { mutate } = useSWRConfig(); + const revalidateResource = useMemo( + () => + ( + query: Q, + varsFilter: ( + vars: RecursiveMaybeFields> + ) => boolean = _vars => true + ) => { + return mutate(key => { + const res = + Array.isArray(key) && + key[0] === 'cloud' && + key[1] === query.id && + varsFilter(key[2]); + if (res) { + console.debug('revalidate resource', key); + } + return res; + }); + }, + [mutate] + ); + + return revalidateResource; +};