Merge pull request #15681 from atom/aw-unmounted-drive

Notify when deserializing project state for missing directories
This commit is contained in:
Ash Wilson
2017-09-20 11:21:04 -07:00
committed by GitHub
7 changed files with 250 additions and 38 deletions

View File

@@ -322,6 +322,44 @@ describe "AtomEnvironment", ->
expect(atom2.textEditors.getGrammarOverride(editor)).toBe('text.plain')
atom2.destroy()
describe "deserialization failures", ->
it "propagates project state restoration failures", ->
spyOn(atom.project, 'deserialize').andCallFake ->
err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo']
Promise.reject(err)
spyOn(atom.notifications, 'addError')
waitsForPromise -> atom.deserialize({project: 'should work'})
runs ->
expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open project directory',
{description: 'Project directory `/foo` is no longer on disk.'}
it "accumulates and reports two errors with one notification", ->
spyOn(atom.project, 'deserialize').andCallFake ->
err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo', '/wat']
Promise.reject(err)
spyOn(atom.notifications, 'addError')
waitsForPromise -> atom.deserialize({project: 'should work'})
runs ->
expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open 2 project directories',
{description: 'Project directories `/foo` and `/wat` are no longer on disk.'}
it "accumulates and reports three+ errors with one notification", ->
spyOn(atom.project, 'deserialize').andCallFake ->
err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo', '/wat', '/stuff', '/things']
Promise.reject(err)
spyOn(atom.notifications, 'addError')
waitsForPromise -> atom.deserialize({project: 'should work'})
runs ->
expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open 4 project directories',
{description: 'Project directories `/foo`, `/wat`, `/stuff`, and `/things` are no longer on disk.'}
describe "openInitialEmptyEditorIfNecessary", ->
describe "when there are no paths set", ->
beforeEach ->

View File

