Fix handling of .save and .saveAs rejections

* Make Pane.close, Pane.saveActiveItem, and Pane.saveActiveItemAs async.
* Refactor the logic for prompting to save on window unload
This commit is contained in:
Max Brunsfeld
2017-05-31 12:37:52 -07:00
parent 798bbfcae8
commit dc320181fc
15 changed files with 249 additions and 247 deletions

View File

@@ -477,8 +477,6 @@ describe "AtomEnvironment", ->
devMode: atom.inDevMode() devMode: atom.inDevMode()
safeMode: atom.inSafeMode() safeMode: atom.inSafeMode()
describe "::unloadEditorWindow()", -> describe "::unloadEditorWindow()", ->
it "saves the BlobStore so it can be loaded after reload", -> it "saves the BlobStore so it can be loaded after reload", ->
configDirPath = temp.mkdirSync('atom-spec-environment') configDirPath = temp.mkdirSync('atom-spec-environment')

View File

@@ -207,7 +207,7 @@ describe('AtomApplication', function () {
sendBackToMainProcess(null) sendBackToMainProcess(null)
}) })
}) })
await window1.saveState() await window1.prepareToUnload()
window1.close() window1.close()
await window1.closedPromise await window1.closedPromise
@@ -221,7 +221,7 @@ describe('AtomApplication', function () {
sendBackToMainProcess(textEditor.getText()) sendBackToMainProcess(textEditor.getText())
}) })
assert.equal(window2Text, 'Hello World! How are you?') assert.equal(window2Text, 'Hello World! How are you?')
await window2.saveState() await window2.prepareToUnload()
window2.close() window2.close()
await window2.closedPromise await window2.closedPromise
@@ -354,8 +354,8 @@ describe('AtomApplication', function () {
]) ])
await Promise.all([ await Promise.all([
app1Window1.saveState(), app1Window1.prepareToUnload(),
app1Window2.saveState() app1Window2.prepareToUnload()
]) ])
const atomApplication2 = buildAtomApplication() const atomApplication2 = buildAtomApplication()
@@ -471,7 +471,7 @@ describe('AtomApplication', function () {
await focusWindow(window2) await focusWindow(window2)
electron.app.quit() electron.app.quit()
assert(!electron.app.hasQuitted()) assert(!electron.app.hasQuitted())
await Promise.all([window1.lastSaveStatePromise, window2.lastSaveStatePromise]) await Promise.all([window1.lastPrepareToUnloadPromise, window2.lastPrepareToUnloadPromise])
assert(electron.app.hasQuitted()) assert(electron.app.hasQuitted())
}) })
}) })

View File

@@ -149,11 +149,11 @@ describe "PaneContainerElement", ->
) )
expectPaneScale [leftPane, 0.5], [middlePane, 0.75], [rightPane, 1.75] expectPaneScale [leftPane, 0.5], [middlePane, 0.75], [rightPane, 1.75]
middlePane.close() waitsForPromise -> middlePane.close()
expectPaneScale [leftPane, 0.44], [rightPane, 1.55] runs -> expectPaneScale [leftPane, 0.44], [rightPane, 1.55]
leftPane.close() waitsForPromise -> leftPane.close()
expectPaneScale [rightPane, 1] runs -> expectPaneScale [rightPane, 1]
it "splits or closes panes in orthogonal direction that the pane is being dragged", -> it "splits or closes panes in orthogonal direction that the pane is being dragged", ->
leftPane = container.getActivePane() leftPane = container.getActivePane()
@@ -173,8 +173,8 @@ describe "PaneContainerElement", ->
expectPaneScale [lowerPane, 1], [leftPane, 1], [leftPane.getParent(), 0.5] expectPaneScale [lowerPane, 1], [leftPane, 1], [leftPane.getParent(), 0.5]
# dynamically close pane, the pane's flexscale will recorver to origin value # dynamically close pane, the pane's flexscale will recorver to origin value
lowerPane.close() waitsForPromise -> lowerPane.close()
expectPaneScale [leftPane, 0.5], [rightPane, 1.5] runs -> expectPaneScale [leftPane, 0.5], [rightPane, 1.5]
it "unsubscribes from mouse events when the pane is detached", -> it "unsubscribes from mouse events when the pane is detached", ->
container.getActivePane().splitRight() container.getActivePane().splitRight()

View File

@@ -200,15 +200,17 @@ describe "PaneContainer", ->
it "returns true if the user saves all modified files when prompted", -> it "returns true if the user saves all modified files when prompted", ->
confirm.andReturn(0) confirm.andReturn(0)
saved = container.confirmClose() waitsForPromise ->
expect(saved).toBeTruthy() container.confirmClose().then (saved) ->
expect(confirm).toHaveBeenCalled() expect(confirm).toHaveBeenCalled()
expect(saved).toBeTruthy()
it "returns false if the user cancels saving any modified file", -> it "returns false if the user cancels saving any modified file", ->
confirm.andReturn(1) confirm.andReturn(1)
saved = container.confirmClose() waitsForPromise ->
expect(saved).toBeFalsy() container.confirmClose().then (saved) ->
expect(confirm).toHaveBeenCalled() expect(confirm).toHaveBeenCalled()
expect(saved).toBeFalsy()
describe "::onDidAddPane(callback)", -> describe "::onDidAddPane(callback)", ->
it "invokes the given callback when panes are added", -> it "invokes the given callback when panes are added", ->

