Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"yargs": "^6.5.0"
},
"dependencies": {
"@pulsar-edit/pathwatcher": "^9.0.3",
"@pulsar-edit/pathwatcher": "^9.0.4",
"@pulsar-edit/superstring": "^3.0.4",
"delegato": "^1.0.0",
"diff": "^2.2.1",
Expand Down
43 changes: 43 additions & 0 deletions spec/text-buffer-io-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('TextBuffer IO', () => {
buffer = await TextBuffer.load(filePath)
expect(buffer.getText()).toBe('')
expect(buffer.isModified()).toBe(true)
expect(buffer.isDeleted()).toBe(false)
expect(buffer.undo()).toBe(false)
expect(buffer.getText()).toBe('')
done()
Expand Down Expand Up @@ -654,6 +655,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 @@ -673,6 +694,28 @@ 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

// Simulate an external program recreating the file.
buffer.onDidReload(() => reloadedCount++)
buffer.onDidConflict(() => conflictedCount++)
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()

expect(reloadedCount).toBe(1)
expect(conflictedCount).toBe(1)
expect(buffer.isDeleted()).toBe(false)
expect(buffer.isModified()).toBe(true)
})

describe('and re-saved', () => {
Expand Down
92 changes: 87 additions & 5 deletions src/text-buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,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 @@ -2017,6 +2039,7 @@ class TextBuffer {

this.setFile(file)
this.fileHasChangedSinceLastLoad = false
this.emitConflictedStatusChanged(false)
this.digestWhenLastPersisted = this.buffer.baseTextDigest()
this.loaded = true
this.emitModifiedStatusChanged(false)
Expand All @@ -2031,6 +2054,27 @@ 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 () {
if (this.isDeleted()) return
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 @@ -2158,7 +2202,11 @@ class TextBuffer {
Grim.deprecate('The .load instance method is deprecated. Create a loaded buffer using TextBuffer.load(filePath) instead.')
}

this.didHaveFileOnDisk = true
if (this.file instanceof File) {
// The consumer is allowed to set a `File` instance with a path that does
// not currently exist on disk.
this.didHaveFileOnDisk = this.file.existsSync()
}

const source = this.file instanceof File
? this.file.getPath()
Expand All @@ -2169,15 +2217,24 @@ 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
}
)

if (this.loaded && !force && !patch) {
// We have attempted to patch the buffer and failed; since we're not
// forcing a reload, this will result in a conflict. Emit the status
// so the user understands why the buffer contents do not match what is
// on disk.
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 Down Expand Up @@ -2214,6 +2271,7 @@ class TextBuffer {
}

this.fileHasChangedSinceLastLoad = false
this.emitConflictedStatusChanged(false)
this.digestWhenLastPersisted = this.buffer.baseTextDigest()
this.cachedText = null

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

// 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 @@ -2358,7 +2422,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 @@ -2497,7 +2561,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