@@ -16,20 +16,47 @@ describe "Project", ->
describe "serialization", ->
deserializedProject = null
notQuittingProject = null
quittingProject = null
afterEach ->
deserializedProject?.destroy()
notQuittingProject?.destroy()
quittingProject?.destroy()
it "does not deserialize paths to non directories", ->
it "does not deserialize paths to directories that don't exist", ->
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
state = atom.project.serialize()
state.paths.push('/directory/that/does/not/exist')
err = null
waitsForPromise ->
deserializedProject.deserialize(state, atom.deserializers)
.catch (e) -> err = e
runs ->
expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths())
expect(err.missingProjectPaths).toEqual ['/directory/that/does/not/exist']
it "does not deserialize paths that are now files", ->
childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child')
fs.mkdirSync(childPath)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
atom.project.setPaths([childPath])
state = atom.project.serialize()
fs.rmdirSync(childPath)
fs.writeFileSync(childPath, 'surprise!\n')
err = null
waitsForPromise ->
deserializedProject.deserialize(state, atom.deserializers)
.catch (e) -> err = e
runs ->
expect(deserializedProject.getPaths()).toEqual([])
expect(err.missingProjectPaths).toEqual [childPath]
it "does not include unretained buffers in the serialized state", ->
waitsForPromise ->
@@ -62,7 +89,7 @@ describe "Project", ->
deserializedProject.getBuffers()[0].destroy()
expect(deserializedProject.getBuffers().length).toBe 0
it "does not deserialize buffers when their path is a directory that exists", ->
it "does not deserialize buffers when their path is now a directory", ->
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
waitsForPromise ->
@@ -72,7 +99,11 @@ describe "Project", ->
expect(atom.project.getBuffers().length).toBe 1
fs.mkdirSync(pathToOpen)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "does not deserialize buffers when their path is inaccessible", ->
@@ -87,12 +118,53 @@ describe "Project", ->
expect(atom.project.getBuffers().length).toBe 1
fs.chmodSync(pathToOpen, '000')
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "serializes marker layers and history only if Atom is quitting", ->
it "does not deserialize buffers with their path is no longer present", ->
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
fs.writeFileSync(pathToOpen, '')
waitsForPromise ->
atom.workspace.open('a')
atom.workspace.open(pathToOpen)
runs ->
expect(atom.project.getBuffers().length).toBe 1
fs.unlinkSync(pathToOpen)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "deserializes buffers that have never been saved before", ->
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
waitsForPromise ->
atom.workspace.open(pathToOpen)
runs ->
atom.workspace.getActiveTextEditor().setText('unsaved\n')
expect(atom.project.getBuffers().length).toBe 1
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 1
expect(deserializedProject.getBuffers()[0].getPath()).toBe pathToOpen
expect(deserializedProject.getBuffers()[0].getText()).toBe 'unsaved\n'
it "serializes marker layers and history only if Atom is quitting", ->
waitsForPromise -> atom.workspace.open('a')
bufferA = null
layerA = null
@@ -103,18 +175,20 @@ describe "Project", ->
layerA = bufferA.addMarkerLayer(persistent: true)
markerA = layerA.markPosition([0, 3])
bufferA.append('!')
waitsForPromise ->
notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
notQuittingProject.deserialize(atom.project.serialize({isUnloading: false})).then ->
expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined()
expect(notQuittingProject.getBuffers()[0].undo()).toBe(false)
waitsForPromise ->
waitsForPromise -> notQuittingProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined()
expect(notQuittingProject.getBuffers()[0].undo()).toBe(false)
quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
quittingProject.deserialize(atom.project.serialize({isUnloading: true})).then ->
expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined()
expect(quittingProject.getBuffers()[0].undo()).toBe(true)
waitsForPromise -> quittingProject.deserialize(atom.project.serialize({isUnloading: true}))
runs ->
expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined()
expect(quittingProject.getBuffers()[0].undo()).toBe(true)
describe "when an editor is saved and the project has no path", ->
it "sets the project's path to the saved file's parent directory", ->
@@ -411,9 +485,9 @@ describe "Project", ->
runs ->
expect(repository.isDestroyed()).toBe(false)
describe ".setPaths(paths)", ->
describe ".setPaths(paths, options)", ->
describe "when path is a file", ->
it "sets its path to the files parent directory and updates the root directory", ->
it "sets its path to the file's parent directory and updates the root directory", ->
filePath = require.resolve('./fixtures/dir/a')
atom.project.setPaths([filePath])
expect(atom.project.getPaths()[0]).toEqual path.dirname(filePath)
@@ -448,6 +522,17 @@ describe "Project", ->
expect(onDidChangePathsSpy.callCount).toBe 1
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths)
it "optionally throws an error with any paths that did not exist", ->
paths = [temp.mkdirSync("exists0"), "/doesnt-exists/0", temp.mkdirSync("exists1"), "/doesnt-exists/1"]
try
atom.project.setPaths paths, mustExist: true
expect('no exception thrown').toBeUndefined()
catch e
expect(e.missingProjectPaths).toEqual [paths[1], paths[3]]
expect(atom.project.getPaths()).toEqual [paths[0], paths[2]]
describe "when no paths are given", ->
it "clears its path", ->
atom.project.setPaths([])
@@ -459,7 +544,7 @@ describe "Project", ->
expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a'))
expect(atom.project.getDirectories()[0].path).toEqual path.dirname(require.resolve('./fixtures/dir/a'))
describe ".addPath(path)", ->
describe ".addPath(path, options)", ->
it "calls callbacks registered with ::onDidChangePaths", ->
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
@@ -498,6 +583,11 @@ describe "Project", ->
atom.project.addPath('/this-definitely/does-not-exist')
expect(atom.project.getPaths()).toEqual(previousPaths)
it "optionally throws on non-existent directories", ->
expect ->
atom.project.addPath '/this-definitely/does-not-exist', mustExist: true
.toThrow()
describe ".removePath(path)", ->
onDidChangePathsSpy = null

View File

@@ -76,6 +76,15 @@ describe "TextEditor", ->
expect(editor2.displayLayer.tabLength).toBe(editor2.getTabLength())
expect(editor2.displayLayer.softWrapColumn).toBe(editor2.getSoftWrapColumn())
it "ignores buffers with retired IDs", ->
editor2 = TextEditor.deserialize(editor.serialize(), {
assert: atom.assert,
textEditors: atom.textEditors,
project: {bufferForIdSync: -> null}
})
expect(editor2).toBeNull()
describe "when the editor is constructed with the largeFileMode option set to true", ->
it "loads the editor but doesn't tokenize", ->
editor = null

View File

