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
@@ -495,6 +495,7 @@
"disabled": "",
"disabling": "",
"disagree": "",
"discard_changes": "",
"disconnected": "",
"discount": "",
"discount_of": "",
@@ -549,7 +550,6 @@
"download_as_source_zip": "",
"download_csv": "",
"download_file": "",
"download_local_version": "",
"download_metadata": "",
"download_pdf": "",
"download_zip_file": "",
@@ -1368,7 +1368,8 @@
"of": "",
"off": "",
"official": "",
"offline_edits_couldnt_be_synced_detail": "",
"offline_edits_couldnt_combine": "",
"offline_save_explanation": "",
"ok": "",
"ok_continue_to_project": "",
"ok_join_project": "",
@@ -1764,6 +1765,7 @@
"saml_response": "",
"save": "",
"save_20_percent": "",
"save_as_new_file": "",
"save_or_cancel-cancel": "",
"save_or_cancel-or": "",
"save_or_cancel-save": "",
@@ -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]
@@ -182,6 +182,34 @@ $editor-toggler-bg-dark-color: color.adjust(
}
}
.unable-to-sync-modal {
.diff-container {
@include body-sm;
@include font-mono;
max-height: 400px;
width: 100%;
background-color: var(--bg-info-03);
overflow-y: auto;
border: 1px solid var(--border-divider);
border-radius: var(--border-radius-base);
padding: var(--spacing-04) var(--spacing-05);
text-align: left;
white-space: pre-wrap;
ins {
background-color: var(--green-10);
color: var(--green-70);
text-decoration: none;
}
del {
color: var(--red-50);
text-decoration: line-through;
}
}
}
.horizontal-resize-handle {
width: 7px !important;
height: 100%;
+4 -2
View File
@@ -644,6 +644,7 @@
"disabled": "Disabled",
"disabling": "Disabling",
"disagree": "Disagree",
"discard_changes": "Discard changes",
"disconnected": "Disconnected",
"discount": "Discount",
"discount_of": "Discount of __amount__",
@@ -707,7 +708,6 @@
"download_as_source_zip": "Download as source (.zip)",
"download_csv": "Download CSV",
"download_file": "Download file",
"download_local_version": "Download local version",
"download_metadata": "Download Overleaf metadata",
"download_pdf": "Download PDF",
"download_zip_file": "Download .zip file",
@@ -1774,7 +1774,8 @@
"october": "October",
"off": "Off",
"official": "Official",
"offline_edits_couldnt_be_synced_detail": "This file changed while you were offline, so we’ve loaded the latest version. Download your local copy below to add back any changes you made.",
"offline_edits_couldnt_combine": "This file changed while you were offline, so we couldn’t combine both versions automatically. Here’s what you changed:",
"offline_save_explanation": "Saving adds your local version as a new file in your project, so you can compare it with the current one.",
"ok": "OK",
"ok_continue_to_project": "OK, continue to project",
"ok_join_project": "OK, join project",
@@ -2297,6 +2298,7 @@
"save": "Save",
"save_20_percent": "save 20%",
"save_an_extra_10_on_group_licenses": "Save an extra 10% on group licenses",
"save_as_new_file": "Save as new file",
"save_new_password": "Save new password",
"save_or_cancel-cancel": "Cancel",
"save_or_cancel-or": "or",
@@ -168,9 +168,8 @@ describe('EditorManagerProvider docError sync modals', function () {
cy.findByRole('dialog').within(() => {
cy.findByText('Your offline edits couldn’t be synced').should('exist')
cy.findByRole('button', { name: 'Download local version' }).should(
'exist'
)
cy.findByRole('button', { name: 'Save as new file' }).should('exist')
cy.findByRole('button', { name: 'Discard changes' }).should('exist')
})
cy.then(() => {
@@ -204,30 +203,12 @@ describe('EditorManagerProvider docError sync modals', function () {
})
})
it('passes editorContent and docName through to the download button', function () {
it('passes editorContent and docName through to the modal', function () {
cy.then(() => {
setSplitTest(true)
plantBackup(CURRENT_DOC_ID)
})
const captured: { blob: Blob | null; filename: string | null } = {
blob: null,
filename: null,
}
cy.window().then(win => {
cy.stub(win.URL, 'createObjectURL').callsFake((blob: Blob) => {
captured.blob = blob
return 'blob:mock'
})
cy.stub(win.URL, 'revokeObjectURL')
cy.stub(win.HTMLAnchorElement.prototype, 'click').callsFake(function (
this: HTMLAnchorElement
) {
captured.filename = this.download
})
})
mount()
cy.then(() => {
@@ -239,12 +220,11 @@ describe('EditorManagerProvider docError sync modals', function () {
)
})
cy.findByRole('button', { name: 'Download local version' }).click()
cy.then(() => {
expect(captured.filename).to.equal(currentDoc.docName)
return captured.blob!.text()
}).should('equal', 'the exact offline content')
cy.findByRole('dialog').within(() => {
cy.findByText('Your offline edits couldn’t be synced').should('exist')
cy.findByRole('button', { name: 'Save as new file' }).should('exist')
cy.findByRole('button', { name: 'Discard changes' }).should('exist')
})
})
it('falls back to OutOfSyncModal when no backup exists', function () {
@@ -289,6 +269,7 @@ describe('EditorManagerProvider docError sync modals', function () {
capturedEmitter!.emit('ide:unableToSyncOfflineChanges', {
docId: CURRENT_DOC_ID,
editorContent: 'recovered content',
baseContent: 'original content',
docName: 'recovered.tex',
})
})