Procházet zdrojové kódy

Merge pull request #19867 from overleaf/em-hide-history-resync-details

Hide history resync changes

GitOrigin-RevId: a36876ff637d0a4c34150b21f4b4e623dff0ab03
Eric Mc Sween před 2 roky
rodič
revize
e10478f256

+ 17 - 13
services/project-history/app/js/ChunkTranslator.js

@@ -137,7 +137,7 @@ class UpdateSetBuilder {
     }
 
     for (const op of change.operations) {
-      this.applyOperation(op, timestamp, authors)
+      this.applyOperation(op, timestamp, authors, change.origin)
     }
 
     this.currentUpdate.pathnames = Array.from(this.currentUpdate.pathnames)
@@ -146,9 +146,9 @@ class UpdateSetBuilder {
     this.version += 1
   }
 
-  applyOperation(op, timestamp, authors) {
+  applyOperation(op, timestamp, authors, origin) {
     if (UpdateSetBuilder._isTextOperation(op)) {
-      this.applyTextOperation(op, timestamp, authors)
+      this.applyTextOperation(op, timestamp, authors, origin)
     } else if (UpdateSetBuilder._isRenameOperation(op)) {
       this.applyRenameOperation(op, timestamp, authors)
     } else if (UpdateSetBuilder._isRemoveFileOperation(op)) {
@@ -158,7 +158,7 @@ class UpdateSetBuilder {
     }
   }
 
-  applyTextOperation(operation, timestamp, authors) {
+  applyTextOperation(operation, timestamp, authors, origin) {
     const { pathname } = operation
     if (pathname === '') {
       // this shouldn't happen, but we continue to allow the user to see the history
@@ -180,7 +180,7 @@ class UpdateSetBuilder {
       return
     }
 
-    file.applyTextOperation(authors, timestamp, this.version, operation)
+    file.applyTextOperation(authors, timestamp, this.version, operation, origin)
     this.currentUpdate.pathnames.add(pathname)
   }
 
@@ -285,8 +285,8 @@ class File {
     this.operations = []
   }
 
-  applyTextOperation(authors, timestamp, version, operation) {
-    this.operations.push({ authors, timestamp, version, operation })
+  applyTextOperation(authors, timestamp, version, operation, origin) {
+    this.operations.push({ authors, timestamp, version, operation, origin })
   }
 
   rename(pathname) {
@@ -309,13 +309,12 @@ class File {
       let initialContent
       const updates = []
 
-      for (let operation of this.operations) {
-        if (!('textOperation' in operation.operation)) {
+      for (const operationInfo of this.operations) {
+        if (!('textOperation' in operationInfo.operation)) {
           // We only care about text operations
           continue
         }
-        let authors, ops, timestamp, version
-        ;({ authors, timestamp, version, operation } = operation)
+        const { authors, timestamp, version, operation } = operationInfo
         // Set the initialContent to the latest version we have before the diff
         // begins. 'version' here refers to the document version as we are
         // applying the updates. So we store the content *before* applying the
@@ -327,6 +326,7 @@ class File {
           )
         }
 
+        let ops
         ;({ content, ops } = this._convertTextOperation(
           content,
           operation,
@@ -335,7 +335,7 @@ class File {
 
         // We only need to return the updates between fromVersion and toVersion
         if (fromVersion <= version && version < toVersion) {
-          updates.push({
+          const update = {
             meta: {
               users: authors,
               start_ts: timestamp.getTime(),
@@ -343,7 +343,11 @@ class File {
             },
             v: version,
             op: ops,
-          })
+          }
+          if (operationInfo.origin) {
+            update.meta.origin = operationInfo.origin
+          }
+          updates.push(update)
         }
       }
 

+ 51 - 50
services/project-history/app/js/DiffGenerator.js

@@ -1,18 +1,4 @@
-/* eslint-disable
-    no-proto,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-
-import logger from '@overleaf/logger'
+import _ from 'lodash'
 import OError from '@overleaf/o-error'
 
 export class ConsistencyError extends OError {}
@@ -26,7 +12,7 @@ export const _mocks = {}
 
 export function buildDiff(initialContent, updates) {
   let diff = [{ u: initialContent }]
-  for (const update of Array.from(updates)) {
+  for (const update of updates) {
     diff = applyUpdateToDiff(diff, update)
   }
   diff = compressDiff(diff)
@@ -35,38 +21,54 @@ export function buildDiff(initialContent, updates) {
 
 _mocks.compressDiff = diff => {
   const newDiff = []
-  for (const part of Array.from(diff)) {
-    const lastPart = newDiff[newDiff.length - 1]
-    if (
-      lastPart != null &&
-      (lastPart.meta != null ? lastPart.meta.user : undefined) != null &&
-      (part.meta != null ? part.meta.user : undefined) != null
-    ) {
-      if (
-        lastPart.i != null &&
-        part.i != null &&
-        lastPart.meta.user.id === part.meta.user.id
-      ) {
-        lastPart.i += part.i
-        lastPart.meta.start_ts = Math.min(
-          lastPart.meta.start_ts,
-          part.meta.start_ts
-        )
-        lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts)
-      } else if (
-        lastPart.d != null &&
-        part.d != null &&
-        lastPart.meta.user.id === part.meta.user.id
-      ) {
-        lastPart.d += part.d
-        lastPart.meta.start_ts = Math.min(
-          lastPart.meta.start_ts,
-          part.meta.start_ts
-        )
-        lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts)
-      } else {
+  for (const part of diff) {
+    const users = part.meta?.users ?? []
+
+    if (part.meta?.origin?.kind === 'history-resync') {
+      // Skip history resync updates. Inserts are converted to unchanged text
+      // and deletes are skipped, so that they effectively don't appear in the
+      // diff.
+      if (part.u != null) {
         newDiff.push(part)
+      } else if (part.i != null) {
+        newDiff.push({ u: part.i })
       }
+      continue
+    }
+
+    if (newDiff.length === 0) {
+      // If we haven't seen other parts yet, we have nothing to merge.
+      newDiff.push(part)
+      continue
+    }
+
+    const lastPart = newDiff[newDiff.length - 1]
+    const lastUsers = lastPart.meta?.users ?? []
+    const usersNotInBothParts = _.xor(users, lastUsers)
+
+    if (usersNotInBothParts.length > 0) {
+      // If the set of users in the last part and this part are not the same, we
+      // can't merge.
+      newDiff.push(part)
+      continue
+    }
+
+    if (lastPart.i != null && part.i != null) {
+      // Merge two inserts
+      lastPart.i += part.i
+      lastPart.meta.start_ts = Math.min(
+        lastPart.meta.start_ts,
+        part.meta.start_ts
+      )
+      lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts)
+    } else if (lastPart.d != null && part.d != null) {
+      // Merge two deletes
+      lastPart.d += part.d
+      lastPart.meta.start_ts = Math.min(
+        lastPart.meta.start_ts,
+        part.meta.start_ts
+      )
+      lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts)
     } else {
       newDiff.push(part)
     }
@@ -80,7 +82,6 @@ export function compressDiff(...args) {
 
 export function applyOpToDiff(diff, op, meta) {
   let consumedDiff
-  const position = 0
 
   let remainingDiff = diff.slice()
   ;({ consumedDiff, remainingDiff } = _consumeToOffset(remainingDiff, op.p))
@@ -97,16 +98,16 @@ export function applyOpToDiff(diff, op, meta) {
       op,
       meta
     ))
-    newDiff.push(...Array.from(consumedDiff || []))
+    newDiff.push(...(consumedDiff || []))
   }
 
-  newDiff.push(...Array.from(remainingDiff || []))
+  newDiff.push(...(remainingDiff || []))
 
   return newDiff
 }
 
 _mocks.applyUpdateToDiff = (diff, update) => {
-  for (const op of Array.from(update.op)) {
+  for (const op of update.op) {
     if (op.broken !== true) {
       diff = applyOpToDiff(diff, op, update.meta)
     }

+ 7 - 11
services/project-history/app/js/SummarizedUpdatesManager.js

@@ -189,10 +189,10 @@ function _summarizeUpdates(updates, labels, existingSummarizedUpdates, toV) {
       toV = update.v + 1
     }
 
-    // Skip empty and history-resync updates (only record their version).
-    // Empty updates are updates that only contain comment operations. We don't have a UI for
+    // Skip empty updates (only record their version). Empty updates are
+    // updates that only contain comment operations. We don't have a UI for
     // these yet.
-    if (isUpdateEmpty(update) || isHistoryResyncUpdate(update)) {
+    if (isUpdateEmpty(update)) {
       continue
     }
 
@@ -282,8 +282,11 @@ function _shouldMergeUpdate(update, summarizedUpdate, labels) {
   const updateHasFileOps = update.project_ops.length > 0
   const summarizedUpdateHasTextOps = summarizedUpdate.pathnames.size > 0
   const summarizedUpdateHasFileOps = summarizedUpdate.project_ops.length > 0
+  const isHistoryResync =
+    update.meta.origin &&
+    ['history-resync', 'history-migration'].includes(update.meta.origin.kind)
   if (
-    !isHistoryResyncUpdate(update) &&
+    !isHistoryResync &&
     ((updateHasTextOps && summarizedUpdateHasFileOps) ||
       (updateHasFileOps && summarizedUpdateHasTextOps))
   ) {
@@ -343,10 +346,3 @@ function _mergeUpdate(update, summarizedUpdate) {
 function isUpdateEmpty(update) {
   return update.project_ops.length === 0 && update.pathnames.length === 0
 }
-
-function isHistoryResyncUpdate(update) {
-  return (
-    update.meta.origin?.kind === 'history-resync' ||
-    update.meta.origin?.kind === 'history-migration'
-  )
-}

+ 80 - 75
services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js

@@ -1,16 +1,3 @@
-/* eslint-disable
-    no-return-assign,
-    no-undef,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
 import sinon from 'sinon'
 import { expect } from 'chai'
 import { strict as esmock } from 'esmock'
@@ -23,11 +10,11 @@ describe('DiffGenerator', function () {
     this.ts = Date.now()
     this.user_id = 'mock-user-id'
     this.user_id_2 = 'mock-user-id-2'
-    return (this.meta = {
+    this.meta = {
       start_ts: this.ts,
       end_ts: this.ts,
       user_id: this.user_id,
-    })
+    }
   })
 
   describe('buildDiff', function () {
@@ -43,18 +30,15 @@ describe('DiffGenerator', function () {
         .stub()
         .returns(this.diff)
       this.DiffGenerator._mocks.compressDiff = sinon.stub().returns(this.diff)
-      return (this.result = this.DiffGenerator.buildDiff(
-        this.content,
-        this.updates
-      ))
+      this.result = this.DiffGenerator.buildDiff(this.content, this.updates)
     })
 
     it('should return the diff', function () {
-      return this.result.should.deep.equal(this.diff)
+      this.result.should.deep.equal(this.diff)
     })
 
     it('should build the content into an initial diff', function () {
-      return this.DiffGenerator._mocks.applyUpdateToDiff
+      this.DiffGenerator._mocks.applyUpdateToDiff
         .calledWith(
           [
             {
@@ -67,106 +51,127 @@ describe('DiffGenerator', function () {
     })
 
     it('should apply each update', function () {
-      return Array.from(this.updates).map(update =>
+      this.updates.map(update =>
         this.DiffGenerator._mocks.applyUpdateToDiff
           .calledWith(sinon.match.any, update)
           .should.equal(true)
       )
     })
 
-    return it('should compress the diff', function () {
-      return this.DiffGenerator._mocks.compressDiff
+    it('should compress the diff', function () {
+      this.DiffGenerator._mocks.compressDiff
         .calledWith(this.diff)
         .should.equal(true)
     })
   })
 
   describe('compressDiff', function () {
-    describe('with adjacent inserts with the same user_id', function () {
-      return it('should create one update with combined meta data and min/max timestamps', function () {
+    describe('with adjacent inserts with the same user id', function () {
+      it('should create one update with combined meta data and min/max timestamps', function () {
         const diff = this.DiffGenerator.compressDiff([
           {
             i: 'foo',
-            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } },
+            meta: { start_ts: 10, end_ts: 20, users: [this.user_id] },
           },
           {
             i: 'bar',
-            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } },
+            meta: { start_ts: 5, end_ts: 15, users: [this.user_id] },
           },
         ])
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           {
             i: 'foobar',
-            meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } },
+            meta: { start_ts: 5, end_ts: 20, users: [this.user_id] },
           },
         ])
       })
     })
 
-    describe('with adjacent inserts with different user_ids', function () {
-      return it('should leave the inserts unchanged', function () {
+    describe('with adjacent inserts with different user ids', function () {
+      it('should leave the inserts unchanged', function () {
         const input = [
           {
             i: 'foo',
-            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } },
+            meta: { start_ts: 10, end_ts: 20, users: [this.user_id] },
           },
           {
             i: 'bar',
-            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } },
+            meta: { start_ts: 5, end_ts: 15, users: [this.user_id_2] },
           },
         ]
         const output = this.DiffGenerator.compressDiff(input)
-        return expect(output).to.deep.equal(input)
+        expect(output).to.deep.equal(input)
       })
     })
 
-    describe('with adjacent deletes with the same user_id', function () {
-      return it('should create one update with combined meta data and min/max timestamps', function () {
+    describe('with adjacent deletes with the same user id', function () {
+      it('should create one update with combined meta data and min/max timestamps', function () {
         const diff = this.DiffGenerator.compressDiff([
           {
             d: 'foo',
-            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } },
+            meta: { start_ts: 10, end_ts: 20, users: [this.user_id] },
           },
           {
             d: 'bar',
-            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } },
+            meta: { start_ts: 5, end_ts: 15, users: [this.user_id] },
           },
         ])
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           {
             d: 'foobar',
-            meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } },
+            meta: { start_ts: 5, end_ts: 20, users: [this.user_id] },
           },
         ])
       })
     })
 
-    return describe('with adjacent deletes with different user_ids', function () {
-      return it('should leave the deletes unchanged', function () {
+    describe('with adjacent deletes with different user ids', function () {
+      it('should leave the deletes unchanged', function () {
         const input = [
           {
             d: 'foo',
-            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } },
+            meta: { start_ts: 10, end_ts: 20, users: [this.user_id] },
           },
           {
             d: 'bar',
-            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } },
+            meta: { start_ts: 5, end_ts: 15, users: [this.user_id_2] },
+          },
+        ]
+        const output = this.DiffGenerator.compressDiff(input)
+        expect(output).to.deep.equal(input)
+      })
+    })
+
+    describe('with history resync updates', function () {
+      it('should keep only inserts and mark them as unchanged text', function () {
+        const input = [
+          { u: 'untracked text' },
+          {
+            i: 'inserted anonymously',
+            meta: { origin: { kind: 'history-resync' } },
+          },
+          {
+            d: 'deleted anonymously',
+            meta: { origin: { kind: 'history-resync' } },
           },
         ]
         const output = this.DiffGenerator.compressDiff(input)
-        return expect(output).to.deep.equal(input)
+        expect(output).to.deep.equal([
+          { u: 'untracked text' },
+          { u: 'inserted anonymously' },
+        ])
       })
     })
   })
 
-  return describe('applyUpdateToDiff', function () {
+  describe('applyUpdateToDiff', function () {
     describe('an insert', function () {
       it('should insert into the middle of (u)nchanged text', function () {
         const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], {
           op: [{ p: 3, i: 'baz' }],
           meta: this.meta,
         })
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           { u: 'foo' },
           { i: 'baz', meta: this.meta },
           { u: 'bar' },
@@ -178,7 +183,7 @@ describe('DiffGenerator', function () {
           op: [{ p: 0, i: 'baz' }],
           meta: this.meta,
         })
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           { i: 'baz', meta: this.meta },
           { u: 'foobar' },
         ])
@@ -189,7 +194,7 @@ describe('DiffGenerator', function () {
           op: [{ p: 6, i: 'baz' }],
           meta: this.meta,
         })
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           { u: 'foobar' },
           { i: 'baz', meta: this.meta },
         ])
@@ -200,19 +205,19 @@ describe('DiffGenerator', function () {
           [{ i: 'foobar', meta: this.meta }],
           { op: [{ p: 3, i: 'baz' }], meta: this.meta }
         )
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           { i: 'foo', meta: this.meta },
           { i: 'baz', meta: this.meta },
           { i: 'bar', meta: this.meta },
         ])
       })
 
-      return it('should not count deletes in the running length total', function () {
+      it('should not count deletes in the running length total', function () {
         const diff = this.DiffGenerator.applyUpdateToDiff(
           [{ d: 'deleted', meta: this.meta }, { u: 'foobar' }],
           { op: [{ p: 3, i: 'baz' }], meta: this.meta }
         )
-        return expect(diff).to.deep.equal([
+        expect(diff).to.deep.equal([
           { d: 'deleted', meta: this.meta },
           { u: 'foo' },
           { i: 'baz', meta: this.meta },
@@ -221,14 +226,14 @@ describe('DiffGenerator', function () {
       })
     })
 
-    return describe('a delete', function () {
+    describe('a delete', function () {
       describe('deleting unchanged text', function () {
         it('should delete from the middle of (u)nchanged text', function () {
           const diff = this.DiffGenerator.applyUpdateToDiff(
             [{ u: 'foobazbar' }],
             { op: [{ p: 3, d: 'baz' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { u: 'foo' },
             { d: 'baz', meta: this.meta },
             { u: 'bar' },
@@ -240,7 +245,7 @@ describe('DiffGenerator', function () {
             [{ u: 'foobazbar' }],
             { op: [{ p: 0, d: 'foo' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { d: 'foo', meta: this.meta },
             { u: 'bazbar' },
           ])
@@ -251,18 +256,18 @@ describe('DiffGenerator', function () {
             [{ u: 'foobazbar' }],
             { op: [{ p: 6, d: 'bar' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { u: 'foobaz' },
             { d: 'bar', meta: this.meta },
           ])
         })
 
-        return it('should delete across multiple (u)changed text parts', function () {
+        it('should delete across multiple (u)changed text parts', function () {
           const diff = this.DiffGenerator.applyUpdateToDiff(
             [{ u: 'foo' }, { u: 'baz' }, { u: 'bar' }],
             { op: [{ p: 2, d: 'obazb' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { u: 'fo' },
             { d: 'o', meta: this.meta },
             { d: 'baz', meta: this.meta },
@@ -278,7 +283,7 @@ describe('DiffGenerator', function () {
             [{ i: 'foobazbar', meta: this.meta }],
             { op: [{ p: 3, d: 'baz' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { i: 'foo', meta: this.meta },
             { i: 'bar', meta: this.meta },
           ])
@@ -289,7 +294,7 @@ describe('DiffGenerator', function () {
             [{ i: 'foobazbar', meta: this.meta }],
             { op: [{ p: 0, d: 'foo' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([{ i: 'bazbar', meta: this.meta }])
+          expect(diff).to.deep.equal([{ i: 'bazbar', meta: this.meta }])
         })
 
         it('should delete from the end of (u)nchanged text', function () {
@@ -297,15 +302,15 @@ describe('DiffGenerator', function () {
             [{ i: 'foobazbar', meta: this.meta }],
             { op: [{ p: 6, d: 'bar' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([{ i: 'foobaz', meta: this.meta }])
+          expect(diff).to.deep.equal([{ i: 'foobaz', meta: this.meta }])
         })
 
-        return it('should delete across multiple (u)changed and (i)nserted text parts', function () {
+        it('should delete across multiple (u)changed and (i)nserted text parts', function () {
           const diff = this.DiffGenerator.applyUpdateToDiff(
             [{ u: 'foo' }, { i: 'baz', meta: this.meta }, { u: 'bar' }],
             { op: [{ p: 2, d: 'obazb' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { u: 'fo' },
             { d: 'o', meta: this.meta },
             { d: 'b', meta: this.meta },
@@ -315,12 +320,12 @@ describe('DiffGenerator', function () {
       })
 
       describe('deleting over existing deletes', function () {
-        return it('should delete across multiple (u)changed and (d)deleted text parts', function () {
+        it('should delete across multiple (u)changed and (d)deleted text parts', function () {
           const diff = this.DiffGenerator.applyUpdateToDiff(
             [{ u: 'foo' }, { d: 'baz', meta: this.meta }, { u: 'bar' }],
             { op: [{ p: 2, d: 'ob' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { u: 'fo' },
             { d: 'o', meta: this.meta },
             { d: 'baz', meta: this.meta },
@@ -332,7 +337,7 @@ describe('DiffGenerator', function () {
 
       describe("deleting when the text doesn't match", function () {
         it('should throw an error when deleting from the middle of (u)nchanged text', function () {
-          return expect(() =>
+          expect(() =>
             this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], {
               op: [{ p: 3, d: 'xxx' }],
               meta: this.meta,
@@ -341,7 +346,7 @@ describe('DiffGenerator', function () {
         })
 
         it('should throw an error when deleting from the start of (u)nchanged text', function () {
-          return expect(() =>
+          expect(() =>
             this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], {
               op: [{ p: 0, d: 'xxx' }],
               meta: this.meta,
@@ -349,8 +354,8 @@ describe('DiffGenerator', function () {
           ).to.throw(this.DiffGenerator.ConsistencyError)
         })
 
-        return it('should throw an error when deleting from the end of (u)nchanged text', function () {
-          return expect(() =>
+        it('should throw an error when deleting from the end of (u)nchanged text', function () {
+          expect(() =>
             this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], {
               op: [{ p: 6, d: 'xxx' }],
               meta: this.meta,
@@ -360,12 +365,12 @@ describe('DiffGenerator', function () {
       })
 
       describe('when the last update in the existing diff is a delete', function () {
-        return it('should insert the new update before the delete', function () {
+        it('should insert the new update before the delete', function () {
           const diff = this.DiffGenerator.applyUpdateToDiff(
             [{ u: 'foo' }, { d: 'bar', meta: this.meta }],
             { op: [{ p: 3, i: 'baz' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { u: 'foo' },
             { i: 'baz', meta: this.meta },
             { d: 'bar', meta: this.meta },
@@ -373,13 +378,13 @@ describe('DiffGenerator', function () {
         })
       })
 
-      return describe('when the only update in the existing diff is a delete', function () {
-        return it('should insert the new update after the delete', function () {
+      describe('when the only update in the existing diff is a delete', function () {
+        it('should insert the new update after the delete', function () {
           const diff = this.DiffGenerator.applyUpdateToDiff(
             [{ d: 'bar', meta: this.meta }],
             { op: [{ p: 0, i: 'baz' }], meta: this.meta }
           )
-          return expect(diff).to.deep.equal([
+          expect(diff).to.deep.equal([
             { d: 'bar', meta: this.meta },
             { i: 'baz', meta: this.meta },
           ])

+ 48 - 8
services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js

@@ -595,12 +595,12 @@ describe('SummarizedUpdatesManager', function () {
             makeUpdate({
               startTs: 20,
               v: 3,
-              origin: { kind: 'origin-a' },
+              origin: { kind: 'history-resync' },
             }),
             makeUpdate({
               startTs: 30,
               v: 4,
-              origin: { kind: 'origin-a' },
+              origin: { kind: 'history-resync' },
             }),
             makeUpdate({ startTs: 40, v: 5 }),
             makeUpdate({ startTs: 50, v: 6 }),
@@ -617,7 +617,7 @@ describe('SummarizedUpdatesManager', function () {
               endTs: 40,
               fromV: 3,
               toV: 5,
-              origin: { kind: 'origin-a' },
+              origin: { kind: 'history-resync' },
             }),
             makeSummary({ startTs: 0, endTs: 20, fromV: 1, toV: 3 }),
           ]
@@ -687,7 +687,13 @@ describe('SummarizedUpdatesManager', function () {
       describe('history resync updates', function () {
         setupChunks([
           [
-            makeUpdate({ startTs: 0, v: 1 }),
+            makeUpdate({
+              startTs: 0,
+              v: 1,
+              origin: { kind: 'history-resync' },
+              projectOps: [{ add: { pathname: 'file1.tex' } }],
+              pathnames: [],
+            }),
             makeUpdate({
               startTs: 20,
               v: 2,
@@ -703,13 +709,47 @@ describe('SummarizedUpdatesManager', function () {
               v: 3,
               origin: { kind: 'history-resync' },
               projectOps: [{ add: { pathname: 'file4.tex' } }],
+              pathnames: [],
+            }),
+            makeUpdate({
+              startTs: 60,
+              v: 4,
+              origin: { kind: 'history-resync' },
+              projectOps: [],
+              pathnames: ['file1.tex', 'file2.tex', 'file5.tex'],
             }),
-            makeUpdate({ startTs: 60, v: 4 }),
-            makeUpdate({ startTs: 80, v: 5 }),
+            makeUpdate({
+              startTs: 80,
+              v: 5,
+              origin: { kind: 'history-resync' },
+              projectOps: [],
+              pathnames: ['file4.tex'],
+            }),
+            makeUpdate({ startTs: 100, v: 6, pathnames: ['file1.tex'] }),
           ],
         ])
-        expectSummaries('should skip history-resync updates', {}, [
-          makeSummary({ startTs: 0, endTs: 90, fromV: 1, toV: 6 }),
+        expectSummaries('should merge creates and edits', {}, [
+          makeSummary({
+            startTs: 100,
+            endTs: 110,
+            fromV: 6,
+            toV: 7,
+            pathnames: ['file1.tex'],
+          }),
+          makeSummary({
+            startTs: 0,
+            endTs: 90,
+            fromV: 1,
+            toV: 6,
+            origin: { kind: 'history-resync' },
+            pathnames: ['file5.tex'],
+            projectOps: [
+              { add: { pathname: 'file4.tex' }, atV: 3 },
+              { add: { pathname: 'file2.tex' }, atV: 2 },
+              { add: { pathname: 'file3.tex' }, atV: 2 },
+              { add: { pathname: 'file1.tex' }, atV: 1 },
+            ],
+          }),
         ])
       })
     })

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -580,6 +580,7 @@
   "history_label_project_current_state": "",
   "history_label_this_version": "",
   "history_new_label_name": "",
+  "history_resync": "",
   "history_view_a11y_description": "",
   "history_view_all": "",
   "history_view_labels": "",

+ 9 - 0
services/web/frontend/js/features/history/components/change-list/history-resync-change.tsx

@@ -0,0 +1,9 @@
+import { useTranslation } from 'react-i18next'
+
+function HistoryResyncChange() {
+  const { t } = useTranslation()
+
+  return <div>{t('history_resync')}</div>
+}
+
+export default HistoryResyncChange

+ 13 - 6
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -20,6 +20,7 @@ import CompareItems from './dropdown/menu-item/compare-items'
 import CompareVersionDropdown from './dropdown/compare-version-dropdown'
 import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
 import FileRestoreChange from './file-restore-change'
+import HistoryResyncChange from './history-resync-change'
 
 type HistoryVersionProps = {
   update: LoadedUpdate
@@ -167,18 +168,24 @@ function HistoryVersion({
             ))}
             {update.meta.origin?.kind === 'file-restore' ? (
               <FileRestoreChange origin={update.meta.origin} />
+            ) : update.meta.origin?.kind === 'history-resync' ? (
+              <HistoryResyncChange />
             ) : (
               <Changes
                 pathnames={update.pathnames}
                 projectOps={update.project_ops}
               />
             )}
-            <MetadataUsersList
-              users={update.meta.users}
-              origin={update.meta.origin}
-              currentUserId={currentUserId}
-            />
-            <Origin origin={update.meta.origin} />
+            {update.meta.origin?.kind !== 'history-resync' ? (
+              <>
+                <MetadataUsersList
+                  users={update.meta.users}
+                  origin={update.meta.origin}
+                  currentUserId={currentUserId}
+                />
+                <Origin origin={update.meta.origin} />
+              </>
+            ) : null}
           </div>
         </HistoryVersionDetails>
       </div>

+ 1 - 0
services/web/locales/en.json

@@ -855,6 +855,7 @@
   "history_label_project_current_state": "Current state",
   "history_label_this_version": "Label this version",
   "history_new_label_name": "New label name",
+  "history_resync": "History resync",
   "history_view_a11y_description": "Show all of the project history or only labelled versions.",
   "history_view_all": "All history",
   "history_view_labels": "Labels",