@@ -998,11 +998,18 @@ class AtomEnvironment extends Model
@setFullScreen(state.fullScreen)
missingProjectPaths = []
@packages.packageStates = state.packageStates ? {}
startTime = Date.now()
if state.project?
projectPromise = @project.deserialize(state.project, @deserializers)
.catch (err) =>
if err.missingProjectPaths?
missingProjectPaths.push(err.missingProjectPaths...)
else
@notifications.addError "Unable to deserialize project", description: err.message, stack: err.stack
else
projectPromise = Promise.resolve()
@@ -1015,6 +1022,19 @@ class AtomEnvironment extends Model
@workspace.deserialize(state.workspace, @deserializers) if state.workspace?
@deserializeTimings.workspace = Date.now() - startTime
if missingProjectPaths.length > 0
count = if missingProjectPaths.length is 1 then '' else missingProjectPaths.length + ' '
noun = if missingProjectPaths.length is 1 then 'directory' else 'directories'
toBe = if missingProjectPaths.length is 1 then 'is' else 'are'
escaped = missingProjectPaths.map (projectPath) -> "`#{projectPath}`"
group = switch escaped.length
when 1 then escaped[0]
when 2 then "#{escaped[0]} and #{escaped[1]}"
else escaped[..-2].join(", ") + ", and #{escaped[escaped.length - 1]}"
@notifications.addError "Unable to open #{count}project #{noun}",
description: "Project #{noun} #{group} #{toBe} no longer on disk."
getStateKey: (paths) ->
if paths?.length > 0
sha1 = crypto.createHash('sha1').update(paths.slice().sort().join("\n")).digest('hex')

View File

