Skip to content

Commit fe2b1ae

Browse files
committed
feat(webapp): harden version history preview and stateless client
Keep the history skeleton until snapshots decode and apply cleanly, avoid stale list responses overwriting an in-flight version watch, and route all editor hydration through one apply helper with shared wire conventions. Made-with: Cursor
1 parent dc43835 commit fe2b1ae

18 files changed

Lines changed: 482 additions & 168 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Wire format (Hocuspocus stateless): client sends `{ msg: 'history', type, documentId? }`;
3+
* server answers on the same connection with `{ msg: 'history.response', type, response }`.
4+
* History queries always use the WebSocket document room id; a mismatched `documentId` gets `history_failed`.
5+
* List payloads may include `latestSnapshot` (one RTT); failures use `error: 'history_failed'`.
6+
*/
7+
import type { Editor } from '@tiptap/react'
8+
import type { HistoryItem } from '@types'
9+
10+
import { tryGetProsemirrorFromHistoryYdoc } from './helpers'
11+
12+
export type ApplyHistoryToEditorResult = 'applied' | 'decode_failed' | 'no_editor'
13+
14+
/** Yjs snapshot (base64) → ProseMirror → TipTap `setContent` — single path for all history hydrations. */
15+
export function applyHistoryItemToEditor(
16+
editor: Editor | null,
17+
item: Pick<HistoryItem, 'data' | 'version'>
18+
): ApplyHistoryToEditorResult {
19+
const doc = tryGetProsemirrorFromHistoryYdoc(item.data)
20+
if (doc == null) return 'decode_failed'
21+
if (!editor || editor.isDestroyed) return 'no_editor'
22+
try {
23+
editor.commands.setContent(doc)
24+
return 'applied'
25+
} catch {
26+
return 'decode_failed'
27+
}
28+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Button from '@components/ui/Button'
2+
import { Modal, ModalContent } from '@components/ui/Dialog'
3+
4+
type Props = {
5+
open: boolean
6+
onOpenChange: (open: boolean) => void
7+
version: number | undefined
8+
onConfirm: () => void
9+
}
10+
11+
/** Confirm before replacing live editor content with a history snapshot. */
12+
export function HistoryRestoreModal({ open, onOpenChange, version, onConfirm }: Props) {
13+
return (
14+
<Modal open={open} onOpenChange={onOpenChange}>
15+
<ModalContent size="sm" className="p-6">
16+
<h2 className="text-base-content text-lg font-semibold" id="history-restore-title">
17+
Revert to version {version}?
18+
</h2>
19+
<p className="text-base-content/70 mt-2 text-sm" id="history-restore-desc">
20+
The editor will show this snapshot. Save or sync applies it like any other edit.
21+
</p>
22+
<div className="mt-6 flex flex-wrap justify-end gap-2">
23+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
24+
Cancel
25+
</Button>
26+
<Button
27+
type="button"
28+
variant="primary"
29+
onClick={() => {
30+
onConfirm()
31+
onOpenChange(false)
32+
}}>
33+
Revert
34+
</Button>
35+
</div>
36+
</ModalContent>
37+
</Modal>
38+
)
39+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Button from '@components/ui/Button'
2+
3+
import { formatVersionDate } from '../helpers'
4+
import type { HistoryToolbarVersion } from '../hooks/useGetVersionInfo'
5+
6+
type Props = {
7+
versionInfo: HistoryToolbarVersion | null
8+
onRequestRestore: () => void
9+
variant: 'desktop' | 'mobile'
10+
}
11+
12+
/**
13+
* Shared restore + timestamp block for history toolbars (desktop row vs mobile compact).
14+
*/
15+
export function HistoryToolbarVersionBlock({ versionInfo, onRequestRestore, variant }: Props) {
16+
if (!versionInfo) return null
17+
18+
const { date, time } = formatVersionDate(versionInfo.createdAt)
19+
const showRestore = !versionInfo.isLatestVersion
20+
21+
if (variant === 'mobile') {
22+
return (
23+
<>
24+
{showRestore && (
25+
<Button
26+
variant="primary"
27+
onClick={onRequestRestore}
28+
aria-label="Restore this version"
29+
tooltip={`Restore document to version ${versionInfo.version}`}
30+
tooltipPlacement="bottom">
31+
Restore this version
32+
</Button>
33+
)}
34+
<div className="text-center text-sm">
35+
<span className="font-medium">{date}</span>
36+
<br />
37+
<span className="text-base-content/60">{time}</span>
38+
</div>
39+
</>
40+
)
41+
}
42+
43+
return (
44+
<div className="flex min-w-0 items-center justify-end gap-3">
45+
{showRestore && (
46+
<Button
47+
variant="primary"
48+
className="font-normal"
49+
onClick={onRequestRestore}
50+
aria-label="Restore this version"
51+
tooltip={`Restore document to version ${versionInfo.version}`}
52+
tooltipPlacement="bottom">
53+
Restore this version
54+
</Button>
55+
)}
56+
<div className="text-sm whitespace-nowrap">
57+
<span className="font-medium">{date}</span>
58+
<span className="text-base-content/60 ml-2">{time}</span>
59+
</div>
60+
</div>
61+
)
62+
}

packages/webapp/src/components/pages/history/desktop/DesktopHistory.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ const DesktopHistory = () => {
2222
if (!editor) return null
2323

2424
return (
25-
<div className="pad tiptap history_editor bg-base-200 flex h-full flex-col">
26-
<div className="editor relative flex size-full flex-row justify-around align-top">
27-
<div className="mainWrapper relative flex flex-1 flex-col align-top">
25+
<div className="pad tiptap history_editor bg-base-200 flex h-full min-h-0 flex-col overflow-hidden">
26+
<div className="editor relative flex min-h-0 min-w-0 flex-1 flex-row justify-around align-top">
27+
<div className="mainWrapper relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden align-top">
2828
<Toolbar />
2929
<EditorContent />
3030
</div>

0 commit comments

Comments
 (0)