diff --git a/spec/integration/helpers/atom-launcher.sh b/spec/integration/helpers/atom-launcher.sh index 5cc3b8a12..905ed976c 100755 --- a/spec/integration/helpers/atom-launcher.sh +++ b/spec/integration/helpers/atom-launcher.sh @@ -6,10 +6,9 @@ # arguments, so this script accepts the following special switches: # # * `atom-path`: The path to the `Atom` binary. -# * `atom-arg`: A positional argument to pass to Atom. This flag can be specified -# multiple times. -# * `atom-env`: A key=value environment variable to set for Atom. This flag can -# be specified multiple times. +# * `atom-args`: A space-separated list of positional arguments to pass to Atom. +# * `atom-env`: A space-separated list of key=value pairs representing environment +# variables to set for Atom. # # Any other switches will be passed through to `Atom`. @@ -23,12 +22,18 @@ for arg in "$@"; do atom_path="${arg#*=}" ;; - --atom-arg=*) - atom_args+=(${arg#*=}) + --atom-args=*) + atom_arg_string="${arg#*=}" + for atom_arg in $atom_arg_string; do + atom_args+=($atom_arg) + done ;; --atom-env=*) - export ${arg#*=} + atom_env_string="${arg#*=}" + for atom_env_pair in $atom_env_string; do + export $atom_env_pair + done ;; *) diff --git a/spec/integration/helpers/start-atom.coffee b/spec/integration/helpers/start-atom.coffee index 9716b82de..8597dabdd 100644 --- a/spec/integration/helpers/start-atom.coffee +++ b/spec/integration/helpers/start-atom.coffee @@ -1,10 +1,10 @@ path = require "path" temp = require("temp").track() remote = require "remote" -{map, extend} = require "underscore-plus" +async = require "async" +{map, extend, once, difference} = require "underscore-plus" {spawn, spawnSync} = require "child_process" webdriverio = require "../../../build/node_modules/webdriverio" -async = require "async" AtomPath = remote.process.argv[0] AtomLauncherPath = path.join(__dirname, "..", "helpers", "atom-launcher.sh") @@ -12,77 +12,120 @@ ChromedriverPath = path.resolve(__dirname, '..', '..', '..', 'atom-shell', 'chro SocketPath = path.join(temp.mkdirSync("socket-dir"), "atom.sock") ChromedriverPort = 9515 -module.exports = - driverTest: (fn) -> - chromedriver = spawn(ChromedriverPath, [ - "--verbose", - "--port=#{ChromedriverPort}", - "--url-base=/wd/hub" - ]) +buildAtomClient = (args, env) -> + client = webdriverio.remote( + host: 'localhost' + port: ChromedriverPort + desiredCapabilities: + browserName: "atom" + chromeOptions: + binary: AtomLauncherPath + args: [ + "atom-path=#{AtomPath}" + "atom-args=#{args.join(" ")}" + "atom-env=#{map(env, (value, key) -> "#{key}=#{value}").join(" ")}" + "dev" + "safe" + "user-data-dir=#{temp.mkdirSync('atom-user-data-dir')}" + "socket-path=#{SocketPath}" + ]) - logs = [] + isRunning = false + client.on "init", -> isRunning = true + client.on "end", -> isRunning = false + + client + .addCommand "waitUntil", (conditionFn, timeout, cb) -> + timedOut = succeeded = false + pollingInterval = Math.min(timeout, 100) + setTimeout((-> timedOut = true), timeout) + async.until( + (-> succeeded or timedOut), + ((next) => + setTimeout(=> + conditionFn.call(this).then( + ((result) -> + succeeded = result + next()), + ((err) -> next(err)) + ) + , pollingInterval)), + ((err) -> cb(err, succeeded))) + + .addCommand "waitForWindowCount", (count, timeout, cb) -> + @waitUntil( + (-> @windowHandles().then(({value}) -> value.length is count)), + timeout) + .then((result) -> expect(result).toBe(true)) + .windowHandles(cb) + + .addCommand "waitForPaneItemCount", (count, timeout, cb) -> + @waitUntil((-> + @execute(-> atom.workspace?.getActivePane()?.getItems().length) + .then(({value}) -> value is count)), timeout) + .then (result) -> + expect(result).toBe(true) + cb(null) + + .addCommand("waitForNewWindow", (fn, timeout, done) -> + @windowHandles() + .then(({value}) -> + return done() unless isRunning + oldWindowHandles = value + @call(-> fn.call(this)) + .waitForWindowCount(oldWindowHandles.length + 1, 5000) + .then(({value}) -> + [newWindowHandle] = difference(value, oldWindowHandles) + @window(newWindowHandle, done)))) + + .addCommand "startAnotherAtom", (args, env, done) -> + @call -> + if isRunning + spawnSync(AtomPath, args.concat([ + "--dev" + "--safe" + "--socket-path=#{SocketPath}" + ]), env: extend({}, process.env, env)) + done() + +module.exports = (args, env, fn) -> + chromedriver = spawn(ChromedriverPath, [ + "--verbose", + "--port=#{ChromedriverPort}", + "--url-base=/wd/hub" + ]) + + waits(50) + + chromedriverLogs = [] + chromedriverExit = new Promise (resolve) -> errorCode = null chromedriver.on "exit", (code, signal) -> errorCode = code unless signal? chromedriver.stderr.on "data", (log) -> - logs.push(log.toString()) + chromedriverLogs.push(log.toString()) chromedriver.stderr.on "close", -> - if errorCode? - jasmine.getEnv().currentSpec.fail "Chromedriver exited. code: #{errorCode}. Logs: #{logs.join("\n")}" + resolve(errorCode) - waitsFor "webdriver steps to complete", (done) -> - fn() - .catch((error) -> jasmine.getEnv().currentSpec.fail(err.message)) + waitsFor("webdriver to finish", (done) -> + finish = once -> + client .end() - .call(done) - , 30000 + .then(-> chromedriver.kill()) + .then(chromedriverExit.then( + (errorCode) -> + if errorCode? + jasmine.getEnv().currentSpec.fail """ + Chromedriver exited with code #{errorCode}. + Logs:\n#{chromedriverLogs.join("\n")} + """ + done())) - runs -> chromedriver.kill() + client = buildAtomClient(args, env) - # Start Atom using chromedriver. - startAtom: (args, env={}) -> - webdriverio.remote( - host: 'localhost' - port: ChromedriverPort - desiredCapabilities: - browserName: "atom" - chromeOptions: - binary: AtomLauncherPath - args: [ - "atom-path=#{AtomPath}" - "dev" - "safe" - "user-data-dir=#{temp.mkdirSync('integration-spec-')}" - "socket-path=#{SocketPath}" - ] - .concat(map args, (arg) -> "atom-arg=#{arg}") - .concat(map env, (value, key) -> "atom-env=#{key}=#{value}")) - .init() - .addCommand "waitForCondition", (conditionFn, timeout, cb) -> - timedOut = succeeded = false - pollingInterval = Math.min(timeout, 100) + client.on "error", (err) -> + jasmine.getEnv().currentSpec.fail(JSON.stringify(err)) + finish() - setTimeout((-> timedOut = true), timeout) - - async.until( - (-> succeeded or timedOut), - ((next) => - setTimeout(=> - conditionFn.call(this).then( - ((result) -> - succeeded = result - next()), - ((err) -> next(err)) - ) - , pollingInterval)), - ((err) -> cb(err, succeeded)) - ) - - # Once one `Atom` window is open, subsequent invocations of `Atom` will exit - # immediately. - startAnotherAtom: (args, env={}) -> - spawnSync(AtomPath, args.concat([ - "--dev" - "--safe" - "--socket-path=#{SocketPath}" - ]), env: extend({}, process.env, env)) + fn(client.init()).then(finish) + , 30000) diff --git a/spec/integration/startup-spec.coffee b/spec/integration/startup-spec.coffee index 4a2a27f6a..4cc5b1525 100644 --- a/spec/integration/startup-spec.coffee +++ b/spec/integration/startup-spec.coffee @@ -7,34 +7,33 @@ fs = require "fs" path = require "path" temp = require("temp").track() AtomHome = temp.mkdirSync('atom-home') - fs.writeFileSync(path.join(AtomHome, 'config.cson'), fs.readFileSync(path.join(__dirname, 'fixtures', 'atom-home', 'config.cson'))) - -{startAtom, startAnotherAtom, driverTest} = require("./helpers/start-atom") +runAtom = require("./helpers/start-atom") describe "Starting Atom", -> beforeEach -> jasmine.useRealClock() describe "opening paths via commmand-line arguments", -> - [tempDirPath, tempFilePath] = [] + [tempDirPath, tempFilePath, otherTempDirPath] = [] beforeEach -> tempDirPath = temp.mkdirSync("empty-dir") + otherTempDirPath = temp.mkdirSync("another-temp-dir") tempFilePath = path.join(tempDirPath, "an-existing-file") - fs.writeFileSync(tempFilePath, "This was already here.") + fs.writeFileSync(tempFilePath, "This file was already here.") it "reuses existing windows when directories are reopened", -> - driverTest -> + runAtom [path.join(tempDirPath, "new-file")], {ATOM_HOME: AtomHome}, (client) -> + client - # Opening a new file creates one window with one empty text editor. - startAtom([path.join(tempDirPath, "new-file")], ATOM_HOME: AtomHome) + # Opening a new file creates one window with one empty text editor. .waitForExist("atom-text-editor", 5000) .then((exists) -> expect(exists).toBe true) - .windowHandles() - .then(({value}) -> expect(value.length).toBe 1) - .execute(-> atom.workspace.getActivePane().getItems().length) - .then(({value}) -> expect(value).toBe 1) + .waitForWindowCount(1, 1000) + .waitForPaneItemCount(1, 1000) + .execute(-> atom.project.getPaths()) + .then(({value}) -> expect(value).toEqual([tempDirPath])) # Typing in the editor changes its text. .execute(-> atom.workspace.getActiveTextEditor().getText()) @@ -46,25 +45,58 @@ describe "Starting Atom", -> # Opening an existing file in the same directory reuses the window and # adds a new tab for the file. - .call(-> startAnotherAtom([tempFilePath], ATOM_HOME: AtomHome)) - .waitForCondition( - (-> @execute((-> atom.workspace.getActivePane().getItems().length)).then ({value}) -> value is 2), - 5000) - .then((result) -> expect(result).toBe(true)) + .startAnotherAtom([tempFilePath], ATOM_HOME: AtomHome) + .waitForExist("atom-workspace") + .waitForPaneItemCount(2, 5000) + .waitForWindowCount(1, 1000) .execute(-> atom.workspace.getActiveTextEditor().getText()) - .then(({value}) -> expect(value).toBe "This was already here.") + .then(({value}) -> expect(value).toBe "This file was already here.") # Opening a different directory creates a second window with no # tabs open. - .call(-> startAnotherAtom([temp.mkdirSync("another-empty-dir")], ATOM_HOME: AtomHome)) - .waitForCondition( - (-> @windowHandles().then(({value}) -> value.length is 2)), - 5000) - .then((result) -> expect(result).toBe(true)) - .windowHandles() - .then(({value}) -> - @window(value[1]) - .waitForExist("atom-workspace", 5000) - .then((exists) -> expect(exists).toBe true) - .execute(-> atom.workspace.getActivePane().getItems().length) - .then(({value}) -> expect(value).toBe 0)) + .waitForNewWindow(-> + @startAnotherAtom([otherTempDirPath], ATOM_HOME: AtomHome) + , 5000) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(0, 1000) + + it "saves the state of closed windows", -> + runAtom [tempDirPath], {ATOM_HOME: AtomHome}, (client) -> + client + + # In a second window, opening a new buffer creates a new tab. + .waitForExist("atom-workspace", 5000) + .waitForNewWindow(-> + @startAnotherAtom([otherTempDirPath], ATOM_HOME: AtomHome) + , 5000) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(0, 3000) + .execute(-> atom.workspace.open()) + .waitForPaneItemCount(1, 3000) + + # Closing that window and reopening that directory shows the + # previously-created new buffer. + .execute(-> atom.unloadEditorWindow()) + .close() + .waitForWindowCount(1, 5000) + .waitForNewWindow(-> + @startAnotherAtom([otherTempDirPath], ATOM_HOME: AtomHome) + , 5000) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(1, 5000) + + it "allows multiple project directories to be passed as separate arguments", -> + runAtom [tempDirPath, otherTempDirPath], {ATOM_HOME: AtomHome}, (client) -> + client + .waitForExist("atom-workspace", 5000) + .then((exists) -> expect(exists).toBe true) + .execute(-> atom.project.getPaths()) + .then(({value}) -> expect(value).toEqual([tempDirPath, otherTempDirPath])) + + # Opening a file in one of the directories reuses the same window + # and does not change the project paths. + .startAnotherAtom([tempFilePath], ATOM_HOME: AtomHome) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(1, 5000) + .execute(-> atom.project.getPaths()) + .then(({value}) -> expect(value).toEqual([tempDirPath, otherTempDirPath])) diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee index 0a5e06b59..dbd0a0fbd 100644 --- a/spec/project-spec.coffee +++ b/spec/project-spec.coffee @@ -7,6 +7,7 @@ path = require 'path' BufferedProcess = require '../src/buffered-process' {Directory} = require 'pathwatcher' GitRepository = require '../src/git-repository' +temp = require "temp" describe "Project", -> beforeEach -> @@ -228,11 +229,33 @@ describe "Project", -> expect(atom.project.getDirectories()[0].path).toEqual path.dirname(require.resolve('./fixtures/dir/a')) describe "when path is a directory", -> - it "sets its path to the directory and updates the root directory", -> - directory = fs.absolute(path.join(__dirname, 'fixtures', 'dir', 'a-dir')) - atom.project.setPaths([directory]) - expect(atom.project.getPaths()[0]).toEqual directory - expect(atom.project.getDirectories()[0].path).toEqual directory + it "assigns the directories and repositories", -> + directory1 = temp.mkdirSync("non-git-repo") + directory2 = temp.mkdirSync("git-repo1") + directory3 = temp.mkdirSync("git-repo2") + + gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git')) + fs.copySync(gitDirPath, path.join(directory2, ".git")) + fs.copySync(gitDirPath, path.join(directory3, ".git")) + + atom.project.setPaths([directory1, directory2, directory3]) + + [repo1, repo2, repo3] = atom.project.getRepositories() + expect(repo1).toBeNull() + expect(repo2.getShortHead()).toBe "master" + expect(repo2.getPath()).toBe fs.realpathSync(path.join(directory2, ".git")) + expect(repo3.getShortHead()).toBe "master" + expect(repo3.getPath()).toBe fs.realpathSync(path.join(directory3, ".git")) + + it "calls callbacks registered with ::onDidChangePaths", -> + onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') + atom.project.onDidChangePaths(onDidChangePathsSpy) + + paths = [ temp.mkdirSync("dir1"), temp.mkdirSync("dir2") ] + atom.project.setPaths(paths) + + expect(onDidChangePathsSpy.callCount).toBe 1 + expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths) describe "when path is null", -> it "sets its path and root directory to null", -> @@ -245,6 +268,53 @@ 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)", -> + it "calls callbacks registered with ::onDidChangePaths", -> + onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') + atom.project.onDidChangePaths(onDidChangePathsSpy) + + [oldPath] = atom.project.getPaths() + + newPath = temp.mkdirSync("dir") + atom.project.addPath(newPath) + + expect(onDidChangePathsSpy.callCount).toBe 1 + expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath]) + + describe "when the project already has the path or one of its descendants", -> + it "doesn't add it again", -> + onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') + atom.project.onDidChangePaths(onDidChangePathsSpy) + + [oldPath] = atom.project.getPaths() + + atom.project.addPath(oldPath) + atom.project.addPath(path.join(oldPath, "some-file.txt")) + atom.project.addPath(path.join(oldPath, "a-dir")) + atom.project.addPath(path.join(oldPath, "a-dir", "oh-git")) + + expect(atom.project.getPaths()).toEqual([oldPath]) + expect(onDidChangePathsSpy).not.toHaveBeenCalled() + + describe ".relativize(path)", -> + it "returns the path, relative to whichever root directory it is inside of", -> + rootPath = atom.project.getPaths()[0] + childPath = path.join(rootPath, "some", "child", "directory") + expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory") + + it "returns the given path if it is not in any of the root directories", -> + randomPath = path.join("some", "random", "path") + expect(atom.project.relativize(randomPath)).toBe randomPath + + describe ".contains(path)", -> + it "returns whether or not the given path is in one of the root directories", -> + rootPath = atom.project.getPaths()[0] + childPath = path.join(rootPath, "some", "child", "directory") + expect(atom.project.contains(childPath)).toBe true + + randomPath = path.join("some", "random", "path") + expect(atom.project.contains(randomPath)).toBe false + describe ".eachBuffer(callback)", -> beforeEach -> atom.project.bufferForPathSync('a') diff --git a/spec/window-spec.coffee b/spec/window-spec.coffee index 1706e042e..883474e85 100644 --- a/spec/window-spec.coffee +++ b/spec/window-spec.coffee @@ -276,33 +276,31 @@ describe "Window", -> elements.trigger "core:focus-previous" expect(elements.find("[tabindex=1]:focus")).toExist() - describe "the window:open-path event", -> + describe "the window:open-locations event", -> beforeEach -> spyOn(atom.workspace, 'open') + atom.project.setPaths([]) - describe "when the project does not have a path", -> - beforeEach -> - atom.project.setPaths([]) + describe "when the opened path exists", -> + it "adds it to the project's paths", -> + pathToOpen = __filename + atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] + expect(atom.project.getPaths()[0]).toBe __dirname - describe "when the opened path exists", -> - it "sets the project path to the opened path", -> - atom.getCurrentWindow().send 'message', 'open-path', pathToOpen: __filename - expect(atom.project.getPaths()[0]).toBe __dirname - - describe "when the opened path does not exist but its parent directory does", -> - it "sets the project path to the opened path's parent directory", -> - pathToOpen = path.join(__dirname, 'this-path-does-not-exist.txt') - atom.getCurrentWindow().send 'message', 'open-path', {pathToOpen} - expect(atom.project.getPaths()[0]).toBe __dirname + describe "when the opened path does not exist but its parent directory does", -> + it "adds the parent directory to the project paths", -> + pathToOpen = path.join(__dirname, 'this-path-does-not-exist.txt') + atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] + expect(atom.project.getPaths()[0]).toBe __dirname describe "when the opened path is a file", -> it "opens it in the workspace", -> - atom.getCurrentWindow().send 'message', 'open-path', pathToOpen: __filename - + pathToOpen = __filename + atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] expect(atom.workspace.open.mostRecentCall.args[0]).toBe __filename describe "when the opened path is a directory", -> it "does not open it in the workspace", -> - atom.getCurrentWindow().send 'message', 'open-path', pathToOpen: __dirname - + pathToOpen = __dirname + atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] expect(atom.workspace.open.callCount).toBe 0 diff --git a/src/atom.coffee b/src/atom.coffee index 0036ba420..dcc8e0cfb 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -95,9 +95,9 @@ class Atom extends Model when 'spec' filename = 'spec' when 'editor' - {initialPath} = @getLoadSettings() - if initialPath - sha1 = crypto.createHash('sha1').update(initialPath).digest('hex') + {initialPaths} = @getLoadSettings() + if initialPaths + sha1 = crypto.createHash('sha1').update(initialPaths.join("\n")).digest('hex') filename = "editor-#{sha1}" if filename @@ -407,6 +407,17 @@ class Atom extends Model open: (options) -> ipc.send('open', options) + # Extended: Show the native dialog to prompt the user to select a folder. + # + # * `callback` A {Function} to call once the user has selected a folder. + # * `path` {String} the path to the folder the user selected. + pickFolder: (callback) -> + responseChannel = "atom-pick-folder-response" + ipc.on responseChannel, (path) -> + ipc.removeAllListeners(responseChannel) + callback(path) + ipc.send("pick-folder", responseChannel) + # Essential: Close the current window. close: -> @getCurrentWindow().close() @@ -693,7 +704,7 @@ class Atom extends Model Project = require './project' startTime = Date.now() - @project ?= @deserializers.deserialize(@state.project) ? new Project(paths: [@getLoadSettings().initialPath]) + @project ?= @deserializers.deserialize(@state.project) ? new Project() @deserializeTimings.project = Date.now() - startTime deserializeWorkspaceView: -> @@ -736,7 +747,7 @@ class Atom extends Model # Notify the browser project of the window's current project path watchProjectPath: -> onProjectPathChanged = => - ipc.send('window-command', 'project-path-changed', @project.getPaths()[0]) + ipc.send('window-command', 'project-path-changed', @project.getPaths()) @subscribe @project.onDidChangePaths(onProjectPathChanged) onProjectPathChanged() diff --git a/src/browser/atom-application.coffee b/src/browser/atom-application.coffee index f83e97aa0..2f46ba6d2 100644 --- a/src/browser/atom-application.coffee +++ b/src/browser/atom-application.coffee @@ -152,11 +152,11 @@ class AtomApplication @on 'application:quit', -> app.quit() @on 'application:new-window', -> @openPath(_.extend(windowDimensions: @focusedWindow()?.getDimensions(), getLoadSettings())) @on 'application:new-file', -> (@focusedWindow() ? this).openPath() - @on 'application:open', -> @promptForPath(_.extend(type: 'all', getLoadSettings())) - @on 'application:open-file', -> @promptForPath(_.extend(type: 'file', getLoadSettings())) - @on 'application:open-folder', -> @promptForPath(_.extend(type: 'folder', getLoadSettings())) - @on 'application:open-dev', -> @promptForPath(devMode: true) - @on 'application:open-safe', -> @promptForPath(safeMode: true) + @on 'application:open', -> @promptForPathToOpen('all', getLoadSettings()) + @on 'application:open-file', -> @promptForPathToOpen('file', getLoadSettings()) + @on 'application:open-folder', -> @promptForPathToOpen('folder', getLoadSettings()) + @on 'application:open-dev', -> @promptForPathToOpen('all', devMode: true) + @on 'application:open-safe', -> @promptForPathToOpen('all', safeMode: true) @on 'application:inspect', ({x,y, atomWindow}) -> atomWindow ?= @focusedWindow() atomWindow?.browserWindow.inspectElement(x, y) @@ -227,7 +227,7 @@ class AtomApplication else new AtomWindow(options) else - @promptForPath({window}) + @promptForPathToOpen('all', {window}) ipc.on 'update-application-menu', (event, template, keystrokesByCommand) => win = BrowserWindow.fromWebContents(event.sender) @@ -247,6 +247,10 @@ class AtomApplication win = BrowserWindow.fromWebContents(event.sender) win[method](args...) + ipc.on 'pick-folder', (event, responseChannel) => + @promptForPath "folder", (selectedPaths) -> + event.sender.send(responseChannel, selectedPaths) + clipboard = null ipc.on 'write-text-to-selection-clipboard', (event, selectedText) -> clipboard ?= require 'clipboard' @@ -307,9 +311,10 @@ class AtomApplication else @openPath({pathToOpen}) - # Returns the {AtomWindow} for the given path. - windowForPath: (pathToOpen) -> - _.find @windows, (atomWindow) -> atomWindow.containsPath(pathToOpen) + # Returns the {AtomWindow} for the given paths. + windowForPaths: (pathsToOpen, devMode) -> + _.find @windows, (atomWindow) -> + atomWindow.devMode is devMode and atomWindow.containsPaths(pathsToOpen) # Returns the {AtomWindow} for the given ipc event. windowForEvent: ({sender}) -> @@ -323,49 +328,39 @@ class AtomApplication # Public: Opens multiple paths, in existing windows if possible. # # options - - # :pathsToOpen - The array of file paths to open + # :pathToOpen - The file path to open # :pidToKillWhenClosed - The integer of the pid to kill # :newWindow - Boolean of whether this should be opened in a new window. # :devMode - Boolean to control the opened window's dev mode. # :safeMode - Boolean to control the opened window's safe mode. # :window - {AtomWindow} to open file paths in. - openPaths: ({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, window}) -> - for pathToOpen in pathsToOpen ? [] - @openPath({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, window}) + openPath: ({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, window}) -> + @openPaths({pathsToOpen: [pathToOpen], pidToKillWhenClosed, newWindow, devMode, safeMode, window}) # Public: Opens a single path, in an existing window if possible. # # options - - # :pathToOpen - The file path to open + # :pathsToOpen - The array of file paths to open # :pidToKillWhenClosed - The integer of the pid to kill # :newWindow - Boolean of whether this should be opened in a new window. # :devMode - Boolean to control the opened window's dev mode. # :safeMode - Boolean to control the opened window's safe mode. # :windowDimensions - Object with height and width keys. # :window - {AtomWindow} to open file paths in. - openPath: ({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, window}={}) -> - {pathToOpen, initialLine, initialColumn} = @locationForPathToOpen(pathToOpen) - pathToOpen = fs.normalize(pathToOpen) + openPaths: ({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, window}={}) -> + pathsToOpen = (fs.normalize(pathToOpen) for pathToOpen in pathsToOpen) + locationsToOpen = (@locationForPathToOpen(pathToOpen) for pathToOpen in pathsToOpen) unless pidToKillWhenClosed or newWindow - pathToOpenStat = fs.statSyncNoException(pathToOpen) + existingWindow = @windowForPaths(pathsToOpen, devMode) - # Default to using the specified window or the last focused window - currentWindow = window ? @lastFocusedWindow - - if pathToOpenStat.isFile?() - # Open the file in the current window - existingWindow = currentWindow - else if pathToOpenStat.isDirectory?() - # Open the folder in the current window if it doesn't have a path - existingWindow = currentWindow unless currentWindow?.hasProjectPath() - - # Don't reuse windows in dev mode - existingWindow ?= @windowForPath(pathToOpen) unless devMode + # Default to using the specified window or the last focused window + if pathsToOpen.every((pathToOpen) -> fs.statSyncNoException(pathToOpen).isFile?()) + existingWindow ?= window ? @lastFocusedWindow if existingWindow? openedWindow = existingWindow - openedWindow.openPath(pathToOpen, initialLine, initialColumn) + openedWindow.openLocations(locationsToOpen) if openedWindow.isMinimized() openedWindow.restore() else @@ -378,7 +373,7 @@ class AtomApplication bootstrapScript ?= require.resolve('../window-bootstrap') resourcePath ?= @resourcePath - openedWindow = new AtomWindow({pathToOpen, initialLine, initialColumn, bootstrapScript, resourcePath, devMode, safeMode, windowDimensions}) + openedWindow = new AtomWindow({locationsToOpen, bootstrapScript, resourcePath, devMode, safeMode, windowDimensions}) if pidToKillWhenClosed? @pidsToOpenWindows[pidToKillWhenClosed] = openedWindow @@ -497,8 +492,11 @@ class AtomApplication # :safeMode - A Boolean which controls whether any newly opened windows # should be in safe mode or not. # :window - An {AtomWindow} to use for opening a selected file path. - promptForPath: ({type, devMode, safeMode, window}={}) -> - type ?= 'all' + promptForPathToOpen: (type, {devMode, safeMode, window}) -> + @promptForPath type, (pathsToOpen) => + @openPaths({pathsToOpen, devMode, safeMode, window}) + + promptForPath: (type, callback) -> properties = switch type when 'file' then ['openFile'] @@ -523,5 +521,4 @@ class AtomApplication openOptions.defaultPath = projectPath dialog = require 'dialog' - dialog.showOpenDialog parentWindow, openOptions, (pathsToOpen) => - @openPaths({pathsToOpen, devMode, safeMode, window}) + dialog.showOpenDialog(parentWindow, openOptions, callback) diff --git a/src/browser/atom-window.coffee b/src/browser/atom-window.coffee index e2a5e6a0a..bf43b0241 100644 --- a/src/browser/atom-window.coffee +++ b/src/browser/atom-window.coffee @@ -18,7 +18,8 @@ class AtomWindow isSpec: null constructor: (settings={}) -> - {@resourcePath, pathToOpen, initialLine, initialColumn, @isSpec, @exitWhenDone, @safeMode, @devMode} = settings + {@resourcePath, pathToOpen, @locationsToOpen, @isSpec, @exitWhenDone, @safeMode, @devMode} = settings + @locationsToOpen ?= [{pathToOpen}] if pathToOpen # Normalize to make sure drive letter case is consistent on Windows @resourcePath = path.normalize(@resourcePath) if @resourcePath @@ -51,21 +52,24 @@ class AtomWindow @constructor.includeShellLoadTime = false loadSettings.shellLoadTime ?= Date.now() - global.shellStartTime - loadSettings.initialPath = pathToOpen - if fs.statSyncNoException(pathToOpen).isFile?() - loadSettings.initialPath = path.dirname(pathToOpen) + loadSettings.initialPaths = for {pathToOpen} in (@locationsToOpen ? []) + if fs.statSyncNoException(pathToOpen).isFile?() + path.dirname(pathToOpen) + else + pathToOpen + loadSettings.initialPaths.sort() @browserWindow.loadSettings = loadSettings @browserWindow.once 'window:loaded', => @emit 'window:loaded' @loaded = true - @browserWindow.on 'project-path-changed', (@projectPath) => + @browserWindow.on 'project-path-changed', (@projectPaths) => @browserWindow.loadUrl @getUrl(loadSettings) @browserWindow.focusOnWebView() if @isSpec - @openPath(pathToOpen, initialLine, initialColumn) unless @isSpecWindow() + @openLocations(@locationsToOpen) unless @isSpecWindow() getUrl: (loadSettingsObj) -> # Ignore the windowState when passing loadSettings via URL, since it could @@ -79,10 +83,7 @@ class AtomWindow slashes: true query: {loadSettings: JSON.stringify(loadSettings)} - hasProjectPath: -> @projectPath?.length > 0 - - getInitialPath: -> - @browserWindow.loadSettings.initialPath + hasProjectPath: -> @projectPaths?.length > 0 setupContextMenu: -> ContextMenu = null @@ -91,20 +92,25 @@ class AtomWindow ContextMenu ?= require './context-menu' new ContextMenu(menuTemplate, this) + containsPaths: (paths) -> + for pathToCheck in paths + return false unless @containsPath(pathToCheck) + true + containsPath: (pathToCheck) -> - initialPath = @getInitialPath() - if not initialPath - false - else if not pathToCheck - false - else if pathToCheck is initialPath - true - else if fs.statSyncNoException(pathToCheck).isDirectory?() - false - else if pathToCheck.indexOf(path.join(initialPath, path.sep)) is 0 - true - else - false + @projectPaths.some (projectPath) -> + if not projectPath + false + else if not pathToCheck + false + else if pathToCheck is projectPath + true + else if fs.statSyncNoException(pathToCheck).isDirectory?() + false + else if pathToCheck.indexOf(path.join(projectPath, path.sep)) is 0 + true + else + false handleEvents: -> @browserWindow.on 'closed', => @@ -148,11 +154,14 @@ class AtomWindow @browserWindow.focusOnWebView() unless @isWindowClosing openPath: (pathToOpen, initialLine, initialColumn) -> + @openLocations([{pathToOpen, initialLine, initialColumn}]) + + openLocations: (locationsToOpen) -> if @loaded @focus() - @sendMessage 'open-path', {pathToOpen, initialLine, initialColumn} + @sendMessage 'open-locations', locationsToOpen else - @browserWindow.once 'window:loaded', => @openPath(pathToOpen, initialLine, initialColumn) + @browserWindow.once 'window:loaded', => @openLocations(locationsToOpen) sendMessage: (message, detail) -> @browserWindow.webContents.send 'message', message, detail diff --git a/src/project.coffee b/src/project.coffee index b97293310..150e28d9a 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -38,6 +38,8 @@ class Project extends Model constructor: ({path, paths, @buffers}={}) -> @emitter = new Emitter @buffers ?= [] + @rootDirectories = [] + @repositories = [] # Mapping from the real path of a {Directory} to a {Promise} that resolves # to either a {Repository} or null. Ideally, the {Directory} would be used @@ -61,12 +63,7 @@ class Project extends Model destroyed: -> buffer.destroy() for buffer in @getBuffers() - @destroyRepo() - - destroyRepo: -> - if @repo? - @repo.destroy() - @repo = null + @setPaths([]) destroyUnretainedBuffers: -> buffer.destroy() for buffer in @getBuffers() when not buffer.isRetained() @@ -118,10 +115,10 @@ class Project extends Model # Promise.all(project.getDirectories().map( # project.repositoryForDirectory.bind(project))) # ``` - getRepositories: -> _.compact([@repo]) + getRepositories: -> @repositories getRepo: -> Grim.deprecate("Use ::getRepositories instead") - @repo + @getRepositories()[0] # Public: Get the repository for a given directory asynchronously. # @@ -154,43 +151,62 @@ class Project extends Model # Public: Get an {Array} of {String}s containing the paths of the project's # directories. - getPaths: -> _.compact([@rootDirectory?.path]) + getPaths: -> rootDirectory.path for rootDirectory in @rootDirectories getPath: -> Grim.deprecate("Use ::getPaths instead") - @rootDirectory?.path + @getPaths()[0] # Public: Set the paths of the project's directories. # # * `projectPaths` {Array} of {String} paths. setPaths: (projectPaths) -> - [projectPath] = projectPaths - projectPath = path.normalize(projectPath) if projectPath - @path = projectPath - @rootDirectory?.off() + rootDirectory.off() for rootDirectory in @rootDirectories + repository?.destroy() for repository in @repositories + @rootDirectories = [] + @repositories = [] - @destroyRepo() - if projectPath? - directory = if fs.isDirectorySync(projectPath) then projectPath else path.dirname(projectPath) - @rootDirectory = new Directory(directory) - - # For now, use only the repositoryProviders with a sync API. - for provider in @repositoryProviders - break if @repo = provider.repositoryForDirectorySync?(@rootDirectory) - else - @rootDirectory = null + @addPath(projectPath, emitEvent: false) for projectPath in projectPaths @emit "path-changed" @emitter.emit 'did-change-paths', projectPaths + setPath: (path) -> Grim.deprecate("Use ::setPaths instead") @setPaths([path]) + # Public: Add a path the project's list of root paths + # + # * `projectPath` {String} The path to the directory to add. + addPath: (projectPath, options) -> + projectPath = path.normalize(projectPath) + + directoryPath = if fs.isDirectorySync(projectPath) + projectPath + else + path.dirname(projectPath) + + return if @getPaths().some (existingPath) -> + (directoryPath is existingPath) or + (directoryPath.indexOf(path.join(existingPath, path.sep)) is 0) + + directory = new Directory(directoryPath) + @rootDirectories.push(directory) + + repo = null + for provider in @repositoryProviders + break if repo = provider.repositoryForDirectorySync?(directory) + @repositories.push(repo ? null) + + unless options?.emitEvent is false + @emit "path-changed" + @emitter.emit 'did-change-paths', @getPaths() + # Public: Get an {Array} of {Directory}s associated with this project. getDirectories: -> - [@rootDirectory] + @rootDirectories getRootDirectory: -> Grim.deprecate("Use ::getDirectories instead") - @rootDirectory + @getDirectories()[0] resolve: (uri) -> Grim.deprecate("Use `Project::getDirectories()[0]?.resolve()` instead") @@ -204,6 +220,8 @@ class Project extends Model else if fs.isAbsolute(uri) path.normalize(fs.absolute(uri)) + + # TODO: what should we do here when there are multiple directories? else if projectPath = @getPaths()[0] path.normalize(fs.absolute(path.join(projectPath, uri))) else @@ -214,7 +232,10 @@ class Project extends Model # * `fullPath` {String} full path relativize: (fullPath) -> return fullPath if fullPath?.match(/[A-Za-z0-9+-.]+:\/\//) # leave path alone if it has a scheme - @rootDirectory?.relativize(fullPath) ? fullPath + for rootDirectory in @rootDirectories + if (relativePath = rootDirectory.relativize(fullPath))? + return relativePath + fullPath # Public: Determines whether the given path (real or symbolic) is inside the # project's directory. @@ -244,7 +265,7 @@ class Project extends Model # # Returns whether the path is inside the project's root directory. contains: (pathToCheck) -> - @rootDirectory?.contains(pathToCheck) ? false + @rootDirectories.some (dir) -> dir.contains(pathToCheck) ### Section: Searching and Replacing diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index d2a79b2a4..6d68f3437 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -17,15 +17,13 @@ class WindowEventHandler @subscribe ipc, 'message', (message, detail) -> switch message - when 'open-path' - {pathToOpen, initialLine, initialColumn} = detail + when 'open-locations' + for {pathToOpen, initialLine, initialColumn} in detail + if pathToOpen and (fs.existsSync(pathToOpen) or fs.existsSync(path.dirname(pathToOpen))) + atom.project?.addPath(pathToOpen) - unless atom.project?.getPaths().length - if fs.existsSync(pathToOpen) or fs.existsSync(path.dirname(pathToOpen)) - atom.project?.setPaths([pathToOpen]) - - unless fs.isDirectorySync(pathToOpen) - atom.workspace?.open(pathToOpen, {initialLine, initialColumn}) + unless fs.isDirectorySync(pathToOpen) + atom.workspace?.open(pathToOpen, {initialLine, initialColumn}) when 'update-available' atom.updateAvailable(detail)