View File

@@ -460,11 +460,12 @@ describe "Pane", ->
it "saves the item before destroying it", -> it "saves the item before destroying it", ->
itemURI = "test" itemURI = "test"
confirm.andReturn(0) confirm.andReturn(0)
pane.destroyItem(item1)
expect(item1.save).toHaveBeenCalled() waitsForPromise ->
expect(item1 in pane.getItems()).toBe false pane.destroyItem(item1).then ->
expect(item1.isDestroyed()).toBe true expect(item1.save).toHaveBeenCalled()
expect(item1 in pane.getItems()).toBe false
expect(item1.isDestroyed()).toBe true
describe "when the item has no uri", -> describe "when the item has no uri", ->
it "presents a save-as dialog, then saves the item with the given uri before removing and destroying it", -> it "presents a save-as dialog, then saves the item with the given uri before removing and destroying it", ->
@@ -472,21 +473,23 @@ describe "Pane", ->
showSaveDialog.andReturn("/selected/path") showSaveDialog.andReturn("/selected/path")
confirm.andReturn(0) confirm.andReturn(0)
pane.destroyItem(item1)
expect(showSaveDialog).toHaveBeenCalled() waitsForPromise ->
expect(item1.saveAs).toHaveBeenCalledWith("/selected/path") pane.destroyItem(item1).then ->
expect(item1 in pane.getItems()).toBe false expect(showSaveDialog).toHaveBeenCalled()
expect(item1.isDestroyed()).toBe true expect(item1.saveAs).toHaveBeenCalledWith("/selected/path")
expect(item1 in pane.getItems()).toBe false
expect(item1.isDestroyed()).toBe true
describe "if the [Don't Save] option is selected", -> describe "if the [Don't Save] option is selected", ->
it "removes and destroys the item without saving it", -> it "removes and destroys the item without saving it", ->
confirm.andReturn(2) confirm.andReturn(2)
pane.destroyItem(item1)
expect(item1.save).not.toHaveBeenCalled() waitsForPromise ->
expect(item1 in pane.getItems()).toBe false pane.destroyItem(item1).then ->
expect(item1.isDestroyed()).toBe true expect(item1.save).not.toHaveBeenCalled()
expect(item1 in pane.getItems()).toBe false
expect(item1.isDestroyed()).toBe true
describe "if the [Cancel] option is selected", -> describe "if the [Cancel] option is selected", ->
it "does not save, remove, or destroy the item", -> it "does not save, remove, or destroy the item", ->
@@ -550,11 +553,14 @@ describe "Pane", ->
it "destroys all items", -> it "destroys all items", ->
pane = new Pane(paneParams(items: [new Item("A"), new Item("B"), new Item("C")])) pane = new Pane(paneParams(items: [new Item("A"), new Item("B"), new Item("C")]))
[item1, item2, item3] = pane.getItems() [item1, item2, item3] = pane.getItems()
pane.destroyItems()
expect(item1.isDestroyed()).toBe true waitsForPromise -> pane.destroyItems()
expect(item2.isDestroyed()).toBe true
expect(item3.isDestroyed()).toBe true runs ->
expect(pane.getItems()).toEqual [] expect(item1.isDestroyed()).toBe true
expect(item2.isDestroyed()).toBe true
expect(item3.isDestroyed()).toBe true
expect(pane.getItems()).toEqual []
describe "::observeItems()", -> describe "::observeItems()", ->
it "invokes the observer with all current and future items", -> it "invokes the observer with all current and future items", ->
@@ -620,24 +626,22 @@ describe "Pane", ->
pane.saveActiveItem() pane.saveActiveItem()
expect(showSaveDialog).not.toHaveBeenCalled() expect(showSaveDialog).not.toHaveBeenCalled()
describe "when the item's saveAs method throws a well-known IO error", -> describe "when the item's saveAs rejects with a well-known IO error", ->
notificationSpy = null
beforeEach ->
atom.notifications.onDidAddNotification notificationSpy = jasmine.createSpy()
it "creates a notification", -> it "creates a notification", ->
pane.getActiveItem().saveAs = -> pane.getActiveItem().saveAs = ->
error = new Error("EACCES, permission denied '/foo'") error = new Error("EACCES, permission denied '/foo'")
error.path = '/foo' error.path = '/foo'
error.code = 'EACCES' error.code = 'EACCES'
throw error Promise.reject(error)
pane.saveActiveItem() waitsFor (done) ->
expect(notificationSpy).toHaveBeenCalled() subscription = atom.notifications.onDidAddNotification (notification) ->
notification = notificationSpy.mostRecentCall.args[0] expect(notification.getType()).toBe 'warning'
expect(notification.getType()).toBe 'warning' expect(notification.getMessage()).toContain 'Permission denied'
expect(notification.getMessage()).toContain 'Permission denied' expect(notification.getMessage()).toContain '/foo'
expect(notification.getMessage()).toContain '/foo' subscription.dispose()
done()
pane.saveActiveItem()
describe "::saveActiveItemAs()", -> describe "::saveActiveItemAs()", ->
pane = null pane = null
@@ -661,23 +665,21 @@ describe "Pane", ->
expect(showSaveDialog).not.toHaveBeenCalled() expect(showSaveDialog).not.toHaveBeenCalled()
describe "when the item's saveAs method throws a well-known IO error", -> describe "when the item's saveAs method throws a well-known IO error", ->
notificationSpy = null
beforeEach ->
atom.notifications.onDidAddNotification notificationSpy = jasmine.createSpy()
it "creates a notification", -> it "creates a notification", ->
pane.getActiveItem().saveAs = -> pane.getActiveItem().saveAs = ->
error = new Error("EACCES, permission denied '/foo'") error = new Error("EACCES, permission denied '/foo'")
error.path = '/foo' error.path = '/foo'
error.code = 'EACCES' error.code = 'EACCES'
throw error Promise.reject(error)
pane.saveActiveItemAs() waitsFor (done) ->
expect(notificationSpy).toHaveBeenCalled() subscription = atom.notifications.onDidAddNotification (notification) ->
notification = notificationSpy.mostRecentCall.args[0] expect(notification.getType()).toBe 'warning'
expect(notification.getType()).toBe 'warning' expect(notification.getMessage()).toContain 'Permission denied'
expect(notification.getMessage()).toContain 'Permission denied' expect(notification.getMessage()).toContain '/foo'
expect(notification.getMessage()).toContain '/foo' subscription.dispose()
done()
pane.saveActiveItemAs()
describe "::itemForURI(uri)", -> describe "::itemForURI(uri)", ->
it "returns the item for which a call to .getURI() returns the given uri", -> it "returns the item for which a call to .getURI() returns the given uri", ->
@@ -787,7 +789,6 @@ describe "Pane", ->
pane2.moveItemToPane(item5, pane1, 0) pane2.moveItemToPane(item5, pane1, 0)
expect(pane1.getPendingItem()).toEqual item6 expect(pane1.getPendingItem()).toEqual item6
describe "split methods", -> describe "split methods", ->
[pane1, item1, container] = [] [pane1, item1, container] = []
@@ -926,11 +927,10 @@ describe "Pane", ->
item1.save = jasmine.createSpy("save") item1.save = jasmine.createSpy("save")
confirm.andReturn(0) confirm.andReturn(0)
pane.close() pane.close().then ->
expect(confirm).toHaveBeenCalled()
expect(confirm).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled()
expect(item1.save).toHaveBeenCalled() expect(pane.isDestroyed()).toBe true
expect(pane.isDestroyed()).toBe true
it "does not destroy the pane if cancel is called", -> it "does not destroy the pane if cancel is called", ->
pane = new Pane(paneParams(items: [new Item("A"), new Item("B")])) pane = new Pane(paneParams(items: [new Item("A"), new Item("B")]))
@@ -941,11 +941,12 @@ describe "Pane", ->
item1.save = jasmine.createSpy("save") item1.save = jasmine.createSpy("save")
confirm.andReturn(1) confirm.andReturn(1)
pane.close()
expect(confirm).toHaveBeenCalled() waitsForPromise ->
expect(item1.save).not.toHaveBeenCalled() pane.close().then ->
expect(pane.isDestroyed()).toBe false expect(confirm).toHaveBeenCalled()
expect(item1.save).not.toHaveBeenCalled()
expect(pane.isDestroyed()).toBe false
describe "when item fails to save", -> describe "when item fails to save", ->
[pane, item1, item2] = [] [pane, item1, item2] = []
@@ -972,12 +973,12 @@ describe "Pane", ->
else else
return 1 # click cancel return 1 # click cancel
pane.close() waitsForPromise ->
pane.close().then ->
expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(atom.applicationDelegate.confirm).toHaveBeenCalled()
expect(confirmations).toBe(2) expect(confirmations).toBe(2)
expect(item1.save).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled()
expect(pane.isDestroyed()).toBe false expect(pane.isDestroyed()).toBe false
it "does destroy the pane if the user saves the file under a new name", -> it "does destroy the pane if the user saves the file under a new name", ->
item1.saveAs = jasmine.createSpy("saveAs").andReturn(true) item1.saveAs = jasmine.createSpy("saveAs").andReturn(true)
@@ -989,14 +990,14 @@ describe "Pane", ->
showSaveDialog.andReturn("new/path") showSaveDialog.andReturn("new/path")
pane.close() waitsForPromise ->
pane.close().then ->
expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(atom.applicationDelegate.confirm).toHaveBeenCalled()
expect(confirmations).toBe(2) expect(confirmations).toBe(2)
expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalled() expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalled()
expect(item1.save).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled()
expect(item1.saveAs).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled()
expect(pane.isDestroyed()).toBe true expect(pane.isDestroyed()).toBe true
it "asks again if the saveAs also fails", -> it "asks again if the saveAs also fails", ->
item1.saveAs = jasmine.createSpy("saveAs").andCallFake -> item1.saveAs = jasmine.createSpy("saveAs").andCallFake ->
@@ -1014,14 +1015,14 @@ describe "Pane", ->
showSaveDialog.andReturn("new/path") showSaveDialog.andReturn("new/path")
pane.close() waitsForPromise ->
pane.close().then ->
expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(atom.applicationDelegate.confirm).toHaveBeenCalled()
expect(confirmations).toBe(3) expect(confirmations).toBe(3)
expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalled() expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalled()
expect(item1.save).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled()
expect(item1.saveAs).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled()
expect(pane.isDestroyed()).toBe true expect(pane.isDestroyed()).toBe true
describe "::destroy()", -> describe "::destroy()", ->
[container, pane1, pane2] = [] [container, pane1, pane2] = []

