Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions spec/text-buffer-io-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,49 @@ describe('TextBuffer IO', () => {
})
})

describe('.resurrect', () => {
let filePath

beforeEach(async done => {
filePath = temp.openSync('atom').path
fs.writeFileSync(filePath, 'abcdefg')
buffer = await TextBuffer.load(filePath)
done()
})

it('resubscribes to the file', async done => {
fs.unlinkSync(filePath)
await wait(500)
expect(buffer.isDeleted()).toBe(true)

// A buffer whose file has been deleted no longer has a file watcher,
// because one cannot watch a path that does not exist. Resurrection must
// therefore start watching the file again; detecting the resurrection
// itself is the caller's job, but resuming our own bookkeeping is ours.
spyOn(buffer, 'subscribeToFile').and.callThrough()

fs.writeFileSync(filePath, 'abcdefg')
await buffer.resurrect()

expect(buffer.subscribeToFile).toHaveBeenCalled()
expect(buffer.isDeleted()).toBe(false)
done()
})

it('does nothing if the file is still missing', async done => {
fs.unlinkSync(filePath)
await wait(500)
expect(buffer.isDeleted()).toBe(true)

spyOn(buffer, 'subscribeToFile').and.callThrough()
await buffer.resurrect()

expect(buffer.subscribeToFile).not.toHaveBeenCalled()
expect(buffer.isDeleted()).toBe(true)
done()
})
})

