mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
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:
@@ -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)
|
||||
+79
-11
@@ -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%;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+9
-28
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user