View File

@@ -48,32 +48,6 @@ describe "WindowEventHandler", ->
window.dispatchEvent(new CustomEvent('window:close')) window.dispatchEvent(new CustomEvent('window:close'))
expect(atom.close).toHaveBeenCalled() expect(atom.close).toHaveBeenCalled()
describe "beforeunload event", ->
beforeEach ->
jasmine.unspy(TextEditor.prototype, "shouldPromptToSave")
spyOn(atom, 'destroy')
spyOn(ipcRenderer, 'send')
describe "when pane items are modified", ->
editor = null
beforeEach ->
waitsForPromise -> atom.workspace.open("sample.js").then (o) -> editor = o
runs -> editor.insertText("I look different, I feel different.")
it "prompts the user to save them, and allows the unload to continue if they confirm", ->
spyOn(atom.workspace, 'confirmClose').andReturn(true)
window.dispatchEvent(new CustomEvent('beforeunload'))
expect(atom.workspace.confirmClose).toHaveBeenCalled()
expect(ipcRenderer.send).not.toHaveBeenCalledWith('did-cancel-window-unload')
expect(atom.destroy).toHaveBeenCalled()
it "cancels the unload if the user selects cancel", ->
spyOn(atom.workspace, 'confirmClose').andReturn(false)
window.dispatchEvent(new CustomEvent('beforeunload'))
expect(atom.workspace.confirmClose).toHaveBeenCalled()
expect(ipcRenderer.send).toHaveBeenCalledWith('did-cancel-window-unload')
expect(atom.destroy).not.toHaveBeenCalled()
describe "when a link is clicked", -> describe "when a link is clicked", ->
it "opens the http/https links in an external application", -> it "opens the http/https links in an external application", ->
{shell} = require 'electron' {shell} = require 'electron'

