diff --git a/package.json b/package.json index d4dd0e3d0..bfacdf744 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@pulsar-edit/pathwatcher": "^9.0.4", - "@pulsar-edit/superstring": "^3.0.4", + "@pulsar-edit/superstring": "^3.0.8", "delegato": "^1.0.0", "diff": "^2.2.1", "event-kit": "^2.4.0", diff --git a/spec/text-buffer-io-spec.js b/spec/text-buffer-io-spec.js index ac7b56d9f..4f0d28853 100644 --- a/spec/text-buffer-io-spec.js +++ b/spec/text-buffer-io-spec.js @@ -785,17 +785,78 @@ describe('TextBuffer IO', () => { // Nothing was reloaded: the whole point of this scenario is that the // buffer's uncommitted changes are preserved. expect(reloadedCount).toBe(0) - expect(conflictedCount).toBe(1) - // The buffer stays conflicted; the status must not flip back on its - // own once the load finishes. - expect(conflictedStatusChanges).toEqual([true]) - expect(buffer.isInConflict()).toBe(true) + // The file came back with exactly the contents our changes are + // based on, so there's nothing to conflict with. + expect(conflictedCount).toBe(0) + expect(conflictedStatusChanges).toEqual([]) + expect(buffer.isInConflict()).toBe(false) expect(buffer.isDeleted()).toBe(false) expect(buffer.isModified()).toBe(true) }) + describe('while the buffer has unsaved changes', () => { + async function deleteAndResurrect (restoredText) { + fs.unlinkSync(filePath) + await wait(500) + expect(buffer.isDeleted()).toBe(true) + + const events = {conflictedCount: 0, conflictedStatusChanges: []} + buffer.onDidConflict(() => events.conflictedCount++) + buffer.onDidChangeConflicted((status) => events.conflictedStatusChanges.push(status)) + fs.writeFileSync(filePath, restoredText) + await buffer.resurrect() + return events + } + + beforeEach(async () => { + buffer.setText(`lorem ipsum`) + await buffer.save() + buffer.setText(`lorem ipsum dolor`) + }) + + it('reports a conflict if the file is restored with different contents', async () => { + const events = await deleteAndResurrect(`dolor sit amet`) + expect(events.conflictedCount).toBe(1) + expect(buffer.isInConflict()).toBe(true) + expect(buffer.isModified()).toBe(true) + expect(buffer.isDeleted()).toBe(false) + expect(buffer.getText()).toBe(`lorem ipsum dolor`) + }) + + it('reports a conflict if the file is restored empty', async () => { + const events = await deleteAndResurrect(``) + expect(events.conflictedCount).toBe(1) + expect(buffer.isInConflict()).toBe(true) + expect(buffer.getText()).toBe(`lorem ipsum dolor`) + }) + + it('does not report a conflict if the file is restored with the contents it had before', async () => { + const events = await deleteAndResurrect(`lorem ipsum`) + expect(events.conflictedCount).toBe(0) + expect(events.conflictedStatusChanges).toEqual([]) + expect(buffer.isInConflict()).toBe(false) + expect(buffer.isModified()).toBe(true) + expect(buffer.isDeleted()).toBe(false) + expect(buffer.getText()).toBe(`lorem ipsum dolor`) + }) + + it('stays in conflict if it was already in conflict before the file was deleted', async () => { + const conflicted = new Promise((resolve) => buffer.onDidConflict(resolve)) + fs.writeFileSync(filePath, `dolor sit amet`) + await conflicted + expect(buffer.isInConflict()).toBe(true) + + // Even a file restored with the buffer's base text doesn't resolve + // a conflict that predates the deletion. + await deleteAndResurrect(`lorem ipsum`) + expect(buffer.isInConflict()).toBe(true) + expect(buffer.isModified()).toBe(true) + expect(buffer.isDeleted()).toBe(false) + }) + }) + describe('and re-saved', () => { it('results in isModified and isDeleted no longer returning true', async () => { buffer.setText(`lorem ipsum`) diff --git a/src/text-buffer.js b/src/text-buffer.js index 72182a433..403a64d4c 100644 --- a/src/text-buffer.js +++ b/src/text-buffer.js @@ -2085,7 +2085,7 @@ class TextBuffer { // Instead, this method exists as an easy way for an external file-watcher to // signal to `TextBuffer` that this buffer's backing file has been // resurrected, and that certain events ought to be emitted. - resurrect () { + async resurrect () { // Either the file came back but was gone again by the time we ran… or // something is calling this method speculatively (perhaps via polling) in // lieu of a file-watcher. @@ -2098,6 +2098,25 @@ class TextBuffer { this.subscribeToFile() this.emitDeletedStatusChanged(false) + + // A modified buffer won't reload, and a refused load is recorded as a + // conflict. That's correct only if the file came back with different + // contents than what our changes are based on — or if we were already in + // conflict before the file was deleted. + if (this.buffer.isModified() && !this.fileHasChangedSinceLastLoad) { + const source = this.file instanceof File + ? this.file.getPath() + : this.file.createReadStream() + let matches = false + try { + matches = await this.buffer.baseTextMatchesFile(source, this.getEncoding()) + } catch (error) { + // If the file vanished again, `load` will handle it. + if (error.code !== 'ENOENT') throw error + } + if (matches) return + } + // This is like calling `reload`, but less aggressive; it will preserve // uncommitted buffer contents and emit a `did-conflict` event instead of // clobbering those changes.