From 36d0a5c227cce50fe5488e3deabea76c83e4e105 Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Fri, 31 Jul 2026 09:48:46 +0100 Subject: [PATCH] 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 --- .../web/frontend/extracted-translations.json | 6 +- .../components/modals/diff-viewer.tsx | 45 ++++++++++ .../modals/unable-to-sync-modal.tsx | 90 ++++++++++++++++--- .../context/editor-manager-context.tsx | 18 +++- .../ide-react/context/modals-context.tsx | 29 ++++-- .../ide-react/create-ide-event-emitter.ts | 7 +- .../ide-react/editor/document-container.ts | 18 +++- .../features/ide-react/editor/share-js-doc.ts | 29 +++--- .../stylesheets/pages/editor/ide.scss | 28 ++++++ services/web/locales/en.json | 6 +- ...ditor-manager-context-out-of-sync.spec.tsx | 37 ++------ 11 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/components/modals/diff-viewer.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 329f294c1e..17773224a6 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/ide-react/components/modals/diff-viewer.tsx b/services/web/frontend/js/features/ide-react/components/modals/diff-viewer.tsx new file mode 100644 index 0000000000..d1cbe70273 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/modals/diff-viewer.tsx @@ -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 and +// 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
+} + +export default memo(DiffViewer) diff --git a/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx b/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx index d50969405e..c789eaabb0 100644 --- a/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx +++ b/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx @@ -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 ( - + {t('your_offline_edits_couldnt_be_synced')} - {t('offline_edits_couldnt_be_synced_detail')} + +

{t('offline_edits_couldnt_combine')}

+ +

{t('offline_save_explanation')}

+
- - {t('download_local_version')} + + {t('discard_changes')} + + + {t('save_as_new_file')}
diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index e0ecfd77c7..ca2f167164 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -119,6 +119,10 @@ export const EditorManagerProvider: FC = ({ }, [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 = ({ // 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 = ({ 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 = ({ useEffect(() => { const handleUnableToSync = ({ - detail: [{ editorContent, docName }], + detail: [{ editorContent, baseContent, docName }], }: CustomEvent) => { - showUnableToSyncModal(editorContent, docName) + showUnableToSyncModal({ + baseContent, + targetContent: editorContent, + docName, + rootFolderId: fileTreeDataRef.current?._id, + }) } eventEmitter.on('ide:unableToSyncOfflineChanges', handleUnableToSync) diff --git a/services/web/frontend/js/features/ide-react/context/modals-context.tsx b/services/web/frontend/js/features/ide-react/context/modals-context.tsx index a0c036a17b..290c406669 100644 --- a/services/web/frontend/js/features/ide-react/context/modals-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/modals-context.tsx @@ -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 = ({ 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 = ({ }, []) const showUnableToSyncModal = useCallback( - (editorContent: string, docName: string | null) => { - setUnableToSyncModalData({ editorContent, docName }) + ( + data: Pick< + UnableToSyncModalProps, + 'baseContent' | 'targetContent' | 'docName' | 'rootFolderId' + > + ) => { + setUnableToSyncModalData(data) setShouldShowUnableToSyncModal(true) }, [] diff --git a/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts b/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts index f9ceba2a3a..2e5e0379c6 100644 --- a/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts +++ b/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts @@ -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 }] } diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index c2feaa3dae..0be71d1459 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -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) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index 59a01b7f52..dd16c1a570 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -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] diff --git a/services/web/frontend/stylesheets/pages/editor/ide.scss b/services/web/frontend/stylesheets/pages/editor/ide.scss index 34f73323ea..efabeea1cb 100644 --- a/services/web/frontend/stylesheets/pages/editor/ide.scss +++ b/services/web/frontend/stylesheets/pages/editor/ide.scss @@ -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%; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 78f1c08387..47855ec7af 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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", diff --git a/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx b/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx index 38136351f9..50aa3d526c 100644 --- a/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx +++ b/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx @@ -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', }) })