View File

@@ -2394,38 +2394,47 @@ i = /test/; #FIXME\
}) })
describe('::saveActivePaneItem()', () => { describe('::saveActivePaneItem()', () => {
let editor = null let editor, notificationSpy
beforeEach(() =>
waitsForPromise(() => atom.workspace.open('sample.js').then(o => { editor = o })) beforeEach(() => {
) waitsForPromise(() => atom.workspace.open('sample.js').then(o => {
editor = o
}))
notificationSpy = jasmine.createSpy('did-add-notification')
atom.notifications.onDidAddNotification(notificationSpy)
})
describe('when there is an error', () => { describe('when there is an error', () => {
it('emits a warning notification when the file cannot be saved', () => { it('emits a warning notification when the file cannot be saved', () => {
let addedSpy
spyOn(editor, 'save').andCallFake(() => { spyOn(editor, 'save').andCallFake(() => {
throw new Error("'/some/file' is a directory") throw new Error("'/some/file' is a directory")
}) })
atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) waitsForPromise(() =>
atom.workspace.saveActivePaneItem() atom.workspace.saveActivePaneItem().then(() => {
expect(addedSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalled()
expect(addedSpy.mostRecentCall.args[0].getType()).toBe('warning') expect(notificationSpy.mostRecentCall.args[0].getType()).toBe('warning')
expect(notificationSpy.mostRecentCall.args[0].getMessage()).toContain('Unable to save')
})
)
}) })
it('emits a warning notification when the directory cannot be written to', () => { it('emits a warning notification when the directory cannot be written to', () => {
let addedSpy
spyOn(editor, 'save').andCallFake(() => { spyOn(editor, 'save').andCallFake(() => {
throw new Error("ENOTDIR, not a directory '/Some/dir/and-a-file.js'") throw new Error("ENOTDIR, not a directory '/Some/dir/and-a-file.js'")
}) })
atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) waitsForPromise(() =>
atom.workspace.saveActivePaneItem() atom.workspace.saveActivePaneItem().then(() => {
expect(addedSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalled()
expect(addedSpy.mostRecentCall.args[0].getType()).toBe('warning') expect(notificationSpy.mostRecentCall.args[0].getType()).toBe('warning')
expect(notificationSpy.mostRecentCall.args[0].getMessage()).toContain('Unable to save')
})
)
}) })
it('emits a warning notification when the user does not have permission', () => { it('emits a warning notification when the user does not have permission', () => {
let addedSpy
spyOn(editor, 'save').andCallFake(() => { spyOn(editor, 'save').andCallFake(() => {
const error = new Error("EACCES, permission denied '/Some/dir/and-a-file.js'") const error = new Error("EACCES, permission denied '/Some/dir/and-a-file.js'")
error.code = 'EACCES' error.code = 'EACCES'
@@ -2433,10 +2442,13 @@ i = /test/; #FIXME\
throw error throw error
}) })
atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) waitsForPromise(() =>
atom.workspace.saveActivePaneItem() atom.workspace.saveActivePaneItem().then(() => {
expect(addedSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalled()
expect(addedSpy.mostRecentCall.args[0].getType()).toBe('warning') expect(notificationSpy.mostRecentCall.args[0].getType()).toBe('warning')
expect(notificationSpy.mostRecentCall.args[0].getMessage()).toContain('Unable to save')
})
)
}) })
it('emits a warning notification when the operation is not permitted', () => { it('emits a warning notification when the operation is not permitted', () => {
@@ -2446,10 +2458,17 @@ i = /test/; #FIXME\
error.path = '/Some/dir/and-a-file.js' error.path = '/Some/dir/and-a-file.js'
throw error throw error
}) })
waitsForPromise(() =>
atom.workspace.saveActivePaneItem().then(() => {
expect(notificationSpy).toHaveBeenCalled()
expect(notificationSpy.mostRecentCall.args[0].getType()).toBe('warning')
expect(notificationSpy.mostRecentCall.args[0].getMessage()).toContain('Unable to save')
})
)
}) })
it('emits a warning notification when the file is already open by another app', () => { it('emits a warning notification when the file is already open by another app', () => {
let addedSpy
spyOn(editor, 'save').andCallFake(() => { spyOn(editor, 'save').andCallFake(() => {
const error = new Error("EBUSY, resource busy or locked '/Some/dir/and-a-file.js'") const error = new Error("EBUSY, resource busy or locked '/Some/dir/and-a-file.js'")
error.code = 'EBUSY' error.code = 'EBUSY'
@@ -2457,17 +2476,16 @@ i = /test/; #FIXME\
throw error throw error
}) })
atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) waitsForPromise(() =>
atom.workspace.saveActivePaneItem() atom.workspace.saveActivePaneItem().then(() => {
expect(addedSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalled()
expect(notificationSpy.mostRecentCall.args[0].getType()).toBe('warning')
const notificaiton = addedSpy.mostRecentCall.args[0] expect(notificationSpy.mostRecentCall.args[0].getMessage()).toContain('Unable to save')
expect(notificaiton.getType()).toBe('warning') })
expect(notificaiton.getMessage()).toContain('Unable to save') )
}) })
it('emits a warning notification when the file system is read-only', () => { it('emits a warning notification when the file system is read-only', () => {
let addedSpy
spyOn(editor, 'save').andCallFake(() => { spyOn(editor, 'save').andCallFake(() => {
const error = new Error("EROFS, read-only file system '/Some/dir/and-a-file.js'") const error = new Error("EROFS, read-only file system '/Some/dir/and-a-file.js'")
error.code = 'EROFS' error.code = 'EROFS'
@@ -2475,13 +2493,13 @@ i = /test/; #FIXME\
throw error throw error
}) })
atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) waitsForPromise(() =>
atom.workspace.saveActivePaneItem() atom.workspace.saveActivePaneItem().then(() => {
expect(addedSpy).toHaveBeenCalled() expect(notificationSpy).toHaveBeenCalled()
expect(notificationSpy.mostRecentCall.args[0].getType()).toBe('warning')
const notification = addedSpy.mostRecentCall.args[0] expect(notificationSpy.mostRecentCall.args[0].getMessage()).toContain('Unable to save')
expect(notification.getType()).toBe('warning') })
expect(notification.getMessage()).toContain('Unable to save') )
}) })
it('emits a warning notification when the file cannot be saved', () => { it('emits a warning notification when the file cannot be saved', () => {
@@ -2489,8 +2507,9 @@ i = /test/; #FIXME\
throw new Error('no one knows') throw new Error('no one knows')
}) })
const save = () => atom.workspace.saveActivePaneItem() waitsForPromise({shouldReject: true}, () =>
expect(save).toThrow() atom.workspace.saveActivePaneItem()
)
}) })
}) })
}) })