describe('.save', () => {
let filePath

Expand Down Expand Up @@ -680,6 +723,26 @@ describe('TextBuffer IO', () => {
await wait(500)
expect(buffer.isModified()).toBe(false)
expect(buffer.isDeleted()).toBe(true)

// Simulate an external program recreating the file.
const deletedStatusChanges = []
let reloadedCount = 0
let conflictedCount = 0
buffer.onDidChangeDeleted((status) => deletedStatusChanges.push(status))
buffer.onDidReload(() => reloadedCount++)
buffer.onDidConflict(() => conflictedCount++)
fs.writeFileSync(filePath, `lorem`)

// Calling `resurrect` triggers the logic that would take place
// automatically if we were able to detect our own file
// resurrections.
await buffer.resurrect()

expect(deletedStatusChanges.length).toBe(1)
expect(deletedStatusChanges[0]).toBe(false)
expect(reloadedCount).toBe(1)
expect(conflictedCount).toBe(0)
expect(buffer.isDeleted()).toBe(false)
})

it('initially reports the modified status as false, but flips it back to true if the user makes further changes', async () => {
Expand All @@ -699,6 +762,38 @@ describe('TextBuffer IO', () => {
buffer.setText(`lorem ipsum`)
expect(buffer.isModified()).toBe(true)
expect(buffer.isDeleted()).toBe(true)

// Now let's add some uncommitted changes in order to complicate the
// resurrection of the file.
buffer.setText('ipsum lorem')

let reloadedCount = 0
let conflictedCount = 0
const conflictedStatusChanges = []

// Simulate an external program recreating the file.
buffer.onDidReload(() => reloadedCount++)
buffer.onDidConflict(() => conflictedCount++)
buffer.onDidChangeConflicted((status) => conflictedStatusChanges.push(status))
fs.writeFileSync(filePath, `lorem ipsum`)

// Calling `resurrect` triggers the logic that would take place
// automatically if we were able to detect our own file
// resurrections.
await buffer.resurrect()

// 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)

expect(buffer.isDeleted()).toBe(false)
expect(buffer.isModified()).toBe(true)
})

describe('and re-saved', () => {
Expand Down
131 changes: 123 additions & 8 deletions src/text-buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ class TextBuffer {
// now.
this.didHaveFileOnDisk = false

// The most recently emitted “deleted” and “conflicted” statuses. They
// start at `false` so that the first status we emit describes an actual
// change rather than the buffer's initial state.
this.previousDeletedStatus = false
this.previousConflictedStatus = false

// When a buffer's backing file is deleted while the file is unmodified,
// this trait flips to `true`… and then flips back to `false` if any
// further edits are made.
Expand Down Expand Up @@ -390,6 +396,28 @@ class TextBuffer {
return this.emitter.on('did-change-modified', callback)
}

// Public: Invoke the given callback when the buffer's "deleted" state is
// changed.
//
// Unlike {::onDidDelete}, this callback will fire when a buffer's "deleted"
// state is removed, not just when it is added.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeDeleted (callback) {
return this.emitter.on('did-change-deleted', callback)
}

// Public: Invoke the given callback when the buffer's "conflicted" state is
// changed.
//
// Unlike {::onDidConflict}, this callback will fire when a buffer's
// "conflicted" state is removed, not just when it is added.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeConflicted (callback) {
return this.emitter.on('did-change-conflicted', callback)
}

// Public: Invoke the given callback when all marker `::onDidChange`
// observers have been notified following a change to the buffer.
//
Expand Down Expand Up @@ -2029,6 +2057,8 @@ class TextBuffer {

this.setFile(file)
this.fileHasChangedSinceLastLoad = false
this.emitConflictedStatusChanged(false)
this.emitDeletedStatusChanged(this.isDeleted())
this.digestWhenLastPersisted = this.buffer.baseTextDigest()
this.loaded = true
this.emitModifiedStatusChanged(false)
Expand All @@ -2043,6 +2073,37 @@ class TextBuffer {
return this.load({discardChanges: true, internal: true})
}

// Extended: Notify a text buffer that its backing file has been restored and
// trigger a reload.
//
// `TextBuffer` uses a file-watcher library to detect when a file on disk is
// changed, renamed, or deleted so that it can update its internal state
// accordingly. But that library does not detect changes recursively, so
// `TextBuffer` cannot detect on its own when a previously deleted file is
// re-created by an external tool.
//
// 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 () {
// 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.
if (this.isDeleted()) return

// A buffer whose file is deleted loses its file watcher, since one cannot
// watch a path that does not exist. Now that there is a file at this path
// again, we can resume watching it; otherwise this buffer would stay deaf
// to every subsequent change on disk.
this.subscribeToFile()

this.emitDeletedStatusChanged(false)
// 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.
return this.load({discardChanges: false, internal: true})
}

/*
Section: Display Layers
*/
Expand Down Expand Up @@ -2183,15 +2244,27 @@ class TextBuffer {
let checkpoint = null
let patch
try {
let force = options?.discardChanges
patch = await this.buffer.load(
source,
{
encoding: this.getEncoding(),
force: options && options.discardChanges,
force,
patch: this.loaded
}
)

const bailedOut = Boolean(this.loaded && !force && !patch)
if (bailedOut) {
// A `null` patch means the native layer declined to load. That means
// the buffer has uncommitted changes and we aren't forcing a reload.
// Its contents are now purposefully out of step with what's on disk.
// So instead of a succeeded load, we record it as a file that has
// changed underneath us.
this.fileHasChangedSinceLastLoad = true
this.emitConflictedStatusChanged(true)
}

// If this is not the most recent load of this file, then we should bow
// out and let the newer call to `load` handle the tasks below.
if (this.loadCount > loadCount) return
Expand All @@ -2205,7 +2278,7 @@ class TextBuffer {
this.emitter.emit('will-reload')
}
}
this.finishLoading(checkpoint, patch, options)
this.finishLoading(checkpoint, patch, options, bailedOut)
} catch (error) {
if ((!options || !options.mustExist) && error.code === 'ENOENT') {
this.emitter.emit('will-reload')
Expand All @@ -2219,15 +2292,21 @@ class TextBuffer {
return this
}

finishLoading (checkpoint, patch, options) {
finishLoading (checkpoint, patch, options, bailedOut = false) {
if (this.isDestroyed() || (this.loaded && checkpoint == null && patch != null)) {
if (options && options.discardChanges) {
this.emitter.emit('did-reload')
}
return
}

this.fileHasChangedSinceLastLoad = false
if (!bailedOut) this.fileHasChangedSinceLastLoad = false

// Emit the statuses the buffer actually holds rather than assuming a load
// clears them. Both emitters are no-ops when nothing has changed.
this.emitConflictedStatusChanged(this.isInConflict())
this.emitDeletedStatusChanged(this.isDeleted())

this.digestWhenLastPersisted = this.buffer.baseTextDigest()
this.cachedText = null

Expand Down Expand Up @@ -2275,7 +2354,13 @@ class TextBuffer {
}

this.loaded = true
this.emitter.emit('did-reload')

// A load that bailed out deliberately left the buffer's contents alone, so
// it did not reload anything; a consumer that hears `did-reload` would
// wrongly assume the buffer now matches what is on disk. Such a load emits
// no `will-reload` either, so staying silent here keeps the pair
// symmetrical.
if (!bailedOut) this.emitter.emit('did-reload')
return this
}

Expand Down Expand Up @@ -2341,25 +2426,37 @@ class TextBuffer {
// consistent behavior with Mac/Windows.
if (!this.file.existsSync()) return
if (this.outstandingSaveCount > 0) return

// The file exists, so the buffer is no longer deleted, whether or not
// anything ever told it so. A file that is deleted and then recreated
// otherwise leaves the deleted status stuck at `true` forever, which
// would swallow the _next_ deletion.
this.emitDeletedStatusChanged(this.isDeleted())

// This file has changed since we last loaded it from disk, but that
// does not automatically mean there is a conflict. Set the flag, but
// do not emit a `did-conflict` event until we are sure.
this.fileHasChangedSinceLastLoad = true

if (this.isModified()) {
const source = this.file.getPath()
if (!(await this.buffer.baseTextMatchesFile(source, this.getEncoding()))) {
// Emit `did-conflict` and take no other action. We will keep the
// current buffer contents so that the user's changes are not lost.
this.emitter.emit('did-conflict')
this.emitConflictedStatusChanged(true)
} else {
// Despite being modified, we're once again in alignment with what
// is on disk. This file is not in conflict.
this.fileHasChangedSinceLastLoad = false
this.emitConflictedStatusChanged(false)
}
} else {
// This buffer was previously in sync with what was on disk, so we
// can update its contents to match the new contents on disk. By
// definition, this means there is no conflict, so we'll reset the
// appropriate flag.
this.fileHasChangedSinceLastLoad = false
this.emitConflictedStatusChanged(false)
return this.load({internal: true})
}
}, this.fileChangeDelay)))
Expand All @@ -2375,7 +2472,7 @@ class TextBuffer {
// exists on disk.
const modified = this.buffer.isModified()
this.retainsUnmodifiedTraitAfterDeletion = !modified
this.emitter.emit('did-delete')
this.emitDeletedStatusChanged(true)
if (!modified && this.shouldDestroyOnFileDelete()) {
return this.destroy()
} else {
Expand Down Expand Up @@ -2514,7 +2611,25 @@ class TextBuffer {
emitModifiedStatusChanged (modifiedStatus) {
if (modifiedStatus === this.previousModifiedStatus) return
this.previousModifiedStatus = modifiedStatus
return this.emitter.emit('did-change-modified', modifiedStatus)
this.emitter.emit('did-change-modified', modifiedStatus)
}

emitDeletedStatusChanged (deletedStatus) {
if (deletedStatus === this.previousDeletedStatus) return
this.previousDeletedStatus = deletedStatus
this.emitter.emit('did-change-deleted', deletedStatus)
if (deletedStatus) {
this.emitter.emit('did-delete')
}
}

emitConflictedStatusChanged (conflictedStatus) {
if (conflictedStatus === this.previousConflictedStatus) return
this.previousConflictedStatus = conflictedStatus
this.emitter.emit('did-change-conflicted', conflictedStatus)
if (conflictedStatus) {
this.emitter.emit('did-conflict')
}
}

logLines (start = 0, end = this.getLastRow()) {
Expand Down
Loading