@@ -30,6 +30,8 @@ class Project extends Model
@repositoryProviders = [new GitRepositoryProvider(this, config)]
@loadPromisesByPath = {}
@watcherPromisesByPath = {}
@retiredBufferIDs = new Set()
@retiredBufferPaths = new Set()
@consumeServices(packageManager)
destroyed: ->
@@ -47,6 +49,8 @@ class Project extends Model
@buffers = []
@setPaths([])
@loadPromisesByPath = {}
@retiredBufferIDs = new Set()
@retiredBufferPaths = new Set()
@consumeServices(packageManager)
destroyUnretainedBuffers: ->
@@ -58,21 +62,28 @@ class Project extends Model
###
deserialize: (state) ->
bufferPromises = []
for bufferState in state.buffers
continue if fs.isDirectorySync(bufferState.filePath)
if bufferState.filePath
try
fs.closeSync(fs.openSync(bufferState.filePath, 'r'))
catch error
continue unless error.code is 'ENOENT'
unless bufferState.shouldDestroyOnFileDelete?
bufferState.shouldDestroyOnFileDelete = ->
atom.config.get('core.closeDeletedFileTabs')
bufferPromises.push(TextBuffer.deserialize(bufferState))
Promise.all(bufferPromises).then (@buffers) =>
@retiredBufferIDs = new Set()
@retiredBufferPaths = new Set()
handleBufferState = (bufferState) =>
bufferState.shouldDestroyOnFileDelete ?= -> atom.config.get('core.closeDeletedFileTabs')
# Use a little guilty knowledge of the way TextBuffers are serialized.
# This allows TextBuffers that have never been saved (but have filePaths) to be deserialized, but prevents
# TextBuffers backed by files that have been deleted from being saved.
bufferState.mustExist = bufferState.digestWhenLastPersisted isnt false
TextBuffer.deserialize(bufferState).catch (err) =>
@retiredBufferIDs.add(bufferState.id)
@retiredBufferPaths.add(bufferState.filePath)
null
bufferPromises = (handleBufferState(bufferState) for bufferState in state.buffers)
Promise.all(bufferPromises).then (buffers) =>
@buffers = buffers.filter(Boolean)
@subscribeToBuffer(buffer) for buffer in @buffers
@setPaths(state.paths)
@setPaths(state.paths or [], mustExist: true, exact: true)
serialize: (options={}) ->
deserializer: 'Project'
@@ -211,7 +222,12 @@ class Project extends Model
# Public: Set the paths of the project's directories.
#
# * `projectPaths` {Array} of {String} paths.
setPaths: (projectPaths) ->
# * `options` An optional {Object} that may contain the following keys:
# * `mustExist` If `true`, throw an Error if any `projectPaths` do not exist. Any remaining `projectPaths` that
# do exist will still be added to the project. Default: `false`.
# * `exact` If `true`, only add a `projectPath` if it names an existing directory. If `false` and any `projectPath`
# is a file or does not exist, its parent directory will be added instead. Default: `false`.
setPaths: (projectPaths, options = {}) ->
repository?.destroy() for repository in @repositories
@rootDirectories = []
@repositories = []
@@ -219,16 +235,46 @@ class Project extends Model
watcher.then((w) -> w.dispose()) for _, watcher in @watcherPromisesByPath
@watcherPromisesByPath = {}
@addPath(projectPath, emitEvent: false) for projectPath in projectPaths
missingProjectPaths = []
for projectPath in projectPaths
try
@addPath projectPath, emitEvent: false, mustExist: true, exact: options.exact is true
catch e
if e.missingProjectPaths?
missingProjectPaths.push e.missingProjectPaths...
else
throw e
@emitter.emit 'did-change-paths', projectPaths
if options.mustExist is true and missingProjectPaths.length > 0
err = new Error "One or more project directories do not exist"
err.missingProjectPaths = missingProjectPaths
throw err
# Public: Add a path to the project's list of root paths
#
# * `projectPath` {String} The path to the directory to add.
addPath: (projectPath, options) ->
# * `options` An optional {Object} that may contain the following keys:
# * `mustExist` If `true`, throw an Error if the `projectPath` does not exist. If `false`, a `projectPath` that does
# not exist is ignored. Default: `false`.
# * `exact` If `true`, only add `projectPath` if it names an existing directory. If `false`, if `projectPath` is a
# a file or does not exist, its parent directory will be added instead.
addPath: (projectPath, options = {}) ->
directory = @getDirectoryForProjectPath(projectPath)
return unless directory.existsSync()
ok = true
ok = ok and directory.getPath() is projectPath if options.exact is true
ok = ok and directory.existsSync()
unless ok
if options.mustExist is true
err = new Error "Project directory #{directory} does not exist"
err.missingProjectPaths = [projectPath]
throw err
else
return
for existingDirectory in @getDirectories()
return if existingDirectory.getPath() is directory.getPath()
@@ -248,7 +294,7 @@ class Project extends Model
break if repo = provider.repositoryForDirectorySync?(directory)
@repositories.push(repo ? null)
unless options?.emitEvent is false
unless options.emitEvent is false
@emitter.emit 'did-change-paths', @getPaths()
getDirectoryForProjectPath: (projectPath) ->
@@ -412,11 +458,13 @@ class Project extends Model
# Only to be used in specs
bufferForPathSync: (filePath) ->
absoluteFilePath = @resolvePath(filePath)
return null if @retiredBufferPaths.has absoluteFilePath
existingBuffer = @findBufferForPath(absoluteFilePath) if filePath
existingBuffer ? @buildBufferSync(absoluteFilePath)
# Only to be used when deserializing
bufferForIdSync: (id) ->
return null if @retiredBufferIDs.has id
existingBuffer = @findBufferForId(id) if id
existingBuffer ? @buildBufferSync()

View File

@@ -128,7 +128,10 @@ class TextEditor extends Model
state.tokenizedBuffer = state.displayBuffer.tokenizedBuffer
try
state.tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment)
tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment)
return null unless tokenizedBuffer?
state.tokenizedBuffer = tokenizedBuffer
state.tabLength = state.tokenizedBuffer.getTabLength()
catch error
if error.syscall is 'read'

View File

@@ -23,11 +23,15 @@ class TokenizedBuffer extends Model
changeCount: 0
@deserialize: (state, atomEnvironment) ->
buffer = null
if state.bufferId
state.buffer = atomEnvironment.project.bufferForIdSync(state.bufferId)
buffer = atomEnvironment.project.bufferForIdSync(state.bufferId)
else
# TODO: remove this fallback after everyone transitions to the latest version.
state.buffer = atomEnvironment.project.bufferForPathSync(state.bufferPath)
buffer = atomEnvironment.project.bufferForPathSync(state.bufferPath)
return null unless buffer?
state.buffer = buffer
state.assert = atomEnvironment.assert
new this(state)