View File

@@ -232,19 +232,14 @@ class ApplicationDelegate
new Disposable -> new Disposable ->
ipcRenderer.removeListener('context-command', outerCallback) ipcRenderer.removeListener('context-command', outerCallback)
onSaveWindowStateRequest: (callback) -> onDidRequestUnload: (callback) ->
outerCallback = (event, message) -> outerCallback = (event, message) ->
callback(event) callback(event).then (shouldUnload) ->
ipcRenderer.send('did-prepare-to-unload', shouldUnload)
ipcRenderer.on('save-window-state', outerCallback) ipcRenderer.on('prepare-to-unload', outerCallback)
new Disposable -> new Disposable ->
ipcRenderer.removeListener('save-window-state', outerCallback) ipcRenderer.removeListener('prepare-to-unload', outerCallback)
didSaveWindowState: ->
ipcRenderer.send('did-save-window-state')
didCancelWindowUnload: ->
ipcRenderer.send('did-cancel-window-unload')
onDidChangeHistoryManager: (callback) -> onDidChangeHistoryManager: (callback) ->
outerCallback = (event, message) -> outerCallback = (event, message) ->

View File

@@ -694,9 +694,14 @@ class AtomEnvironment extends Model
@disposables.add(@applicationDelegate.onDidOpenLocations(@openLocations.bind(this))) @disposables.add(@applicationDelegate.onDidOpenLocations(@openLocations.bind(this)))
@disposables.add(@applicationDelegate.onApplicationMenuCommand(@dispatchApplicationMenuCommand.bind(this))) @disposables.add(@applicationDelegate.onApplicationMenuCommand(@dispatchApplicationMenuCommand.bind(this)))
@disposables.add(@applicationDelegate.onContextMenuCommand(@dispatchContextMenuCommand.bind(this))) @disposables.add(@applicationDelegate.onContextMenuCommand(@dispatchContextMenuCommand.bind(this)))
@disposables.add @applicationDelegate.onSaveWindowStateRequest => @disposables.add @applicationDelegate.onDidRequestUnload =>
callback = => @applicationDelegate.didSaveWindowState() @saveState({isUnloading: true})
@saveState({isUnloading: true}).catch(callback).then(callback) .catch(console.error)
.then =>
@workspace?.confirmClose({
windowCloseRequested: true,
projectHasPaths: @project.getPaths().length > 0
})
@listenForUpdates() @listenForUpdates()

