Files
atom/src/project.js

714 lines
24 KiB
JavaScript

const path = require('path')
const _ = require('underscore-plus')
const fs = require('fs-plus')
const {Emitter, Disposable} = require('event-kit')
const TextBuffer = require('text-buffer')
const {watchPath} = require('./path-watcher')
const DefaultDirectoryProvider = require('./default-directory-provider')
const Model = require('./model')
const GitRepositoryProvider = require('./git-repository-provider')
// Extended: Represents a project that's opened in Atom.
//
// An instance of this class is always available as the `atom.project` global.
module.exports =
class Project extends Model {
/*
Section: Construction and Destruction
*/
constructor ({notificationManager, packageManager, config, applicationDelegate}) {
super()
this.notificationManager = notificationManager
this.applicationDelegate = applicationDelegate
this.emitter = new Emitter()
this.buffers = []
this.rootDirectories = []
this.repositories = []
this.directoryProviders = []
this.defaultDirectoryProvider = new DefaultDirectoryProvider()
this.repositoryPromisesByPath = new Map()
this.repositoryProviders = [new GitRepositoryProvider(this, config)]
this.loadPromisesByPath = {}
this.watcherPromisesByPath = {}
this.retiredBufferIDs = new Set()
this.retiredBufferPaths = new Set()
this.consumeServices(packageManager)
}
destroyed () {
for (let buffer of this.buffers.slice()) { buffer.destroy() }
for (let repository of this.repositories.slice()) {
if (repository != null) repository.destroy()
}
for (let path in this.watcherPromisesByPath) {
this.watcherPromisesByPath[path].then(watcher => { watcher.dispose() })
}
this.rootDirectories = []
this.repositories = []
}
reset (packageManager) {
this.emitter.dispose()
this.emitter = new Emitter()
for (let buffer of this.buffers) {
if (buffer != null) buffer.destroy()
}
this.buffers = []
this.setPaths([])
this.loadPromisesByPath = {}
this.retiredBufferIDs = new Set()
this.retiredBufferPaths = new Set()
this.consumeServices(packageManager)
}
destroyUnretainedBuffers () {
for (let buffer of this.getBuffers()) {
if (!buffer.isRetained()) buffer.destroy()
}
}
/*
Section: Serialization
*/
deserialize (state) {
this.retiredBufferIDs = new Set()
this.retiredBufferPaths = new Set()
const handleBufferState = (bufferState) => {
if (bufferState.shouldDestroyOnFileDelete == null) {
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 !== false
return TextBuffer.deserialize(bufferState).catch((_) => {
this.retiredBufferIDs.add(bufferState.id)
this.retiredBufferPaths.add(bufferState.filePath)
return null
})
}
const bufferPromises = []
for (let bufferState of state.buffers) {
bufferPromises.push(handleBufferState(bufferState))
}
return Promise.all(bufferPromises).then(buffers => {
this.buffers = buffers.filter(Boolean)
for (let buffer of this.buffers) {
this.subscribeToBuffer(buffer)
}
this.setPaths(state.paths || [], {mustExist: true, exact: true})
})
}
serialize (options = {}) {
return {
deserializer: 'Project',
paths: this.getPaths(),
buffers: _.compact(this.buffers.map(function (buffer) {
if (buffer.isRetained()) {
const isUnloading = options.isUnloading === true
return buffer.serialize({markerLayers: isUnloading, history: isUnloading})
}
}))
}
}
/*
Section: Event Subscription
*/
// Public: Invoke the given callback when the project paths change.
//
// * `callback` {Function} to be called after the project paths change.
// * `projectPaths` An {Array} of {String} project paths.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangePaths (callback) {
return this.emitter.on('did-change-paths', callback)
}
// Public: Invoke the given callback when a text buffer is added to the
// project.
//
// * `callback` {Function} to be called when a text buffer is added.
// * `buffer` A {TextBuffer} item.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidAddBuffer (callback) {
return this.emitter.on('did-add-buffer', callback)
}
// Public: Invoke the given callback with all current and future text
// buffers in the project.
//
// * `callback` {Function} to be called with current and future text buffers.
// * `buffer` A {TextBuffer} item.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeBuffers (callback) {
for (let buffer of this.getBuffers()) { callback(buffer) }
return this.onDidAddBuffer(callback)
}
// Extended: Invoke a callback when a filesystem change occurs within any open
// project path.
//
// ```js
// const disposable = atom.project.onDidChangeFiles(events => {
// for (const event of events) {
// // "created", "modified", "deleted", or "renamed"
// console.log(`Event action: ${event.type}`)
//
// // absolute path to the filesystem entry that was touched
// console.log(`Event path: ${event.path}`)
//
// if (event.type === 'renamed') {
// console.log(`.. renamed from: ${event.oldPath}`)
// }
// }
// }
//
// disposable.dispose()
// ```
//
// To watch paths outside of open projects, use the `watchPaths` function instead; see {PathWatcher}.
//
// When writing tests against functionality that uses this method, be sure to wait for the
// {Promise} returned by {getWatcherPromise()} before manipulating the filesystem to ensure that
// the watcher is receiving events.
//
// * `callback` {Function} to be called with batches of filesystem events reported by
// the operating system.
// * `events` An {Array} of objects that describe a batch of filesystem events.
// * `action` {String} describing the filesystem action that occurred. One of `"created"`,
// `"modified"`, `"deleted"`, or `"renamed"`.
// * `path` {String} containing the absolute path to the filesystem entry
// that was acted upon.
// * `oldPath` For rename events, {String} containing the filesystem entry's
// former absolute path.
//
// Returns a {Disposable} to manage this event subscription.
onDidChangeFiles (callback) {
return this.emitter.on('did-change-files', callback)
}
/*
Section: Accessing the git repository
*/
// Public: Get an {Array} of {GitRepository}s associated with the project's
// directories.
//
// This method will be removed in 2.0 because it does synchronous I/O.
// Prefer the following, which evaluates to a {Promise} that resolves to an
// {Array} of {Repository} objects:
// ```
// Promise.all(atom.project.getDirectories().map(
// atom.project.repositoryForDirectory.bind(atom.project)))
// ```
getRepositories () {
return this.repositories
}
// Public: Get the repository for a given directory asynchronously.
//
// * `directory` {Directory} for which to get a {Repository}.
//
// Returns a {Promise} that resolves with either:
// * {Repository} if a repository can be created for the given directory
// * `null` if no repository can be created for the given directory.
repositoryForDirectory (directory) {
const pathForDirectory = directory.getRealPathSync()
let promise = this.repositoryPromisesByPath.get(pathForDirectory)
if (!promise) {
const promises = this.repositoryProviders.map((provider) =>
provider.repositoryForDirectory(directory)
)
promise = Promise.all(promises).then((repositories) => {
const repo = repositories.find((repo) => repo != null) || null
// If no repository is found, remove the entry for the directory in
// @repositoryPromisesByPath in case some other RepositoryProvider is
// registered in the future that could supply a Repository for the
// directory.
if (repo == null) this.repositoryPromisesByPath.delete(pathForDirectory)
if (repo && repo.onDidDestroy) {
repo.onDidDestroy(() => this.repositoryPromisesByPath.delete(pathForDirectory))
}
return repo
})
this.repositoryPromisesByPath.set(pathForDirectory, promise)
}
return promise
}
/*
Section: Managing Paths
*/
// Public: Get an {Array} of {String}s containing the paths of the project's
// directories.
getPaths () {
return this.rootDirectories.map((rootDirectory) => rootDirectory.getPath())
}
// Public: Set the paths of the project's directories.
//
// * `projectPaths` {Array} of {String} paths.
// * `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 = {}) {
for (let repository of this.repositories) {
if (repository != null) repository.destroy()
}
this.rootDirectories = []
this.repositories = []
for (let path in this.watcherPromisesByPath) {
this.watcherPromisesByPath[path].then(watcher => { watcher.dispose() })
}
this.watcherPromisesByPath = {}
const missingProjectPaths = []
for (let projectPath of projectPaths) {
try {
this.addPath(projectPath, {emitEvent: false, mustExist: true, exact: options.exact === true})
} catch (e) {
if (e.missingProjectPaths != null) {
missingProjectPaths.push(...e.missingProjectPaths)
} else {
throw e
}
}
}
this.emitter.emit('did-change-paths', projectPaths)
if ((options.mustExist === true) && (missingProjectPaths.length > 0)) {
const 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.
// * `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 = {}) {
const directory = this.getDirectoryForProjectPath(projectPath)
let ok = true
if (options.exact === true) {
ok = (directory.getPath() === projectPath)
}
ok = ok && directory.existsSync()
if (!ok) {
if (options.mustExist === true) {
const err = new Error(`Project directory ${directory} does not exist`)
err.missingProjectPaths = [projectPath]
throw err
} else {
return
}
}
for (let existingDirectory of this.getDirectories()) {
if (existingDirectory.getPath() === directory.getPath()) { return }
}
this.rootDirectories.push(directory)
const didChangeCallback = events => {
// Stop event delivery immediately on removal of a rootDirectory, even if its watcher
// promise has yet to resolve at the time of removal
if (this.rootDirectories.includes(directory)) {
this.emitter.emit('did-change-files', events)
}
}
// We'll use the directory's custom onDidChangeFiles callback, if available.
// CustomDirectory::onDidChangeFiles should match the signature of
// Project::onDidChangeFiles below (although it may resolve asynchronously)
this.watcherPromisesByPath[directory.getPath()] =
directory.onDidChangeFiles != null
? Promise.resolve(directory.onDidChangeFiles(didChangeCallback))
: watchPath(directory.getPath(), {}, didChangeCallback)
for (let watchedPath in this.watcherPromisesByPath) {
if (!this.rootDirectories.find(dir => dir.getPath() === watchedPath)) {
this.watcherPromisesByPath[watchedPath].then(watcher => { watcher.dispose() })
}
}
let repo = null
for (let provider of this.repositoryProviders) {
if (provider.repositoryForDirectorySync) {
repo = provider.repositoryForDirectorySync(directory)
}
if (repo) { break }
}
this.repositories.push(repo != null ? repo : null)
if (options.emitEvent !== false) {
this.emitter.emit('did-change-paths', this.getPaths())
}
}
getDirectoryForProjectPath (projectPath) {
let directory = null
for (let provider of this.directoryProviders) {
if (typeof provider.directoryForURISync === 'function') {
directory = provider.directoryForURISync(projectPath)
if (directory) break
}
}
if (directory == null) {
directory = this.defaultDirectoryProvider.directoryForURISync(projectPath)
}
return directory
}
// Extended: Access a {Promise} that resolves when the filesystem watcher associated with a project
// root directory is ready to begin receiving events.
//
// This is especially useful in test cases, where it's important to know that the watcher is
// ready before manipulating the filesystem to produce events.
//
// * `projectPath` {String} One of the project's root directories.
//
// Returns a {Promise} that resolves with the {PathWatcher} associated with this project root
// once it has initialized and is ready to start sending events. The Promise will reject with
// an error instead if `projectPath` is not currently a root directory.
getWatcherPromise (projectPath) {
return this.watcherPromisesByPath[projectPath] ||
Promise.reject(new Error(`${projectPath} is not a project root`))
}
// Public: remove a path from the project's list of root paths.
//
// * `projectPath` {String} The path to remove.
removePath (projectPath) {
// The projectPath may be a URI, in which case it should not be normalized.
if (!this.getPaths().includes(projectPath)) {
projectPath = this.defaultDirectoryProvider.normalizePath(projectPath)
}
let indexToRemove = null
for (let i = 0; i < this.rootDirectories.length; i++) {
const directory = this.rootDirectories[i]
if (directory.getPath() === projectPath) {
indexToRemove = i
break
}
}
if (indexToRemove != null) {
this.rootDirectories.splice(indexToRemove, 1)
const [removedRepository] = this.repositories.splice(indexToRemove, 1)
if (!this.repositories.includes(removedRepository)) {
if (removedRepository) removedRepository.destroy()
}
if (this.watcherPromisesByPath[projectPath] != null) {
this.watcherPromisesByPath[projectPath].then(w => w.dispose())
}
delete this.watcherPromisesByPath[projectPath]
this.emitter.emit('did-change-paths', this.getPaths())
return true
} else {
return false
}
}
// Public: Get an {Array} of {Directory}s associated with this project.
getDirectories () {
return this.rootDirectories
}
resolvePath (uri) {
if (!uri) { return }
if (uri.match(/[A-Za-z0-9+-.]+:\/\//)) { // leave path alone if it has a scheme
return uri
} else {
let projectPath
if (fs.isAbsolute(uri)) {
return this.defaultDirectoryProvider.normalizePath(fs.resolveHome(uri))
// TODO: what should we do here when there are multiple directories?
} else if ((projectPath = this.getPaths()[0])) {
return this.defaultDirectoryProvider.normalizePath(fs.resolveHome(path.join(projectPath, uri)))
} else {
return undefined
}
}
}
relativize (fullPath) {
return this.relativizePath(fullPath)[1]
}
// Public: Get the path to the project directory that contains the given path,
// and the relative path from that project directory to the given path.
//
// * `fullPath` {String} An absolute path.
//
// Returns an {Array} with two elements:
// * `projectPath` The {String} path to the project directory that contains the
// given path, or `null` if none is found.
// * `relativePath` {String} The relative path from the project directory to
// the given path.
relativizePath (fullPath) {
let result = [null, fullPath]
if (fullPath != null) {
for (let rootDirectory of this.rootDirectories) {
const relativePath = rootDirectory.relativize(fullPath)
if ((relativePath != null) && (relativePath.length < result[1].length)) {
result = [rootDirectory.getPath(), relativePath]
}
}
}
return result
}
// Public: Determines whether the given path (real or symbolic) is inside the
// project's directory.
//
// This method does not actually check if the path exists, it just checks their
// locations relative to each other.
//
// ## Examples
//
// Basic operation
//
// ```coffee
// # Project's root directory is /foo/bar
// project.contains('/foo/bar/baz') # => true
// project.contains('/usr/lib/baz') # => false
// ```
//
// Existence of the path is not required
//
// ```coffee
// # Project's root directory is /foo/bar
// fs.existsSync('/foo/bar/baz') # => false
// project.contains('/foo/bar/baz') # => true
// ```
//
// * `pathToCheck` {String} path
//
// Returns whether the path is inside the project's root directory.
contains (pathToCheck) {
return this.rootDirectories.some(dir => dir.contains(pathToCheck))
}
/*
Section: Private
*/
consumeServices ({serviceHub}) {
serviceHub.consume(
'atom.directory-provider',
'^0.1.0',
provider => {
this.directoryProviders.unshift(provider)
return new Disposable(() => {
return this.directoryProviders.splice(this.directoryProviders.indexOf(provider), 1)
})
})
return serviceHub.consume(
'atom.repository-provider',
'^0.1.0',
provider => {
this.repositoryProviders.unshift(provider)
if (this.repositories.includes(null)) { this.setPaths(this.getPaths()) }
return new Disposable(() => {
return this.repositoryProviders.splice(this.repositoryProviders.indexOf(provider), 1)
})
})
}
// Retrieves all the {TextBuffer}s in the project; that is, the
// buffers for all open files.
//
// Returns an {Array} of {TextBuffer}s.
getBuffers () {
return this.buffers.slice()
}
// Is the buffer for the given path modified?
isPathModified (filePath) {
const bufferForPath = this.findBufferForPath(this.resolvePath(filePath))
return bufferForPath && bufferForPath.isModified()
}
findBufferForPath (filePath) {
return _.find(this.buffers, buffer => buffer.getPath() === filePath)
}
findBufferForId (id) {
return _.find(this.buffers, buffer => buffer.getId() === id)
}
// Only to be used in specs
bufferForPathSync (filePath) {
const absoluteFilePath = this.resolvePath(filePath)
if (this.retiredBufferPaths.has(absoluteFilePath)) { return null }
let existingBuffer
if (filePath) { existingBuffer = this.findBufferForPath(absoluteFilePath) }
return existingBuffer != null ? existingBuffer : this.buildBufferSync(absoluteFilePath)
}
// Only to be used when deserializing
bufferForIdSync (id) {
if (this.retiredBufferIDs.has(id)) { return null }
let existingBuffer
if (id) { existingBuffer = this.findBufferForId(id) }
return existingBuffer != null ? existingBuffer : this.buildBufferSync()
}
// Given a file path, this retrieves or creates a new {TextBuffer}.
//
// If the `filePath` already has a `buffer`, that value is used instead. Otherwise,
// `text` is used as the contents of the new buffer.
//
// * `filePath` A {String} representing a path. If `null`, an "Untitled" buffer is created.
//
// Returns a {Promise} that resolves to the {TextBuffer}.
bufferForPath (absoluteFilePath) {
let existingBuffer
if (absoluteFilePath != null) { existingBuffer = this.findBufferForPath(absoluteFilePath) }
if (existingBuffer) {
return Promise.resolve(existingBuffer)
} else {
return this.buildBuffer(absoluteFilePath)
}
}
shouldDestroyBufferOnFileDelete () {
return atom.config.get('core.closeDeletedFileTabs')
}
// Still needed when deserializing a tokenized buffer
buildBufferSync (absoluteFilePath) {
const params = {shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete}
let buffer
if (absoluteFilePath != null) {
buffer = TextBuffer.loadSync(absoluteFilePath, params)
} else {
buffer = new TextBuffer(params)
}
this.addBuffer(buffer)
return buffer
}
// Given a file path, this sets its {TextBuffer}.
//
// * `absoluteFilePath` A {String} representing a path.
// * `text` The {String} text to use as a buffer.
//
// Returns a {Promise} that resolves to the {TextBuffer}.
buildBuffer (absoluteFilePath) {
const params = {shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete}
let promise
if (absoluteFilePath != null) {
if (this.loadPromisesByPath[absoluteFilePath] == null) {
this.loadPromisesByPath[absoluteFilePath] =
TextBuffer.load(absoluteFilePath, params).catch(error => {
delete this.loadPromisesByPath[absoluteFilePath]
throw error
})
}
promise = this.loadPromisesByPath[absoluteFilePath]
} else {
promise = Promise.resolve(new TextBuffer(params))
}
return promise.then(buffer => {
delete this.loadPromisesByPath[absoluteFilePath]
this.addBuffer(buffer)
return buffer
})
}
addBuffer (buffer, options = {}) {
return this.addBufferAtIndex(buffer, this.buffers.length, options)
}
addBufferAtIndex (buffer, index, options = {}) {
this.buffers.splice(index, 0, buffer)
this.subscribeToBuffer(buffer)
this.emitter.emit('did-add-buffer', buffer)
return buffer
}
// Removes a {TextBuffer} association from the project.
//
// Returns the removed {TextBuffer}.
removeBuffer (buffer) {
const index = this.buffers.indexOf(buffer)
if (index !== -1) { return this.removeBufferAtIndex(index) }
}
removeBufferAtIndex (index, options = {}) {
const [buffer] = this.buffers.splice(index, 1)
return (buffer != null ? buffer.destroy() : undefined)
}
eachBuffer (...args) {
let subscriber
if (args.length > 1) { subscriber = args.shift() }
const callback = args.shift()
for (let buffer of this.getBuffers()) { callback(buffer) }
if (subscriber) {
return subscriber.subscribe(this, 'buffer-created', buffer => callback(buffer))
} else {
return this.on('buffer-created', buffer => callback(buffer))
}
}
subscribeToBuffer (buffer) {
buffer.onWillSave(({path}) => this.applicationDelegate.emitWillSavePath(path))
buffer.onDidSave(({path}) => this.applicationDelegate.emitDidSavePath(path))
buffer.onDidDestroy(() => this.removeBuffer(buffer))
buffer.onDidChangePath(() => {
if (!(this.getPaths().length > 0)) {
this.setPaths([path.dirname(buffer.getPath())])
}
})
buffer.onWillThrowWatchError(({error, handle}) => {
handle()
const message =
`Unable to read file after file \`${error.eventType}\` event.` +
`Make sure you have permission to access \`${buffer.getPath()}\`.`
this.notificationManager.addWarning(message, {
detail: error.message,
dismissable: true
})
})
}
}