Merge pull request #35855 from overleaf/ds-unable-to-sync-modal-improved

[WEB] Adding improved version of the unable to sync modal

GitOrigin-RevId: 1cfe2b3a2e8417028339efddb18385a5224e50c3
This commit is contained in:
Davinder Singh
2026-09-17 11:43:20 +00:00
committed by Copybot
parent 469f5629d2
commit 36d0a5c227
11 changed files with 241 additions and 72 deletions
@@ -0,0 +1,45 @@
import { memo, useCallback, useMemo } from 'react'
import { diffWordsWithSpace } from 'diff'
type DiffViewerProps = {
baseContent: string
targetContent: string
}
// Renders an inline diff between two document versions using <ins> and <del>
// tags. This mirrors the DOM-based rendering approach used by the workbench
// CodeDiff component (modules/workbench/frontend/js/components/code-diff.tsx).
// TODO: refactor both to share a common diff renderer from a shared location.
function DiffViewer({ baseContent, targetContent }: DiffViewerProps) {
const changes = useMemo(
() => diffWordsWithSpace(baseContent, targetContent),
[baseContent, targetContent]
)
const createDiff = useCallback(
(element: HTMLDivElement) => {
if (!element) return
element.replaceChildren()
for (const change of changes) {
if (change.added) {
const ins = document.createElement('ins')
ins.textContent = change.value
element.append(ins)
} else if (change.removed) {
const del = document.createElement('del')
del.textContent = change.value
element.append(del)
} else {
const text = document.createTextNode(change.value)
element.append(text)
}
}
},
[changes]
)
return <div className="diff-container" ref={createDiff} />
}
export default memo(DiffViewer)
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react'
import { memo, useCallback, useState } from 'react'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal,
@@ -8,42 +8,110 @@ import {
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import { downloadFileContent } from '@/utils/download-file'
import DiffViewer from './diff-viewer'
import getMeta from '@/utils/meta'
import { uploadBatch } from '@/infrastructure/batch-file-uploader'
import { debugConsole } from '@/utils/debugging'
export type UnableToSyncModalProps = {
editorContent: string
baseContent: string
targetContent: string
docName: string | null
rootFolderId: string | undefined
show: boolean
onHide: () => void
}
// Generates the offline copy filename with a timestamp to avoid duplicates.
// e.g. main.tex -> main(offline-2026-07-27-14-07).tex
function buildOfflineFilename(docName: string | null): string {
const now = new Date()
const stamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
].join('-')
const suffix = `(offline-${stamp})`
if (!docName) {
return `main${suffix}.tex`
}
const lastDot = docName.lastIndexOf('.')
if (lastDot === -1) {
return `${docName}${suffix}`
}
return `${docName.slice(0, lastDot)}${suffix}${docName.slice(lastDot)}`
}
function UnableToSyncModal({
editorContent,
baseContent,
targetContent,
docName,
rootFolderId,
show,
onHide,
}: UnableToSyncModalProps) {
const { t } = useTranslation()
const [saving, setSaving] = useState(false)
const handleDownload = useCallback(() => {
downloadFileContent(editorContent, docName ?? 'document.txt')
}, [editorContent, docName])
const handleSaveAsNewFile = useCallback(async () => {
const projectId = getMeta('ol-project_id')
const filename = buildOfflineFilename(docName)
setSaving(true)
try {
if (!rootFolderId) {
throw new Error('rootFolderId not available')
}
const [result] = await uploadBatch(
[
{
file: new Blob([targetContent], { type: 'text/plain' }),
name: filename,
},
],
{ projectId, folderId: rootFolderId }
)
if (result.status === 'error') {
throw new Error(result.error)
}
onHide()
} catch (error) {
debugConsole.error('Failed to save offline changes as new file', error)
} finally {
setSaving(false)
}
}, [docName, targetContent, rootFolderId, onHide])
return (
<OLModal
show={show}
onHide={onHide}
className="unable-to-sync-modal"
backdrop="static"
keyboard={false}
centered
>
<OLModalHeader>
<OLModalHeader closeButton={false}>
<OLModalTitle>{t('your_offline_edits_couldnt_be_synced')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>{t('offline_edits_couldnt_be_synced_detail')}</OLModalBody>
<OLModalBody>
<p>{t('offline_edits_couldnt_combine')}</p>
<DiffViewer baseContent={baseContent} targetContent={targetContent} />
<p className="mt-2">{t('offline_save_explanation')}</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="primary" onClick={handleDownload}>
{t('download_local_version')}
<OLButton variant="danger-ghost" onClick={onHide}>
{t('discard_changes')}
</OLButton>
<OLButton
variant="primary"
onClick={handleSaveAsNewFile}
isLoading={saving}
loadingLabel={t('saving')}
>
{t('save_as_new_file')}
</OLButton>
</OLModalFooter>
</OLModal>
@@ -119,6 +119,10 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
}, [wantTrackChanges])
const { fileTreeData } = useFileTreeData()
const fileTreeDataRef = useRef(fileTreeData)
useEffect(() => {
fileTreeDataRef.current = fileTreeData
}, [fileTreeData])
const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
@@ -583,11 +587,11 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
// A failure while there are recoverable offline edits is a failed sync,
// so report it through the same event as the recovery path and let the
// listener show the modal. Otherwise fall back to the out of sync modal.
const hasOfflineBackup = !!OfflineDocBackup.readRecoverable(
const backupRecord = OfflineDocBackup.readRecoverable(
projectId,
document.doc_id
)
if (hasOfflineBackup) {
if (backupRecord) {
if (!isConnected) {
// Still offline, so nothing has been rejected yet: this is the
// fatal op timeout running out while the outage is ongoing. Keep
@@ -598,6 +602,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
eventEmitter.emit('ide:unableToSyncOfflineChanges', {
docId: document.doc_id,
editorContent: editorContent || '',
baseContent: backupRecord.snapshot,
docName: document.docName,
})
OfflineDocBackup.remove(projectId, document.doc_id)
@@ -637,9 +642,14 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
useEffect(() => {
const handleUnableToSync = ({
detail: [{ editorContent, docName }],
detail: [{ editorContent, baseContent, docName }],
}: CustomEvent<IdeEvents['ide:unableToSyncOfflineChanges']>) => {
showUnableToSyncModal(editorContent, docName)
showUnableToSyncModal({
baseContent,
targetContent: editorContent,
docName,
rootFolderId: fileTreeDataRef.current?._id,
})
}
eventEmitter.on('ide:unableToSyncOfflineChanges', handleUnableToSync)
@@ -30,8 +30,10 @@ type ModalsContextValue = {
editorContent: OutOfSyncModalProps['editorContent']
) => void
showUnableToSyncModal: (
editorContent: UnableToSyncModalProps['editorContent'],
docName: UnableToSyncModalProps['docName']
data: Pick<
UnableToSyncModalProps,
'baseContent' | 'targetContent' | 'docName' | 'rootFolderId'
>
) => void
}
@@ -59,12 +61,16 @@ export const ModalsContextProvider: FC<React.PropsWithChildren> = ({
const [shouldShowUnableToSyncModal, setShouldShowUnableToSyncModal] =
useState(false)
const [unableToSyncModalData, setUnableToSyncModalData] = useState<{
editorContent: string
docName: string | null
}>({
editorContent: '',
const [unableToSyncModalData, setUnableToSyncModalData] = useState<
Pick<
UnableToSyncModalProps,
'baseContent' | 'targetContent' | 'docName' | 'rootFolderId'
>
>({
baseContent: '',
targetContent: '',
docName: null,
rootFolderId: undefined,
})
const handleHideGenericModal = useCallback(() => {
@@ -113,8 +119,13 @@ export const ModalsContextProvider: FC<React.PropsWithChildren> = ({
}, [])
const showUnableToSyncModal = useCallback(
(editorContent: string, docName: string | null) => {
setUnableToSyncModalData({ editorContent, docName })
(
data: Pick<
UnableToSyncModalProps,
'baseContent' | 'targetContent' | 'docName' | 'rootFolderId'
>
) => {
setUnableToSyncModalData(data)
setShouldShowUnableToSyncModal(true)
},
[]
@@ -23,7 +23,12 @@ export type IdeEvents = {
'history:toggle': []
'entity:deleted': [entity: FileTreeFindResult]
'ide:unableToSyncOfflineChanges': [
{ docId: string; editorContent: string; docName: string },
{
docId: string
editorContent: string
baseContent: string
docName: string
},
]
'ide:offlineChangesSynced': [{ docId: string }]
}
@@ -2,7 +2,7 @@
// Migrated from services/web/frontend/js/ide/editor/Document.js
import RangesTracker from '@overleaf/ranges-tracker'
import { OTType, ShareJsDoc } from './share-js-doc'
import { OTType, ShareJsDoc, applyOpsToSnapshot } from './share-js-doc'
import { debugConsole } from '@/utils/debugging'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
@@ -638,10 +638,22 @@ export class DocumentContainer extends EventEmitter {
debugConsole.error('[recovery] failed, loading without recovery', error)
this.releaseTrackChangesPin()
this.needsRangesRefetchAfterRecovery = false
const editorContent = this.doc?.getSnapshot() || record.snapshot
// Compute the user's local version so the unable-to-sync modal can
// show a diff of offline changes. If the ops can't be applied (e.g.
// corrupt backup), fall back to the raw snapshot.
let targetContent = record.snapshot
try {
targetContent = applyOpsToSnapshot(record)
} catch (err) {
debugConsole.warn(
'[recovery] failed to apply ops to snapshot for diff',
err
)
}
this.ideEventEmitter.emit('ide:unableToSyncOfflineChanges', {
docId: record.docId,
editorContent,
editorContent: targetContent,
baseContent: record.snapshot,
docName: this.docName,
})
OfflineDocBackup.remove(record.projectId, record.docId)
@@ -36,6 +36,20 @@ const intermittentConnectionImprovementsEnabled = isSplitTestEnabled(
'intermittent-connection-improvements'
)
// Apply buffered ops (inflight + pending) to a backup snapshot to reconstruct
// the user's local document state. Used by both the recovery path and the
// unable-to-sync modal diff.
export function applyOpsToSnapshot(backup: OfflineDocBackupRecord): string {
let snapshot = backup.snapshot
if (backup.inflightOp) {
snapshot = sharejs.types.text.apply(snapshot, backup.inflightOp)
}
if (backup.pendingOp) {
snapshot = sharejs.types.text.apply(snapshot, backup.pendingOp)
}
return snapshot
}
// All times below are in milliseconds
const SINGLE_USER_FLUSH_DELAY = 2000
const MULTI_USER_FLUSH_DELAY = 500
@@ -360,18 +374,9 @@ export class ShareJsDoc extends EventEmitter {
// constructs a doc at the baseline version, restores this, then catches the
// doc up to the current server version.
restoreFromOfflineBackup(backup: OfflineDocBackupRecord) {
// The snapshot must be the local view (baseline with the buffered ops
// applied), matching ShareJS's invariant that snapshot = acked version +
// inflight + pending. Seeding only the baseline would show the server text
// without the recovered edits.
let snapshot = backup.snapshot
if (backup.inflightOp) {
snapshot = sharejs.types.text.apply(snapshot, backup.inflightOp)
}
if (backup.pendingOp) {
snapshot = sharejs.types.text.apply(snapshot, backup.pendingOp)
}
this._doc.snapshot = snapshot
// The snapshot must be the local view (baseline + buffered ops),
// matching ShareJS's invariant: snapshot = acked version + inflight + pending.
this._doc.snapshot = applyOpsToSnapshot(backup)
this._doc.inflightOp = backup.inflightOp
this._doc.pendingOp = backup.pendingOp
this._doc.inflightSubmittedIds = [...backup.inflightSubmittedIds]