View File

@@ -270,7 +270,7 @@ class AtomApplication
unless @quitting unless @quitting
event.preventDefault() event.preventDefault()
@quitting = true @quitting = true
Promise.all(@windows.map((window) -> window.saveState())).then(-> app.quit()) Promise.all(@windows.map((window) -> window.prepareToUnload())).then(-> app.quit())
@disposable.add ipcHelpers.on app, 'will-quit', => @disposable.add ipcHelpers.on app, 'will-quit', =>
@killAllProcesses() @killAllProcesses()
@@ -373,11 +373,6 @@ class AtomApplication
@disposable.add ipcHelpers.respondTo 'set-temporary-window-state', (win, state) -> @disposable.add ipcHelpers.respondTo 'set-temporary-window-state', (win, state) ->
win.temporaryState = state win.temporaryState = state
@disposable.add ipcHelpers.on ipcMain, 'did-cancel-window-unload', =>
@quitting = false
for window in @windows
window.didCancelWindowUnload()
clipboard = require '../safe-clipboard' clipboard = require '../safe-clipboard'
@disposable.add ipcHelpers.on ipcMain, 'write-text-to-selection-clipboard', (event, selectedText) -> @disposable.add ipcHelpers.on ipcMain, 'write-text-to-selection-clipboard', (event, selectedText) ->
clipboard.writeText(selectedText, 'selection') clipboard.writeText(selectedText, 'selection')

View File

@@ -147,7 +147,8 @@ class AtomWindow
event.preventDefault() event.preventDefault()
@unloading = true @unloading = true
@atomApplication.saveState(false) @atomApplication.saveState(false)
@saveState().then(=> @close()) @prepareToUnload().then (result) =>
@close() if result
@browserWindow.on 'closed', => @browserWindow.on 'closed', =>
@fileRecoveryService.didCloseWindow(this) @fileRecoveryService.didCloseWindow(this)
@@ -191,21 +192,19 @@ class AtomWindow
@browserWindow.on 'blur', => @browserWindow.on 'blur', =>
@browserWindow.focusOnWebView() @browserWindow.focusOnWebView()
didCancelWindowUnload: -> prepareToUnload: ->
@unloading = false
saveState: ->
if @isSpecWindow() if @isSpecWindow()
return Promise.resolve() return Promise.resolve()
@lastPrepareToUnloadPromise = new Promise (resolve) =>
@lastSaveStatePromise = new Promise (resolve) => callback = (event, result) =>
callback = (event) =>
if BrowserWindow.fromWebContents(event.sender) is @browserWindow if BrowserWindow.fromWebContents(event.sender) is @browserWindow
ipcMain.removeListener('did-save-window-state', callback) ipcMain.removeListener('did-prepare-to-unload', callback)
resolve() unless result
ipcMain.on('did-save-window-state', callback) @unloading = false
@browserWindow.webContents.send('save-window-state') @atomApplication.quitting = false
@lastSaveStatePromise resolve(result)
ipcMain.on('did-prepare-to-unload', callback)
@browserWindow.webContents.send('prepare-to-unload')
openPath: (pathToOpen, initialLine, initialColumn) -> openPath: (pathToOpen, initialLine, initialColumn) ->
@openLocations([{pathToOpen, initialLine, initialColumn}]) @openLocations([{pathToOpen, initialLine, initialColumn}])
@@ -287,7 +286,8 @@ class AtomWindow
reload: -> reload: ->
@loadedPromise = new Promise((@resolveLoadedPromise) =>) @loadedPromise = new Promise((@resolveLoadedPromise) =>)
@saveState().then => @browserWindow.reload() @prepareToUnload().then (result) =>
@browserWindow.reload() if result
@loadedPromise @loadedPromise
showSaveDialog: (params) -> showSaveDialog: (params) ->

View File

@@ -172,18 +172,13 @@ class PaneContainer {
} }
confirmClose (options) { confirmClose (options) {
let allSaved = true const promises = []
for (const pane of this.getPanes()) {
for (let pane of this.getPanes()) { for (const item of pane.getItems()) {
for (let item of pane.getItems()) { promises.push(pane.promptToSaveItem(item, options))
if (!pane.promptToSaveItem(item, options)) {
allSaved = false
break
}
} }
} }
return Promise.all(promises).then((results) => !results.includes(false))
return allSaved
} }
activateNextPane () { activateNextPane () {

View File

@@ -602,39 +602,47 @@ class Pane
# * `force` (optional) {Boolean} Destroy the item without prompting to save # * `force` (optional) {Boolean} Destroy the item without prompting to save
# it, even if the item's `isPermanentDockItem` method returns true. # it, even if the item's `isPermanentDockItem` method returns true.
# #
# Returns a {Boolean} indicating whether or not the item was destroyed. # Returns a {Promise} that resolves with a {Boolean} indicating whether or not
# the item was destroyed.
destroyItem: (item, force) -> destroyItem: (item, force) ->
index = @items.indexOf(item) index = @items.indexOf(item)
if index isnt -1 if index isnt -1
return false if not force and @getContainer()?.getLocation() isnt 'center' and item.isPermanentDockItem?() return false if not force and @getContainer()?.getLocation() isnt 'center' and item.isPermanentDockItem?()
@emitter.emit 'will-destroy-item', {item, index} @emitter.emit 'will-destroy-item', {item, index}
@container?.willDestroyPaneItem({item, index, pane: this}) @container?.willDestroyPaneItem({item, index, pane: this})
if force or @promptToSaveItem(item) if force or not item?.shouldPromptToSave?()
@removeItem(item, false) @removeItem(item, false)
item.destroy?() item.destroy?()
true
else else
false @promptToSaveItem(item).then (result) =>
if result
@removeItem(item, false)
item.destroy?()
result
# Public: Destroy all items. # Public: Destroy all items.
destroyItems: -> destroyItems: ->
@destroyItem(item) for item in @getItems() Promise.all(
return @getItems().map(@destroyItem.bind(this))
)
# Public: Destroy all items except for the active item. # Public: Destroy all items except for the active item.
destroyInactiveItems: -> destroyInactiveItems: ->
@destroyItem(item) for item in @getItems() when item isnt @activeItem Promise.all(
return @getItems()
.filter((item) => item isnt @activeItem)
.map(@destroyItem.bind(this))
)
promptToSaveItem: (item, options={}) -> promptToSaveItem: (item, options={}) ->
return true unless item.shouldPromptToSave?(options) return Promise.resolve(true) unless item.shouldPromptToSave?(options)
if typeof item.getURI is 'function' if typeof item.getURI is 'function'
uri = item.getURI() uri = item.getURI()
else if typeof item.getUri is 'function' else if typeof item.getUri is 'function'
uri = item.getUri() uri = item.getUri()
else else
return true return Promise.resolve(true)
saveDialog = (saveButtonText, saveFn, message) => saveDialog = (saveButtonText, saveFn, message) =>
chosen = @applicationDelegate.confirm chosen = @applicationDelegate.confirm
@@ -642,15 +650,21 @@ class Pane
detailedMessage: "Your changes will be lost if you close this item without saving." detailedMessage: "Your changes will be lost if you close this item without saving."
buttons: [saveButtonText, "Cancel", "Don't Save"] buttons: [saveButtonText, "Cancel", "Don't Save"]
switch chosen switch chosen
when 0 then saveFn(item, saveError) when 0
when 1 then false new Promise (resolve) ->
when 2 then true saveFn item, (error) ->
console.log 'error', error
saveError(error).then(resolve)
when 1
Promise.resolve(false)
when 2
Promise.resolve(true)
saveError = (error) => saveError = (error) =>
if error if error
saveDialog("Save as", @saveItemAs, "'#{item.getTitle?() ? uri}' could not be saved.\nError: #{@getMessageForErrorCode(error.code)}") saveDialog("Save as", @saveItemAs, "'#{item.getTitle?() ? uri}' could not be saved.\nError: #{@getMessageForErrorCode(error.code)}")
else else
true Promise.resolve(true)
saveDialog("Save", @saveItem, "'#{item.getTitle?() ? uri}' has changes, do you want to save them?") saveDialog("Save", @saveItem, "'#{item.getTitle?() ? uri}' has changes, do you want to save them?")
@@ -663,6 +677,8 @@ class Pane
# #
# * `nextAction` (optional) {Function} which will be called after the item is # * `nextAction` (optional) {Function} which will be called after the item is
# successfully saved. # successfully saved.
#
# Returns a {Promise} that resolves when the save is complete
saveActiveItemAs: (nextAction) -> saveActiveItemAs: (nextAction) ->
@saveItemAs(@getActiveItem(), nextAction) @saveItemAs(@getActiveItem(), nextAction)
@@ -673,6 +689,8 @@ class Pane
# after the item is successfully saved, or with the error if it failed. # after the item is successfully saved, or with the error if it failed.
# The return value will be that of `nextAction` or `undefined` if it was not # The return value will be that of `nextAction` or `undefined` if it was not
# provided # provided
#
# Returns a {Promise} that resolves when the save is complete
saveItem: (item, nextAction) => saveItem: (item, nextAction) =>
if typeof item?.getURI is 'function' if typeof item?.getURI is 'function'
itemURI = item.getURI() itemURI = item.getURI()
@@ -680,14 +698,16 @@ class Pane
itemURI = item.getUri() itemURI = item.getUri()
if itemURI? if itemURI?
try if item.save?
item.save?() promisify -> item.save()
.then -> nextAction?()
.catch (error) =>
if nextAction
nextAction(error)
else
@handleSaveError(error, item)
else
nextAction?() nextAction?()
catch error
if nextAction
nextAction(error)
else
@handleSaveError(error, item)
else else
@saveItemAs(item, nextAction) @saveItemAs(item, nextAction)
@@ -706,14 +726,13 @@ class Pane
saveOptions.defaultPath ?= item.getPath() saveOptions.defaultPath ?= item.getPath()
newItemPath = @applicationDelegate.showSaveDialog(saveOptions) newItemPath = @applicationDelegate.showSaveDialog(saveOptions)
if newItemPath if newItemPath
try promisify -> item.saveAs(newItemPath)
item.saveAs(newItemPath) .then -> nextAction?()
nextAction?() .catch (error) =>
catch error if nextAction?
if nextAction nextAction(error)
nextAction(error) else
else @handleSaveError(error, item)
@handleSaveError(error, item)
# Public: Save all items. # Public: Save all items.
saveItems: -> saveItems: ->
@@ -909,13 +928,13 @@ class Pane
bottommostSibling = @findBottommostSibling() bottommostSibling = @findBottommostSibling()
if bottommostSibling is this then @splitDown() else bottommostSibling if bottommostSibling is this then @splitDown() else bottommostSibling
# Private: Close the pane unless the user cancels the action via a dialog.
#
# Returns a {Promise} that resolves once the pane is either closed, or the
# closing has been cancelled.
close: -> close: ->
@destroy() if @confirmClose() Promise.all(@getItems().map(@promptToSaveItem.bind(this))).then (results) =>
@destroy() unless results.includes(false)
confirmClose: ->
for item in @getItems()
return false unless @promptToSaveItem(item)
true
handleSaveError: (error, item) -> handleSaveError: (error, item) ->
itemPath = error.path ? item?.getPath?() itemPath = error.path ? item?.getPath?()
@@ -948,3 +967,9 @@ class Pane
when 'EROFS' then 'Read-only file system' when 'EROFS' then 'Read-only file system'
when 'ESPIPE' then 'Invalid seek' when 'ESPIPE' then 'Invalid seek'
when 'ETIMEDOUT' then 'Connection timed out' when 'ETIMEDOUT' then 'Connection timed out'
promisify = (callback) ->
try
Promise.resolve(callback())
catch error
Promise.reject(error)

View File

@@ -147,19 +147,12 @@ class WindowEventHandler
@document.body.classList.remove("fullscreen") @document.body.classList.remove("fullscreen")
handleWindowBeforeunload: (event) => handleWindowBeforeunload: (event) =>
projectHasPaths = @atomEnvironment.project.getPaths().length > 0 if not @reloadRequested and not @atomEnvironment.inSpecMode() and @atomEnvironment.getCurrentWindow().isWebViewFocused()
confirmed = @atomEnvironment.workspace?.confirmClose(windowCloseRequested: true, projectHasPaths: projectHasPaths)
if confirmed and not @reloadRequested and not @atomEnvironment.inSpecMode() and @atomEnvironment.getCurrentWindow().isWebViewFocused()
@atomEnvironment.hide() @atomEnvironment.hide()
@reloadRequested = false @reloadRequested = false
@atomEnvironment.storeWindowDimensions() @atomEnvironment.storeWindowDimensions()
if confirmed @atomEnvironment.unloadEditorWindow()
@atomEnvironment.unloadEditorWindow() @atomEnvironment.destroy()
@atomEnvironment.destroy()
else
@applicationDelegate.didCancelWindowUnload()
event.returnValue = false
handleWindowToggleFullScreen: => handleWindowToggleFullScreen: =>
@atomEnvironment.toggleFullScreen() @atomEnvironment.toggleFullScreen()

View File

@@ -1299,9 +1299,9 @@ module.exports = class Workspace extends Model {
} }
confirmClose (options) { confirmClose (options) {
return this.getPaneContainers() return Promise.all(this.getPaneContainers().map(container =>
.map(container => container.confirmClose(options)) container.confirmClose(options)
.every(saved => saved) )).then((results) => !results.includes(false))
} }
// Save the active pane item. // Save the active pane item.
@@ -1311,7 +1311,7 @@ module.exports = class Workspace extends Model {
// {::saveActivePaneItemAs} # will be called instead. This method does nothing // {::saveActivePaneItemAs} # will be called instead. This method does nothing
// if the active item does not implement a `.save` method. // if the active item does not implement a `.save` method.
saveActivePaneItem () { saveActivePaneItem () {
this.getCenter().getActivePane().saveActiveItem() return this.getCenter().getActivePane().saveActiveItem()
} }
// Prompt the user for a path and save the active pane item to it. // Prompt the user for a path and save the active pane item to it.