diff --git a/.gitignore b/.gitignore index 15d8caaf4..678779b26 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ .project .svn .nvm-version -atom-build atom.xcodeproj build .xcodebuild-info @@ -10,3 +9,4 @@ node_modules npm-debug.log /tags /cef/ +/sources.gypi diff --git a/.gitmodules b/.gitmodules index d843b2761..5e7fd303a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -51,7 +51,7 @@ url = https://github.com/mmcgrana/textmate-clojure [submodule "prebuilt-cef"] path = prebuilt-cef - url = git@github.com:github/prebuilt-cef.git + url = https://github.com/github/prebuilt-cef [submodule "vendor/packages/yaml.tmbundle"] path = vendor/packages/yaml.tmbundle url = https://github.com/textmate/yaml.tmbundle.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/README.md b/README.md index 11eee545e..715e0b5d4 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # Atom — Futuristic Text Editing -![atom](http://f.cl.ly/items/3h1L1O333p1d0W3D2K3r/atom-sketch.jpg) +![atom](https://f.cloud.github.com/assets/1300064/208230/4cefbca4-821a-11e2-8139-92c0328abf68.png) Check out our [documentation on the docs tab](https://github.com/github/atom/docs). ## Building from source -Requirements +### Requirements -**Mountain Lion** + * Mountain Lion + * The Setup™ or Boxen + * Xcode (available in the App Store) -**The Setup™** +### Installation -**Xcode** (Get Xcode from the App Store (ugh, I know)) + 1. `gh-setup atom` -1. gh-setup atom - -2. cd ~/github/atom && `rake install` + 2. `cd ~/github/atom && rake install` diff --git a/Rakefile b/Rakefile index 2b846e966..5893ed201 100644 --- a/Rakefile +++ b/Rakefile @@ -1,9 +1,9 @@ ATOM_SRC_PATH = File.dirname(__FILE__) -BUILD_DIR = 'atom-build' +BUILD_DIR = '/tmp/atom-build' desc "Build Atom via `xcodebuild`" task :build => "create-xcode-project" do - command = "xcodebuild -target Atom -configuration Release SYMROOT=#{BUILD_DIR}" + command = "xcodebuild -target Atom SYMROOT=#{BUILD_DIR}" output = `#{command}` if $?.exitstatus != 0 $stderr.puts "Error #{$?.exitstatus}:\n#{output}" @@ -14,6 +14,7 @@ end desc "Create xcode project from gyp file" task "create-xcode-project" => "update-cef" do `rm -rf atom.xcodeproj` + `script/generate-sources-gypi` `gyp --depth=. -D CODE_SIGN="#{ENV['CODE_SIGN']}" atom.gyp` end @@ -42,13 +43,17 @@ task :install => [:clean, :build] do # Install Atom.app dest_path = "/Applications/#{File.basename(path)}" `rm -rf #{dest_path}` - `cp -r #{path} #{File.expand_path(dest_path)}` + `cp -a #{path} #{File.expand_path(dest_path)}` # Install atom cli if File.directory?("/opt/boxen") cli_path = "/opt/boxen/bin/atom" - else + elsif File.directory?("/opt/github") cli_path = "/opt/github/bin/atom" + elsif File.directory?("/usr/local") + cli_path = "/usr/local/bin/atom" + else + raise "Missing directory for `atom` binary" end FileUtils.cp("#{ATOM_SRC_PATH}/atom.sh", cli_path) @@ -59,18 +64,6 @@ task :install => [:clean, :build] do puts "\033[32mAtom is installed at `#{dest_path}`. Atom cli is installed at `#{cli_path}`\033[0m" end -desc "Package up the app for speakeasy" -task :package => ["setup-codesigning", "build"] do - path = application_path() - exit 1 if not path - - dest_path = '/tmp/atom-for-speakeasy/Atom.tar.bz2' - `mkdir -p $(dirname #{dest_path})` - `rm -rf #{dest_path}` - `tar --directory $(dirname #{path}) -jcf #{dest_path} $(basename #{path})` - `open $(dirname #{dest_path})` -end - task "setup-codesigning" do ENV['CODE_SIGN'] = "Developer ID Application: GitHub" end @@ -90,7 +83,7 @@ task :clean do end desc "Run the specs" -task :test => ["update-cef", "clone-default-bundles", "build"] do +task :test => ["clean", "update-cef", "clone-default-bundles", "build"] do `pkill Atom` if path = application_path() cmd = "#{path}/Contents/MacOS/Atom --test --resource-path=#{ATOM_SRC_PATH} 2> /dev/null" @@ -107,7 +100,7 @@ task :benchmark do end task :nof do - system %{find . -name *spec.coffee | grep --invert-match --regexp "#{BUILD_DIR}\\|##package-name##" | xargs sed -E -i "" "s/f+(it|describe) +(['\\"])/\\1 \\2/g"} + system %{find . -name *spec.coffee | grep --invert-match --regexp "#{BUILD_DIR}\\|__package-name__" | xargs sed -E -i "" "s/f+(it|describe) +(['\\"])/\\1 \\2/g"} end task :tags do diff --git a/atom.gyp b/atom.gyp index a4d09f442..417fe2de1 100644 --- a/atom.gyp +++ b/atom.gyp @@ -16,13 +16,30 @@ 'toolkit_uses_gtk%': 0, }], ], + 'fix_framework_link_command': [ + 'install_name_tool', + '-change', + '@executable_path/libcef.dylib', + '@rpath/Chromium Embedded Framework.framework/Libraries/libcef.dylib', + '-change', + '@executable_path/../Frameworks/CocoaOniguruma.framework/Versions/A/CocoaOniguruma', + '@rpath/CocoaOniguruma.framework/Versions/A/CocoaOniguruma', + '-change', + '@loader_path/../Frameworks/Sparkle.framework/Versions/A/Sparkle', + '@rpath/Sparkle.framework/Versions/A/Sparkle', + '-change', + '@executable_path/libgit2.0.17.0.dylib', + '@rpath/libgit2.framework/Libraries/libgit2.0.17.0.dylib', + '${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}' + ], }, 'includes': [ 'cef/cef_paths2.gypi', 'git2/libgit2.gypi', + 'sources.gypi', ], 'target_defaults': { - 'default_configuration': 'Debug', + 'default_configuration': 'Release', 'configurations': { 'Debug': { 'defines': ['DEBUG=1'], @@ -45,39 +62,20 @@ 'mac_bundle': 1, 'msvs_guid': 'D22C6F51-AA2D-457C-B579-6C97A96C724D', 'dependencies': [ - 'libcef_dll_wrapper', + 'atom_framework', ], - 'defines': [ - 'USING_CEF_SHARED', - ], - 'include_dirs': [ '.', 'cef', 'git2' ], 'mac_framework_dirs': [ 'native/frameworks' ], - 'libraries': [ 'native/frameworks/CocoaOniguruma.framework', 'native/frameworks/Sparkle.framework'], 'sources': [ - '<@(includes_common)', - '<@(includes_wrapper)', - 'native/main_mac.mm', - 'native/atom_application.h', - 'native/atom_application.mm', - 'native/atom_cef_app.h', - 'native/atom_window_controller.h', - 'native/atom_window_controller.mm', - 'native/atom_cef_client_mac.mm', - 'native/atom_cef_client.cpp', - 'native/atom_cef_client.h', - 'native/message_translation.cpp', - 'native/message_translation.h', + 'native/main.cpp', ], 'mac_bundle_resources': [ 'native/mac/atom.icns', 'native/mac/file.icns', 'native/mac/speakeasy.pem', - 'native/mac/English.lproj/MainMenu.xib', - 'native/mac/English.lproj/AtomWindow.xib', ], 'xcode_settings': { - 'INFOPLIST_FILE': 'native/mac/info.plist', - 'OTHER_LDFLAGS': ['-Wl,-headerpad_max_install_names'], # Necessary to avoid an "install_name_tool: changing install names or rpaths can't be redone" error. + 'INFOPLIST_FILE': 'native/mac/Atom-Info.plist', + 'LD_RUNPATH_SEARCH_PATHS': '@executable_path/../Frameworks', }, 'conditions': [ ['CODE_SIGN' , { @@ -142,6 +140,8 @@ { 'destination': '<(PRODUCT_DIR)/Atom.app/Contents/Frameworks', 'files': [ + '<(PRODUCT_DIR)/Atom Helper.app', + '<(PRODUCT_DIR)/Atom.framework', 'native/frameworks/CocoaOniguruma.framework', 'native/frameworks/Sparkle.framework', ], @@ -152,40 +152,18 @@ 'git2/frameworks/libgit2.0.17.0.dylib', ], }, + { + 'destination': '<(PRODUCT_DIR)/Atom.app/Contents/Frameworks/Chromium Embedded Framework.framework', + 'files': [ + 'cef/Resources', + ], + }, ], 'postbuilds': [ - { - 'postbuild_name': 'Copy and Compile Static Files', - 'action': [ - 'script/copy-files-to-bundle' - ], - }, - { - 'postbuild_name': 'Copy Helper App', - 'action': [ - 'cp', - '-r', - '${BUILT_PRODUCTS_DIR}/Atom Helper.app', - '${BUILT_PRODUCTS_DIR}/Atom.app/Contents/Frameworks', - ], - }, { 'postbuild_name': 'Fix Framework Link', 'action': [ - 'install_name_tool', - '-change', - '@executable_path/libcef.dylib', - '@executable_path/../Frameworks/Chromium Embedded Framework.framework/Libraries/libcef.dylib', - '${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}' - ], - }, - { - 'postbuild_name': 'Copy Framework Resources Directory', - 'action': [ - 'cp', - '-r', - 'cef/Resources', - '${BUILT_PRODUCTS_DIR}/Atom.app/Contents/Frameworks/Chromium Embedded Framework.framework/' + '<@(fix_framework_link_command)', ], }, { @@ -204,6 +182,12 @@ 'Atom', ], }, + { + 'postbuild_name': 'Print env for Constructicon', + 'action': [ + 'env', + ], + }, ], 'link_settings': { 'libraries': [ @@ -231,6 +215,89 @@ }], ], }, + { + 'target_name': 'atom_framework', + 'product_name': 'Atom', + 'type': 'shared_library', + 'mac_bundle': 1, + 'dependencies': [ + 'generated_sources', + 'libcef_dll_wrapper', + ], + 'defines': [ + 'USING_CEF_SHARED', + ], + 'xcode_settings': { + 'INFOPLIST_FILE': 'native/mac/framework-info.plist', + 'LD_DYLIB_INSTALL_NAME': '@rpath/Atom.framework/Atom', + }, + 'include_dirs': [ '.', 'cef', 'git2' ], + 'mac_framework_dirs': [ 'native/frameworks' ], + 'sources': [ + '<@(includes_common)', + '<@(includes_wrapper)', + 'native/atom_application.h', + 'native/atom_application.mm', + 'native/atom_cef_app.h', + 'native/atom_cef_app.h', + 'native/atom_cef_client.cpp', + 'native/atom_cef_client.h', + 'native/atom_cef_client_mac.mm', + 'native/atom_cef_render_process_handler.h', + 'native/atom_cef_render_process_handler.mm', + 'native/atom_window_controller.h', + 'native/atom_window_controller.mm', + 'native/atom_main.h', + 'native/atom_main_mac.mm', + 'native/message_translation.cpp', + 'native/message_translation.cpp', + 'native/message_translation.h', + 'native/message_translation.h', + 'native/path_watcher.h', + 'native/path_watcher.mm', + 'native/v8_extensions/atom.h', + 'native/v8_extensions/atom.mm', + 'native/v8_extensions/git.h', + 'native/v8_extensions/git.mm', + 'native/v8_extensions/native.h', + 'native/v8_extensions/native.mm', + 'native/v8_extensions/onig_reg_exp.h', + 'native/v8_extensions/onig_reg_exp.mm', + 'native/v8_extensions/onig_scanner.h', + 'native/v8_extensions/onig_scanner.mm', + 'native/v8_extensions/readtags.c', + 'native/v8_extensions/readtags.h', + 'native/v8_extensions/tags.h', + 'native/v8_extensions/tags.mm', + ], + 'link_settings': { + 'libraries': [ + '$(SDKROOT)/System/Library/Frameworks/AppKit.framework', + 'git2/frameworks/libgit2.0.17.0.dylib', + 'native/frameworks/CocoaOniguruma.framework', + 'native/frameworks/Sparkle.framework', + ], + }, + 'mac_bundle_resources': [ + 'native/mac/English.lproj/AtomWindow.xib', + 'native/mac/English.lproj/MainMenu.xib', + ], + 'postbuilds': [ + { + 'postbuild_name': 'Copy Static Files', + 'action': [ + 'script/copy-files-to-bundle', + '<(compiled_sources_dir_xcode)', + ], + }, + { + 'postbuild_name': 'Fix Framework Link', + 'action': [ + '<@(fix_framework_link_command)', + ], + }, + ], + }, { 'target_name': 'libcef_dll_wrapper', 'type': 'static_library', @@ -253,6 +320,48 @@ ], } }, + { + 'target_name': 'generated_sources', + 'type': 'none', + 'sources': [ + '<@(coffee_sources)', + '<@(cson_sources)', + ], + 'rules': [ + { + 'rule_name': 'coffee', + 'extension': 'coffee', + 'inputs': [ + 'script/compile-coffee', + ], + 'outputs': [ + '<(compiled_sources_dir)/<(RULE_INPUT_DIRNAME)/<(RULE_INPUT_ROOT).js', + ], + 'action': [ + 'sh', + 'script/compile-coffee', + '<(RULE_INPUT_PATH)', + '<(compiled_sources_dir)/<(RULE_INPUT_DIRNAME)/<(RULE_INPUT_ROOT).js', + ], + }, + { + 'rule_name': 'cson2json', + 'extension': 'cson', + 'inputs': [ + 'script/compile-cson', + ], + 'outputs': [ + '<(compiled_sources_dir)/<(RULE_INPUT_DIRNAME)/<(RULE_INPUT_ROOT).json', + ], + 'action': [ + 'sh', + 'script/compile-cson', + '<(RULE_INPUT_PATH)', + '<(compiled_sources_dir)/<(RULE_INPUT_DIRNAME)/<(RULE_INPUT_ROOT).json', + ], + }, + ], + }, ], 'conditions': [ ['os_posix==1 and OS!="mac" and OS!="android" and gcc_version==46', { @@ -271,46 +380,15 @@ 'product_name': 'Atom Helper', 'mac_bundle': 1, 'dependencies': [ - 'libcef_dll_wrapper', + 'atom_framework', ], 'defines': [ 'USING_CEF_SHARED', 'PROCESS_HELPER_APP', ], - 'include_dirs': [ '.', 'cef', 'git2' ], 'mac_framework_dirs': [ 'native/frameworks' ], - 'link_settings': { - 'libraries': [ - '$(SDKROOT)/System/Library/Frameworks/AppKit.framework', - ], - }, - 'libraries': [ - 'native/frameworks/CocoaOniguruma.framework', - 'git2/frameworks/libgit2.0.17.0.dylib', - ], 'sources': [ - 'native/atom_cef_app.h', - 'native/atom_cef_render_process_handler.h', - 'native/atom_cef_render_process_handler.mm', - 'native/message_translation.cpp', - 'native/message_translation.h', - 'native/path_watcher.mm', - 'native/path_watcher.h', - 'native/main_helper_mac.mm', - 'native/v8_extensions/native.mm', - 'native/v8_extensions/native.h', - 'native/v8_extensions/onig_reg_exp.mm', - 'native/v8_extensions/onig_reg_exp.h', - 'native/v8_extensions/onig_scanner.mm', - 'native/v8_extensions/onig_scanner.h', - 'native/v8_extensions/atom.mm', - 'native/v8_extensions/atom.h', - 'native/v8_extensions/git.mm', - 'native/v8_extensions/git.h', - 'native/v8_extensions/readtags.h', - 'native/v8_extensions/readtags.c', - 'native/v8_extensions/tags.h', - 'native/v8_extensions/tags.mm', + 'native/main.cpp', ], # TODO(mark): For now, don't put any resources into this app. Its # resources directory will be a symbolic link to the browser app's @@ -320,45 +398,13 @@ ], 'xcode_settings': { 'INFOPLIST_FILE': 'native/mac/helper-info.plist', - 'OTHER_LDFLAGS': ['-Wl,-headerpad_max_install_names'], # Necessary to avoid an "install_name_tool: changing install names or rpaths can't be redone" error. + 'LD_RUNPATH_SEARCH_PATHS': '@executable_path/../../..', }, - 'copies': [ - { - 'destination': '<(PRODUCT_DIR)/Atom Helper.app/Contents/Frameworks', - 'files': [ - 'native/frameworks/CocoaOniguruma.framework', - ], - }, - ], 'postbuilds': [ { - # The framework defines its load-time path - # (DYLIB_INSTALL_NAME_BASE) relative to the main executable - # (chrome). A different relative path needs to be used in - # atom_helper_app. - 'postbuild_name': 'Fix CEF Framework Link', + 'postbuild_name': 'Fix Framework Link', 'action': [ - 'install_name_tool', - '-change', - '@executable_path/libcef.dylib', - '@executable_path/../../../../Frameworks/Chromium Embedded Framework.framework/Libraries/libcef.dylib', - '${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}' - ], - }, - { - 'postbuild_name': 'Fix libgit2 Framework Link', - 'action': [ - 'install_name_tool', - '-change', - '@executable_path/libgit2.0.17.0.dylib', - '@executable_path/../../../../Frameworks/libgit2.framework/Libraries/libgit2.0.17.0.dylib', - '${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}' - ], - }, - { - 'postbuild_name': 'Copy and Compile Static Files', - 'action': [ - 'script/copy-files-to-bundle' + '<@(fix_framework_link_command)', ], }, ], diff --git a/benchmark/benchmark-helper.coffee b/benchmark/benchmark-helper.coffee index dff4cee35..b9ec9e3aa 100644 --- a/benchmark/benchmark-helper.coffee +++ b/benchmark/benchmark-helper.coffee @@ -7,7 +7,7 @@ Config = require 'config' Project = require 'project' require 'window' -requireStylesheet "jasmine.css" +requireStylesheet "jasmine.less" # Load TextMate bundles, which specs rely on (but not other packages) atom.loadTextMatePackages() @@ -127,4 +127,3 @@ $.fn.textInput = (data) -> event = document.createEvent 'TextEvent' event.initTextEvent('textInput', true, true, window, data) this.each -> this.dispatchEvent(event) - diff --git a/benchmark/benchmark-suite.coffee b/benchmark/benchmark-suite.coffee index d77958db7..c0d142a6d 100644 --- a/benchmark/benchmark-suite.coffee +++ b/benchmark/benchmark-suite.coffee @@ -107,7 +107,7 @@ describe "TokenizedBuffer.", -> [languageMode, buffer] = [] beforeEach -> - editSession = benchmarkFixturesProject.buildEditSessionForPath('medium.coffee') + editSession = benchmarkFixturesProject.buildEditSession('medium.coffee') { languageMode, buffer } = editSession benchmark "construction", 20, -> diff --git a/docs/getting-started.md b/docs/getting-started.md index 969272d2f..b5abb2d63 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,7 +11,7 @@ always hit `meta-p` to bring up a list of commands that are relevant to the currently focused UI element. If there is a key binding for a given command, it is also displayed. This is a great way to explore the system and get to know the key commands interactively. If you'd like to add or change a binding for a -command, refer to the [keymaps](#keymaps) section to learn how. +command, refer to the [key bindings](#customizing-key-bindings) section to learn how. ![Command Palette](http://f.cl.ly/items/32041o3w471F3C0F0V2O/Screen%20Shot%202013-02-13%20at%207.27.41%20PM.png) @@ -157,10 +157,10 @@ its own namespace. - hideGitIgnoredFiles: Whether files in the .gitignore should be hidden - ignoredNames: File names to ignore across all of atom (not fully implemented) - themes: An array of theme names to load, in cascading order + - autosave: Save a resource when its view loses focus - editor - autoIndent: Enable/disable basic auto-indent (defaults to true) - autoIndentOnPaste: Enable/disable auto-indented pasted text (defaults to false) - - autosave: Save a file when an editor loses focus - nonWordCharacters: A string of non-word characters to define word boundaries - fontSize - fontFamily diff --git a/docs/internals/configuration.md b/docs/internals/configuration.md index c25b0155d..d1df1d358 100644 --- a/docs/internals/configuration.md +++ b/docs/internals/configuration.md @@ -7,7 +7,7 @@ read config settings. You can read a value from `config` with `config.get`: ```coffeescript # read a value with `config.get` -@autosave() if config.get "editor.autosave" +@autosave() if config.get "core.autosave" ``` Or you can use `observeConfig` to track changes from a view object. @@ -47,7 +47,7 @@ the following way: ```coffeescript # basic key update -config.set("editor.autosave", true) +config.set("core.autosave", true) # if you mutate a config key, you'll need to call `config.update` to inform # observers of the change diff --git a/docs/internals/serialization.md b/docs/internals/serialization.md new file mode 100644 index 000000000..ce1d7a78e --- /dev/null +++ b/docs/internals/serialization.md @@ -0,0 +1,97 @@ +## Serialization in Atom + +When a window is refreshed or restored from a previous session, the view and its +associated objects are *deserialized* from a JSON representation that was stored +during the window's previous shutdown. For your own views and objects to be +compatible with refreshing, you'll need to make them play nicely with the +serializing and deserializing. + +### Package Serialization Hook + +Your package's main module can optionally include a `serialize` method, which +will be called before your package is deactivated. You should return JSON, which +will be handed back to you as an argument to `activate` next time it is called. +In the following example, the package keeps an instance of `MyObject` in the +same state across refreshes. + +```coffee-script +module.exports = + activate: (state) -> + @myObject = + if state + deserialize(state) + else + new MyObject("Hello") + + serialize: -> + @myObject.serialize() +``` + +### Serialization Methods + +```coffee-script +class MyObject + registerDeserializer(this) + @deserialize: ({data}) -> new MyObject(data) + constructor: (@data) -> + serialize: -> { deserializer: 'MyObject', data: @data } +``` + +#### .serialize() +Objects that you want to serialize should implement `.serialize()`. This method +should return a serializable object, and it must contain a key named +`deserializer` whose value is the name of a registered deserializer that can +convert the rest of the data to an object. It's usually just the name of the +class itself. + +#### @deserialize(data) +The other side of the coin is the `deserialize` method, which is usually a +class-level method on the same class that implements `serialize`. This method's +job is to convert a state object returned from a previous call `serialize` back +into a genuine object. + +#### registerDeserializer(klass) +You need to call the global `registerDeserializer` method with your class in +order to make it available to the deserialization system. Now you can call the +global `deserialize` method with state returned from `serialize`, and your +class's `deserialize` method will be selected automatically. + +### Versioning + +```coffee-script +class MyObject + @version: 2 + @deserialize: (state) -> ... + serialize: -> { version: MyObject.version, ... } +``` + +Your serializable class can optionally have a class-level `@version` property +and include a `version` key in its serialized state. When deserializing, Atom +will only attempt to call deserialize if the two versions match, and otherwise +return undefined. We plan on implementing a migration system in the future, but +this at least protects you from improperly deserializing old state. If you find +yourself in dire need of the migration system, let us know. + +### Deferred Package Deserializers + +If your package defers loading on startup with an `activationEvents` property in +its `package.cson`, your deserializers won't be loaded until your package is +activated. If you want to deserialize an object from your package on startup, +this could be a problem. + +The solution is to also supply a `deferredDeserializers` array in your +`package.cson` with the names of all your deserializers. When Atom attempts to +deserialize some state whose `deserializer` matches one of these names, it will +load your package first so it can register any necessary deserializers before +proceeding. + +For example, the markdown preview package doesn't fully load until a preview is +triggered. But if you refresh a window with a preview pane, it loads the +markdown package early so Atom can deserialize the view correctly. + +```coffee-script +# markdown-preview/package.cson +'activationEvents': 'markdown-preview:toggle': '.editor' +'deferredDeserializers': ['MarkdownPreviewView'] +... +``` diff --git a/git2/frameworks/libgit2.0.17.0.dylib b/git2/frameworks/libgit2.0.17.0.dylib index f76d63a47..96d777c4d 100755 Binary files a/git2/frameworks/libgit2.0.17.0.dylib and b/git2/frameworks/libgit2.0.17.0.dylib differ diff --git a/native/atom_application.h b/native/atom_application.h index cef8719b1..d2f79686b 100644 --- a/native/atom_application.h +++ b/native/atom_application.h @@ -18,6 +18,7 @@ class AtomCefClient; + (CefSettings)createCefSettings; + (NSDictionary *)parseArguments:(char **)argv count:(int)argc; - (void)open:(NSString *)path; +- (void)openDev:(NSString *)path; - (void)open:(NSString *)path pidToKillWhenWindowCloses:(NSNumber *)pid; - (IBAction)runSpecs:(id)sender; - (IBAction)runBenchmarks:(id)sender; diff --git a/native/atom_cef_app.h b/native/atom_cef_app.h index faa5e9a15..5cd8ce3ac 100644 --- a/native/atom_cef_app.h +++ b/native/atom_cef_app.h @@ -4,17 +4,13 @@ #include "include/cef_app.h" -#ifdef PROCESS_HELPER_APP #include "atom_cef_render_process_handler.h" -#endif class AtomCefApp : public CefApp { -#ifdef PROCESS_HELPER_APP virtual CefRefPtr GetRenderProcessHandler() OVERRIDE { return CefRefPtr(new AtomCefRenderProcessHandler); } -#endif IMPLEMENT_REFCOUNTING(AtomCefApp); }; diff --git a/native/atom_cef_render_process_handler.mm b/native/atom_cef_render_process_handler.mm index fe49a8935..d36a6c235 100644 --- a/native/atom_cef_render_process_handler.mm +++ b/native/atom_cef_render_process_handler.mm @@ -34,7 +34,6 @@ void AtomCefRenderProcessHandler::OnWorkerContextCreated(int worker_id, void AtomCefRenderProcessHandler::OnWorkerContextReleased(int worker_id, const CefString& url, CefRefPtr context) { - NSLog(@"Web worker context released"); } void AtomCefRenderProcessHandler::OnWorkerUncaughtException(int worker_id, diff --git a/native/atom_main.h b/native/atom_main.h new file mode 100644 index 000000000..a90926e32 --- /dev/null +++ b/native/atom_main.h @@ -0,0 +1 @@ +__attribute__((visibility("default"))) int AtomMain(int argc, char* argv[]); diff --git a/native/main_mac.mm b/native/atom_main_mac.mm similarity index 90% rename from native/main_mac.mm rename to native/atom_main_mac.mm index 8ccb8f887..ed1f3c3ca 100644 --- a/native/main_mac.mm +++ b/native/atom_main_mac.mm @@ -1,3 +1,5 @@ +#import "atom_main.h" +#import "atom_cef_app.h" #import "include/cef_application_mac.h" #import "native/atom_application.h" #include @@ -10,7 +12,15 @@ void listenForPathToOpen(int fd, NSString *socketPath); void activateOpenApp(); BOOL isAppAlreadyOpen(); -int main(int argc, char* argv[]) { +int AtomMain(int argc, char* argv[]) { + // Check if we're being run as a secondary process. + CefMainArgs main_args(argc, argv); + CefRefPtr app(new AtomCefApp); + int exitCode = CefExecuteProcess(main_args, app); + if (exitCode >= 0) + return exitCode; + + // We're the main process. @autoreleasepool { handleBeingOpenedAgain(argc, argv); @@ -18,8 +28,8 @@ int main(int argc, char* argv[]) { AtomApplication *application = [AtomApplication applicationWithArguments:argv count:argc]; NSString *mainNibName = [infoDictionary objectForKey:@"NSMainNibFile"]; - NSNib *mainNib = [[NSNib alloc] initWithNibNamed:mainNibName bundle:[NSBundle mainBundle]]; - [mainNib instantiateNibWithOwner:application topLevelObjects:nil]; + NSNib *mainNib = [[NSNib alloc] initWithNibNamed:mainNibName bundle:[NSBundle bundleWithIdentifier:@"com.github.atom.framework"]]; + [mainNib instantiateWithOwner:application topLevelObjects:nil]; CefRunMessageLoop(); } diff --git a/native/atom_window_controller.mm b/native/atom_window_controller.mm index ebed667ee..1d9acd5d2 100644 --- a/native/atom_window_controller.mm +++ b/native/atom_window_controller.mm @@ -34,8 +34,7 @@ _resourcePath = [atomApplication.arguments objectForKey:@"resource-path"]; if (!alwaysUseBundleResourcePath && !_resourcePath) { - NSString *defaultRepositoryPath = @"~/github/atom"; - defaultRepositoryPath = [defaultRepositoryPath stringByStandardizingPath]; + NSString *defaultRepositoryPath = [@"~/github/atom" stringByStandardizingPath]; if ([defaultRepositoryPath characterAtIndex:0] == '/') { BOOL isDir = false; BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:defaultRepositoryPath isDirectory:&isDir]; @@ -45,8 +44,14 @@ } if (alwaysUseBundleResourcePath || !_resourcePath) { - _resourcePath = [[NSBundle mainBundle] resourcePath]; + _resourcePath = [[NSBundle bundleForClass:self.class] resourcePath]; } + + if ([self isDevMode]) { + [self displayDevIcon]; + } + + _resourcePath = [_resourcePath stringByStandardizingPath]; [_resourcePath retain]; if (!background) { @@ -69,7 +74,6 @@ - (id)initDevWithPath:(NSString *)path { _pathToOpen = [path retain]; - AtomApplication *atomApplication = (AtomApplication *)[AtomApplication sharedApplication]; return [self initWithBootstrapScript:@"window-bootstrap" background:NO alwaysUseBundleResourcePath:false]; } @@ -119,11 +123,13 @@ // have the correct initial size based on the frame's last stored size. // HACK: I hate this and want to place this code directly in windowDidLoad - (void)attachWebView { - NSURL *url = [[NSBundle mainBundle] resourceURL]; + NSURL *url = [[NSBundle bundleForClass:self.class] resourceURL]; NSMutableString *urlString = [NSMutableString string]; [urlString appendString:[[url URLByAppendingPathComponent:@"static/index.html"] absoluteString]]; [urlString appendFormat:@"?bootstrapScript=%@", [self encodeUrlParam:_bootstrapScript]]; [urlString appendFormat:@"&resourcePath=%@", [self encodeUrlParam:_resourcePath]]; + if ([self isDevMode]) + [urlString appendFormat:@"&devMode=1"]; if (_exitWhenDone) [urlString appendString:@"&exitWhenDone=1"]; if (_pathToOpen) @@ -202,6 +208,33 @@ return YES; } +- (bool)isDevMode { + NSString *bundleResourcePath = [[NSBundle bundleForClass:self.class] resourcePath]; + return ![_resourcePath isEqualToString:bundleResourcePath]; +} + +- (void)displayDevIcon { + NSView *themeFrame = [self.window.contentView superview]; + NSButton *fullScreenButton = nil; + for (NSView *view in themeFrame.subviews) { + if (![view isKindOfClass:NSButton.class]) continue; + NSButton *button = (NSButton *)view; + if (button.action != @selector(toggleFullScreen:)) continue; + fullScreenButton = button; + break; + } + + NSButton *devButton = [[NSButton alloc] init]; + [devButton setTitle:@"\xF0\x9F\x92\x80"]; + devButton.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin; + devButton.buttonType = NSMomentaryChangeButton; + devButton.bordered = NO; + [devButton sizeToFit]; + devButton.frame = NSMakeRect(fullScreenButton.frame.origin.x - devButton.frame.size.width - 5, fullScreenButton.frame.origin.y, devButton.frame.size.width, devButton.frame.size.height); + + [[self.window.contentView superview] addSubview:devButton]; +} + - (void)populateBrowserSettings:(CefBrowserSettings &)settings { CefString(&settings.default_encoding) = "UTF-8"; settings.remote_fonts_disabled = false; diff --git a/native/mac/info.plist b/native/mac/Atom-Info.plist similarity index 96% rename from native/mac/info.plist rename to native/mac/Atom-Info.plist index 708d0b78f..c295d2e6a 100644 --- a/native/mac/info.plist +++ b/native/mac/Atom-Info.plist @@ -7,6 +7,8 @@ CFBundleDocumentTypes + CFBundleTypeRole + Editor CFBundleTypeIconFile file.icns LSItemContentTypes diff --git a/native/mac/framework-info.plist b/native/mac/framework-info.plist new file mode 100644 index 000000000..fab48f695 --- /dev/null +++ b/native/mac/framework-info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIconFile + + CFBundleIdentifier + com.github.atom.framework + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + NSPrincipalClass + + + diff --git a/native/main.cpp b/native/main.cpp new file mode 100644 index 000000000..43e9b291d --- /dev/null +++ b/native/main.cpp @@ -0,0 +1,5 @@ +#include "atom_main.h" + +int main(int argc, char* argv[]) { + return AtomMain(argc, argv); +} diff --git a/native/main_helper_mac.mm b/native/main_helper_mac.mm deleted file mode 100644 index 9e0fa0689..000000000 --- a/native/main_helper_mac.mm +++ /dev/null @@ -1,8 +0,0 @@ -#include "include/cef_app.h" -#include "atom_cef_app.h" - -int main(int argc, char* argv[]) { - CefMainArgs main_args(argc, argv); - CefRefPtr app(new AtomCefApp); - return CefExecuteProcess(main_args, app); // Execute the secondary process. -} diff --git a/native/path_watcher.mm b/native/path_watcher.mm index 2bf5907e4..e5cc65afe 100644 --- a/native/path_watcher.mm +++ b/native/path_watcher.mm @@ -245,7 +245,7 @@ static NSMutableArray *gPathWatchers; char pathBuffer[MAXPATHLEN]; fcntl((int)event.ident, F_GETPATH, &pathBuffer); close(event.ident); - newPath = [NSString stringWithUTF8String:pathBuffer]; + newPath = [[NSString stringWithUTF8String:pathBuffer] stringByStandardizingPath]; if (!newPath) { NSLog(@"WARNING: Ignoring rename event for deleted file '%@'", path); continue; diff --git a/native/v8_extensions/atom.mm b/native/v8_extensions/atom.mm index 403cac409..0acb51d82 100644 --- a/native/v8_extensions/atom.mm +++ b/native/v8_extensions/atom.mm @@ -22,24 +22,26 @@ namespace v8_extensions { const CefV8ValueList& arguments, CefRefPtr& retval, CefString& exception) { - CefRefPtr browser = CefV8Context::GetCurrentContext()->GetBrowser(); + @autoreleasepool { + CefRefPtr browser = CefV8Context::GetCurrentContext()->GetBrowser(); - if (name == "sendMessageToBrowserProcess") { - if (arguments.size() == 0 || !arguments[0]->IsString()) { - exception = "You must supply a message name"; - return false; + if (name == "sendMessageToBrowserProcess") { + if (arguments.size() == 0 || !arguments[0]->IsString()) { + exception = "You must supply a message name"; + return false; + } + + CefString name = arguments[0]->GetStringValue(); + CefRefPtr message = CefProcessMessage::Create(name); + + if (arguments.size() > 1 && arguments[1]->IsArray()) { + TranslateList(arguments[1], message->GetArgumentList()); + } + + browser->SendProcessMessage(PID_BROWSER, message); + return true; } - - CefString name = arguments[0]->GetStringValue(); - CefRefPtr message = CefProcessMessage::Create(name); - - if (arguments.size() > 1 && arguments[1]->IsArray()) { - TranslateList(arguments[1], message->GetArgumentList()); - } - - browser->SendProcessMessage(PID_BROWSER, message); - return true; + return false; } - return false; }; } diff --git a/native/v8_extensions/git.mm b/native/v8_extensions/git.mm index d2cda0a29..49aa98bf1 100644 --- a/native/v8_extensions/git.mm +++ b/native/v8_extensions/git.mm @@ -8,6 +8,21 @@ namespace v8_extensions { private: git_repository *repo; + static int CollectStatus(const char *path, unsigned int status, void *payload) { + if ((status & GIT_STATUS_IGNORED) == 0) { + std::map *statuses = (std::map *) payload; + statuses->insert(std::pair(path, status)); + } + return 0; + } + + static int CollectDiffHunk(const git_diff_delta *delta, const git_diff_range *range, + const char *header, size_t header_len, void *payload) { + std::vector *ranges = (std::vector *) payload; + ranges->push_back(*range); + return 0; + } + public: GitRepository(const char *pathInRepo) { if (git_repository_open_ext(&repo, pathInRepo, 0, NULL) != GIT_OK) { @@ -55,6 +70,126 @@ namespace v8_extensions { return CefV8Value::CreateNull(); } + CefRefPtr GetStatuses() { + std::map statuses; + git_status_foreach(repo, CollectStatus, &statuses); + std::map::iterator iter = statuses.begin(); + CefRefPtr v8Statuses = CefV8Value::CreateObject(NULL); + for (; iter != statuses.end(); ++iter) { + v8Statuses->SetValue(iter->first, CefV8Value::CreateInt(iter->second), V8_PROPERTY_ATTRIBUTE_NONE); + } + return v8Statuses; + } + + int GetCommitCount(const git_oid* fromCommit, const git_oid* toCommit) { + int count = 0; + git_revwalk *revWalk; + if (git_revwalk_new(&revWalk, repo) == GIT_OK) { + git_revwalk_push(revWalk, fromCommit); + git_revwalk_hide(revWalk, toCommit); + git_oid currentCommit; + while (git_revwalk_next(¤tCommit, revWalk) == GIT_OK) + count++; + git_revwalk_free(revWalk); + } + return count; + } + + void GetShortBranchName(const char** out, const char* branchName) { + *out = NULL; + if (branchName == NULL) + return; + int branchNameLength = strlen(branchName); + if (branchNameLength < 12) + return; + if (strncmp("refs/heads/", branchName, 11) != 0) + return; + + int shortNameLength = branchNameLength - 11; + char* shortName = (char*) malloc(sizeof(char) * (shortNameLength + 1)); + shortName[shortNameLength] = '\0'; + strncpy(shortName, &branchName[11], shortNameLength); + *out = shortName; + } + + void GetUpstreamBranch(const char** out, git_reference *branch) { + *out = NULL; + + const char* branchName = git_reference_name(branch); + const char* shortBranchName; + GetShortBranchName(&shortBranchName, branchName); + if (shortBranchName == NULL) + return; + + int shortBranchNameLength = strlen(shortBranchName); + char* remoteKey = (char*) malloc(sizeof(char) * (shortBranchNameLength + 15)); + sprintf(remoteKey, "branch.%s.remote", shortBranchName); + char* mergeKey = (char*) malloc(sizeof(char) * (shortBranchNameLength + 14)); + sprintf(mergeKey, "branch.%s.merge", shortBranchName); + free((char*)shortBranchName); + + git_config *config; + if (git_repository_config(&config, repo) != GIT_OK) { + free(remoteKey); + free(mergeKey); + return; + } + + const char *remote; + const char *merge; + if (git_config_get_string(&remote, config, remoteKey) == GIT_OK + && git_config_get_string(&merge, config, mergeKey) == GIT_OK) { + int remoteLength = strlen(remote); + if (remoteLength > 0) { + const char *shortMergeBranchName; + GetShortBranchName(&shortMergeBranchName, merge); + if (shortMergeBranchName != NULL) { + int updateBranchLength = remoteLength + strlen(shortMergeBranchName) + 14; + char* upstreamBranch = (char*) malloc(sizeof(char) * (updateBranchLength + 1)); + sprintf(upstreamBranch, "refs/remotes/%s/%s", remote, shortMergeBranchName); + *out = upstreamBranch; + } + free((char*)shortMergeBranchName); + } + } + + free(remoteKey); + free(mergeKey); + git_config_free(config); + } + + CefRefPtr GetAheadBehindCounts() { + CefRefPtr result = NULL; + git_reference *head; + if (git_repository_head(&head, repo) == GIT_OK) { + const char* upstreamBranchName; + GetUpstreamBranch(&upstreamBranchName, head); + if (upstreamBranchName != NULL) { + git_reference *upstream; + if (git_reference_lookup(&upstream, repo, upstreamBranchName) == GIT_OK) { + const git_oid* headSha = git_reference_target(head); + const git_oid* upstreamSha = git_reference_target(upstream); + git_oid mergeBase; + if (git_merge_base(&mergeBase, repo, headSha, upstreamSha) == GIT_OK) { + result = CefV8Value::CreateObject(NULL); + int ahead = GetCommitCount(headSha, &mergeBase); + result->SetValue("ahead", CefV8Value::CreateInt(ahead), V8_PROPERTY_ATTRIBUTE_NONE); + int behind = GetCommitCount(upstreamSha, &mergeBase); + result->SetValue("behind", CefV8Value::CreateInt(behind), V8_PROPERTY_ATTRIBUTE_NONE); + } + git_reference_free(upstream); + } + free((char*)upstreamBranchName); + } + git_reference_free(head); + } + + if (result != NULL) + return result; + else + return CefV8Value::CreateNull(); + } + CefRefPtr IsIgnored(const char *path) { int ignored; if (git_ignore_path_is_ignored(&ignored, repo, path) == GIT_OK) { @@ -123,6 +258,7 @@ namespace v8_extensions { git_diff_list *diffs; int diffStatus = git_diff_tree_to_workdir(&diffs, repo, tree, &options); + git_tree_free(tree); free(copiedPath); if (diffStatus != GIT_OK || git_diff_num_deltas(diffs) != 1) { return CefV8Value::CreateNull(); @@ -162,6 +298,58 @@ namespace v8_extensions { return result; } + CefRefPtr GetLineDiffs(const char *path, const char *text) { + git_reference *head; + if (git_repository_head(&head, repo) != GIT_OK) + return CefV8Value::CreateNull(); + + const git_oid* sha = git_reference_target(head); + git_commit *commit; + int commitStatus = git_commit_lookup(&commit, repo, sha); + git_reference_free(head); + if (commitStatus != GIT_OK) + return CefV8Value::CreateNull(); + + git_tree *tree; + int treeStatus = git_commit_tree(&tree, commit); + git_commit_free(commit); + if (treeStatus != GIT_OK) + return CefV8Value::CreateNull(); + + git_tree_entry* treeEntry; + git_tree_entry_bypath(&treeEntry, tree, path); + git_blob *blob = NULL; + if (treeEntry != NULL) { + const git_oid *blobSha = git_tree_entry_id(treeEntry); + if (blobSha == NULL || git_blob_lookup(&blob, repo, blobSha) != GIT_OK) + blob = NULL; + } + git_tree_free(tree); + if (blob == NULL) + return CefV8Value::CreateNull(); + + int size = strlen(text); + std::vector ranges; + git_diff_options options = GIT_DIFF_OPTIONS_INIT; + options.context_lines = 1; + if (git_diff_blob_to_buffer(blob, text, size, &options, NULL, CollectDiffHunk, NULL, &ranges) == GIT_OK) { + CefRefPtr v8Ranges = CefV8Value::CreateArray(ranges.size()); + for(int i = 0; i < ranges.size(); i++) { + CefRefPtr v8Range = CefV8Value::CreateObject(NULL); + v8Range->SetValue("oldStart", CefV8Value::CreateInt(ranges[i].old_start), V8_PROPERTY_ATTRIBUTE_NONE); + v8Range->SetValue("oldLines", CefV8Value::CreateInt(ranges[i].old_lines), V8_PROPERTY_ATTRIBUTE_NONE); + v8Range->SetValue("newStart", CefV8Value::CreateInt(ranges[i].new_start), V8_PROPERTY_ATTRIBUTE_NONE); + v8Range->SetValue("newLines", CefV8Value::CreateInt(ranges[i].new_lines), V8_PROPERTY_ATTRIBUTE_NONE); + v8Ranges->SetValue(i, v8Range); + } + git_blob_free(blob); + return v8Ranges; + } else { + git_blob_free(blob); + return CefV8Value::CreateNull(); + } + } + CefRefPtr IsSubmodule(const char *path) { BOOL isSubmodule = false; git_index* index; @@ -185,12 +373,14 @@ namespace v8_extensions { }; Git::Git() : CefV8Handler() { + git_threads_init(); } void Git::CreateContextBinding(CefRefPtr context) { const char* methodNames[] = { "getRepository", "getHead", "getPath", "isIgnored", "getStatus", "checkoutHead", - "getDiffStats", "isSubmodule", "refreshIndex", "destroy" + "getDiffStats", "isSubmodule", "refreshIndex", "destroy", "getStatuses", + "getAheadBehindCounts", "getLineDiffs" }; CefRefPtr nativeObject = CefV8Value::CreateObject(NULL); @@ -210,72 +400,94 @@ namespace v8_extensions { const CefV8ValueList& arguments, CefRefPtr& retval, CefString& exception) { - if (name == "getRepository") { - GitRepository *repository = new GitRepository(arguments[0]->GetStringValue().ToString().c_str()); - if (repository->Exists()) { - CefRefPtr userData = repository; - retval = CefV8Value::CreateObject(NULL); - retval->SetUserData(userData); - } else { - retval = CefV8Value::CreateNull(); + @autoreleasepool { + if (name == "getRepository") { + GitRepository *repository = new GitRepository(arguments[0]->GetStringValue().ToString().c_str()); + if (repository->Exists()) { + CefRefPtr userData = repository; + retval = CefV8Value::CreateObject(NULL); + retval->SetUserData(userData); + } else { + retval = CefV8Value::CreateNull(); + } + return true; } - return true; - } - if (name == "getHead") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->GetHead(); - return true; - } + if (name == "getHead") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetHead(); + return true; + } - if (name == "getPath") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->GetPath(); - return true; - } + if (name == "getPath") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetPath(); + return true; + } - if (name == "isIgnored") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->IsIgnored(arguments[0]->GetStringValue().ToString().c_str()); - return true; - } + if (name == "isIgnored") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->IsIgnored(arguments[0]->GetStringValue().ToString().c_str()); + return true; + } - if (name == "getStatus") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->GetStatus(arguments[0]->GetStringValue().ToString().c_str()); - return true; - } + if (name == "getStatus") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetStatus(arguments[0]->GetStringValue().ToString().c_str()); + return true; + } - if (name == "checkoutHead") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->CheckoutHead(arguments[0]->GetStringValue().ToString().c_str()); - return true; - } + if (name == "checkoutHead") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->CheckoutHead(arguments[0]->GetStringValue().ToString().c_str()); + return true; + } - if (name == "getDiffStats") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->GetDiffStats(arguments[0]->GetStringValue().ToString().c_str()); - return true; - } + if (name == "getDiffStats") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetDiffStats(arguments[0]->GetStringValue().ToString().c_str()); + return true; + } - if (name == "isSubmodule") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - retval = userData->IsSubmodule(arguments[0]->GetStringValue().ToString().c_str()); - return true; - } + if (name == "isSubmodule") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->IsSubmodule(arguments[0]->GetStringValue().ToString().c_str()); + return true; + } - if (name == "refreshIndex") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - userData->RefreshIndex(); - return true; - } + if (name == "refreshIndex") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + userData->RefreshIndex(); + return true; + } - if (name == "destroy") { - GitRepository *userData = (GitRepository *)object->GetUserData().get(); - userData->Destroy(); - return true; - } + if (name == "destroy") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + userData->Destroy(); + return true; + } - return false; + if (name == "getStatuses") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetStatuses(); + return true; + } + + if (name == "getAheadBehindCounts") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetAheadBehindCounts(); + return true; + } + + if (name == "getLineDiffs") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + std::string path = arguments[0]->GetStringValue().ToString(); + std::string text = arguments[1]->GetStringValue().ToString(); + retval = userData->GetLineDiffs(path.c_str(), text.c_str()); + return true; + } + + return false; + } } } diff --git a/native/v8_extensions/native.mm b/native/v8_extensions/native.mm index b5a7e3449..b34b68145 100644 --- a/native/v8_extensions/native.mm +++ b/native/v8_extensions/native.mm @@ -48,6 +48,7 @@ namespace v8_extensions { const CefV8ValueList& arguments, CefRefPtr& retval, CefString& exception) { + @autoreleasepool { if (name == "exists") { std::string cc_value = arguments[0]->GetStringValue().ToString(); const char *path = cc_value.c_str(); @@ -165,8 +166,7 @@ namespace v8_extensions { } else if (name == "traverseTree") { std::string argument = arguments[0]->GetStringValue().ToString(); - int rootPathLength = argument.size() + 1; - char rootPath[rootPathLength]; + char rootPath[argument.size() + 1]; strcpy(rootPath, argument.c_str()); char * const paths[] = {rootPath, NULL}; @@ -190,12 +190,8 @@ namespace v8_extensions { continue; } - int pathLength = entry->fts_pathlen - rootPathLength; - char relative[pathLength + 1]; - relative[pathLength] = '\0'; - strncpy(relative, entry->fts_path + rootPathLength, pathLength); args.clear(); - args.push_back(CefV8Value::CreateString(relative)); + args.push_back(CefV8Value::CreateString(entry->fts_path)); if (isFile) { onFile->ExecuteFunction(onFile, args); } @@ -526,10 +522,8 @@ namespace v8_extensions { NSString *word = stringFromCefV8Value(arguments[0]); NSSpellChecker *spellChecker = [NSSpellChecker sharedSpellChecker]; @synchronized(spellChecker) { - @autoreleasepool { - NSRange range = [spellChecker checkSpellingOfString:word startingAt:0]; - retval = CefV8Value::CreateBool(range.length > 0); - } + NSRange range = [spellChecker checkSpellingOfString:word startingAt:0]; + retval = CefV8Value::CreateBool(range.length > 0); } return true; } @@ -538,23 +532,22 @@ namespace v8_extensions { NSString *misspelling = stringFromCefV8Value(arguments[0]); NSSpellChecker *spellChecker = [NSSpellChecker sharedSpellChecker]; @synchronized(spellChecker) { - @autoreleasepool { - NSString *language = [spellChecker language]; - NSRange range; - range.location = 0; - range.length = [misspelling length]; - NSArray *guesses = [spellChecker guessesForWordRange:range inString:misspelling language:language inSpellDocumentWithTag:0]; - CefRefPtr v8Guesses = CefV8Value::CreateArray([guesses count]); - for (int i = 0; i < [guesses count]; i++) { - v8Guesses->SetValue(i, CefV8Value::CreateString([[guesses objectAtIndex:i] UTF8String])); - } - retval = v8Guesses; + NSString *language = [spellChecker language]; + NSRange range; + range.location = 0; + range.length = [misspelling length]; + NSArray *guesses = [spellChecker guessesForWordRange:range inString:misspelling language:language inSpellDocumentWithTag:0]; + CefRefPtr v8Guesses = CefV8Value::CreateArray([guesses count]); + for (int i = 0; i < [guesses count]; i++) { + v8Guesses->SetValue(i, CefV8Value::CreateString([[guesses objectAtIndex:i] UTF8String])); } + retval = v8Guesses; } return true; } return false; + } }; NSString *stringFromCefV8Value(const CefRefPtr& value) { diff --git a/native/v8_extensions/onig_reg_exp.mm b/native/v8_extensions/onig_reg_exp.mm index 6f8b5edcd..21b59b342 100644 --- a/native/v8_extensions/onig_reg_exp.mm +++ b/native/v8_extensions/onig_reg_exp.mm @@ -73,32 +73,34 @@ bool OnigRegExp::Execute(const CefString& name, CefRefPtr& retval, CefString& exception) { - if (name == "search") { - CefRefPtr string = arguments[0]; - CefRefPtr index = arguments.size() > 1 ? arguments[1] : CefV8Value::CreateInt(0); - OnigRegExpUserData *userData = (OnigRegExpUserData *)object->GetUserData().get(); - retval = userData->Search(string, index); - return true; - } - else if (name == "test") { - CefRefPtr string = arguments[0]; - CefRefPtr index = arguments.size() > 1 ? arguments[1] : CefV8Value::CreateInt(0); - OnigRegExpUserData *userData = (OnigRegExpUserData *)object->GetUserData().get(); - retval = userData->Test(string, index); - return true; - } - else if (name == "buildOnigRegExp") { - CefRefPtr pattern = arguments[0]; - CefRefPtr userData = new OnigRegExpUserData(pattern); - if (!userData->m_regex) { - exception = std::string("Failed to create OnigRegExp from pattern '") + pattern->GetStringValue().ToString() + "'"; + @autoreleasepool { + if (name == "search") { + CefRefPtr string = arguments[0]; + CefRefPtr index = arguments.size() > 1 ? arguments[1] : CefV8Value::CreateInt(0); + OnigRegExpUserData *userData = (OnigRegExpUserData *)object->GetUserData().get(); + retval = userData->Search(string, index); + return true; + } + else if (name == "test") { + CefRefPtr string = arguments[0]; + CefRefPtr index = arguments.size() > 1 ? arguments[1] : CefV8Value::CreateInt(0); + OnigRegExpUserData *userData = (OnigRegExpUserData *)object->GetUserData().get(); + retval = userData->Test(string, index); + return true; + } + else if (name == "buildOnigRegExp") { + CefRefPtr pattern = arguments[0]; + CefRefPtr userData = new OnigRegExpUserData(pattern); + if (!userData->m_regex) { + exception = std::string("Failed to create OnigRegExp from pattern '") + pattern->GetStringValue().ToString() + "'"; + } + retval = CefV8Value::CreateObject(NULL); + retval->SetUserData((CefRefPtr)userData); + return true; } - retval = CefV8Value::CreateObject(NULL); - retval->SetUserData((CefRefPtr)userData); - return true; - } - return false; + return false; + } } } // namespace v8_extensions \ No newline at end of file diff --git a/native/v8_extensions/onig_scanner.mm b/native/v8_extensions/onig_scanner.mm index 9d429de97..3a8db417f 100644 --- a/native/v8_extensions/onig_scanner.mm +++ b/native/v8_extensions/onig_scanner.mm @@ -152,18 +152,20 @@ bool OnigScanner::Execute(const CefString& name, const CefV8ValueList& arguments, CefRefPtr& retval, CefString& exception) { - if (name == "findNextMatch") { - OnigScannerUserData *userData = (OnigScannerUserData *)object->GetUserData().get(); - retval = userData->FindNextMatch(arguments[0], arguments[1]); - return true; - } - else if (name == "buildScanner") { - retval = CefV8Value::CreateObject(NULL); - retval->SetUserData(new OnigScannerUserData(arguments[0])); - return true; - } + @autoreleasepool { + if (name == "findNextMatch") { + OnigScannerUserData *userData = (OnigScannerUserData *)object->GetUserData().get(); + retval = userData->FindNextMatch(arguments[0], arguments[1]); + return true; + } + else if (name == "buildScanner") { + retval = CefV8Value::CreateObject(NULL); + retval->SetUserData(new OnigScannerUserData(arguments[0])); + return true; + } - return false; + return false; + } } } // namespace v8_extensions diff --git a/native/v8_extensions/tags.mm b/native/v8_extensions/tags.mm index 74e46f275..2e826d323 100644 --- a/native/v8_extensions/tags.mm +++ b/native/v8_extensions/tags.mm @@ -37,78 +37,80 @@ namespace v8_extensions { CefRefPtr& retval, CefString& exception) { - if (name == "find") { - std::string path = arguments[0]->GetStringValue().ToString(); - std::string tag = arguments[1]->GetStringValue().ToString(); - tagFileInfo info; - tagFile* tagFile; - tagFile = tagsOpen(path.c_str(), &info); - if (info.status.opened) { - tagEntry entry; - std::vector> entries; - if (tagsFind(tagFile, &entry, tag.c_str(), TAG_FULLMATCH | TAG_OBSERVECASE) == TagSuccess) { - entries.push_back(ParseEntry(entry)); - while (tagsFindNext(tagFile, &entry) == TagSuccess) { - entries.push_back(ParseEntry(entry)); - } - } - - retval = CefV8Value::CreateArray(entries.size()); - for (int i = 0; i < entries.size(); i++) { - retval->SetValue(i, entries[i]); - } - tagsClose(tagFile); - } - return true; - } - - if (name == "getAllTagsAsync") { - std::string path = arguments[0]->GetStringValue().ToString(); - CefRefPtr callback = arguments[1]; - CefRefPtr context = CefV8Context::GetCurrentContext(); - - dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); - dispatch_async(queue, ^{ + @autoreleasepool { + if (name == "find") { + std::string path = arguments[0]->GetStringValue().ToString(); + std::string tag = arguments[1]->GetStringValue().ToString(); tagFileInfo info; tagFile* tagFile; tagFile = tagsOpen(path.c_str(), &info); - std::vector entries; - if (info.status.opened) { tagEntry entry; - while (tagsNext(tagFile, &entry) == TagSuccess) { - entry.name = strdup(entry.name); - entry.file = strdup(entry.file); - if (entry.address.pattern) { - entry.address.pattern = strdup(entry.address.pattern); + std::vector> entries; + if (tagsFind(tagFile, &entry, tag.c_str(), TAG_FULLMATCH | TAG_OBSERVECASE) == TagSuccess) { + entries.push_back(ParseEntry(entry)); + while (tagsFindNext(tagFile, &entry) == TagSuccess) { + entries.push_back(ParseEntry(entry)); } - entries.push_back(entry); + } + + retval = CefV8Value::CreateArray(entries.size()); + for (int i = 0; i < entries.size(); i++) { + retval->SetValue(i, entries[i]); } tagsClose(tagFile); } + return true; + } - dispatch_queue_t mainQueue = dispatch_get_main_queue(); - dispatch_async(mainQueue, ^{ - context->Enter(); - CefRefPtr v8Tags = CefV8Value::CreateArray(entries.size()); - for (int i = 0; i < entries.size(); i++) { - v8Tags->SetValue(i, ParseEntry(entries[i])); - free((void*)entries[i].name); - free((void*)entries[i].file); - if (entries[i].address.pattern) { - free((void*)entries[i].address.pattern); + if (name == "getAllTagsAsync") { + std::string path = arguments[0]->GetStringValue().ToString(); + CefRefPtr callback = arguments[1]; + CefRefPtr context = CefV8Context::GetCurrentContext(); + + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + dispatch_async(queue, ^{ + tagFileInfo info; + tagFile* tagFile; + tagFile = tagsOpen(path.c_str(), &info); + std::vector entries; + + if (info.status.opened) { + tagEntry entry; + while (tagsNext(tagFile, &entry) == TagSuccess) { + entry.name = strdup(entry.name); + entry.file = strdup(entry.file); + if (entry.address.pattern) { + entry.address.pattern = strdup(entry.address.pattern); + } + entries.push_back(entry); } + tagsClose(tagFile); } - CefV8ValueList callbackArgs; - callbackArgs.push_back(v8Tags); - callback->ExecuteFunction(callback, callbackArgs); - context->Exit(); - }); - }); - return true; - } - return false; + dispatch_queue_t mainQueue = dispatch_get_main_queue(); + dispatch_async(mainQueue, ^{ + context->Enter(); + CefRefPtr v8Tags = CefV8Value::CreateArray(entries.size()); + for (int i = 0; i < entries.size(); i++) { + v8Tags->SetValue(i, ParseEntry(entries[i])); + free((void*)entries[i].name); + free((void*)entries[i].file); + if (entries[i].address.pattern) { + free((void*)entries[i].address.pattern); + } + } + CefV8ValueList callbackArgs; + callbackArgs.push_back(v8Tags); + callback->ExecuteFunction(callback, callbackArgs); + context->Exit(); + }); + }); + return true; + } + + return false; + } } } diff --git a/package.json b/package.json index 1d8e2a164..6633d19bd 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version" : "0.0.0", "dependencies": { - "coffee-script": "1.x", - "cson": "1.x" + "coffee-script": "1.5" }, + "private": true, + "scripts": { "preinstall": "true" } diff --git a/prebuilt-cef b/prebuilt-cef index c24e35c3e..3ced0be18 160000 --- a/prebuilt-cef +++ b/prebuilt-cef @@ -1 +1 @@ -Subproject commit c24e35c3ed60952f9e3ed6b3a73e17f0714d147b +Subproject commit 3ced0be18717e65fcf4d832bded37156cd0710e7 diff --git a/script/compile-coffee b/script/compile-coffee new file mode 100755 index 000000000..f3d19392c --- /dev/null +++ b/script/compile-coffee @@ -0,0 +1,21 @@ +#!/bin/sh + +set -e + +# Because of the way xcodebuild invokes external scripts we need to load +# The Setup's environment ourselves. If this isn't done, things like the +# node shim won't be able to find the stuff they need. + +node --version > /dev/null 2>&1 || { + if [ -e /opt/github/env.sh ]; then + source /opt/github/env.sh + else + # Try Constructicon's PATH. + export PATH="/usr/local/Cellar/node/0.8.21/bin:${PATH}" + fi +} + +INPUT_FILE="${1}" +OUTPUT_FILE="${2}" + +node_modules/.bin/coffee -c -p "${INPUT_FILE}" > "${OUTPUT_FILE}" diff --git a/script/compile-cson b/script/compile-cson new file mode 100755 index 000000000..7168fd19d --- /dev/null +++ b/script/compile-cson @@ -0,0 +1,21 @@ +#!/bin/sh + +set -e + +# Because of the way xcodebuild invokes external scripts we need to load +# The Setup's environment ourselves. If this isn't done, things like the +# node shim won't be able to find the stuff they need. + +node --version > /dev/null 2>&1 || { + if [ -e /opt/github/env.sh ]; then + source /opt/github/env.sh + else + # Try Constructicon's PATH. + export PATH="/usr/local/Cellar/node/0.8.21/bin:${PATH}" + fi +} + +INPUT_FILE="${1}" +OUTPUT_FILE="${2}" + +node_modules/.bin/coffee script/compile-cson.coffee -- "${INPUT_FILE}" "${OUTPUT_FILE}" diff --git a/script/compile-cson.coffee b/script/compile-cson.coffee new file mode 100644 index 000000000..42d35c93d --- /dev/null +++ b/script/compile-cson.coffee @@ -0,0 +1,23 @@ +fs = require 'fs' +{exec} = require 'child_process' + +inputFile = process.argv[2] +unless inputFile?.length > 0 + console.error("Input file must be first argument") + process.exit(1) + +outputFile = process.argv[3] +unless outputFile?.length > 0 + console.error("Output file must be second arguments") + process.exit(1) + +contents = fs.readFileSync(inputFile)?.toString() ? '' +exec "node_modules/.bin/coffee -bcp #{inputFile}", (error, stdout, stderr) -> + if error + console.error(error) + process.exit(1) + json = eval(stdout.toString()) ? {} + if json isnt Object(json) + console.error("CSON file does not contain valid JSON") + process.exit(1) + fs.writeFileSync(outputFile, JSON.stringify(json, null, 2)) diff --git a/script/constructicon/prebuild b/script/constructicon/prebuild new file mode 100755 index 000000000..734ca7c58 --- /dev/null +++ b/script/constructicon/prebuild @@ -0,0 +1,9 @@ +#!/bin/sh + +set -ex + +cd "$(dirname "$0")/../.." + +export PATH="/usr/local/Cellar/node/0.8.21/bin:/usr/local/bin:${PATH}" + +rake setup-codesigning create-xcode-project diff --git a/script/copy-files-to-bundle b/script/copy-files-to-bundle index 67dfed4c4..c4eed5d02 100755 --- a/script/copy-files-to-bundle +++ b/script/copy-files-to-bundle @@ -1,41 +1,10 @@ #!/bin/sh # This can only be run by xcode or xcodebuild! -# Because of the way xcodebuild invokes external scripts we need to load -# The Setup's environment ourselves. If this isn't done, things like the -# node shim won't be able to find the stuff they need. - -if [ -f /opt/github/env.sh ]; then - source /opt/github/env.sh -fi +set -e +COMPILED_SOURCES_DIR="${1}" RESOUCES_PATH="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" -DIRS="src static vendor" - -# Compile .coffee files into bundle -COFFEE_FILES=$(find $DIRS -type file -name '*.coffee') -for COFFEE_FILE in $COFFEE_FILES; do - JS_FILE=$(echo "$RESOUCES_PATH/$COFFEE_FILE" | sed 's/.coffee/.js/' ) - OUTPUT_PATH="$RESOUCES_PATH/$(dirname "$COFFEE_FILE")" - - if [ $COFFEE_FILE -nt "$JS_FILE" ]; then - mkdir -p "$OUTPUT_PATH" - node_modules/.bin/coffee -c -o "$OUTPUT_PATH" "$COFFEE_FILE" || exit 1 - fi -done; - -# Compile .cson files into bundle -CSON_FILES=$(find $DIRS -type file -name '*.cson') -for CSON_FILE in $CSON_FILES; do - JSON_FILE=$(echo "$RESOUCES_PATH/$CSON_FILE" | sed 's/.cson/.json/' ) - OUTPUT_PATH="$RESOUCES_PATH/$(dirname "$CSON_FILE")" - - if [ $CSON_FILE -nt "$JSON_FILE" ]; then - mkdir -p "$OUTPUT_PATH" - node_modules/.bin/cson2json "$CSON_FILE" > "$JSON_FILE" - fi -done; - # Copy non-coffee files into bundle -rsync --archive --recursive --exclude="src/**/*.coffee" --exclude="src/**/*.cson" src static vendor spec benchmark themes dot-atom atom.sh "$RESOUCES_PATH" +rsync --archive --recursive --exclude="src/**/*.coffee" --exclude="src/**/*.cson" src static vendor spec benchmark themes dot-atom atom.sh "${COMPILED_SOURCES_DIR}/" "$RESOUCES_PATH" diff --git a/script/generate-sources-gypi b/script/generate-sources-gypi new file mode 100755 index 000000000..266fde180 --- /dev/null +++ b/script/generate-sources-gypi @@ -0,0 +1,32 @@ +#!/bin/sh + +set -e + +cd "$(dirname $0)/.." + +DIRS="src static vendor" + +find_files() { + find ${DIRS} -type file -name ${1} +} + +file_list() { + while read file; do + echo " '${file}'," + done +} + +cat > sources.gypi < + [packageMainModule, pack] = [] + + beforeEach -> + pack = new AtomPackage(fs.resolve(config.packageDirPaths..., 'package-with-activation-events')) + pack.load() + describe ".load()", -> + describe "if the package's metadata has a `deferredDeserializers` array", -> + it "requires the package's main module attempting to use deserializers named in the array", -> + expect(pack.mainModule).toBeNull() + object = deserialize(deserializer: 'Foo', data: "Hello") + expect(object.constructor.name).toBe 'Foo' + expect(object.data).toBe 'Hello' + expect(pack.mainModule).toBeDefined() + expect(pack.mainModule.activateCallCount).toBe 0 + + describe ".activate()", -> beforeEach -> window.rootView = new RootView + packageMainModule = require 'fixtures/packages/package-with-activation-events/main' + spyOn(packageMainModule, 'activate').andCallThrough() describe "when the package metadata includes activation events", -> - [packageMainModule, pack] = [] - beforeEach -> - pack = new AtomPackage(fs.resolve(config.packageDirPaths..., 'package-with-activation-events')) - packageMainModule = require 'fixtures/packages/package-with-activation-events/main' - spyOn(packageMainModule, 'activate').andCallThrough() - pack.load() + pack.activate() it "defers activating the package until an activation event bubbles to the root view", -> expect(packageMainModule.activate).not.toHaveBeenCalled() @@ -23,7 +36,7 @@ describe "AtomPackage", -> it "triggers the activation event on all handlers registered during activation", -> rootView.open('sample.js') - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() eventHandler = jasmine.createSpy("activation-event") editor.command 'activation-event', eventHandler editor.trigger 'activation-event' @@ -44,6 +57,7 @@ describe "AtomPackage", -> expect(packageMainModule.activate).not.toHaveBeenCalled() pack.load() + pack.activate() expect(packageMainModule.activate).toHaveBeenCalled() describe "when the package doesn't have an index.coffee", -> diff --git a/spec/app/atom-spec.coffee b/spec/app/atom-spec.coffee index 261c86d4f..3b007f125 100644 --- a/spec/app/atom-spec.coffee +++ b/spec/app/atom-spec.coffee @@ -84,26 +84,26 @@ describe "the `atom` global", -> describe "activation", -> it "calls activate on the package main with its previous state", -> pack = window.loadPackage('package-with-module') - spyOn(pack.packageMain, 'activate') + spyOn(pack.mainModule, 'activate') serializedState = rootView.serialize() rootView.deactivate() RootView.deserialize(serializedState) window.loadPackage('package-with-module') - expect(pack.packageMain.activate).toHaveBeenCalledWith(someNumber: 1) + expect(pack.mainModule.activate).toHaveBeenCalledWith(someNumber: 1) describe "deactivation", -> it "deactivates and removes the package module from the package module map", -> pack = window.loadPackage('package-with-module') expect(atom.activatedAtomPackages.length).toBe 1 - spyOn(pack.packageMain, "deactivate").andCallThrough() + spyOn(pack.mainModule, "deactivate").andCallThrough() atom.deactivateAtomPackages() - expect(pack.packageMain.deactivate).toHaveBeenCalled() + expect(pack.mainModule.deactivate).toHaveBeenCalled() expect(atom.activatedAtomPackages.length).toBe 0 describe "serialization", -> - it "uses previous serialization state on unactivated packages", -> + it "uses previous serialization state on packages whose activation has been deferred", -> atom.atomPackageStates['package-with-activation-events'] = {previousData: 'exists'} unactivatedPackage = window.loadPackage('package-with-activation-events') activatedPackage = window.loadPackage('package-with-module') @@ -115,7 +115,8 @@ describe "the `atom` global", -> 'previousData': 'exists' # ensure serialization occurs when the packageis activated - unactivatedPackage.activatePackageMain() + unactivatedPackage.deferActivation = false + unactivatedPackage.activate() expect(atom.serializeAtomPackages()).toEqual 'package-with-module': 'someNumber': 1 @@ -124,8 +125,8 @@ describe "the `atom` global", -> it "absorbs exceptions that are thrown by the package module's serialize methods", -> spyOn(console, 'error') - window.loadPackage('package-with-module') - window.loadPackage('package-with-serialize-error', activateImmediately: true) + window.loadPackage('package-with-module', activateImmediately: true) + window.loadPackage('package-with-serialize-error', activateImmediately: true) packageStates = atom.serializeAtomPackages() expect(packageStates['package-with-module']).toEqual someNumber: 1 @@ -141,3 +142,74 @@ describe "the `atom` global", -> runs -> expect(versionHandler.argsForCall[0][0]).toMatch /^\d+\.\d+(\.\d+)?$/ + + describe "modal native dialogs", -> + beforeEach -> + spyOn(atom, 'sendMessageToBrowserProcess') + atom.sendMessageToBrowserProcess.simulateConfirmation = (buttonText) -> + labels = @argsForCall[0][1][2...] + callbacks = @argsForCall[0][2] + @reset() + callbacks[labels.indexOf(buttonText)]() + advanceClock 50 + + atom.sendMessageToBrowserProcess.simulatePathSelection = (path) -> + callback = @argsForCall[0][2] + @reset() + callback(path) + advanceClock 50 + + it "only presents one native dialog at a time", -> + confirmHandler = jasmine.createSpy("confirmHandler") + selectPathHandler = jasmine.createSpy("selectPathHandler") + + atom.confirm "Are you happy?", "really, truly happy?", "Yes", confirmHandler, "No" + atom.confirm "Are you happy?", "really, truly happy?", "Yes", confirmHandler, "No" + atom.showSaveDialog(selectPathHandler) + atom.showSaveDialog(selectPathHandler) + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + atom.sendMessageToBrowserProcess.simulateConfirmation("Yes") + expect(confirmHandler).toHaveBeenCalled() + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + atom.sendMessageToBrowserProcess.simulateConfirmation("No") + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + atom.sendMessageToBrowserProcess.simulatePathSelection('/selected/path') + expect(selectPathHandler).toHaveBeenCalledWith('/selected/path') + selectPathHandler.reset() + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + + it "prioritizes dialogs presented as the result of dismissing other dialogs before any previously deferred dialogs", -> + atom.confirm "A1", "", "Next", -> + atom.confirm "B1", "", "Next", -> + atom.confirm "C1", "", "Next", -> + atom.confirm "C2", "", "Next", -> + atom.confirm "B2", "", "Next", -> + atom.confirm "A2", "", "Next", -> + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + expect(atom.sendMessageToBrowserProcess.argsForCall[0][1][0]).toBe "A1" + atom.sendMessageToBrowserProcess.simulateConfirmation('Next') + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + expect(atom.sendMessageToBrowserProcess.argsForCall[0][1][0]).toBe "B1" + atom.sendMessageToBrowserProcess.simulateConfirmation('Next') + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + expect(atom.sendMessageToBrowserProcess.argsForCall[0][1][0]).toBe "C1" + atom.sendMessageToBrowserProcess.simulateConfirmation('Next') + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + expect(atom.sendMessageToBrowserProcess.argsForCall[0][1][0]).toBe "C2" + atom.sendMessageToBrowserProcess.simulateConfirmation('Next') + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + expect(atom.sendMessageToBrowserProcess.argsForCall[0][1][0]).toBe "B2" + atom.sendMessageToBrowserProcess.simulateConfirmation('Next') + + expect(atom.sendMessageToBrowserProcess.callCount).toBe 1 + expect(atom.sendMessageToBrowserProcess.argsForCall[0][1][0]).toBe "A2" + atom.sendMessageToBrowserProcess.simulateConfirmation('Next') diff --git a/spec/app/buffer-spec.coffee b/spec/app/buffer-spec.coffee index 3510d46f4..84b58ddeb 100644 --- a/spec/app/buffer-spec.coffee +++ b/spec/app/buffer-spec.coffee @@ -180,24 +180,88 @@ describe 'Buffer', -> waitsFor 'change event', -> changeHandler.callCount > 0 - describe ".isModified()", -> - it "returns true when user changes buffer", -> + describe "modified status", -> + it "reports the modified status changing to true or false after the user changes buffer", -> + modifiedHandler = jasmine.createSpy("modifiedHandler") + buffer.on 'modified-status-changed', modifiedHandler + expect(buffer.isModified()).toBeFalsy() buffer.insert([0,0], "hi") expect(buffer.isModified()).toBe true - it "returns false after modified buffer is saved", -> + advanceClock(buffer.stoppedChangingDelay) + expect(modifiedHandler).toHaveBeenCalledWith(true) + + modifiedHandler.reset() + buffer.insert([0,2], "ho") + advanceClock(buffer.stoppedChangingDelay) + expect(modifiedHandler).not.toHaveBeenCalled() + + modifiedHandler.reset() + buffer.undo() + buffer.undo() + advanceClock(buffer.stoppedChangingDelay) + expect(modifiedHandler).toHaveBeenCalledWith(false) + + it "reports the modified status changing to true after the underlying file is deleted", -> + buffer.release() + filePath = "/tmp/atom-tmp-file" + fs.write(filePath, 'delete me') + buffer = new Buffer(filePath) + modifiedHandler = jasmine.createSpy("modifiedHandler") + buffer.on 'modified-status-changed', modifiedHandler + + fs.remove(filePath) + + waitsFor "modified status to change", -> modifiedHandler.callCount + runs -> expect(buffer.isModified()).toBe true + + it "reports the modified status changing to false after a modified buffer is saved", -> filePath = "/tmp/atom-tmp-file" fs.write(filePath, '') buffer.release() buffer = new Buffer(filePath) - expect(buffer.isModified()).toBe false + modifiedHandler = jasmine.createSpy("modifiedHandler") + buffer.on 'modified-status-changed', modifiedHandler buffer.insert([0,0], "hi") + advanceClock(buffer.stoppedChangingDelay) expect(buffer.isModified()).toBe true + modifiedHandler.reset() buffer.save() + + expect(modifiedHandler).toHaveBeenCalledWith(false) expect(buffer.isModified()).toBe false + modifiedHandler.reset() + + buffer.insert([0, 0], 'x') + advanceClock(buffer.stoppedChangingDelay) + expect(modifiedHandler).toHaveBeenCalledWith(true) + expect(buffer.isModified()).toBe true + + it "reports the modified status changing to false after a modified buffer is reloaded", -> + filePath = "/tmp/atom-tmp-file" + fs.write(filePath, '') + buffer.release() + buffer = new Buffer(filePath) + modifiedHandler = jasmine.createSpy("modifiedHandler") + buffer.on 'modified-status-changed', modifiedHandler + + buffer.insert([0,0], "hi") + advanceClock(buffer.stoppedChangingDelay) + expect(buffer.isModified()).toBe true + modifiedHandler.reset() + + buffer.reload() + expect(modifiedHandler).toHaveBeenCalledWith(false) + expect(buffer.isModified()).toBe false + modifiedHandler.reset() + + buffer.insert([0, 0], 'x') + advanceClock(buffer.stoppedChangingDelay) + expect(modifiedHandler).toHaveBeenCalledWith(true) + expect(buffer.isModified()).toBe true it "returns false for an empty buffer with no path", -> buffer.release() @@ -1056,61 +1120,30 @@ describe 'Buffer', -> expect(buffer.isEmpty()).toBeFalsy() describe "'contents-modified' event", -> - describe "when the buffer is deleted", -> - it "triggers the contents-modified event", -> - delay = buffer.stoppedChangingDelay - path = "/tmp/atom-file-to-delete.txt" - fs.write(path, 'delete me') - bufferToDelete = new Buffer(path) - contentsModifiedHandler = jasmine.createSpy("contentsModifiedHandler") - bufferToDelete.on 'contents-modified', contentsModifiedHandler + it "triggers the 'contents-modified' event with the current modified status when the buffer changes, rate-limiting events with a delay", -> + delay = buffer.stoppedChangingDelay + contentsModifiedHandler = jasmine.createSpy("contentsModifiedHandler") + buffer.on 'contents-modified', contentsModifiedHandler - expect(bufferToDelete.getPath()).toBe path - expect(bufferToDelete.isModified()).toBeFalsy() - expect(contentsModifiedHandler).not.toHaveBeenCalled() + buffer.insert([0, 0], 'a') + expect(contentsModifiedHandler).not.toHaveBeenCalled() - removeHandler = jasmine.createSpy('removeHandler') - bufferToDelete.file.on 'removed', removeHandler - fs.remove(path) - waitsFor "file to be removed", -> - removeHandler.callCount > 0 + advanceClock(delay / 2) - runs -> - expect(contentsModifiedHandler).toHaveBeenCalledWith(differsFromDisk:true) - bufferToDelete.destroy() + buffer.insert([0, 0], 'b') + expect(contentsModifiedHandler).not.toHaveBeenCalled() - describe "when the buffer text has been changed", -> - it "triggers the contents-modified event 'stoppedChangingDelay' ms after the last buffer change", -> - delay = buffer.stoppedChangingDelay - contentsModifiedHandler = jasmine.createSpy("contentsModifiedHandler") - buffer.on 'contents-modified', contentsModifiedHandler + advanceClock(delay / 2) + expect(contentsModifiedHandler).not.toHaveBeenCalled() - buffer.insert([0, 0], 'a') - expect(contentsModifiedHandler).not.toHaveBeenCalled() + advanceClock(delay / 2) + expect(contentsModifiedHandler).toHaveBeenCalledWith(true) - advanceClock(delay / 2) - - buffer.insert([0, 0], 'b') - expect(contentsModifiedHandler).not.toHaveBeenCalled() - - advanceClock(delay / 2) - expect(contentsModifiedHandler).not.toHaveBeenCalled() - - advanceClock(delay / 2) - expect(contentsModifiedHandler).toHaveBeenCalled() - - it "triggers the contents-modified event with data about whether its contents differ from the contents on disk", -> - delay = buffer.stoppedChangingDelay - contentsModifiedHandler = jasmine.createSpy("contentsModifiedHandler") - buffer.on 'contents-modified', contentsModifiedHandler - - buffer.insert([0, 0], 'a') - advanceClock(delay) - expect(contentsModifiedHandler).toHaveBeenCalledWith(differsFromDisk:true) - - buffer.delete([[0, 0], [0, 1]], '') - advanceClock(delay) - expect(contentsModifiedHandler).toHaveBeenCalledWith(differsFromDisk:false) + contentsModifiedHandler.reset() + buffer.undo() + buffer.undo() + advanceClock(delay) + expect(contentsModifiedHandler).toHaveBeenCalledWith(false) describe ".append(text)", -> it "adds text to the end of the buffer", -> diff --git a/spec/app/config-spec.coffee b/spec/app/config-spec.coffee index 7062c4042..39aae22fe 100644 --- a/spec/app/config-spec.coffee +++ b/spec/app/config-spec.coffee @@ -1,3 +1,4 @@ +Config = require 'config' fs = require 'fs' describe "Config", -> @@ -133,3 +134,21 @@ describe "Config", -> expect(fs.isFile(fs.join(config.configDirPath, 'themes/atom-light-ui/package.cson'))).toBeTruthy() expect(fs.isFile(fs.join(config.configDirPath, 'themes/atom-dark-syntax.css'))).toBeTruthy() expect(fs.isFile(fs.join(config.configDirPath, 'themes/atom-light-syntax.css'))).toBeTruthy() + + describe "when the config file is not parseable", -> + beforeEach -> + config.configDirPath = '/tmp/dot-atom-dir' + config.configFilePath = fs.join(config.configDirPath, "config.cson") + expect(fs.exists(config.configDirPath)).toBeFalsy() + + afterEach -> + fs.remove('/tmp/dot-atom-dir') if fs.exists('/tmp/dot-atom-dir') + + it "logs an error to the console and does not overwrite the config file", -> + config.save.reset() + spyOn(console, 'error') + fs.write(config.configFilePath, "{{{{{") + config.loadUserConfig() + config.set("hair", "blonde") # trigger a save + expect(console.error).toHaveBeenCalled() + expect(config.save).not.toHaveBeenCalled() \ No newline at end of file diff --git a/spec/app/display-buffer-spec.coffee b/spec/app/display-buffer-spec.coffee index 6f87a7ddb..2381d76e2 100644 --- a/spec/app/display-buffer-spec.coffee +++ b/spec/app/display-buffer-spec.coffee @@ -1,11 +1,12 @@ DisplayBuffer = require 'display-buffer' Buffer = require 'buffer' +_ = require 'underscore' describe "DisplayBuffer", -> [editSession, displayBuffer, buffer, changeHandler, tabLength] = [] beforeEach -> tabLength = 2 - editSession = fixturesProject.buildEditSessionForPath('sample.js', { tabLength }) + editSession = project.buildEditSession('sample.js', { tabLength }) { buffer, displayBuffer } = editSession changeHandler = jasmine.createSpy 'changeHandler' displayBuffer.on 'changed', changeHandler @@ -55,6 +56,13 @@ describe "DisplayBuffer", -> describe "when the buffer changes", -> describe "when buffer lines are updated", -> + describe "when whitespace is added after the max line length", -> + it "adds whitespace to the end of the current line and wraps an empty line", -> + fiftyCharacters = _.multiplyString("x", 50) + editSession.buffer.setText(fiftyCharacters) + editSession.setCursorBufferPosition([0, 51]) + editSession.insertText(" ") + describe "when the update makes a soft-wrapped line shorter than the max line length", -> it "rewraps the line and emits a change event", -> buffer.delete([[6, 24], [6, 42]]) @@ -220,7 +228,7 @@ describe "DisplayBuffer", -> editSession2 = null beforeEach -> - editSession2 = fixturesProject.buildEditSessionForPath('two-hundred.txt') + editSession2 = project.buildEditSession('two-hundred.txt') { buffer, displayBuffer } = editSession2 displayBuffer.on 'changed', changeHandler diff --git a/spec/app/edit-session-spec.coffee b/spec/app/edit-session-spec.coffee index 51a1fa12f..d9f7be7f6 100644 --- a/spec/app/edit-session-spec.coffee +++ b/spec/app/edit-session-spec.coffee @@ -9,12 +9,30 @@ describe "EditSession", -> buffer.setText(buffer.getText().replace(/[ ]{2}/g, "\t")) beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('sample.js', autoIndent: false) + editSession = project.buildEditSession('sample.js', autoIndent: false) buffer = editSession.buffer lineLengths = buffer.getLines().map (line) -> line.length - afterEach -> - fixturesProject.destroy() + describe "title", -> + describe ".getTitle()", -> + it "uses the basename of the buffer's path as its title, or 'untitled' if the path is undefined", -> + expect(editSession.getTitle()).toBe 'sample.js' + buffer.setPath(undefined) + expect(editSession.getTitle()).toBe 'untitled' + + describe ".getLongTitle()", -> + it "appends the name of the containing directory to the basename of the file", -> + expect(editSession.getLongTitle()).toBe 'sample.js - fixtures' + buffer.setPath(undefined) + expect(editSession.getLongTitle()).toBe 'untitled' + + it "emits 'title-changed' events when the underlying buffer path", -> + titleChangedHandler = jasmine.createSpy("titleChangedHandler") + editSession.on 'title-changed', titleChangedHandler + + buffer.setPath('/foo/bar/baz.txt') + buffer.setPath(undefined) + expect(titleChangedHandler.callCount).toBe 2 describe "cursor", -> describe ".getCursor()", -> @@ -793,12 +811,21 @@ describe "EditSession", -> expect(editSession.indentationForBufferRow(2)).toBe editSession.indentationForBufferRow(1) describe "when the preceding does not match an auto-indent pattern", -> - it "auto-decreases the indentation of the line to be one level below that of the preceding line", -> - editSession.setCursorBufferPosition([3, Infinity]) - editSession.insertText('\n', autoIndent: true) - expect(editSession.indentationForBufferRow(4)).toBe editSession.indentationForBufferRow(3) - editSession.insertText(' }', autoIndent: true) - expect(editSession.indentationForBufferRow(4)).toBe editSession.indentationForBufferRow(3) - 1 + describe "when the inserted text is whitespace", -> + it "does not auto-decreases the indentation", -> + editSession.setCursorBufferPosition([12, 0]) + editSession.insertText(' ', autoIndent: true) + expect(editSession.lineForBufferRow(12)).toBe ' };' + editSession.insertText('\t\t', autoIndent: true) + expect(editSession.lineForBufferRow(12)).toBe ' \t\t};' + + describe "when the inserted text is non-whitespace", -> + it "auto-decreases the indentation of the line to be one level below that of the preceding line", -> + editSession.setCursorBufferPosition([3, Infinity]) + editSession.insertText('\n', autoIndent: true) + expect(editSession.indentationForBufferRow(4)).toBe editSession.indentationForBufferRow(3) + editSession.insertText(' }', autoIndent: true) + expect(editSession.indentationForBufferRow(4)).toBe editSession.indentationForBufferRow(3) - 1 describe "when the current line does not match an auto-outdent pattern", -> it "leaves the line unchanged", -> @@ -1706,7 +1733,7 @@ describe "EditSession", -> it "does not explode if the current language mode has no comment regex", -> editSession.destroy() - editSession = fixturesProject.buildEditSessionForPath(null, autoIndent: false) + editSession = project.buildEditSession(null, autoIndent: false) editSession.setSelectedBufferRange([[4, 5], [4, 5]]) editSession.toggleLineCommentsInSelection() expect(buffer.lineForRow(4)).toBe " while(items.length > 0) {" @@ -1784,7 +1811,7 @@ describe "EditSession", -> expect(editSession.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] it "restores selected ranges even when the change occurred in another edit session", -> - otherEditSession = fixturesProject.buildEditSessionForPath(editSession.getPath()) + otherEditSession = project.buildEditSession(editSession.getPath()) otherEditSession.setSelectedBufferRange([[2, 2], [3, 3]]) otherEditSession.delete() @@ -1977,13 +2004,13 @@ describe "EditSession", -> describe "soft-tabs detection", -> it "assign soft / hard tabs based on the contents of the buffer, or uses the default if unknown", -> - editSession = fixturesProject.buildEditSessionForPath('sample.js', softTabs: false) + editSession = project.buildEditSession('sample.js', softTabs: false) expect(editSession.softTabs).toBeTruthy() - editSession = fixturesProject.buildEditSessionForPath('sample-with-tabs.coffee', softTabs: true) + editSession = project.buildEditSession('sample-with-tabs.coffee', softTabs: true) expect(editSession.softTabs).toBeFalsy() - editSession = fixturesProject.buildEditSessionForPath(null, softTabs: false) + editSession = project.buildEditSession(null, softTabs: false) expect(editSession.softTabs).toBeFalsy() describe ".indentLevelForLine(line)", -> @@ -2006,6 +2033,19 @@ describe "EditSession", -> editSession.buffer.reload() expect(editSession.getCursorScreenPosition()).toEqual [0,1] + describe "when the 'grammars-loaded' event is triggered on the syntax global", -> + it "reloads the edit session's grammar and re-tokenizes the buffer if it changes", -> + editSession.destroy() + grammarToReturn = syntax.grammarByFileTypeSuffix('txt') + spyOn(syntax, 'grammarForFilePath').andCallFake -> grammarToReturn + + editSession = project.buildEditSession('sample.js', autoIndent: false) + expect(editSession.lineForScreenRow(0).tokens.length).toBe 1 + + grammarToReturn = syntax.grammarByFileTypeSuffix('js') + syntax.trigger 'grammars-loaded' + expect(editSession.lineForScreenRow(0).tokens.length).toBeGreaterThan 1 + describe "auto-indent", -> describe "editor.autoIndent", -> it "auto-indents newlines if editor.autoIndent is true", -> diff --git a/spec/app/editor-spec.coffee b/spec/app/editor-spec.coffee index f7a553cab..04e6c9b57 100644 --- a/spec/app/editor-spec.coffee +++ b/spec/app/editor-spec.coffee @@ -1,4 +1,3 @@ -RootView = require 'root-view' EditSession = require 'edit-session' Buffer = require 'buffer' Editor = require 'editor' @@ -10,75 +9,41 @@ _ = require 'underscore' fs = require 'fs' describe "Editor", -> - [buffer, editor, cachedLineHeight] = [] + [buffer, editor, editSession, cachedLineHeight, cachedCharWidth] = [] + + beforeEach -> + editSession = project.buildEditSession('sample.js') + buffer = editSession.buffer + editor = new Editor(editSession) + editor.lineOverdraw = 2 + editor.isFocused = true + editor.enableKeymap() + editor.attachToDom = ({ heightInLines, widthInChars } = {}) -> + heightInLines ?= this.getBuffer().getLineCount() + this.height(getLineHeight() * heightInLines) + this.width(getCharWidth() * widthInChars) if widthInChars + $('#jasmine-content').append(this) getLineHeight = -> return cachedLineHeight if cachedLineHeight? - editorForMeasurement = new Editor(editSession: project.buildEditSessionForPath('sample.js')) - editorForMeasurement.attachToDom() - cachedLineHeight = editorForMeasurement.lineHeight - editorForMeasurement.remove() + calcDimensions() cachedLineHeight - beforeEach -> - window.rootView = new RootView - rootView.open('sample.js') - editor = rootView.getActiveEditor() - buffer = editor.getBuffer() + getCharWidth = -> + return cachedCharWidth if cachedCharWidth? + calcDimensions() + cachedCharWidth - editor.attachToDom = ({ heightInLines } = {}) -> - heightInLines ?= this.getBuffer().getLineCount() - this.height(getLineHeight() * heightInLines) - $('#jasmine-content').append(this) - - editor.lineOverdraw = 2 - editor.enableKeymap() - editor.isFocused = true + calcDimensions = -> + editorForMeasurement = new Editor(editSession: project.buildEditSession('sample.js')) + editorForMeasurement.attachToDom() + cachedLineHeight = editorForMeasurement.lineHeight + cachedCharWidth = editorForMeasurement.charWidth + editorForMeasurement.remove() describe "construction", -> - it "throws an error if no editor session is given unless deserializing", -> + it "throws an error if no edit session is given", -> expect(-> new Editor).toThrow() - expect(-> new Editor(deserializing: true)).not.toThrow() - - describe ".copy()", -> - it "builds a new editor with the same edit sessions, cursor position, and scroll position as the receiver", -> - rootView.attachToDom() - rootView.height(8 * editor.lineHeight) - rootView.width(50 * editor.charWidth) - - editor.edit(project.buildEditSessionForPath('two-hundred.txt')) - editor.setCursorScreenPosition([5, 1]) - editor.scrollTop(1.5 * editor.lineHeight) - editor.scrollView.scrollLeft(44) - - # proves this test covers serialization and deserialization - spyOn(editor, 'serialize').andCallThrough() - spyOn(Editor, 'deserialize').andCallThrough() - - newEditor = editor.copy() - expect(editor.serialize).toHaveBeenCalled() - expect(Editor.deserialize).toHaveBeenCalled() - - expect(newEditor.getBuffer()).toBe editor.getBuffer() - expect(newEditor.getCursorScreenPosition()).toEqual editor.getCursorScreenPosition() - expect(newEditor.editSessions).toEqual(editor.editSessions) - expect(newEditor.activeEditSession).toEqual(editor.activeEditSession) - expect(newEditor.getActiveEditSessionIndex()).toEqual(editor.getActiveEditSessionIndex()) - - newEditor.height(editor.height()) - newEditor.width(editor.width()) - - newEditor.attachToDom() - expect(newEditor.scrollTop()).toBe editor.scrollTop() - expect(newEditor.scrollView.scrollLeft()).toBe 44 - - it "does not blow up if no file exists for a previous edit session, but prints a warning", -> - spyOn(console, 'warn') - fs.write('/tmp/delete-me') - editor.edit(project.buildEditSessionForPath('/tmp/delete-me')) - fs.remove('/tmp/delete-me') - newEditor = editor.copy() - expect(console.warn).toHaveBeenCalled() describe "when the editor is attached to the dom", -> it "calculates line height and char width and updates the pixel position of the cursor", -> @@ -117,7 +82,7 @@ describe "Editor", -> it "triggers an alert", -> path = "/tmp/atom-changed-file.txt" fs.write(path, "") - editSession = project.buildEditSessionForPath(path) + editSession = project.buildEditSession(path) editor.edit(editSession) editor.insertText("now the buffer is modified") @@ -135,272 +100,66 @@ describe "Editor", -> expect(atom.confirm).toHaveBeenCalled() describe ".remove()", -> - it "removes subscriptions from all edit session buffers", -> - previousEditSession = editor.activeEditSession - otherEditSession = project.buildEditSessionForPath(project.resolve('sample.txt')) - expect(previousEditSession.buffer.subscriptionCount()).toBeGreaterThan 1 - - editor.edit(otherEditSession) - expect(otherEditSession.buffer.subscriptionCount()).toBeGreaterThan 1 - + it "destroys the edit session", -> editor.remove() - expect(previousEditSession.buffer.subscriptionCount()).toBe 0 - expect(otherEditSession.buffer.subscriptionCount()).toBe 0 - - describe "when 'close' is triggered", -> - it "adds a closed session path to the array", -> - editor.edit(project.buildEditSessionForPath()) - editSession = editor.activeEditSession - expect(editor.closedEditSessions.length).toBe 0 - editor.trigger "core:close" - expect(editor.closedEditSessions.length).toBe 0 - editor.edit(project.buildEditSessionForPath(project.resolve('sample.txt'))) - editor.trigger "core:close" - expect(editor.closedEditSessions.length).toBe 1 - - it "closes the active edit session and loads next edit session", -> - editor.edit(project.buildEditSessionForPath()) - editSession = editor.activeEditSession - spyOn(editSession.buffer, 'isModified').andReturn false - spyOn(editSession, 'destroy').andCallThrough() - spyOn(editor, "remove").andCallThrough() - editor.trigger "core:close" - expect(editSession.destroy).toHaveBeenCalled() - expect(editor.remove).not.toHaveBeenCalled() - expect(editor.getBuffer()).toBe buffer - - it "triggers the 'editor:edit-session-removed' event with the edit session and its former index", -> - editor.edit(project.buildEditSessionForPath()) - editSession = editor.activeEditSession - index = editor.getActiveEditSessionIndex() - spyOn(editSession.buffer, 'isModified').andReturn false - - editSessionRemovedHandler = jasmine.createSpy('editSessionRemovedHandler') - editor.on 'editor:edit-session-removed', editSessionRemovedHandler - editor.trigger "core:close" - - expect(editSessionRemovedHandler).toHaveBeenCalled() - expect(editSessionRemovedHandler.argsForCall[0][1..2]).toEqual [editSession, index] - - it "calls remove on the editor if there is one edit session and mini is false", -> - editSession = editor.activeEditSession - expect(editor.mini).toBeFalsy() - expect(editor.editSessions.length).toBe 1 - spyOn(editor, 'remove').andCallThrough() - editor.trigger 'core:close' - spyOn(editSession, 'destroy').andCallThrough() - expect(editor.remove).toHaveBeenCalled() - - miniEditor = new Editor(mini: true) - spyOn(miniEditor, 'remove').andCallThrough() - miniEditor.trigger 'core:close' - expect(miniEditor.remove).not.toHaveBeenCalled() - - describe "when buffer is modified", -> - it "triggers an alert and does not close the session", -> - spyOn(editor, 'remove').andCallThrough() - spyOn(atom, 'confirm') - editor.insertText("I AM CHANGED!") - editor.trigger "core:close" - expect(editor.remove).not.toHaveBeenCalled() - expect(atom.confirm).toHaveBeenCalled() - - it "doesn't trigger an alert if the buffer is opened in multiple sessions", -> - spyOn(editor, 'remove').andCallThrough() - spyOn(atom, 'confirm') - editor.insertText("I AM CHANGED!") - editor.splitLeft() - editor.trigger "core:close" - expect(editor.remove).toHaveBeenCalled() - expect(atom.confirm).not.toHaveBeenCalled() + expect(editor.activeEditSession.destroyed).toBeTruthy() describe ".edit(editSession)", -> - otherEditSession = null + [newEditSession, newBuffer] = [] beforeEach -> - otherEditSession = project.buildEditSessionForPath() + newEditSession = project.buildEditSession('two-hundred.txt') + newBuffer = newEditSession.buffer - describe "when the edit session wasn't previously assigned to this editor", -> - it "adds edit session to editor and triggers the 'editor:edit-session-added' event", -> - editSessionAddedHandler = jasmine.createSpy('editSessionAddedHandler') - editor.on 'editor:edit-session-added', editSessionAddedHandler + it "updates the rendered lines, cursors, selections, scroll position, and event subscriptions to match the given edit session", -> + editor.attachToDom(heightInLines: 5, widthInChars: 30) + editor.setCursorBufferPosition([3, 5]) + editor.scrollToBottom() + editor.scrollView.scrollLeft(150) + previousScrollHeight = editor.verticalScrollbar.prop('scrollHeight') + previousScrollTop = editor.scrollTop() + previousScrollLeft = editor.scrollView.scrollLeft() - originalEditSessionCount = editor.editSessions.length - editor.edit(otherEditSession) - expect(editor.activeEditSession).toBe otherEditSession - expect(editor.editSessions.length).toBe originalEditSessionCount + 1 + newEditSession.scrollTop = 120 + newEditSession.setSelectedBufferRange([[40, 0], [43, 1]]) - expect(editSessionAddedHandler).toHaveBeenCalled() - expect(editSessionAddedHandler.argsForCall[0][1..2]).toEqual [otherEditSession, originalEditSessionCount] + editor.edit(newEditSession) + { firstRenderedScreenRow, lastRenderedScreenRow } = editor + expect(editor.lineElementForScreenRow(firstRenderedScreenRow).text()).toBe newBuffer.lineForRow(firstRenderedScreenRow) + expect(editor.lineElementForScreenRow(lastRenderedScreenRow).text()).toBe newBuffer.lineForRow(editor.lastRenderedScreenRow) + expect(editor.scrollTop()).toBe 120 + expect(editor.getSelectionView().regions[0].position().top).toBe 40 * editor.lineHeight + editor.insertText("hello") + expect(editor.lineElementForScreenRow(40).text()).toBe "hello3" - describe "when the edit session was previously assigned to this editor", -> - it "restores the previous edit session associated with the editor", -> - previousEditSession = editor.activeEditSession + editor.edit(editSession) + { firstRenderedScreenRow, lastRenderedScreenRow } = editor + expect(editor.lineElementForScreenRow(firstRenderedScreenRow).text()).toBe buffer.lineForRow(firstRenderedScreenRow) + expect(editor.lineElementForScreenRow(lastRenderedScreenRow).text()).toBe buffer.lineForRow(editor.lastRenderedScreenRow) + expect(editor.verticalScrollbar.prop('scrollHeight')).toBe previousScrollHeight + expect(editor.scrollTop()).toBe previousScrollTop + expect(editor.scrollView.scrollLeft()).toBe previousScrollLeft + expect(editor.getCursorView().position()).toEqual { top: 3 * editor.lineHeight, left: 5 * editor.charWidth } + editor.insertText("goodbye") + expect(editor.lineElementForScreenRow(3).text()).toMatch /^ vgoodbyear/ - editor.edit(otherEditSession) - expect(editor.activeEditSession).not.toBe previousEditSession + it "triggers alert if edit session's buffer goes into conflict with changes on disk", -> + path = "/tmp/atom-changed-file.txt" + fs.write(path, "") + tempEditSession = project.buildEditSession(path) + editor.edit(tempEditSession) + tempEditSession.insertText("a buffer change") - editor.edit(previousEditSession) - expect(editor.activeEditSession).toBe previousEditSession + spyOn(atom, "confirm") - it "handles buffer manipulation correctly after switching to a new edit session", -> - editor.attachToDom() - editor.insertText("abc\n") - expect(editor.lineElementForScreenRow(0).text()).toBe 'abc' + contentsConflictedHandler = jasmine.createSpy("contentsConflictedHandler") + tempEditSession.on 'contents-conflicted', contentsConflictedHandler + fs.write(path, "a file change") + waitsFor -> + contentsConflictedHandler.callCount > 0 - editor.edit(otherEditSession) - expect(editor.lineElementForScreenRow(0).html()).toBe ' ' - - editor.insertText("def\n") - expect(editor.lineElementForScreenRow(0).text()).toBe 'def' - - it "removes the opened session from the closed sessions array", -> - editor.edit(project.buildEditSessionForPath('sample.txt')) - expect(editor.closedEditSessions.length).toBe 0 - editor.trigger "core:close" - expect(editor.closedEditSessions.length).toBe 1 - editor.edit(project.buildEditSessionForPath('sample.txt')) - expect(editor.closedEditSessions.length).toBe 0 - - describe "switching edit sessions", -> - [session0, session1, session2] = [] - - beforeEach -> - session0 = editor.activeEditSession - - editor.edit(project.buildEditSessionForPath('sample.txt')) - session1 = editor.activeEditSession - - editor.edit(project.buildEditSessionForPath('two-hundred.txt')) - session2 = editor.activeEditSession - - describe ".setActiveEditSessionIndex(index)", -> - it "restores the buffer, cursors, selections, and scroll position of the edit session associated with the index", -> - editor.attachToDom(heightInLines: 10) - editor.setSelectedBufferRange([[40, 0], [43, 1]]) - expect(editor.getSelection().getScreenRange()).toEqual [[40, 0], [43, 1]] - previousScrollHeight = editor.verticalScrollbar.prop('scrollHeight') - - editor.scrollTop(750) - expect(editor.scrollTop()).toBe 750 - - editor.setActiveEditSessionIndex(0) - expect(editor.getBuffer()).toBe session0.buffer - - editor.setActiveEditSessionIndex(2) - expect(editor.getBuffer()).toBe session2.buffer - expect(editor.getCursorScreenPosition()).toEqual [43, 1] - expect(editor.verticalScrollbar.prop('scrollHeight')).toBe previousScrollHeight - expect(editor.scrollTop()).toBe 750 - expect(editor.getSelection().getScreenRange()).toEqual [[40, 0], [43, 1]] - expect(editor.getSelectionView().find('.region')).toExist() - - editor.setActiveEditSessionIndex(0) - editor.activeEditSession.selectToEndOfLine() - expect(editor.getSelectionView().find('.region')).toExist() - - it "triggers alert if edit session's buffer goes into conflict with changes on disk", -> - path = "/tmp/atom-changed-file.txt" - fs.write(path, "") - editSession = project.buildEditSessionForPath(path) - editor.edit editSession - editSession.insertText("a buffer change") - - spyOn(atom, "confirm") - - contentsConflictedHandler = jasmine.createSpy("contentsConflictedHandler") - editSession.on 'contents-conflicted', contentsConflictedHandler - fs.write(path, "a file change") - waitsFor -> - contentsConflictedHandler.callCount > 0 - - runs -> - expect(atom.confirm).toHaveBeenCalled() - - it "emits an editor:active-edit-session-changed event with the edit session and its index", -> - activeEditSessionChangeHandler = jasmine.createSpy('activeEditSessionChangeHandler') - editor.on 'editor:active-edit-session-changed', activeEditSessionChangeHandler - - editor.setActiveEditSessionIndex(2) - expect(activeEditSessionChangeHandler).toHaveBeenCalled() - expect(activeEditSessionChangeHandler.argsForCall[0][1..2]).toEqual [editor.activeEditSession, 2] - activeEditSessionChangeHandler.reset() - - editor.setActiveEditSessionIndex(0) - expect(activeEditSessionChangeHandler.argsForCall[0][1..2]).toEqual [editor.activeEditSession, 0] - activeEditSessionChangeHandler.reset() - - describe ".loadNextEditSession()", -> - it "loads the next editor state and wraps to beginning when end is reached", -> - expect(editor.activeEditSession).toBe session2 - editor.loadNextEditSession() - expect(editor.activeEditSession).toBe session0 - editor.loadNextEditSession() - expect(editor.activeEditSession).toBe session1 - editor.loadNextEditSession() - expect(editor.activeEditSession).toBe session2 - - describe ".loadPreviousEditSession()", -> - it "loads the next editor state and wraps to beginning when end is reached", -> - expect(editor.activeEditSession).toBe session2 - editor.loadPreviousEditSession() - expect(editor.activeEditSession).toBe session1 - editor.loadPreviousEditSession() - expect(editor.activeEditSession).toBe session0 - editor.loadPreviousEditSession() - expect(editor.activeEditSession).toBe session2 - - describe ".save()", -> - describe "when the current buffer has a path", -> - tempFilePath = null - - beforeEach -> - project.setPath('/tmp') - tempFilePath = '/tmp/atom-temp.txt' - fs.write(tempFilePath, "") - rootView.open(tempFilePath) - editor = rootView.getActiveEditor() - expect(editor.getPath()).toBe tempFilePath - - afterEach -> - expect(fs.remove(tempFilePath)) - - it "saves the current buffer to disk", -> - editor.getBuffer().setText 'Edited!' - expect(fs.read(tempFilePath)).not.toBe "Edited!" - - editor.save() - - expect(fs.exists(tempFilePath)).toBeTruthy() - expect(fs.read(tempFilePath)).toBe 'Edited!' - - describe "when the current buffer has no path", -> - selectedFilePath = null - beforeEach -> - editor.edit(project.buildEditSessionForPath()) - - expect(editor.getPath()).toBeUndefined() - editor.getBuffer().setText 'Save me to a new path' - spyOn(atom, 'showSaveDialog').andCallFake (callback) -> callback(selectedFilePath) - - it "presents a 'save as' dialog", -> - editor.save() - expect(atom.showSaveDialog).toHaveBeenCalled() - - describe "when a path is chosen", -> - it "saves the buffer to the chosen path", -> - selectedFilePath = '/tmp/temp.txt' - - editor.save() - - expect(fs.exists(selectedFilePath)).toBeTruthy() - expect(fs.read(selectedFilePath)).toBe 'Save me to a new path' - - describe "when dialog is cancelled", -> - it "does not save the buffer", -> - selectedFilePath = null - editor.save() - expect(fs.exists(selectedFilePath)).toBeFalsy() + runs -> + expect(atom.confirm).toHaveBeenCalled() describe ".scrollTop(n)", -> beforeEach -> @@ -449,29 +208,6 @@ describe "Editor", -> editor.scrollTop(50) expect(editor.scrollTop()).toBe 50 - describe "split methods", -> - describe "when inside a pane", -> - fakePane = null - beforeEach -> - fakePane = { splitUp: jasmine.createSpy('splitUp').andReturn({}), remove: -> } - spyOn(editor, 'pane').andReturn(fakePane) - - it "calls the corresponding split method on the containing pane with a new editor containing a copy of the active edit session", -> - editor.edit project.buildEditSessionForPath("sample.txt") - editor.splitUp() - expect(fakePane.splitUp).toHaveBeenCalled() - [newEditor] = fakePane.splitUp.argsForCall[0] - expect(newEditor.editSessions.length).toEqual 1 - expect(newEditor.activeEditSession.buffer).toBe editor.activeEditSession.buffer - newEditor.remove() - - describe "when not inside a pane", -> - it "does not split the editor, but doesn't throw an exception", -> - editor.splitUp().remove() - editor.splitDown().remove() - editor.splitLeft().remove() - editor.splitRight().remove() - describe "editor:attached event", -> it 'only triggers an editor:attached event when it is first added to the DOM', -> openHandler = jasmine.createSpy('openHandler') @@ -486,7 +222,7 @@ describe "Editor", -> editor.attachToDom() expect(openHandler).not.toHaveBeenCalled() - describe "editor-path-changed event", -> + describe "editor:path-changed event", -> path = null beforeEach -> path = "/tmp/something.txt" @@ -504,7 +240,7 @@ describe "Editor", -> it "emits event when editor receives a new buffer", -> eventHandler = jasmine.createSpy('eventHandler') editor.on 'editor:path-changed', eventHandler - editor.edit(project.buildEditSessionForPath(path)) + editor.edit(project.buildEditSession(path)) expect(eventHandler).toHaveBeenCalled() it "stops listening to events on previously set buffers", -> @@ -512,7 +248,7 @@ describe "Editor", -> oldBuffer = editor.getBuffer() editor.on 'editor:path-changed', eventHandler - editor.edit(project.buildEditSessionForPath(path)) + editor.edit(project.buildEditSession(path)) expect(eventHandler).toHaveBeenCalled() eventHandler.reset() @@ -539,30 +275,21 @@ describe "Editor", -> afterEach -> editor.clearFontFamily() - it "updates the font family on new and existing editors", -> - rootView.attachToDom() - rootView.height(200) - rootView.width(200) - - config.set("editor.fontFamily", "Courier") - newEditor = editor.splitRight() - - expect($("head style.editor-font-family").text()).toMatch "{font-family: Courier}" - expect(editor.css('font-family')).toBe 'Courier' - expect(newEditor.css('font-family')).toBe 'Courier' - it "updates the font family of editors and recalculates dimensions critical to cursor positioning", -> - rootView.attachToDom() - rootView.height(200) - rootView.width(200) - + editor.attachToDom(12) lineHeightBefore = editor.lineHeight charWidthBefore = editor.charWidth - config.set("editor.fontFamily", "Courier") editor.setCursorScreenPosition [5, 6] + + config.set("editor.fontFamily", "PCMyungjo") + expect(editor.css('font-family')).toBe 'PCMyungjo' + expect($("head style.editor-font-family").text()).toMatch "{font-family: PCMyungjo}" expect(editor.charWidth).not.toBe charWidthBefore expect(editor.getCursorView().position()).toEqual { top: 5 * editor.lineHeight, left: 6 * editor.charWidth } - expect(editor.verticalScrollbarContent.height()).toBe buffer.getLineCount() * editor.lineHeight + + newEditor = new Editor(editor.activeEditSession.copy()) + newEditor.attachToDom() + expect(newEditor.css('font-family')).toBe 'PCMyungjo' describe "font size", -> beforeEach -> @@ -574,24 +301,9 @@ describe "Editor", -> expect($("head style.font-size").text()).toMatch "{font-size: #{config.get('editor.fontSize')}px}" describe "when the font size changes", -> - it "updates the font family on new and existing editors", -> - rootView.attachToDom() - rootView.height(200) - rootView.width(200) - - config.set("editor.fontSize", 20) - newEditor = editor.splitRight() - - expect($("head style.font-size").text()).toMatch "{font-size: 20px}" - expect(editor.css('font-size')).toBe '20px' - expect(newEditor.css('font-size')).toBe '20px' - it "updates the font sizes of editors and recalculates dimensions critical to cursor positioning", -> - rootView.attachToDom() - rootView.height(200) - rootView.width(200) - config.set("editor.fontSize", 10) + editor.attachToDom() lineHeightBefore = editor.lineHeight charWidthBefore = editor.charWidth editor.setCursorScreenPosition [5, 6] @@ -604,10 +316,14 @@ describe "Editor", -> expect(editor.renderedLines.outerHeight()).toBe buffer.getLineCount() * editor.lineHeight expect(editor.verticalScrollbarContent.height()).toBe buffer.getLineCount() * editor.lineHeight + newEditor = new Editor(editor.activeEditSession.copy()) + newEditor.attachToDom() + expect(editor.css('font-size')).toBe '30px' + it "updates the position and size of selection regions", -> - rootView.attachToDom() config.set("editor.fontSize", 10) editor.setSelectedBufferRange([[5, 2], [5, 7]]) + editor.attachToDom() config.set("editor.fontSize", 30) selectionRegion = editor.find('.region') @@ -617,10 +333,10 @@ describe "Editor", -> expect(selectionRegion.width()).toBe 5 * editor.charWidth it "updates the gutter width and font size", -> - rootView.attachToDom() - config.set("editor.fontSize", 16 * 4) - expect(editor.gutter.css('font-size')).toBe "#{16 * 4}px" - expect(editor.gutter.width()).toBe(64 + editor.gutter.calculateLineNumberPadding()) + editor.attachToDom() + config.set("editor.fontSize", 20) + expect(editor.gutter.css('font-size')).toBe "20px" + expect(editor.gutter.width()).toBe(editor.charWidth * 2 + editor.gutter.calculateLineNumberPadding()) it "updates lines if there are unrendered lines", -> editor.attachToDom(heightInLines: 5) @@ -630,22 +346,25 @@ describe "Editor", -> config.set("editor.fontSize", 10) expect(editor.renderedLines.find(".line").length).toBeGreaterThan originalLineCount - describe "when the editor is detached", -> + describe "when the font size changes while editor is detached", -> it "redraws the editor according to the new font size when it is reattached", -> - rootView.attachToDom() - rootView.height(200) - rootView.width(200) + editor.setCursorScreenPosition([4, 2]) + editor.attachToDom() + initialLineHeight = editor.lineHeight + initialCharWidth = editor.charWidth + initialCursorPosition = editor.getCursorView().position() + initialScrollbarHeight = editor.verticalScrollbarContent.height() + editor.detach() - newEditor = editor.splitRight() - newEditorParent = newEditor.parent() - newEditor.detach() config.set("editor.fontSize", 10) - newEditorParent.append(newEditor) + expect(editor.lineHeight).toBe initialLineHeight + expect(editor.charWidth).toBe initialCharWidth - expect(newEditor.lineHeight).toBe editor.lineHeight - expect(newEditor.charWidth).toBe editor.charWidth - expect(newEditor.getCursorView().position()).toEqual editor.getCursorView().position() - expect(newEditor.verticalScrollbarContent.height()).toBe editor.verticalScrollbarContent.height() + editor.attachToDom() + expect(editor.lineHeight).not.toBe initialLineHeight + expect(editor.charWidth).not.toBe initialCharWidth + expect(editor.getCursorView().position()).not.toEqual initialCursorPosition + expect(editor.verticalScrollbarContent.height()).not.toBe initialScrollbarHeight describe "mouse events", -> beforeEach -> @@ -1373,7 +1092,7 @@ describe "Editor", -> expect(editor.bufferPositionForScreenPosition(editor.getCursorScreenPosition())).toEqual [3, 60] it "does not wrap the lines of any newly assigned buffers", -> - otherEditSession = project.buildEditSessionForPath() + otherEditSession = project.buildEditSession() otherEditSession.buffer.setText([1..100].join('')) editor.edit(otherEditSession) expect(editor.renderedLines.find('.line').length).toBe(1) @@ -1409,7 +1128,7 @@ describe "Editor", -> expect(editor.getCursorScreenPosition()).toEqual [11, 0] it "calls .setSoftWrapColumn() when the editor is attached because now its dimensions are available to calculate it", -> - otherEditor = new Editor(editSession: project.buildEditSessionForPath('sample.js')) + otherEditor = new Editor(editSession: project.buildEditSession('sample.js')) spyOn(otherEditor, 'setSoftWrapColumn') otherEditor.setSoftWrap(true) @@ -1417,6 +1136,7 @@ describe "Editor", -> otherEditor.simulateDomAttachment() expect(otherEditor.setSoftWrapColumn).toHaveBeenCalled() + otherEditor.remove() describe "when some lines at the end of the buffer are not visible on screen", -> beforeEach -> @@ -1705,7 +1425,7 @@ describe "Editor", -> describe "when autoscrolling at the end of the document", -> it "renders lines properly", -> - editor.edit(project.buildEditSessionForPath('two-hundred.txt')) + editor.edit(project.buildEditSession('two-hundred.txt')) editor.attachToDom(heightInLines: 5.5) expect(editor.renderedLines.find('.line').length).toBe 8 @@ -1716,7 +1436,7 @@ describe "Editor", -> describe "when line has a character that could push it to be too tall (regression)", -> it "does renders the line at a consistent height", -> - rootView.attachToDom() + editor.attachToDom() buffer.insert([0, 0], "–") expect(editor.find('.line:eq(0)').outerHeight()).toBe editor.find('.line:eq(1)').outerHeight() @@ -1747,21 +1467,11 @@ describe "Editor", -> expect(editor.find('.line').html()).toBe 'var' it "allows invisible glyphs to be customized via config.editor.invisibles", -> - rootView.height(200) - rootView.attachToDom() - rightEditor = rootView.getActiveEditor() - rightEditor.setText(" \t ") - leftEditor = rightEditor.splitLeft() - - config.set "editor.showInvisibles", true - config.set "editor.invisibles", - eol: ";" - space: "_" - tab: "tab" - config.update() - - expect(rightEditor.find(".line:first").text()).toBe "_tab _;" - expect(leftEditor.find(".line:first").text()).toBe "_tab _;" + editor.setText(" \t ") + editor.attachToDom() + config.set("editor.showInvisibles", true) + config.set("editor.invisibles", eol: ";", space: "_", tab: "tab") + expect(editor.find(".line:first").text()).toBe "_tab _;" it "displays trailing carriage return using a visible non-empty value", -> editor.setText "a line that ends with a carriage return\r\n" @@ -1986,11 +1696,14 @@ describe "Editor", -> describe "when the switching from an edit session for a long buffer to an edit session for a short buffer", -> it "updates the line numbers to reflect the shorter buffer", -> - editor.edit(fixturesProject.buildEditSessionForPath(null)) + emptyEditSession = project.buildEditSession(null) + editor.edit(emptyEditSession) expect(editor.gutter.lineNumbers.find('.line-number').length).toBe 1 - editor.setActiveEditSessionIndex(0) - editor.setActiveEditSessionIndex(1) + editor.edit(editSession) + expect(editor.gutter.lineNumbers.find('.line-number').length).toBeGreaterThan 1 + + editor.edit(emptyEditSession) expect(editor.gutter.lineNumbers.find('.line-number').length).toBe 1 describe "when the editor is mini", -> @@ -2120,7 +1833,7 @@ describe "Editor", -> describe "folding", -> beforeEach -> - editSession = project.buildEditSessionForPath('two-hundred.txt') + editSession = project.buildEditSession('two-hundred.txt') buffer = editSession.buffer editor.edit(editSession) editor.attachToDom() @@ -2209,14 +1922,6 @@ describe "Editor", -> editor.scrollTop(0) expect(editor.lineElementForScreenRow(2)).toMatchSelector('.fold.selected') - describe ".getOpenBufferPaths()", -> - it "returns the paths of all non-anonymous buffers with edit sessions on this editor", -> - editor.edit(project.buildEditSessionForPath('sample.txt')) - editor.edit(project.buildEditSessionForPath('two-hundred.txt')) - editor.edit(project.buildEditSessionForPath()) - paths = editor.getOpenBufferPaths().map (path) -> project.relativize(path) - expect(paths).toEqual = ['sample.js', 'sample.txt', 'two-hundred.txt'] - describe "paging up and down", -> beforeEach -> editor.attachToDom() @@ -2258,37 +1963,20 @@ describe "Editor", -> expect(editor.getCursor().getScreenPosition().row).toBe(0) expect(editor.getFirstVisibleScreenRow()).toBe(0) - describe "when autosave is enabled", -> - it "autosaves the current buffer when the editor loses focus or switches edit sessions", -> - config.set "editor.autosave", true - rootView.attachToDom() - editor2 = editor.splitRight() - spyOn(editor2.activeEditSession, 'save') - - editor.focus() - expect(editor2.activeEditSession.save).toHaveBeenCalled() - - editSession = editor.activeEditSession - spyOn(editSession, 'save') - rootView.open('sample.txt') - expect(editSession.save).toHaveBeenCalled() - describe ".checkoutHead()", -> [path, originalPathText] = [] beforeEach -> - path = require.resolve('fixtures/git/working-dir/file.txt') + path = project.resolve('git/working-dir/file.txt') originalPathText = fs.read(path) - rootView.open(path) - editor = rootView.getActiveEditor() - editor.attachToDom() + editor.edit(project.buildEditSession(path)) afterEach -> fs.write(path, originalPathText) it "restores the contents of the editor to the HEAD revision", -> editor.setText('') - editor.save() + editor.getBuffer().save() fileChangeHandler = jasmine.createSpy('fileChange') editor.getBuffer().file.on 'contents-changed', fileChangeHandler @@ -2342,7 +2030,7 @@ describe "Editor", -> describe "when clicking below the last line", -> beforeEach -> - rootView.attachToDom() + editor.attachToDom() it "move the cursor to the end of the file", -> expect(editor.getCursorScreenPosition()).toEqual [0,0] @@ -2359,68 +2047,19 @@ describe "Editor", -> editor.underlayer.trigger event expect(editor.getSelection().getScreenRange()).toEqual [[0,0], [12,2]] - describe ".destroyEditSessionIndex(index)", -> - it "prompts to save dirty buffers before closing", -> - editor.setText("I'm dirty") - rootView.open('sample.txt') - expect(editor.getEditSessions().length).toBe 2 - spyOn(atom, "confirm") - editor.destroyEditSessionIndex(0) - expect(atom.confirm).toHaveBeenCalled() - expect(editor.getEditSessions().length).toBe 2 - expect(editor.getEditSessions()[0].buffer.isModified()).toBeTruthy() - - describe ".destroyInactiveEditSessions()", -> - it "destroys every edit session except the active one", -> - rootView.open('sample.txt') - cssSession = rootView.open('css.css') - rootView.open('coffee.coffee') - rootView.open('hello.rb') - expect(editor.getEditSessions().length).toBe 5 - editor.setActiveEditSessionIndex(2) - editor.destroyInactiveEditSessions() - expect(editor.getActiveEditSessionIndex()).toBe 0 - expect(editor.getEditSessions().length).toBe 1 - expect(editor.getEditSessions()[0]).toBe cssSession - - it "prompts to save dirty buffers before destroying", -> - editor.setText("I'm dirty") - dirtySession = editor.activeEditSession - rootView.open('sample.txt') - expect(editor.getEditSessions().length).toBe 2 - spyOn(atom, "confirm") - editor.destroyInactiveEditSessions() - expect(atom.confirm).toHaveBeenCalled() - expect(editor.getEditSessions().length).toBe 2 - expect(editor.getEditSessions()[0].buffer.isModified()).toBeTruthy() - - describe ".destroyAllEditSessions()", -> - it "destroys every edit session", -> - rootView.open('sample.txt') - rootView.open('css.css') - rootView.open('coffee.coffee') - rootView.open('hello.rb') - expect(editor.getEditSessions().length).toBe 5 - editor.setActiveEditSessionIndex(2) - editor.destroyAllEditSessions() - expect(editor.pane()).toBeUndefined() - expect(editor.getEditSessions().length).toBe 0 - describe ".reloadGrammar()", -> [path] = [] beforeEach -> path = "/tmp/grammar-change.txt" fs.write(path, "var i;") - rootView.attachToDom() afterEach -> - project.removeGrammarOverrideForPath(path) fs.remove(path) if fs.exists(path) it "updates all the rendered lines when the grammar changes", -> - rootView.open(path) - editor = rootView.getActiveEditor() + editor.edit(project.buildEditSession(path)) + expect(editor.getGrammar().name).toBe 'Plain Text' jsGrammar = syntax.grammarForFilePath('/tmp/js.js') expect(jsGrammar.name).toBe 'JavaScript' @@ -2434,12 +2073,6 @@ describe "Editor", -> expect(line0.tokens.length).toBe 3 expect(line0.tokens[0]).toEqual(value: 'var', scopes: ['source.js', 'storage.modifier.js']) - line0 = editor.renderedLines.find('.line:first') - span0 = line0.children('span:eq(0)') - expect(span0).toMatchSelector '.source.js' - expect(span0.children('span:eq(0)')).toMatchSelector '.storage.modifier.js' - expect(span0.children('span:eq(0)').text()).toBe 'var' - it "doesn't update the rendered lines when the grammar doesn't change", -> expect(editor.getGrammar().name).toBe 'JavaScript' spyOn(editor, 'updateDisplay').andCallThrough() @@ -2449,8 +2082,8 @@ describe "Editor", -> expect(editor.getGrammar().name).toBe 'JavaScript' it "emits an editor:grammar-changed event when updated", -> - rootView.open(path) - editor = rootView.getActiveEditor() + editor.edit(project.buildEditSession(path)) + eventHandler = jasmine.createSpy('eventHandler') editor.on('editor:grammar-changed', eventHandler) editor.reloadGrammar() @@ -2768,104 +2401,6 @@ describe "Editor", -> expect(buffer.lineForRow(15)).toBeUndefined() expect(editor.getCursorBufferPosition()).toEqual [13, 0] - describe ".moveEditSessionToIndex(fromIndex, toIndex)", -> - describe "when the edit session moves to a later index", -> - it "updates the edit session order", -> - jsPath = editor.getPath() - rootView.open("sample.txt") - txtPath = editor.getPath() - expect(editor.editSessions[0].getPath()).toBe jsPath - expect(editor.editSessions[1].getPath()).toBe txtPath - editor.moveEditSessionToIndex(0, 1) - expect(editor.editSessions[0].getPath()).toBe txtPath - expect(editor.editSessions[1].getPath()).toBe jsPath - - it "fires an editor:edit-session-order-changed event", -> - eventHandler = jasmine.createSpy("eventHandler") - rootView.open("sample.txt") - editor.on "editor:edit-session-order-changed", eventHandler - editor.moveEditSessionToIndex(0, 1) - expect(eventHandler).toHaveBeenCalled() - - it "sets the moved session as the editor's active session", -> - jsPath = editor.getPath() - rootView.open("sample.txt") - txtPath = editor.getPath() - expect(editor.activeEditSession.getPath()).toBe txtPath - editor.moveEditSessionToIndex(0, 1) - expect(editor.activeEditSession.getPath()).toBe jsPath - - describe "when the edit session moves to an earlier index", -> - it "updates the edit session order", -> - jsPath = editor.getPath() - rootView.open("sample.txt") - txtPath = editor.getPath() - expect(editor.editSessions[0].getPath()).toBe jsPath - expect(editor.editSessions[1].getPath()).toBe txtPath - editor.moveEditSessionToIndex(1, 0) - expect(editor.editSessions[0].getPath()).toBe txtPath - expect(editor.editSessions[1].getPath()).toBe jsPath - - it "fires an editor:edit-session-order-changed event", -> - eventHandler = jasmine.createSpy("eventHandler") - rootView.open("sample.txt") - editor.on "editor:edit-session-order-changed", eventHandler - editor.moveEditSessionToIndex(1, 0) - expect(eventHandler).toHaveBeenCalled() - - it "sets the moved session as the editor's active session", -> - jsPath = editor.getPath() - rootView.open("sample.txt") - txtPath = editor.getPath() - expect(editor.activeEditSession.getPath()).toBe txtPath - editor.moveEditSessionToIndex(1, 0) - expect(editor.activeEditSession.getPath()).toBe txtPath - - describe ".moveEditSessionToEditor(fromIndex, toEditor, toIndex)", -> - it "closes the edit session in the source editor", -> - jsPath = editor.getPath() - rootView.open("sample.txt") - txtPath = editor.getPath() - rightEditor = editor.splitRight() - expect(editor.editSessions[0].getPath()).toBe jsPath - expect(editor.editSessions[1].getPath()).toBe txtPath - editor.moveEditSessionToEditor(0, rightEditor, 1) - expect(editor.editSessions[0].getPath()).toBe txtPath - expect(editor.editSessions[1]).toBeUndefined() - - it "opens the edit session in the destination editor at the target index", -> - jsPath = editor.getPath() - rootView.open("sample.txt") - txtPath = editor.getPath() - rightEditor = editor.splitRight() - expect(rightEditor.editSessions[0].getPath()).toBe txtPath - expect(rightEditor.editSessions[1]).toBeUndefined() - editor.moveEditSessionToEditor(0, rightEditor, 0) - expect(rightEditor.editSessions[0].getPath()).toBe jsPath - expect(rightEditor.editSessions[1].getPath()).toBe txtPath - - describe "when editor:undo-close-session is triggered", -> - describe "when an edit session is opened back up after it is closed", -> - it "is removed from the undo stack and not reopened when the event is triggered", -> - rootView.open('sample.txt') - expect(editor.getPath()).toBe fixturesProject.resolve('sample.txt') - editor.trigger "core:close" - expect(editor.closedEditSessions.length).toBe 1 - rootView.open('sample.txt') - expect(editor.closedEditSessions.length).toBe 0 - editor.trigger 'editor:undo-close-session' - expect(editor.getPath()).toBe fixturesProject.resolve('sample.txt') - - it "opens the closed session back up at the previous index", -> - rootView.open('sample.txt') - editor.loadPreviousEditSession() - expect(editor.getPath()).toBe fixturesProject.resolve('sample.js') - editor.trigger "core:close" - expect(editor.getPath()).toBe fixturesProject.resolve('sample.txt') - editor.trigger 'editor:undo-close-session' - expect(editor.getPath()).toBe fixturesProject.resolve('sample.js') - expect(editor.getActiveEditSessionIndex()).toBe 0 - describe "editor:save-debug-snapshot", -> it "saves the state of the rendered lines, the display buffer, and the buffer to a file of the user's choosing", -> saveDialogCallback = null diff --git a/spec/app/git-spec.coffee b/spec/app/git-spec.coffee index fc9c85f46..fcdb120d8 100644 --- a/spec/app/git-spec.coffee +++ b/spec/app/git-spec.coffee @@ -1,5 +1,6 @@ Git = require 'git' fs = require 'fs' +Task = require 'task' describe "Git", -> repo = null @@ -21,11 +22,11 @@ describe "Git", -> describe ".getPath()", -> it "returns the repository path for a .git directory path", -> repo = new Git(require.resolve('fixtures/git/master.git/HEAD')) - expect(repo.getPath()).toBe require.resolve('fixtures/git/master.git') + '/' + expect(repo.getPath()).toBe require.resolve('fixtures/git/master.git') it "returns the repository path for a repository path", -> repo = new Git(require.resolve('fixtures/git/master.git')) - expect(repo.getPath()).toBe require.resolve('fixtures/git/master.git') + '/' + expect(repo.getPath()).toBe require.resolve('fixtures/git/master.git') describe ".getHead()", -> it "returns a branch name for a non-empty repository", -> @@ -126,6 +127,18 @@ describe "Git", -> expect(fs.read(path2)).toBe('path 2 is edited') expect(repo.isPathModified(path2)).toBeTruthy() + it "fires a status-changed event if the checkout completes successfully", -> + fs.write(path1, '') + repo.getPathStatus(path1) + statusHandler = jasmine.createSpy('statusHandler') + repo.on 'status-changed', statusHandler + repo.checkoutHead(path1) + expect(statusHandler.callCount).toBe 1 + expect(statusHandler.argsForCall[0][0..1]).toEqual [path1, 0] + + repo.checkoutHead(path1) + expect(statusHandler.callCount).toBe 1 + describe ".destroy()", -> it "throws an exception when any method is called after it is called", -> repo = new Git(require.resolve('fixtures/git/master.git/HEAD')) @@ -147,3 +160,75 @@ describe "Git", -> expect(repo.getDiffStats(path)).toEqual {added: 0, deleted: 0} fs.write(path, "#{originalPathText} edited line") expect(repo.getDiffStats(path)).toEqual {added: 1, deleted: 1} + + describe ".getPathStatus(path)", -> + [path, originalPathText] = [] + + beforeEach -> + repo = new Git(require.resolve('fixtures/git/working-dir')) + path = require.resolve('fixtures/git/working-dir/file.txt') + originalPathText = fs.read(path) + + afterEach -> + fs.write(path, originalPathText) + + it "trigger a status-changed event when the new status differs from the last cached one", -> + statusHandler = jasmine.createSpy("statusHandler") + repo.on 'status-changed', statusHandler + fs.write(path, '') + status = repo.getPathStatus(path) + expect(statusHandler.callCount).toBe 1 + expect(statusHandler.argsForCall[0][0..1]).toEqual [path, status] + + fs.write(path, 'abc') + status = repo.getPathStatus(path) + expect(statusHandler.callCount).toBe 1 + + describe ".refreshStatus()", -> + [newPath, modifiedPath, cleanPath, originalModifiedPathText] = [] + + beforeEach -> + repo = new Git(require.resolve('fixtures/git/working-dir')) + modifiedPath = project.resolve('git/working-dir/file.txt') + originalModifiedPathText = fs.read(modifiedPath) + newPath = project.resolve('git/working-dir/untracked.txt') + cleanPath = project.resolve('git/working-dir/other.txt') + fs.write(newPath, '') + + afterEach -> + fs.write(modifiedPath, originalModifiedPathText) + fs.remove(newPath) if fs.exists(newPath) + + it "returns status information for all new and modified files", -> + fs.write(modifiedPath, 'making this path modified') + statusHandler = jasmine.createSpy('statusHandler') + repo.on 'statuses-changed', statusHandler + repo.refreshStatus() + + waitsFor -> + statusHandler.callCount > 0 + + runs -> + statuses = repo.statuses + expect(statuses[cleanPath]).toBeUndefined() + expect(repo.isStatusNew(statuses[newPath])).toBeTruthy() + expect(repo.isStatusModified(statuses[modifiedPath])).toBeTruthy() + + it "only starts a single web worker at a time and schedules a restart if one is already running", => + fs.write(modifiedPath, 'making this path modified') + statusHandler = jasmine.createSpy('statusHandler') + repo.on 'statuses-changed', statusHandler + + spyOn(Task.prototype, "start").andCallThrough() + repo.refreshStatus() + expect(Task.prototype.start.callCount).toBe 1 + repo.refreshStatus() + expect(Task.prototype.start.callCount).toBe 1 + repo.refreshStatus() + expect(Task.prototype.start.callCount).toBe 1 + + waitsFor -> + statusHandler.callCount > 0 + + runs -> + expect(Task.prototype.start.callCount).toBe 2 diff --git a/spec/app/grammar-view-spec.coffee b/spec/app/grammar-view-spec.coffee index afa03839d..45157aab0 100644 --- a/spec/app/grammar-view-spec.coffee +++ b/spec/app/grammar-view-spec.coffee @@ -7,10 +7,8 @@ describe "GrammarView", -> beforeEach -> window.rootView = new RootView - project.removeGrammarOverrideForPath('sample.js') rootView.open('sample.js') - editor = rootView.getActiveEditor() - rootView.attachToDom() + editor = rootView.getActiveView() textGrammar = _.find syntax.grammars, (grammar) -> grammar.name is 'Plain Text' expect(textGrammar).toBeTruthy() jsGrammar = _.find syntax.grammars, (grammar) -> grammar.name is 'JavaScript' diff --git a/spec/app/language-mode-spec.coffee b/spec/app/language-mode-spec.coffee index a68f778dc..5b2704947 100644 --- a/spec/app/language-mode-spec.coffee +++ b/spec/app/language-mode-spec.coffee @@ -10,18 +10,18 @@ describe "LanguageMode", -> describe "common behavior", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('sample.js', autoIndent: false) + editSession = project.buildEditSession('sample.js', autoIndent: false) { buffer, languageMode } = editSession describe "language detection", -> it "uses the file name as the file type if it has no extension", -> - jsEditSession = fixturesProject.buildEditSessionForPath('js', autoIndent: false) + jsEditSession = project.buildEditSession('js', autoIndent: false) expect(jsEditSession.languageMode.grammar.name).toBe "JavaScript" jsEditSession.destroy() describe "javascript", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('sample.js', autoIndent: false) + editSession = project.buildEditSession('sample.js', autoIndent: false) { buffer, languageMode } = editSession describe ".toggleLineCommentsForBufferRows(start, end)", -> @@ -63,7 +63,7 @@ describe "LanguageMode", -> describe "coffeescript", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('coffee.coffee', autoIndent: false) + editSession = project.buildEditSession('coffee.coffee', autoIndent: false) { buffer, languageMode } = editSession describe ".toggleLineCommentsForBufferRows(start, end)", -> @@ -98,7 +98,7 @@ describe "LanguageMode", -> describe "css", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('css.css', autoIndent: false) + editSession = project.buildEditSession('css.css', autoIndent: false) { buffer, languageMode } = editSession describe ".toggleLineCommentsForBufferRows(start, end)", -> diff --git a/spec/app/pane-container-spec.coffee b/spec/app/pane-container-spec.coffee new file mode 100644 index 000000000..708beb081 --- /dev/null +++ b/spec/app/pane-container-spec.coffee @@ -0,0 +1,192 @@ +PaneContainer = require 'pane-container' +Pane = require 'pane' +{View, $$} = require 'space-pen' +_ = require 'underscore' +$ = require 'jquery' + +describe "PaneContainer", -> + [TestView, container, pane1, pane2, pane3] = [] + + beforeEach -> + class TestView extends View + registerDeserializer(this) + @deserialize: ({name}) -> new TestView(name) + @content: -> @div tabindex: -1 + initialize: (@name) -> @text(@name) + serialize: -> { deserializer: 'TestView', @name } + getUri: -> "/tmp/#{@name}" + save: -> @saved = true + isEqual: (other) -> @name is other.name + + container = new PaneContainer + pane1 = new Pane(new TestView('1')) + container.append(pane1) + pane2 = pane1.splitRight(new TestView('2')) + pane3 = pane2.splitDown(new TestView('3')) + + afterEach -> + unregisterDeserializer(TestView) + + describe ".focusNextPane()", -> + it "focuses the pane following the focused pane or the first pane if no pane has focus", -> + container.attachToDom() + container.focusNextPane() + expect(pane1.activeItem).toMatchSelector ':focus' + container.focusNextPane() + expect(pane2.activeItem).toMatchSelector ':focus' + container.focusNextPane() + expect(pane3.activeItem).toMatchSelector ':focus' + container.focusNextPane() + expect(pane1.activeItem).toMatchSelector ':focus' + + describe ".getActivePane()", -> + it "returns the most-recently focused pane", -> + focusStealer = $$ -> @div tabindex: -1, "focus stealer" + focusStealer.attachToDom() + container.attachToDom() + + pane2.focus() + expect(container.getFocusedPane()).toBe pane2 + expect(container.getActivePane()).toBe pane2 + + focusStealer.focus() + expect(container.getFocusedPane()).toBeUndefined() + expect(container.getActivePane()).toBe pane2 + + pane3.focus() + expect(container.getFocusedPane()).toBe pane3 + expect(container.getActivePane()).toBe pane3 + + # returns the first pane if none have been set to active + container.find('.pane.active').removeClass('active') + expect(container.getActivePane()).toBe pane1 + + describe ".eachPane(callback)", -> + it "runs the callback with all current and future panes until the subscription is cancelled", -> + panes = [] + subscription = container.eachPane (pane) -> panes.push(pane) + expect(panes).toEqual [pane1, pane2, pane3] + + panes = [] + pane4 = pane3.splitRight() + expect(panes).toEqual [pane4] + + panes = [] + subscription.cancel() + pane4.splitDown() + expect(panes).toEqual [] + + describe ".reopenItem()", -> + describe "when there is an active pane", -> + it "reconstructs and shows the last-closed pane item", -> + expect(container.getActivePane()).toBe pane3 + item3 = pane3.activeItem + item4 = new TestView('4') + pane3.showItem(item4) + + pane3.destroyItem(item3) + pane3.destroyItem(item4) + expect(container.getActivePane()).toBe pane1 + + expect(container.reopenItem()).toBeTruthy() + expect(pane1.activeItem).toEqual item4 + + expect(container.reopenItem()).toBeTruthy() + expect(pane1.activeItem).toEqual item3 + + expect(container.reopenItem()).toBeFalsy() + expect(pane1.activeItem).toEqual item3 + + describe "when there is no active pane", -> + it "attaches a new pane with the reconstructed last pane item", -> + pane1.remove() + pane2.remove() + item3 = pane3.activeItem + pane3.destroyItem(item3) + expect(container.getActivePane()).toBeUndefined() + + container.reopenItem() + + expect(container.getActivePane().activeItem).toEqual item3 + + it "does not reopen an item that is already open", -> + item3 = pane3.activeItem + item4 = new TestView('4') + pane3.showItem(item4) + pane3.destroyItem(item3) + pane3.destroyItem(item4) + + expect(container.getActivePane()).toBe pane1 + pane1.showItem(new TestView('4')) + + expect(container.reopenItem()).toBeTruthy() + expect(_.pluck(pane1.getItems(), 'name')).toEqual ['1', '4', '3'] + expect(pane1.activeItem).toEqual item3 + + expect(container.reopenItem()).toBeFalsy() + expect(pane1.activeItem).toEqual item3 + + describe ".saveAll()", -> + it "saves all open pane items", -> + pane1.showItem(new TestView('4')) + + container.saveAll() + + for pane in container.getPanes() + for item in pane.getItems() + expect(item.saved).toBeTruthy() + + describe ".confirmClose()", -> + it "resolves the returned promise after modified files are saved", -> + pane1.itemAtIndex(0).isModified = -> true + pane2.itemAtIndex(0).isModified = -> true + spyOn(atom, "confirm").andCallFake (a, b, c, d, e, f, g, noSaveFn) -> noSaveFn() + + promiseHandler = jasmine.createSpy("promiseHandler") + failedPromiseHandler = jasmine.createSpy("failedPromiseHandler") + promise = container.confirmClose() + promise.done promiseHandler + promise.fail failedPromiseHandler + + waitsFor -> + promiseHandler.wasCalled + + runs -> + expect(failedPromiseHandler).not.toHaveBeenCalled() + expect(atom.confirm).toHaveBeenCalled() + + it "rejects the returned promise if the user cancels saving", -> + pane1.itemAtIndex(0).isModified = -> true + pane2.itemAtIndex(0).isModified = -> true + spyOn(atom, "confirm").andCallFake (a, b, c, d, e, cancelFn, f, g) -> cancelFn() + + promiseHandler = jasmine.createSpy("promiseHandler") + failedPromiseHandler = jasmine.createSpy("failedPromiseHandler") + promise = container.confirmClose() + promise.done promiseHandler + promise.fail failedPromiseHandler + + waitsFor -> + failedPromiseHandler.wasCalled + + runs -> + expect(promiseHandler).not.toHaveBeenCalled() + expect(atom.confirm).toHaveBeenCalled() + + describe "serialization", -> + it "can be serialized and deserialized, and correctly adjusts dimensions of deserialized panes after attach", -> + newContainer = deserialize(container.serialize()) + expect(newContainer.find('.row > :contains(1)')).toExist() + expect(newContainer.find('.row > .column > :contains(2)')).toExist() + expect(newContainer.find('.row > .column > :contains(3)')).toExist() + + newContainer.height(200).width(300).attachToDom() + expect(newContainer.find('.row > :contains(1)').width()).toBe 150 + expect(newContainer.find('.row > .column > :contains(2)').height()).toBe 100 + + it "removes empty panes on deserialization", -> + # only deserialize pane 1's view successfully + TestView.deserialize = ({name}) -> new TestView(name) if name is '1' + newContainer = deserialize(container.serialize()) + expect(newContainer.find('.row, .column')).not.toExist() + expect(newContainer.find('> :contains(1)')).toExist() diff --git a/spec/app/pane-spec.coffee b/spec/app/pane-spec.coffee new file mode 100644 index 000000000..96f379acd --- /dev/null +++ b/spec/app/pane-spec.coffee @@ -0,0 +1,701 @@ +PaneContainer = require 'pane-container' +Pane = require 'pane' +{$$} = require 'space-pen' +$ = require 'jquery' + +describe "Pane", -> + [container, view1, view2, editSession1, editSession2, pane] = [] + + beforeEach -> + container = new PaneContainer + view1 = $$ -> @div id: 'view-1', tabindex: -1, 'View 1' + view2 = $$ -> @div id: 'view-2', tabindex: -1, 'View 2' + editSession1 = project.buildEditSession('sample.js') + editSession2 = project.buildEditSession('sample.txt') + pane = new Pane(view1, editSession1, view2, editSession2) + container.append(pane) + + describe ".initialize(items...)", -> + it "displays the first item in the pane", -> + expect(pane.itemViews.find('#view-1')).toExist() + + describe ".showItem(item)", -> + it "hides all item views except the one being shown and sets the activeItem", -> + expect(pane.activeItem).toBe view1 + pane.showItem(view2) + expect(view1.css('display')).toBe 'none' + expect(view2.css('display')).toBe '' + expect(pane.activeItem).toBe view2 + + it "triggers 'pane:active-item-changed' if the item isn't already the activeItem", -> + pane.makeActive() + itemChangedHandler = jasmine.createSpy("itemChangedHandler") + container.on 'pane:active-item-changed', itemChangedHandler + + expect(pane.activeItem).toBe view1 + pane.showItem(view2) + pane.showItem(view2) + expect(itemChangedHandler.callCount).toBe 1 + expect(itemChangedHandler.argsForCall[0][1]).toBe view2 + itemChangedHandler.reset() + + pane.showItem(editSession1) + expect(itemChangedHandler).toHaveBeenCalled() + expect(itemChangedHandler.argsForCall[0][1]).toBe editSession1 + itemChangedHandler.reset() + + describe "if the pane's active view is focused before calling showItem", -> + it "focuses the new active view", -> + container.attachToDom() + pane.focus() + expect(pane.activeView).not.toBe view2 + expect(pane.activeView).toMatchSelector ':focus' + pane.showItem(view2) + expect(view2).toMatchSelector ':focus' + + describe "when the given item isn't yet in the items list on the pane", -> + view3 = null + beforeEach -> + view3 = $$ -> @div id: 'view-3', "View 3" + pane.showItem(editSession1) + expect(pane.getActiveItemIndex()).toBe 1 + + it "adds it to the items list after the active item", -> + pane.showItem(view3) + expect(pane.getItems()).toEqual [view1, editSession1, view3, view2, editSession2] + expect(pane.activeItem).toBe view3 + expect(pane.getActiveItemIndex()).toBe 2 + + it "triggers the 'item-added' event with the item and its index before the 'active-item-changed' event", -> + events = [] + container.on 'pane:item-added', (e, item, index) -> events.push(['pane:item-added', item, index]) + container.on 'pane:active-item-changed', (e, item) -> events.push(['pane:active-item-changed', item]) + pane.showItem(view3) + expect(events).toEqual [['pane:item-added', view3, 2], ['pane:active-item-changed', view3]] + + describe "when showing a model item", -> + describe "when no view has yet been appended for that item", -> + it "appends and shows a view to display the item based on its `.getViewClass` method", -> + pane.showItem(editSession1) + editor = pane.activeView + expect(editor.css('display')).toBe '' + expect(editor.activeEditSession).toBe editSession1 + + describe "when a valid view has already been appended for another item", -> + it "recycles the existing view by assigning the selected item to it", -> + pane.showItem(editSession1) + pane.showItem(editSession2) + expect(pane.itemViews.find('.editor').length).toBe 1 + editor = pane.activeView + expect(editor.css('display')).toBe '' + expect(editor.activeEditSession).toBe editSession2 + + describe "when showing a view item", -> + it "appends it to the itemViews div if it hasn't already been appended and shows it", -> + expect(pane.itemViews.find('#view-2')).not.toExist() + pane.showItem(view2) + expect(pane.itemViews.find('#view-2')).toExist() + expect(pane.activeView).toBe view2 + + describe ".destroyItem(item)", -> + describe "if the item is not modified", -> + it "removes the item and tries to call destroy on it", -> + pane.destroyItem(editSession2) + expect(pane.getItems().indexOf(editSession2)).toBe -1 + expect(editSession2.destroyed).toBeTruthy() + + describe "if the item is modified", -> + beforeEach -> + spyOn(atom, 'confirm') + spyOn(atom, 'showSaveDialog') + spyOn(editSession2, 'save') + spyOn(editSession2, 'saveAs') + + atom.confirm.selectOption = (buttonText) -> + for arg, i in @argsForCall[0] when arg is buttonText + @argsForCall[0][i + 1]?() + + editSession2.insertText('a') + expect(editSession2.isModified()).toBeTruthy() + pane.destroyItem(editSession2) + + it "presents a dialog with the option to save the item first", -> + expect(atom.confirm).toHaveBeenCalled() + expect(pane.getItems().indexOf(editSession2)).not.toBe -1 + expect(editSession2.destroyed).toBeFalsy() + + describe "if the [Save] option is selected", -> + describe "when the item has a uri", -> + it "saves the item before removing and destroying it", -> + atom.confirm.selectOption('Save') + + expect(editSession2.save).toHaveBeenCalled() + expect(pane.getItems().indexOf(editSession2)).toBe -1 + expect(editSession2.destroyed).toBeTruthy() + + 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", -> + editSession2.buffer.setPath(undefined) + + atom.confirm.selectOption('Save') + + expect(atom.showSaveDialog).toHaveBeenCalled() + + atom.showSaveDialog.argsForCall[0][0]("/selected/path") + + expect(editSession2.saveAs).toHaveBeenCalledWith("/selected/path") + expect(pane.getItems().indexOf(editSession2)).toBe -1 + expect(editSession2.destroyed).toBeTruthy() + + describe "if the [Don't Save] option is selected", -> + it "removes and destroys the item without saving it", -> + atom.confirm.selectOption("Don't Save") + + expect(editSession2.save).not.toHaveBeenCalled() + expect(pane.getItems().indexOf(editSession2)).toBe -1 + expect(editSession2.destroyed).toBeTruthy() + + describe "if the [Cancel] option is selected", -> + it "does not save, remove, or destroy the item", -> + atom.confirm.selectOption("Cancel") + + expect(editSession2.save).not.toHaveBeenCalled() + expect(pane.getItems().indexOf(editSession2)).not.toBe -1 + expect(editSession2.destroyed).toBeFalsy() + + describe ".removeItem(item)", -> + it "removes the item from the items list and shows the next item if it was showing", -> + pane.removeItem(view1) + expect(pane.getItems()).toEqual [editSession1, view2, editSession2] + expect(pane.activeItem).toBe editSession1 + + pane.showItem(editSession2) + pane.removeItem(editSession2) + expect(pane.getItems()).toEqual [editSession1, view2] + expect(pane.activeItem).toBe editSession1 + + it "triggers 'pane:item-removed' with the item and its former index", -> + itemRemovedHandler = jasmine.createSpy("itemRemovedHandler") + pane.on 'pane:item-removed', itemRemovedHandler + pane.removeItem(editSession1) + expect(itemRemovedHandler).toHaveBeenCalled() + expect(itemRemovedHandler.argsForCall[0][1..2]).toEqual [editSession1, 1] + + describe "when removing the last item", -> + it "removes the pane", -> + pane.removeItem(item) for item in pane.getItems() + expect(pane.hasParent()).toBeFalsy() + + describe "when the pane is focused", -> + it "shifts focus to the next pane", -> + container.attachToDom() + pane2 = pane.splitRight($$ -> @div class: 'view-3', tabindex: -1, 'View 3') + pane.focus() + expect(pane).toMatchSelector(':has(:focus)') + pane.removeItem(item) for item in pane.getItems() + expect(pane2).toMatchSelector ':has(:focus)' + + describe "when the item is a view", -> + it "removes the item from the 'item-views' div", -> + expect(view1.parent()).toMatchSelector pane.itemViews + pane.removeItem(view1) + expect(view1.parent()).not.toMatchSelector pane.itemViews + + describe "when the item is a model", -> + it "removes the associated view only when all items that require it have been removed", -> + pane.showItem(editSession2) + pane.removeItem(editSession2) + expect(pane.itemViews.find('.editor')).toExist() + pane.removeItem(editSession1) + expect(pane.itemViews.find('.editor')).not.toExist() + + describe ".moveItem(item, index)", -> + it "moves the item to the given index and emits a 'pane:item-moved' event with the item and the new index", -> + itemMovedHandler = jasmine.createSpy("itemMovedHandler") + pane.on 'pane:item-moved', itemMovedHandler + + pane.moveItem(view1, 2) + expect(pane.getItems()).toEqual [editSession1, view2, view1, editSession2] + expect(itemMovedHandler).toHaveBeenCalled() + expect(itemMovedHandler.argsForCall[0][1..2]).toEqual [view1, 2] + itemMovedHandler.reset() + + pane.moveItem(editSession1, 3) + expect(pane.getItems()).toEqual [view2, view1, editSession2, editSession1] + expect(itemMovedHandler).toHaveBeenCalled() + expect(itemMovedHandler.argsForCall[0][1..2]).toEqual [editSession1, 3] + itemMovedHandler.reset() + + pane.moveItem(editSession1, 1) + expect(pane.getItems()).toEqual [view2, editSession1, view1, editSession2] + expect(itemMovedHandler).toHaveBeenCalled() + expect(itemMovedHandler.argsForCall[0][1..2]).toEqual [editSession1, 1] + itemMovedHandler.reset() + + describe ".moveItemToPane(item, pane, index)", -> + [pane2, view3] = [] + + beforeEach -> + view3 = $$ -> @div id: 'view-3', "View 3" + pane2 = pane.splitRight(view3) + + it "moves the item to the given pane at the given index", -> + pane.moveItemToPane(view1, pane2, 1) + expect(pane.getItems()).toEqual [editSession1, view2, editSession2] + expect(pane2.getItems()).toEqual [view3, view1] + + describe "when it is the last item on the source pane", -> + it "removes the source pane, but does not destroy the item", -> + pane.removeItem(view1) + pane.removeItem(view2) + pane.removeItem(editSession2) + + expect(pane.getItems()).toEqual [editSession1] + pane.moveItemToPane(editSession1, pane2, 1) + + expect(pane.hasParent()).toBeFalsy() + expect(pane2.getItems()).toEqual [view3, editSession1] + expect(editSession1.destroyed).toBeFalsy() + + describe "core:close", -> + it "destroys the active item and does not bubble the event", -> + containerCloseHandler = jasmine.createSpy("containerCloseHandler") + container.on 'core:close', containerCloseHandler + + pane.showItem(editSession1) + initialItemCount = pane.getItems().length + pane.trigger 'core:close' + expect(pane.getItems().length).toBe initialItemCount - 1 + expect(editSession1.destroyed).toBeTruthy() + + expect(containerCloseHandler).not.toHaveBeenCalled() + + describe "pane:close", -> + it "destroys all items and removes the pane", -> + pane.showItem(editSession1) + pane.trigger 'pane:close' + expect(pane.hasParent()).toBeFalsy() + expect(editSession2.destroyed).toBeTruthy() + expect(editSession1.destroyed).toBeTruthy() + + describe "pane:close-other-items", -> + it "destroys all items except the current", -> + pane.showItem(editSession1) + pane.trigger 'pane:close-other-items' + expect(editSession2.destroyed).toBeTruthy() + expect(pane.getItems()).toEqual [editSession1] + + describe "core:save", -> + describe "when the current item has a uri", -> + describe "when the current item has a save method", -> + it "saves the current item", -> + spyOn(editSession2, 'save') + pane.showItem(editSession2) + pane.trigger 'core:save' + expect(editSession2.save).toHaveBeenCalled() + + describe "when the current item has no save method", -> + it "does nothing", -> + expect(pane.activeItem.save).toBeUndefined() + pane.trigger 'core:save' + + describe "when the current item has no uri", -> + beforeEach -> + spyOn(atom, 'showSaveDialog') + + describe "when the current item has a saveAs method", -> + it "opens a save dialog and saves the current item as the selected path", -> + spyOn(editSession2, 'saveAs') + editSession2.buffer.setPath(undefined) + pane.showItem(editSession2) + + pane.trigger 'core:save' + + expect(atom.showSaveDialog).toHaveBeenCalled() + atom.showSaveDialog.argsForCall[0][0]('/selected/path') + expect(editSession2.saveAs).toHaveBeenCalledWith('/selected/path') + + describe "when the current item has no saveAs method", -> + it "does nothing", -> + expect(pane.activeItem.saveAs).toBeUndefined() + pane.trigger 'core:save' + expect(atom.showSaveDialog).not.toHaveBeenCalled() + + describe "core:save-as", -> + beforeEach -> + spyOn(atom, 'showSaveDialog') + + describe "when the current item has a saveAs method", -> + it "opens the save dialog and calls saveAs on the item with the selected path", -> + spyOn(editSession2, 'saveAs') + pane.showItem(editSession2) + + pane.trigger 'core:save-as' + + expect(atom.showSaveDialog).toHaveBeenCalled() + atom.showSaveDialog.argsForCall[0][0]('/selected/path') + expect(editSession2.saveAs).toHaveBeenCalledWith('/selected/path') + + describe "when the current item does not have a saveAs method", -> + it "does nothing", -> + expect(pane.activeItem.saveAs).toBeUndefined() + pane.trigger 'core:save-as' + expect(atom.showSaveDialog).not.toHaveBeenCalled() + + describe "pane:show-next-item and pane:show-previous-item", -> + it "advances forward/backward through the pane's items, looping around at either end", -> + expect(pane.activeItem).toBe view1 + pane.trigger 'pane:show-previous-item' + expect(pane.activeItem).toBe editSession2 + pane.trigger 'pane:show-previous-item' + expect(pane.activeItem).toBe view2 + pane.trigger 'pane:show-next-item' + expect(pane.activeItem).toBe editSession2 + pane.trigger 'pane:show-next-item' + expect(pane.activeItem).toBe view1 + + describe "pane:show-item-N events", -> + it "shows the (n-1)th item if it exists", -> + pane.trigger 'pane:show-item-2' + expect(pane.activeItem).toBe pane.itemAtIndex(1) + pane.trigger 'pane:show-item-1' + expect(pane.activeItem).toBe pane.itemAtIndex(0) + pane.trigger 'pane:show-item-9' # don't fail on out-of-bounds indices + expect(pane.activeItem).toBe pane.itemAtIndex(0) + + describe "when the title of the active item changes", -> + it "emits pane:active-item-title-changed", -> + activeItemTitleChangedHandler = jasmine.createSpy("activeItemTitleChangedHandler") + pane.on 'pane:active-item-title-changed', activeItemTitleChangedHandler + + expect(pane.activeItem).toBe view1 + + view2.trigger 'title-changed' + expect(activeItemTitleChangedHandler).not.toHaveBeenCalled() + + view1.trigger 'title-changed' + expect(activeItemTitleChangedHandler).toHaveBeenCalled() + activeItemTitleChangedHandler.reset() + + pane.showItem(view2) + view2.trigger 'title-changed' + expect(activeItemTitleChangedHandler).toHaveBeenCalled() + + describe ".remove()", -> + it "destroys all the pane's items", -> + pane.remove() + expect(editSession1.destroyed).toBeTruthy() + expect(editSession2.destroyed).toBeTruthy() + + it "triggers a 'pane:removed' event with the pane", -> + removedHandler = jasmine.createSpy("removedHandler") + container.on 'pane:removed', removedHandler + pane.remove() + expect(removedHandler).toHaveBeenCalled() + expect(removedHandler.argsForCall[0][1]).toBe pane + + describe "when there are other panes", -> + [paneToLeft, paneToRight] = [] + + beforeEach -> + pane.showItem(editSession1) + paneToLeft = pane.splitLeft() + paneToRight = pane.splitRight() + container.attachToDom() + + describe "when the removed pane is focused", -> + it "activates and focuses the next pane", -> + pane.focus() + pane.remove() + expect(paneToLeft.isActive()).toBeFalsy() + expect(paneToRight.isActive()).toBeTruthy() + expect(paneToRight).toMatchSelector ':has(:focus)' + + describe "when the removed pane is active but not focused", -> + it "activates the next pane, but does not focus it", -> + $(document.activeElement).blur() + expect(pane).not.toMatchSelector ':has(:focus)' + pane.makeActive() + pane.remove() + expect(paneToLeft.isActive()).toBeFalsy() + expect(paneToRight.isActive()).toBeTruthy() + expect(paneToRight).not.toMatchSelector ':has(:focus)' + + describe "when the removed pane is not active", -> + it "does not affect the active pane or the focus", -> + paneToLeft.focus() + expect(paneToLeft.isActive()).toBeTruthy() + expect(paneToRight.isActive()).toBeFalsy() + + pane.remove() + expect(paneToLeft.isActive()).toBeTruthy() + expect(paneToRight.isActive()).toBeFalsy() + expect(paneToLeft).toMatchSelector ':has(:focus)' + + describe "when it is the last pane", -> + beforeEach -> + expect(container.getPanes().length).toBe 1 + window.rootView = focus: jasmine.createSpy("rootView.focus") + + describe "when the removed pane is focused", -> + it "calls focus on rootView so we don't lose focus", -> + container.attachToDom() + pane.focus() + pane.remove() + expect(rootView.focus).toHaveBeenCalled() + + describe "when the removed pane is not focused", -> + it "does not call focus on root view", -> + expect(pane).not.toMatchSelector ':has(:focus)' + pane.remove() + expect(rootView.focus).not.toHaveBeenCalled() + + describe ".getNextPane()", -> + it "returns the next pane if one exists, wrapping around from the last pane to the first", -> + pane.showItem(editSession1) + expect(pane.getNextPane()).toBeUndefined + pane2 = pane.splitRight() + expect(pane.getNextPane()).toBe pane2 + expect(pane2.getNextPane()).toBe pane + + describe "when the pane is focused", -> + it "focuses the active item view", -> + focusHandler = jasmine.createSpy("focusHandler") + pane.activeItem.on 'focus', focusHandler + pane.focus() + expect(focusHandler).toHaveBeenCalled() + + it "triggers 'pane:became-active' if it was not previously active", -> + becameActiveHandler = jasmine.createSpy("becameActiveHandler") + container.on 'pane:became-active', becameActiveHandler + + expect(pane.isActive()).toBeFalsy() + pane.focusin() + expect(pane.isActive()).toBeTruthy() + pane.focusin() + + expect(becameActiveHandler.callCount).toBe 1 + + describe "split methods", -> + [pane1, view3, view4] = [] + beforeEach -> + pane1 = pane + pane.showItem(editSession1) + view3 = $$ -> @div id: 'view-3', 'View 3' + view4 = $$ -> @div id: 'view-4', 'View 4' + + describe "splitRight(items...)", -> + it "builds a row if needed, then appends a new pane after itself", -> + # creates the new pane with a copy of the active item if none are given + pane2 = pane1.splitRight() + expect(container.find('.row .pane').toArray()).toEqual [pane1[0], pane2[0]] + expect(pane2.items).toEqual [editSession1] + expect(pane2.activeItem).not.toBe editSession1 # it's a copy + + pane3 = pane2.splitRight(view3, view4) + expect(pane3.getItems()).toEqual [view3, view4] + expect(container.find('.row .pane').toArray()).toEqual [pane[0], pane2[0], pane3[0]] + + describe "splitRight(items...)", -> + it "builds a row if needed, then appends a new pane before itself", -> + # creates the new pane with a copy of the active item if none are given + pane2 = pane.splitLeft() + expect(container.find('.row .pane').toArray()).toEqual [pane2[0], pane[0]] + expect(pane2.items).toEqual [editSession1] + expect(pane2.activeItem).not.toBe editSession1 # it's a copy + + pane3 = pane2.splitLeft(view3, view4) + expect(pane3.getItems()).toEqual [view3, view4] + expect(container.find('.row .pane').toArray()).toEqual [pane3[0], pane2[0], pane[0]] + + describe "splitDown(items...)", -> + it "builds a column if needed, then appends a new pane after itself", -> + # creates the new pane with a copy of the active item if none are given + pane2 = pane.splitDown() + expect(container.find('.column .pane').toArray()).toEqual [pane[0], pane2[0]] + expect(pane2.items).toEqual [editSession1] + expect(pane2.activeItem).not.toBe editSession1 # it's a copy + + pane3 = pane2.splitDown(view3, view4) + expect(pane3.getItems()).toEqual [view3, view4] + expect(container.find('.column .pane').toArray()).toEqual [pane[0], pane2[0], pane3[0]] + + describe "splitUp(items...)", -> + it "builds a column if needed, then appends a new pane before itself", -> + # creates the new pane with a copy of the active item if none are given + pane2 = pane.splitUp() + expect(container.find('.column .pane').toArray()).toEqual [pane2[0], pane[0]] + expect(pane2.items).toEqual [editSession1] + expect(pane2.activeItem).not.toBe editSession1 # it's a copy + + pane3 = pane2.splitUp(view3, view4) + expect(pane3.getItems()).toEqual [view3, view4] + expect(container.find('.column .pane').toArray()).toEqual [pane3[0], pane2[0], pane[0]] + + it "lays out nested panes by equally dividing their containing row / column", -> + container.width(520).height(240).attachToDom() + pane1.showItem($("1")) + pane1 + .splitLeft($("2")) + .splitUp($("3")) + .splitLeft($("4")) + .splitDown($("5")) + + row1 = container.children(':eq(0)') + expect(row1.children().length).toBe 2 + column1 = row1.children(':eq(0)').view() + pane1 = row1.children(':eq(1)').view() + expect(column1.outerWidth()).toBe Math.round(2/3 * container.width()) + expect(column1.outerHeight()).toBe container.height() + expect(pane1.outerWidth()).toBe Math.round(1/3 * container.width()) + expect(pane1.outerHeight()).toBe container.height() + expect(Math.round(pane1.position().left)).toBe column1.outerWidth() + + expect(column1.children().length).toBe 2 + row2 = column1.children(':eq(0)').view() + pane2 = column1.children(':eq(1)').view() + expect(row2.outerWidth()).toBe column1.outerWidth() + expect(row2.height()).toBe 2/3 * container.height() + expect(pane2.outerWidth()).toBe column1.outerWidth() + expect(pane2.outerHeight()).toBe 1/3 * container.height() + expect(pane2.position().top).toBe row2.height() + + expect(row2.children().length).toBe 2 + column3 = row2.children(':eq(0)').view() + pane3 = row2.children(':eq(1)').view() + expect(column3.outerWidth()).toBe Math.round(1/3 * container.width()) + expect(column3.outerHeight()).toBe row2.outerHeight() + # the built in rounding seems to be rounding x.5 down, but we need to go up. this sucks. + expect(Math.round(pane3.trueWidth())).toBe Math.round(1/3 * container.width()) + expect(pane3.height()).toBe row2.outerHeight() + expect(Math.round(pane3.position().left)).toBe column3.width() + + expect(column3.children().length).toBe 2 + pane4 = column3.children(':eq(0)').view() + pane5 = column3.children(':eq(1)').view() + expect(pane4.outerWidth()).toBe column3.width() + expect(pane4.outerHeight()).toBe 1/3 * container.height() + expect(pane5.outerWidth()).toBe column3.width() + expect(pane5.position().top).toBe pane4.outerHeight() + expect(pane5.outerHeight()).toBe 1/3 * container.height() + + pane5.remove() + expect(column3.parent()).not.toExist() + expect(pane2.outerHeight()).toBe Math.floor(1/2 * container.height()) + expect(pane3.outerHeight()).toBe Math.floor(1/2 * container.height()) + expect(pane4.outerHeight()).toBe Math.floor(1/2 * container.height()) + + pane4.remove() + expect(row2.parent()).not.toExist() + expect(pane1.outerWidth()).toBe Math.floor(1/2 * container.width()) + expect(pane2.outerWidth()).toBe Math.floor(1/2 * container.width()) + expect(pane3.outerWidth()).toBe Math.floor(1/2 * container.width()) + + pane3.remove() + expect(column1.parent()).not.toExist() + expect(pane2.outerHeight()).toBe container.height() + + pane2.remove() + expect(row1.parent()).not.toExist() + expect(container.children().length).toBe 1 + expect(container.children('.pane').length).toBe 1 + expect(pane1.outerWidth()).toBe container.width() + + describe "autosave", -> + [initialActiveItem, initialActiveItemUri] = [] + + beforeEach -> + initialActiveItem = pane.activeItem + initialActiveItemUri = null + pane.activeItem.getUri = -> initialActiveItemUri + pane.activeItem.save = jasmine.createSpy("activeItem.save") + spyOn(pane, 'saveItem').andCallThrough() + + describe "when the active view loses focus", -> + it "saves the item if core.autosave is true and the item has a uri", -> + pane.activeView.trigger 'focusout' + expect(pane.saveItem).not.toHaveBeenCalled() + expect(pane.activeItem.save).not.toHaveBeenCalled() + + config.set('core.autosave', true) + pane.activeView.trigger 'focusout' + expect(pane.saveItem).not.toHaveBeenCalled() + expect(pane.activeItem.save).not.toHaveBeenCalled() + + initialActiveItemUri = '/tmp/hi' + pane.activeView.trigger 'focusout' + expect(pane.activeItem.save).toHaveBeenCalled() + + describe "when an item becomes inactive", -> + it "saves the item if core.autosave is true and the item has a uri", -> + expect(view2).not.toBe pane.activeItem + expect(pane.saveItem).not.toHaveBeenCalled() + expect(initialActiveItem.save).not.toHaveBeenCalled() + pane.showItem(view2) + + pane.showItem(initialActiveItem) + config.set('core.autosave', true) + pane.showItem(view2) + expect(pane.saveItem).not.toHaveBeenCalled() + expect(initialActiveItem.save).not.toHaveBeenCalled() + + pane.showItem(initialActiveItem) + initialActiveItemUri = '/tmp/hi' + pane.showItem(view2) + expect(initialActiveItem.save).toHaveBeenCalled() + + describe "when an item is destroyed", -> + it "saves the item if core.autosave is true and the item has a uri", -> + # doesn't have to be the active item + expect(view2).not.toBe pane.activeItem + pane.showItem(view2) + + pane.destroyItem(editSession1) + expect(pane.saveItem).not.toHaveBeenCalled() + + config.set("core.autosave", true) + view2.getUri = -> undefined + view2.save = -> + pane.destroyItem(view2) + expect(pane.saveItem).not.toHaveBeenCalled() + + initialActiveItemUri = '/tmp/hi' + pane.destroyItem(initialActiveItem) + expect(initialActiveItem.save).toHaveBeenCalled() + + describe ".itemForUri(uri)", -> + it "returns the item for which a call to .getUri() returns the given uri", -> + expect(pane.itemForUri(editSession1.getUri())).toBe editSession1 + expect(pane.itemForUri(editSession2.getUri())).toBe editSession2 + + describe "serialization", -> + it "can serialize and deserialize the pane and all its serializable items", -> + newPane = deserialize(pane.serialize()) + expect(newPane.getItems()).toEqual [editSession1, editSession2] + + it "restores the active item on deserialization if it serializable", -> + pane.showItem(editSession2) + newPane = deserialize(pane.serialize()) + expect(newPane.activeItem).toEqual editSession2 + + it "defaults to the first item on deserialization if the active item was not serializable", -> + expect(view2.serialize?()).toBeFalsy() + pane.showItem(view2) + newPane = deserialize(pane.serialize()) + expect(newPane.activeItem).toEqual editSession1 + + it "focuses the pane after attach only if had focus when serialized", -> + container.attachToDom() + + pane.focus() + state = pane.serialize() + pane.remove() + newPane = deserialize(state) + container.append(newPane) + expect(newPane).toMatchSelector(':has(:focus)') + + $(document.activeElement).blur() + state = newPane.serialize() + newPane.remove() + newerPane = deserialize(state) + expect(newerPane).not.toMatchSelector(':has(:focus)') diff --git a/spec/app/project-spec.coffee b/spec/app/project-spec.coffee index 09549eb98..136e61a85 100644 --- a/spec/app/project-spec.coffee +++ b/spec/app/project-spec.coffee @@ -3,17 +3,13 @@ fs = require 'fs' _ = require 'underscore' describe "Project", -> - project = null beforeEach -> - project = new Project(require.resolve('fixtures/dir')) + project.setPath(project.resolve('dir')) - afterEach -> - project.destroy() - - describe "when editSession is destroyed", -> + describe "when an edit session is destroyed", -> it "removes edit session and calls destroy on buffer (if buffer is not referenced by other edit sessions)", -> - editSession = project.buildEditSessionForPath("a") - anotherEditSession = project.buildEditSessionForPath("a") + editSession = project.buildEditSession("a") + anotherEditSession = project.buildEditSession("a") expect(project.editSessions.length).toBe 2 expect(editSession.buffer).toBe anotherEditSession.buffer @@ -24,7 +20,17 @@ describe "Project", -> anotherEditSession.destroy() expect(project.editSessions.length).toBe 0 - describe ".buildEditSessionForPath(path)", -> + describe "when an edit session is saved and the project has no path", -> + it "sets the project's path to the saved file's parent directory", -> + path = project.resolve('a') + project.setPath(undefined) + expect(project.getPath()).toBeUndefined() + editSession = project.buildEditSession() + editSession.saveAs('/tmp/atom-test-save-sets-project-path') + expect(project.getPath()).toBe '/tmp' + fs.remove('/tmp/atom-test-save-sets-project-path') + + describe ".buildEditSession(path)", -> [absolutePath, newBufferHandler, newEditSessionHandler] = [] beforeEach -> absolutePath = require.resolve('fixtures/dir/a') @@ -35,30 +41,30 @@ describe "Project", -> describe "when given an absolute path that hasn't been opened previously", -> it "returns a new edit session for the given path and emits 'buffer-created' and 'edit-session-created' events", -> - editSession = project.buildEditSessionForPath(absolutePath) + editSession = project.buildEditSession(absolutePath) expect(editSession.buffer.getPath()).toBe absolutePath expect(newBufferHandler).toHaveBeenCalledWith editSession.buffer expect(newEditSessionHandler).toHaveBeenCalledWith editSession describe "when given a relative path that hasn't been opened previously", -> it "returns a new edit session for the given path (relative to the project root) and emits 'buffer-created' and 'edit-session-created' events", -> - editSession = project.buildEditSessionForPath('a') + editSession = project.buildEditSession('a') expect(editSession.buffer.getPath()).toBe absolutePath expect(newBufferHandler).toHaveBeenCalledWith editSession.buffer expect(newEditSessionHandler).toHaveBeenCalledWith editSession describe "when passed the path to a buffer that has already been opened", -> it "returns a new edit session containing previously opened buffer and emits a 'edit-session-created' event", -> - editSession = project.buildEditSessionForPath(absolutePath) + editSession = project.buildEditSession(absolutePath) newBufferHandler.reset() - expect(project.buildEditSessionForPath(absolutePath).buffer).toBe editSession.buffer - expect(project.buildEditSessionForPath('a').buffer).toBe editSession.buffer + expect(project.buildEditSession(absolutePath).buffer).toBe editSession.buffer + expect(project.buildEditSession('a').buffer).toBe editSession.buffer expect(newBufferHandler).not.toHaveBeenCalled() expect(newEditSessionHandler).toHaveBeenCalledWith editSession describe "when not passed a path", -> it "returns a new edit session and emits 'buffer-created' and 'edit-session-created' events", -> - editSession = project.buildEditSessionForPath() + editSession = project.buildEditSession() expect(editSession.buffer.getPath()).toBeUndefined() expect(newBufferHandler).toHaveBeenCalledWith(editSession.buffer) expect(newEditSessionHandler).toHaveBeenCalledWith editSession diff --git a/spec/app/root-view-spec.coffee b/spec/app/root-view-spec.coffee index 15205bbc1..89c8ef8c1 100644 --- a/spec/app/root-view-spec.coffee +++ b/spec/app/root-view-spec.coffee @@ -4,13 +4,13 @@ Project = require 'project' RootView = require 'root-view' Buffer = require 'buffer' Editor = require 'editor' +Pane = require 'pane' {View, $$} = require 'space-pen' describe "RootView", -> pathToOpen = null beforeEach -> - project.destroy() project.setPath(project.resolve('dir')) pathToOpen = project.resolve('a') window.rootView = new RootView @@ -23,37 +23,39 @@ describe "RootView", -> describe "when the serialized RootView has an unsaved buffer", -> it "constructs the view with the same panes", -> + rootView.attachToDom() rootView.open() - editor1 = rootView.getActiveEditor() + editor1 = rootView.getActiveView() buffer = editor1.getBuffer() editor1.splitRight() viewState = rootView.serialize() rootView.deactivate() - window.rootView = RootView.deserialize(viewState) - rootView.focus() + window.rootView = deserialize(viewState) + rootView.attachToDom() + expect(rootView.getEditors().length).toBe 2 - expect(rootView.getActiveEditor().getText()).toBe buffer.getText() - expect(rootView.getTitle()).toBe "untitled – #{project.getPath()}" + expect(rootView.getActiveView().getText()).toBe buffer.getText() + expect(rootView.title).toBe "untitled - #{project.getPath()}" describe "when the serialized RootView has a project", -> describe "when there are open editors", -> it "constructs the view with the same panes", -> - editor1 = rootView.getActiveEditor() - editor2 = editor1.splitRight() - editor3 = editor2.splitRight() - editor4 = editor2.splitDown() - editor2.edit(project.buildEditSessionForPath('b')) - editor3.edit(project.buildEditSessionForPath('../sample.js')) - editor3.setCursorScreenPosition([2, 4]) - editor4.edit(project.buildEditSessionForPath('../sample.txt')) - editor4.setCursorScreenPosition([0, 2]) rootView.attachToDom() - editor2.focus() + pane1 = rootView.getActivePane() + pane2 = pane1.splitRight() + pane3 = pane2.splitRight() + pane4 = pane2.splitDown() + pane2.showItem(project.buildEditSession('b')) + pane3.showItem(project.buildEditSession('../sample.js')) + pane3.activeItem.setCursorScreenPosition([2, 4]) + pane4.showItem(project.buildEditSession('../sample.txt')) + pane4.activeItem.setCursorScreenPosition([0, 2]) + pane2.focus() viewState = rootView.serialize() rootView.deactivate() - window.rootView = RootView.deserialize(viewState) + window.rootView = deserialize(viewState) rootView.attachToDom() expect(rootView.getEditors().length).toBe 4 @@ -81,276 +83,54 @@ describe "RootView", -> expect(editor3.isFocused).toBeFalsy() expect(editor4.isFocused).toBeFalsy() - expect(rootView.getTitle()).toBe "#{fs.base(editor2.getPath())} – #{project.getPath()}" + expect(rootView.title).toBe "#{fs.base(editor2.getPath())} - #{project.getPath()}" describe "where there are no open editors", -> it "constructs the view with no open editors", -> - rootView.getActiveEditor().remove() + rootView.getActivePane().remove() expect(rootView.getEditors().length).toBe 0 viewState = rootView.serialize() rootView.deactivate() - window.rootView = RootView.deserialize(viewState) + window.rootView = deserialize(viewState) rootView.attachToDom() expect(rootView.getEditors().length).toBe 0 - describe "when a pane's wrapped view cannot be deserialized", -> - it "renders an empty pane", -> - viewState = - panesViewState: - deserializer: "Pane", - wrappedView: - deserializer: "BogusView" - - rootView.deactivate() - window.rootView = RootView.deserialize(viewState) - expect(rootView.find('.pane').length).toBe 1 - expect(rootView.find('.pane').children().length).toBe 0 - describe "focus", -> - describe "when there is an active editor", -> - it "hands off focus to the active editor", -> - rootView.attachToDom() - - rootView.open() # create an editor - expect(rootView).not.toMatchSelector(':focus') - expect(rootView.getActiveEditor().isFocused).toBeTruthy() - + describe "when there is an active view", -> + it "hands off focus to the active view", -> + editor = rootView.getActiveView() + editor.isFocused = false rootView.focus() - expect(rootView).not.toMatchSelector(':focus') - expect(rootView.getActiveEditor().isFocused).toBeTruthy() + expect(editor.isFocused).toBeTruthy() - describe "when there is no active editor", -> + describe "when there is no active view", -> beforeEach -> - rootView.getActiveEditor().remove() + rootView.getActivePane().remove() + expect(rootView.getActiveView()).toBeUndefined() rootView.attachToDom() + expect(document.activeElement).toBe document.body describe "when are visible focusable elements (with a -1 tabindex)", -> it "passes focus to the first focusable element", -> - rootView.horizontal.append $$ -> - @div "One", id: 'one', tabindex: -1 - @div "Two", id: 'two', tabindex: -1 + focusable1 = $$ -> @div "One", id: 'one', tabindex: -1 + focusable2 = $$ -> @div "Two", id: 'two', tabindex: -1 + rootView.horizontal.append(focusable1, focusable2) + expect(document.activeElement).toBe document.body rootView.focus() - expect(rootView).not.toMatchSelector(':focus') - expect(rootView.find('#one')).toMatchSelector(':focus') - expect(rootView.find('#two')).not.toMatchSelector(':focus') + expect(document.activeElement).toBe focusable1[0] describe "when there are no visible focusable elements", -> it "surrenders focus to the body", -> - expect(document.activeElement).toBe $('body')[0] + focusable = $$ -> @div "One", id: 'one', tabindex: -1 + rootView.horizontal.append(focusable) + focusable.hide() + expect(document.activeElement).toBe document.body - describe "panes", -> - [pane1, newPaneContent] = [] - - beforeEach -> - rootView.attachToDom() - rootView.width(800) - rootView.height(600) - pane1 = rootView.find('.pane').view() - pane1.attr('id', 'pane-1') - newPaneContent = $("
New pane content
") - spyOn(newPaneContent, 'focus') - - describe "vertical splits", -> - describe "when .splitRight(view) is called on a pane", -> - it "places a new pane to the right of the current pane in a .row div", -> - expect(rootView.panes.find('.row')).not.toExist() - - pane2 = pane1.splitRight(newPaneContent) - expect(newPaneContent.focus).toHaveBeenCalled() - - expect(rootView.panes.find('.row')).toExist() - expect(rootView.panes.find('.row .pane').length).toBe 2 - [leftPane, rightPane] = rootView.panes.find('.row .pane').map -> $(this) - expect(rightPane[0]).toBe pane2[0] - expect(leftPane.attr('id')).toBe 'pane-1' - expect(rightPane.html()).toBe "
New pane content
" - - expectedColumnWidth = Math.floor(rootView.panes.width() / 2) - expect(leftPane.outerWidth()).toBe expectedColumnWidth - expect(rightPane.position().left).toBe expectedColumnWidth - expect(rightPane.outerWidth()).toBe expectedColumnWidth - - pane2.remove() - - expect(rootView.panes.find('.row')).not.toExist() - expect(rootView.panes.find('.pane').length).toBe 1 - expect(pane1.outerWidth()).toBe rootView.panes.width() - - describe "when splitLeft(view) is called on a pane", -> - it "places a new pane to the left of the current pane in a .row div", -> - expect(rootView.find('.row')).not.toExist() - - pane2 = pane1.splitLeft(newPaneContent) - expect(newPaneContent.focus).toHaveBeenCalled() - - expect(rootView.find('.row')).toExist() - expect(rootView.find('.row .pane').length).toBe 2 - [leftPane, rightPane] = rootView.find('.row .pane').map -> $(this) - expect(leftPane[0]).toBe pane2[0] - expect(rightPane.attr('id')).toBe 'pane-1' - expect(leftPane.html()).toBe "
New pane content
" - - expectedColumnWidth = Math.floor(rootView.panes.width() / 2) - expect(leftPane.outerWidth()).toBe expectedColumnWidth - expect(rightPane.position().left).toBe expectedColumnWidth - expect(rightPane.outerWidth()).toBe expectedColumnWidth - - pane2.remove() - - expect(rootView.panes.find('.row')).not.toExist() - expect(rootView.panes.find('.pane').length).toBe 1 - expect(pane1.outerWidth()).toBe rootView.panes.width() - expect(pane1.position().left).toBe 0 - - describe "horizontal splits", -> - describe "when splitUp(view) is called on a pane", -> - it "places a new pane above the current pane in a .column div", -> - expect(rootView.find('.column')).not.toExist() - - pane2 = pane1.splitUp(newPaneContent) - expect(newPaneContent.focus).toHaveBeenCalled() - - expect(rootView.find('.column')).toExist() - expect(rootView.find('.column .pane').length).toBe 2 - [topPane, bottomPane] = rootView.find('.column .pane').map -> $(this) - expect(topPane[0]).toBe pane2[0] - expect(bottomPane.attr('id')).toBe 'pane-1' - expect(topPane.html()).toBe "
New pane content
" - - expectedRowHeight = Math.floor(rootView.panes.height() / 2) - expect(topPane.outerHeight()).toBe expectedRowHeight - expect(bottomPane.position().top).toBe expectedRowHeight - expect(bottomPane.outerHeight()).toBe expectedRowHeight - - pane2.remove() - - expect(rootView.panes.find('.column')).not.toExist() - expect(rootView.panes.find('.pane').length).toBe 1 - expect(pane1.outerHeight()).toBe rootView.panes.height() - expect(pane1.position().top).toBe 0 - - describe "when splitDown(view) is called on a pane", -> - it "places a new pane below the current pane in a .column div", -> - expect(rootView.find('.column')).not.toExist() - - pane2 = pane1.splitDown(newPaneContent) - expect(newPaneContent.focus).toHaveBeenCalled() - - expect(rootView.find('.column')).toExist() - expect(rootView.find('.column .pane').length).toBe 2 - [topPane, bottomPane] = rootView.find('.column .pane').map -> $(this) - expect(bottomPane[0]).toBe pane2[0] - expect(topPane.attr('id')).toBe 'pane-1' - expect(bottomPane.html()).toBe "
New pane content
" - - expectedRowHeight = Math.floor(rootView.panes.height() / 2) - expect(topPane.outerHeight()).toBe expectedRowHeight - expect(bottomPane.position().top).toBe expectedRowHeight - expect(bottomPane.outerHeight()).toBe expectedRowHeight - - pane2.remove() - - expect(rootView.panes.find('.column')).not.toExist() - expect(rootView.panes.find('.pane').length).toBe 1 - expect(pane1.outerHeight()).toBe rootView.panes.height() - - describe "layout of nested vertical and horizontal splits", -> - it "lays out rows and columns with a consistent width", -> - pane1.html("1") - - pane1 - .splitLeft("2") - .splitUp("3") - .splitLeft("4") - .splitDown("5") - - row1 = rootView.panes.children(':eq(0)') - expect(row1.children().length).toBe 2 - column1 = row1.children(':eq(0)').view() - pane1 = row1.children(':eq(1)').view() - expect(column1.outerWidth()).toBe Math.round(2/3 * rootView.panes.width()) - expect(column1.outerHeight()).toBe rootView.height() - expect(pane1.outerWidth()).toBe Math.round(1/3 * rootView.panes.width()) - expect(pane1.outerHeight()).toBe rootView.height() - expect(Math.round(pane1.position().left)).toBe column1.outerWidth() - - expect(column1.children().length).toBe 2 - row2 = column1.children(':eq(0)').view() - pane2 = column1.children(':eq(1)').view() - expect(row2.outerWidth()).toBe column1.outerWidth() - expect(row2.height()).toBe 2/3 * rootView.panes.height() - expect(pane2.outerWidth()).toBe column1.outerWidth() - expect(pane2.outerHeight()).toBe 1/3 * rootView.panes.height() - expect(pane2.position().top).toBe row2.height() - - expect(row2.children().length).toBe 2 - column3 = row2.children(':eq(0)').view() - pane3 = row2.children(':eq(1)').view() - expect(column3.outerWidth()).toBe Math.round(1/3 * rootView.panes.width()) - expect(column3.outerHeight()).toBe row2.outerHeight() - # the built in rounding seems to be rounding x.5 down, but we need to go up. this sucks. - expect(Math.round(pane3.trueWidth())).toBe Math.round(1/3 * rootView.panes.width()) - expect(pane3.height()).toBe row2.outerHeight() - expect(Math.round(pane3.position().left)).toBe column3.width() - - expect(column3.children().length).toBe 2 - pane4 = column3.children(':eq(0)').view() - pane5 = column3.children(':eq(1)').view() - expect(pane4.outerWidth()).toBe column3.width() - expect(pane4.outerHeight()).toBe 1/3 * rootView.panes.height() - expect(pane5.outerWidth()).toBe column3.width() - expect(pane5.position().top).toBe pane4.outerHeight() - expect(pane5.outerHeight()).toBe 1/3 * rootView.panes.height() - - pane5.remove() - - expect(column3.parent()).not.toExist() - expect(pane2.outerHeight()).toBe Math.floor(1/2 * rootView.panes.height()) - expect(pane3.outerHeight()).toBe Math.floor(1/2 * rootView.panes.height()) - expect(pane4.outerHeight()).toBe Math.floor(1/2 * rootView.panes.height()) - - pane4.remove() - expect(row2.parent()).not.toExist() - expect(pane1.outerWidth()).toBe Math.floor(1/2 * rootView.panes.width()) - expect(pane2.outerWidth()).toBe Math.floor(1/2 * rootView.panes.width()) - expect(pane3.outerWidth()).toBe Math.floor(1/2 * rootView.panes.width()) - - pane3.remove() - expect(column1.parent()).not.toExist() - expect(pane2.outerHeight()).toBe rootView.panes.height() - - pane2.remove() - expect(row1.parent()).not.toExist() - expect(rootView.panes.children().length).toBe 1 - expect(rootView.panes.children('.pane').length).toBe 1 - expect(pane1.outerWidth()).toBe rootView.panes.width() - - describe ".focusNextPane()", -> - it "focuses the wrapped view of the pane after the currently focused pane", -> - class DummyView extends View - @content: (number) -> @div(number, tabindex: -1) - - view1 = pane1.wrappedView - view2 = new DummyView(2) - view3 = new DummyView(3) - pane2 = pane1.splitDown(view2) - pane3 = pane2.splitRight(view3) - rootView.attachToDom() - view1.focus() - - spyOn(view1, 'focus').andCallThrough() - spyOn(view2, 'focus').andCallThrough() - spyOn(view3, 'focus').andCallThrough() - - rootView.focusNextPane() - expect(view2.focus).toHaveBeenCalled() - rootView.focusNextPane() - expect(view3.focus).toHaveBeenCalled() - rootView.focusNextPane() - expect(view1.focus).toHaveBeenCalled() + rootView.focus() + expect(document.activeElement).toBe document.body describe "keymap wiring", -> commandHandler = null @@ -360,249 +140,144 @@ describe "RootView", -> window.keymap.bindKeys('*', 'x': 'foo-command') - describe "when a keydown event is triggered on the RootView (not originating from Ace)", -> + describe "when a keydown event is triggered on the RootView", -> it "triggers matching keybindings for that event", -> event = keydownEvent 'x', target: rootView[0] rootView.trigger(event) expect(commandHandler).toHaveBeenCalled() - describe ".activeKeybindings()", -> - originalKeymap = null - keymap = null - editor = null + describe "window title", -> + describe "when the project has no path", -> + it "sets the title to 'untitled'", -> + project.setPath(undefined) + expect(rootView.title).toBe 'untitled' + describe "when the project has a path", -> beforeEach -> - rootView.attachToDom() - editor = rootView.getActiveEditor() - keymap = new (require 'keymap') - originalKeymap = window.keymap - window.keymap = keymap + rootView.open('b') - afterEach -> - window.keymap = originalKeymap + describe "when there is an active pane item", -> + it "sets the title to the pane item's title plus the project path", -> + item = rootView.getActivePaneItem() + expect(rootView.title).toBe "#{item.getTitle()} - #{project.getPath()}" - it "returns all keybindings available for focused element", -> - editor.on 'test-event-a', => # nothing + describe "when the title of the active pane item changes", -> + it "updates the window title based on the item's new title", -> + editSession = rootView.getActivePaneItem() + editSession.buffer.setPath('/tmp/hi') + expect(rootView.title).toBe "#{editSession.getTitle()} - #{project.getPath()}" - keymap.bindKeys ".editor", - "meta-a": "test-event-a" - "meta-b": "test-event-b" + describe "when the active pane's item changes", -> + it "updates the title to the new item's title plus the project path", -> + rootView.getActivePane().showNextItem() + item = rootView.getActivePaneItem() + expect(rootView.title).toBe "#{item.getTitle()} - #{project.getPath()}" - keybindings = rootView.activeKeybindings() - expect(Object.keys(keybindings).length).toBe 2 - expect(keybindings["meta-a"]).toEqual "test-event-a" + describe "when the last pane item is removed", -> + it "sets the title to the project's path", -> + rootView.getActivePane().remove() + expect(rootView.getActivePaneItem()).toBeUndefined() + expect(rootView.title).toBe project.getPath() - describe "when the path of the active editor changes", -> - it "changes the title and emits an root-view:active-path-changed event", -> - pathChangeHandler = jasmine.createSpy 'pathChangeHandler' - rootView.on 'root-view:active-path-changed', pathChangeHandler - - editor1 = rootView.getActiveEditor() - expect(rootView.getTitle()).toBe "#{fs.base(editor1.getPath())} – #{project.getPath()}" - - editor2 = rootView.getActiveEditor().splitLeft() - - path = project.resolve('b') - editor2.edit(project.buildEditSessionForPath(path)) - expect(pathChangeHandler).toHaveBeenCalled() - expect(rootView.getTitle()).toBe "#{fs.base(editor2.getPath())} – #{project.getPath()}" - - pathChangeHandler.reset() - editor1.getBuffer().saveAs("/tmp/should-not-be-title.txt") - expect(pathChangeHandler).not.toHaveBeenCalled() - expect(rootView.getTitle()).toBe "#{fs.base(editor2.getPath())} – #{project.getPath()}" - - it "sets the project path to the directory of the editor if it was previously unassigned", -> - project.setPath(undefined) - window.rootView = new RootView - rootView.open() - expect(project.getPath()?).toBeFalsy() - rootView.getActiveEditor().getBuffer().saveAs('/tmp/ignore-me') - expect(project.getPath()).toBe '/tmp' - - describe "when editors are focused", -> - it "triggers 'root-view:active-path-changed' events if the path of the active editor actually changes", -> - pathChangeHandler = jasmine.createSpy 'pathChangeHandler' - rootView.on 'root-view:active-path-changed', pathChangeHandler - - editor1 = rootView.getActiveEditor() - editor2 = rootView.getActiveEditor().splitLeft() - - rootView.open(require.resolve('fixtures/sample.txt')) - expect(pathChangeHandler).toHaveBeenCalled() - pathChangeHandler.reset() - - editor1.focus() - expect(pathChangeHandler).toHaveBeenCalled() - pathChangeHandler.reset() - - rootView.focus() - expect(pathChangeHandler).not.toHaveBeenCalled() - - editor2.edit(editor1.activeEditSession.copy()) - editor2.focus() - expect(pathChangeHandler).not.toHaveBeenCalled() - - describe "when the last editor is removed", -> - it "updates the title to the project path", -> - rootView.getEditors()[0].remove() - expect(rootView.getTitle()).toBe project.getPath() + describe "when an inactive pane's item changes", -> + it "does not update the title", -> + pane = rootView.getActivePane() + pane.splitRight() + initialTitle = rootView.title + pane.showNextItem() + expect(rootView.title).toBe initialTitle describe "font size adjustment", -> - editor = null - beforeEach -> - editor = rootView.getActiveEditor() - editor.attachToDom() - it "increases/decreases font size when increase/decrease-font-size events are triggered", -> - fontSizeBefore = editor.getFontSize() + fontSizeBefore = config.get('editor.fontSize') rootView.trigger 'window:increase-font-size' - expect(editor.getFontSize()).toBe fontSizeBefore + 1 + expect(config.get('editor.fontSize')).toBe fontSizeBefore + 1 rootView.trigger 'window:increase-font-size' - expect(editor.getFontSize()).toBe fontSizeBefore + 2 + expect(config.get('editor.fontSize')).toBe fontSizeBefore + 2 rootView.trigger 'window:decrease-font-size' - expect(editor.getFontSize()).toBe fontSizeBefore + 1 + expect(config.get('editor.fontSize')).toBe fontSizeBefore + 1 rootView.trigger 'window:decrease-font-size' - expect(editor.getFontSize()).toBe fontSizeBefore + expect(config.get('editor.fontSize')).toBe fontSizeBefore it "does not allow the font size to be less than 1", -> config.set("editor.fontSize", 1) rootView.trigger 'window:decrease-font-size' - expect(editor.getFontSize()).toBe 1 + expect(config.get('editor.fontSize')).toBe 1 describe ".open(path, options)", -> - describe "when there is no active editor", -> + describe "when there is no active pane", -> beforeEach -> - rootView.getActiveEditor().destroyActiveEditSession() - expect(rootView.getActiveEditor()).toBeUndefined() + spyOn(Pane.prototype, 'focus') + rootView.getActivePane().remove() + expect(rootView.getActivePane()).toBeUndefined() describe "when called with no path", -> - it "opens / returns an edit session for an empty buffer in a new editor", -> + it "creates a empty edit session as an item on a new pane, and focuses the pane", -> editSession = rootView.open() - expect(rootView.getActiveEditor()).toBeDefined() - expect(rootView.getActiveEditor().getPath()).toBeUndefined() - expect(editSession).toBe rootView.getActiveEditor().activeEditSession + expect(rootView.getActivePane().activeItem).toBe editSession + expect(editSession.getPath()).toBeUndefined() + expect(rootView.getActivePane().focus).toHaveBeenCalled() describe "when called with a path", -> - it "opens a buffer with the given path in a new editor", -> + it "creates an edit session for the given path as an item on a new pane, and focuses the pane", -> editSession = rootView.open('b') - expect(rootView.getActiveEditor()).toBeDefined() - expect(rootView.getActiveEditor().getPath()).toBe require.resolve('fixtures/dir/b') - expect(editSession).toBe rootView.getActiveEditor().activeEditSession + expect(rootView.getActivePane().activeItem).toBe editSession + expect(editSession.getPath()).toBe require.resolve('fixtures/dir/b') + expect(rootView.getActivePane().focus).toHaveBeenCalled() - describe "when there is an active editor", -> + describe "when the changeFocus option is false", -> + it "does not focus the new pane", -> + editSession = rootView.open('b', changeFocus: false) + expect(rootView.getActivePane().focus).not.toHaveBeenCalled() + + describe "when there is an active pane", -> + [activePane, initialItemCount] = [] beforeEach -> - expect(rootView.getActiveEditor()).toBeDefined() + activePane = rootView.getActivePane() + spyOn(activePane, 'focus') + initialItemCount = activePane.getItems().length describe "when called with no path", -> - it "opens an empty buffer in the active editor", -> + it "opens an edit session with an empty buffer as an item on the active pane and focuses it", -> editSession = rootView.open() - expect(rootView.getActiveEditor().getPath()).toBeUndefined() - expect(editSession).toBe rootView.getActiveEditor().activeEditSession + expect(activePane.getItems().length).toBe initialItemCount + 1 + expect(activePane.activeItem).toBe editSession + expect(editSession.getPath()).toBeUndefined() + expect(activePane.focus).toHaveBeenCalled() describe "when called with a path", -> - [editor1, editor2] = [] - beforeEach -> - rootView.attachToDom() - editor1 = rootView.getActiveEditor() - editor2 = editor1.splitRight() - rootView.open('b') - editor2.loadPreviousEditSession() - editor1.focus() + describe "when the active pane already has an edit session item for the path being opened", -> + it "shows the existing edit session on the pane", -> + previousEditSession = activePane.activeItem - describe "when allowActiveEditorChange is false (the default)", -> - activeEditor = null - beforeEach -> - activeEditor = rootView.getActiveEditor() + editSession = rootView.open('b') + expect(activePane.activeItem).toBe editSession + expect(editSession).not.toBe previousEditSession - describe "when the active editor has an edit session for the given path", -> - it "re-activates the existing edit session", -> - expect(activeEditor.getPath()).toBe require.resolve('fixtures/dir/a') - previousEditSession = activeEditor.activeEditSession + editSession = rootView.open(previousEditSession.getPath()) + expect(editSession).toBe previousEditSession + expect(activePane.activeItem).toBe editSession - editSession = rootView.open('b') - expect(activeEditor.activeEditSession).not.toBe previousEditSession - expect(editSession).toBe rootView.getActiveEditor().activeEditSession + expect(activePane.focus).toHaveBeenCalled() - editSession = rootView.open('a') - expect(activeEditor.activeEditSession).toBe previousEditSession - expect(editSession).toBe previousEditSession + describe "when the active pane does not have an edit session item for the path being opened", -> + it "creates a new edit session for the given path in the active editor", -> + editSession = rootView.open('b') + expect(activePane.items.length).toBe 2 + expect(activePane.activeItem).toBe editSession + expect(activePane.focus).toHaveBeenCalled() - describe "when the active editor does not have an edit session for the given path", -> - it "creates a new edit session for the given path in the active editor", -> - editSession = rootView.open('b') - expect(activeEditor.editSessions.length).toBe 2 - expect(editSession).toBe rootView.getActiveEditor().activeEditSession - - describe "when the 'allowActiveEditorChange' option is true", -> - describe "when the active editor has an edit session for the given path", -> - it "re-activates the existing edit session regardless of whether any other editor also has an edit session for the path", -> - activeEditor = rootView.getActiveEditor() - expect(activeEditor.getPath()).toBe require.resolve('fixtures/dir/a') - previousEditSession = activeEditor.activeEditSession - - editSession = rootView.open('b') - expect(activeEditor.activeEditSession).not.toBe previousEditSession - expect(editSession).toBe activeEditor.activeEditSession - - editSession = rootView.open('a', allowActiveEditorChange: true) - expect(activeEditor.activeEditSession).toBe previousEditSession - expect(editSession).toBe activeEditor.activeEditSession - - describe "when the active editor does *not* have an edit session for the given path", -> - describe "when another editor has an edit session for the path", -> - it "focuses the other editor and activates its edit session for the path", -> - expect(rootView.getActiveEditor()).toBe editor1 - editSession = rootView.open('b', allowActiveEditorChange: true) - expect(rootView.getActiveEditor()).toBe editor2 - expect(editor2.getPath()).toBe require.resolve('fixtures/dir/b') - expect(editSession).toBe rootView.getActiveEditor().activeEditSession - - describe "when no other editor has an edit session for the path either", -> - it "creates a new edit session for the path on the current active editor", -> - path = require.resolve('fixtures/sample.js') - editSession = rootView.open(path, allowActiveEditorChange: true) - expect(rootView.getActiveEditor()).toBe editor1 - expect(editor1.getPath()).toBe path - expect(editSession).toBe rootView.getActiveEditor().activeEditSession - - describe ".saveAll()", -> - it "saves all open editors", -> - project.setPath('/tmp') - file1 = '/tmp/atom-temp1.txt' - file2 = '/tmp/atom-temp2.txt' - fs.write(file1, "file1") - fs.write(file2, "file2") - rootView.open(file1) - - editor1 = rootView.getActiveEditor() - buffer1 = editor1.activeEditSession.buffer - expect(buffer1.getText()).toBe("file1") - expect(buffer1.isModified()).toBe(false) - buffer1.setText('edited1') - expect(buffer1.isModified()).toBe(true) - - editor2 = editor1.splitRight() - editor2.edit(project.buildEditSessionForPath('atom-temp2.txt')) - buffer2 = editor2.activeEditSession.buffer - expect(buffer2.getText()).toBe("file2") - expect(buffer2.isModified()).toBe(false) - buffer2.setText('edited2') - expect(buffer2.isModified()).toBe(true) - - rootView.saveAll() - - expect(buffer1.isModified()).toBe(false) - expect(fs.read(buffer1.getPath())).toBe("edited1") - expect(buffer2.isModified()).toBe(false) - expect(fs.read(buffer2.getPath())).toBe("edited2") + describe "when the changeFocus option is false", -> + it "does not focus the active pane", -> + editSession = rootView.open('b', changeFocus: false) + expect(activePane.focus).not.toHaveBeenCalled() describe "window:toggle-invisibles event", -> it "shows/hides invisibles in all open and future editors", -> rootView.height(200) rootView.attachToDom() - rightEditor = rootView.getActiveEditor() + rightEditor = rootView.getActiveView() rightEditor.setText(" \t ") leftEditor = rightEditor.splitLeft() expect(rightEditor.find(".line:first").text()).toBe " " @@ -636,7 +311,7 @@ describe "RootView", -> count++ rootView.eachEditor(callback) expect(count).toBe 1 - expect(callbackEditor).toBe rootView.getActiveEditor() + expect(callbackEditor).toBe rootView.getActiveView() it "invokes the callback for new editor", -> count = 0 @@ -648,9 +323,9 @@ describe "RootView", -> rootView.eachEditor(callback) count = 0 callbackEditor = null - rootView.getActiveEditor().splitRight() + rootView.getActiveView().splitRight() expect(count).toBe 1 - expect(callbackEditor).toBe rootView.getActiveEditor() + expect(callbackEditor).toBe rootView.getActiveView() describe ".eachBuffer(callback)", -> beforeEach -> @@ -664,7 +339,7 @@ describe "RootView", -> count++ rootView.eachBuffer(callback) expect(count).toBe 1 - expect(callbackBuffer).toBe rootView.getActiveEditor().getBuffer() + expect(callbackBuffer).toBe rootView.getActiveView().getBuffer() it "invokes the callback for new buffer", -> count = 0 @@ -678,4 +353,4 @@ describe "RootView", -> callbackBuffer = null rootView.open(require.resolve('fixtures/sample.txt')) expect(count).toBe 1 - expect(callbackBuffer).toBe rootView.getActiveEditor().getBuffer() + expect(callbackBuffer).toBe rootView.getActiveView().getBuffer() diff --git a/spec/app/text-mate-grammar-spec.coffee b/spec/app/text-mate-grammar-spec.coffee index 91f220ecb..07d06ca02 100644 --- a/spec/app/text-mate-grammar-spec.coffee +++ b/spec/app/text-mate-grammar-spec.coffee @@ -262,7 +262,7 @@ describe "TextMateGrammar", -> describe "when the grammar is CSON", -> it "loads the grammar and correctly parses a keyword", -> spyOn(syntax, 'addGrammar') - pack = new TextMatePackage(fixturesProject.resolve("packages/package-with-a-cson-grammar.tmbundle")) + pack = new TextMatePackage(project.resolve("packages/package-with-a-cson-grammar.tmbundle")) pack.load() grammar = pack.grammars[0] expect(grammar).toBeTruthy() diff --git a/spec/app/theme-spec.coffee b/spec/app/theme-spec.coffee index 5afebc983..53349c8ba 100644 --- a/spec/app/theme-spec.coffee +++ b/spec/app/theme-spec.coffee @@ -20,33 +20,38 @@ describe "@load(name)", -> expect($(".editor").css("background-color")).toBe("rgb(20, 20, 20)") describe "AtomTheme", -> + describe "when the theme is a file", -> + it "loads and applies css", -> + expect($(".editor").css("padding-bottom")).not.toBe "1234px" + themePath = project.resolve('themes/theme-stylesheet.css') + theme = Theme.load(themePath) + expect($(".editor").css("padding-top")).toBe "1234px" + + it "parses, loads and applies less", -> + expect($(".editor").css("padding-bottom")).not.toBe "1234px" + themePath = project.resolve('themes/theme-stylesheet.less') + theme = Theme.load(themePath) + expect($(".editor").css("padding-top")).toBe "4321px" + describe "when the theme contains a package.json file", -> - it "loads and applies css from package.json in the correct order", -> + it "loads and applies stylesheets from package.json in the correct order", -> expect($(".editor").css("padding-top")).not.toBe("101px") expect($(".editor").css("padding-right")).not.toBe("102px") expect($(".editor").css("padding-bottom")).not.toBe("103px") - themePath = fixturesProject.resolve('themes/theme-with-package-file') + themePath = project.resolve('themes/theme-with-package-file') theme = Theme.load(themePath) expect($(".editor").css("padding-top")).toBe("101px") expect($(".editor").css("padding-right")).toBe("102px") expect($(".editor").css("padding-bottom")).toBe("103px") - describe "when the theme is a CSS file", -> - it "loads and applies the stylesheet", -> - expect($(".editor").css("padding-bottom")).not.toBe "1234px" - - themePath = fixturesProject.resolve('themes/theme-stylesheet.css') - theme = Theme.load(themePath) - expect($(".editor").css("padding-top")).toBe "1234px" - describe "when the theme does not contain a package.json file and is a directory", -> - it "loads all CSS files in the directory", -> + it "loads all stylesheet files in the directory", -> expect($(".editor").css("padding-top")).not.toBe "10px" expect($(".editor").css("padding-right")).not.toBe "20px" expect($(".editor").css("padding-bottom")).not.toBe "30px" - themePath = fixturesProject.resolve('themes/theme-without-package-file') + themePath = project.resolve('themes/theme-without-package-file') theme = Theme.load(themePath) expect($(".editor").css("padding-top")).toBe "10px" expect($(".editor").css("padding-right")).toBe "20px" diff --git a/spec/app/tokenized-buffer-spec.coffee b/spec/app/tokenized-buffer-spec.coffee index 944628904..86bd50520 100644 --- a/spec/app/tokenized-buffer-spec.coffee +++ b/spec/app/tokenized-buffer-spec.coffee @@ -18,7 +18,7 @@ describe "TokenizedBuffer", -> describe "when the buffer contains soft-tabs", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('sample.js', autoIndent: false) + editSession = project.buildEditSession('sample.js', autoIndent: false) buffer = editSession.buffer tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer editSession.setVisible(true) @@ -299,7 +299,7 @@ describe "TokenizedBuffer", -> describe "when the buffer contains hard-tabs", -> beforeEach -> tabLength = 2 - editSession = fixturesProject.buildEditSessionForPath('sample-with-tabs.coffee', { tabLength }) + editSession = project.buildEditSession('sample-with-tabs.coffee', { tabLength }) buffer = editSession.buffer tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer editSession.setVisible(true) @@ -328,7 +328,7 @@ describe "TokenizedBuffer", -> describe "when a Git commit message file is tokenized", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('COMMIT_EDITMSG', autoIndent: false) + editSession = project.buildEditSession('COMMIT_EDITMSG', autoIndent: false) buffer = editSession.buffer tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer editSession.setVisible(true) @@ -355,7 +355,7 @@ describe "TokenizedBuffer", -> describe "when a C++ source file is tokenized", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('includes.cc', autoIndent: false) + editSession = project.buildEditSession('includes.cc', autoIndent: false) buffer = editSession.buffer tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer editSession.setVisible(true) @@ -386,7 +386,7 @@ describe "TokenizedBuffer", -> describe "when a Ruby source file is tokenized", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('hello.rb', autoIndent: false) + editSession = project.buildEditSession('hello.rb', autoIndent: false) buffer = editSession.buffer tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer editSession.setVisible(true) @@ -403,7 +403,7 @@ describe "TokenizedBuffer", -> describe "when an Objective-C source file is tokenized", -> beforeEach -> - editSession = fixturesProject.buildEditSessionForPath('function.mm', autoIndent: false) + editSession = project.buildEditSession('function.mm', autoIndent: false) buffer = editSession.buffer tokenizedBuffer = editSession.displayBuffer.tokenizedBuffer editSession.setVisible(true) diff --git a/spec/app/window-spec.coffee b/spec/app/window-spec.coffee index 32f9397f1..4abcf6d5b 100644 --- a/spec/app/window-spec.coffee +++ b/spec/app/window-spec.coffee @@ -1,5 +1,6 @@ $ = require 'jquery' fs = require 'fs' +{less} = require 'less' describe "Window", -> projectPath = null @@ -34,49 +35,89 @@ describe "Window", -> $(window).trigger 'focus' expect($("body")).not.toHaveClass("is-blurred") - describe ".close()", -> - it "is triggered by the 'core:close' event", -> - spyOn window, 'close' - $(window).trigger 'core:close' - expect(window.close).toHaveBeenCalled() + describe "window:close event", -> + describe "when no pane items are modified", -> + it "calls window.close", -> + spyOn window, 'close' + $(window).trigger 'window:close' + expect(window.close).toHaveBeenCalled() - it "is triggered by the 'window:close event'", -> - spyOn window, 'close' - $(window).trigger 'window:close' - expect(window.close).toHaveBeenCalled() + describe "when pane items are are modified", -> + it "prompts user to save and and calls window.close", -> + spyOn(window, 'close') + spyOn(atom, "confirm").andCallFake (a, b, c, d, e, f, g, noSave) -> noSave() + editSession = rootView.open("sample.js") + editSession.insertText("I look different, I feel different.") + $(window).trigger 'window:close' + expect(window.close).toHaveBeenCalled() + expect(atom.confirm).toHaveBeenCalled() + + it "prompts user to save and aborts if dialog is canceled", -> + spyOn(window, 'close') + spyOn(atom, "confirm").andCallFake (a, b, c, d, e, cancel) -> cancel() + editSession = rootView.open("sample.js") + editSession.insertText("I look different, I feel different.") + $(window).trigger 'window:close' + expect(window.close).not.toHaveBeenCalled() + expect(atom.confirm).toHaveBeenCalled() describe ".reload()", -> - it "returns false when no buffers are modified", -> + beforeEach -> spyOn($native, "reload") + + it "returns false when no buffers are modified", -> window.reload() expect($native.reload).toHaveBeenCalled() - it "shows alert when a modifed buffer exists", -> + it "shows an alert when a modifed buffer exists", -> rootView.open('sample.js') - rootView.getActiveEditor().insertText("hi") + rootView.getActiveView().insertText("hi") spyOn(atom, "confirm") - spyOn($native, "reload") window.reload() expect($native.reload).not.toHaveBeenCalled() expect(atom.confirm).toHaveBeenCalled() describe "requireStylesheet(path)", -> - it "synchronously loads the stylesheet at the given path and installs a style tag for it in the head", -> - $('head style[id*="atom.css"]').remove() + it "synchronously loads css at the given path and installs a style tag for it in the head", -> + cssPath = project.resolve('css.css') lengthBefore = $('head style').length - requireStylesheet('atom.css') + + requireStylesheet(cssPath) expect($('head style').length).toBe lengthBefore + 1 - styleElt = $('head style[id*="atom.css"]') - - fullPath = require.resolve('atom.css') - expect(styleElt.attr('id')).toBe fullPath - expect(styleElt.text()).toBe fs.read(fullPath) + element = $('head style[id*="css.css"]') + expect(element.attr('id')).toBe cssPath + expect(element.text()).toBe fs.read(cssPath) # doesn't append twice - requireStylesheet('atom.css') + requireStylesheet(cssPath) expect($('head style').length).toBe lengthBefore + 1 + $('head style[id*="css.css"]').remove() + + it "synchronously loads and parses less files at the given path and installs a style tag for it in the head", -> + lessPath = project.resolve('sample.less') + lengthBefore = $('head style').length + requireStylesheet(lessPath) + expect($('head style').length).toBe lengthBefore + 1 + + element = $('head style[id*="sample.less"]') + expect(element.attr('id')).toBe lessPath + expect(element.text()).toBe """ + #header { + color: #4d926f; + } + h2 { + color: #4d926f; + } + + """ + + # doesn't append twice + requireStylesheet(lessPath) + expect($('head style').length).toBe lengthBefore + 1 + $('head style[id*="sample.less"]').remove() + describe ".disableStyleSheet(path)", -> it "removes styling applied by given stylesheet path", -> cssPath = require.resolve(fs.join("fixtures", "css.css")) @@ -103,13 +144,13 @@ describe "Window", -> it "unsubscribes from all buffers", -> rootView.open('sample.js') - editor1 = rootView.getActiveEditor() - editor2 = editor1.splitRight() - expect(window.rootView.getEditors().length).toBe 2 + buffer = rootView.getActivePaneItem().buffer + rootView.getActivePane().splitRight() + expect(window.rootView.find('.editor').length).toBe 2 window.shutdown() - expect(editor1.getBuffer().subscriptionCount()).toBe 0 + expect(buffer.subscriptionCount()).toBe 0 it "only serializes window state the first time it is called", -> deactivateSpy = spyOn(atom, "setRootViewStateForPath").andCallThrough() @@ -129,3 +170,34 @@ describe "Window", -> window.installAtomCommand(commandPath) expect(fs.exists(commandPath)).toBeTruthy() expect(fs.read(commandPath).length).toBeGreaterThan 1 + + describe ".deserialize(state)", -> + class Foo + @deserialize: ({name}) -> new Foo(name) + constructor: (@name) -> + + beforeEach -> + registerDeserializer(Foo) + + afterEach -> + unregisterDeserializer(Foo) + + it "calls deserialize on the deserializer for the given state object, or returns undefined if one can't be found", -> + object = deserialize({ deserializer: 'Foo', name: 'Bar' }) + expect(object.name).toBe 'Bar' + expect(deserialize({ deserializer: 'Bogus' })).toBeUndefined() + + describe "when the deserializer has a version", -> + beforeEach -> + Foo.version = 2 + + describe "when the deserialized state has a matching version", -> + it "attempts to deserialize the state", -> + object = deserialize({ deserializer: 'Foo', version: 2, name: 'Bar' }) + expect(object.name).toBe 'Bar' + + describe "when the deserialized state has a non-matching version", -> + it "returns undefined", -> + expect(deserialize({ deserializer: 'Foo', version: 3, name: 'Bar' })).toBeUndefined() + expect(deserialize({ deserializer: 'Foo', version: 1, name: 'Bar' })).toBeUndefined() + expect(deserialize({ deserializer: 'Foo', name: 'Bar' })).toBeUndefined() diff --git a/spec/fixtures/markdown/file.markdown b/spec/fixtures/markdown/file.markdown index e69de29bb..0eec6a120 100644 --- a/spec/fixtures/markdown/file.markdown +++ b/spec/fixtures/markdown/file.markdown @@ -0,0 +1,3 @@ +## File.markdown + +:cool: \ No newline at end of file diff --git a/spec/fixtures/packages/package-with-activation-events/main.coffee b/spec/fixtures/packages/package-with-activation-events/main.coffee index a591812bd..d57ca7c24 100644 --- a/spec/fixtures/packages/package-with-activation-events/main.coffee +++ b/spec/fixtures/packages/package-with-activation-events/main.coffee @@ -1,8 +1,15 @@ +class Foo + registerDeserializer(this) + @deserialize: ({data}) -> new Foo(data) + constructor: (@data) -> + module.exports = + activateCallCount: 0 activationEventCallCount: 0 activate: -> - rootView.getActiveEditor()?.command 'activation-event', => + @activateCallCount++ + rootView.getActiveView()?.command 'activation-event', => @activationEventCallCount++ serialize: -> diff --git a/spec/fixtures/packages/package-with-activation-events/package.cson b/spec/fixtures/packages/package-with-activation-events/package.cson index 80903d6f4..42d3eb78d 100644 --- a/spec/fixtures/packages/package-with-activation-events/package.cson +++ b/spec/fixtures/packages/package-with-activation-events/package.cson @@ -1,2 +1,3 @@ 'activationEvents': ['activation-event'] +'deferredDeserializers': ['Foo'] 'main': 'main' diff --git a/spec/fixtures/sample-with-error.less b/spec/fixtures/sample-with-error.less new file mode 100644 index 000000000..4396e25cf --- /dev/null +++ b/spec/fixtures/sample-with-error.less @@ -0,0 +1 @@ +#header { \ No newline at end of file diff --git a/spec/fixtures/sample.less b/spec/fixtures/sample.less new file mode 100644 index 000000000..a076a9d01 --- /dev/null +++ b/spec/fixtures/sample.less @@ -0,0 +1,8 @@ +@color: #4D926F; + +#header { + color: @color; +} +h2 { + color: @color; +} \ No newline at end of file diff --git a/spec/fixtures/themes/theme-stylesheet.less b/spec/fixtures/themes/theme-stylesheet.less new file mode 100644 index 000000000..29e0d80c6 --- /dev/null +++ b/spec/fixtures/themes/theme-stylesheet.less @@ -0,0 +1,5 @@ +@padding: 4321px; + +.editor { + padding-top: @padding; +} diff --git a/spec/fixtures/themes/theme-with-package-file/package.json b/spec/fixtures/themes/theme-with-package-file/package.json index 9add36774..9dc6565c6 100644 --- a/spec/fixtures/themes/theme-with-package-file/package.json +++ b/spec/fixtures/themes/theme-with-package-file/package.json @@ -1,3 +1,3 @@ { - "stylesheets": ["first.css", "second.css", "last.css"] + "stylesheets": ["first.css", "second.less", "last.css"] } \ No newline at end of file diff --git a/spec/fixtures/themes/theme-with-package-file/second.css b/spec/fixtures/themes/theme-with-package-file/second.css deleted file mode 100644 index 3ddf03add..000000000 --- a/spec/fixtures/themes/theme-with-package-file/second.css +++ /dev/null @@ -1,5 +0,0 @@ -.editor { -/* padding-top: 102px;*/ - padding-right: 102px; - padding-bottom: 102px; -} \ No newline at end of file diff --git a/spec/fixtures/themes/theme-with-package-file/second.less b/spec/fixtures/themes/theme-with-package-file/second.less new file mode 100644 index 000000000..71fad0d44 --- /dev/null +++ b/spec/fixtures/themes/theme-with-package-file/second.less @@ -0,0 +1,7 @@ +@number: 102px; + +.editor { +/* padding-top: 102px;*/ + padding-right: @number; + padding-bottom: @number; +} \ No newline at end of file diff --git a/spec/fixtures/themes/theme-without-package-file/c.css b/spec/fixtures/themes/theme-without-package-file/c.css deleted file mode 100644 index 017dea2af..000000000 --- a/spec/fixtures/themes/theme-without-package-file/c.css +++ /dev/null @@ -1,3 +0,0 @@ -.editor { - padding-bottom: 30px; -} diff --git a/spec/fixtures/themes/theme-without-package-file/c.less b/spec/fixtures/themes/theme-without-package-file/c.less new file mode 100644 index 000000000..91b80c92f --- /dev/null +++ b/spec/fixtures/themes/theme-without-package-file/c.less @@ -0,0 +1,5 @@ +@number: 30px; + +.editor { + padding-bottom: @number; +} diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index c750bf498..8518fa6c3 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -14,7 +14,8 @@ Editor = require 'editor' TokenizedBuffer = require 'tokenized-buffer' fs = require 'fs' RootView = require 'root-view' -requireStylesheet "jasmine.css" +Git = require 'git' +requireStylesheet "jasmine.less" fixturePackagesPath = require.resolve('fixtures/packages') require.paths.unshift(fixturePackagesPath) keymap.loadBundledKeymaps() @@ -29,8 +30,12 @@ jasmine.getEnv().defaultTimeoutInterval = 5000 beforeEach -> jQuery.fx.off = true - window.fixturesProject = new Project(require.resolve('fixtures')) - window.project = fixturesProject + window.project = new Project(require.resolve('fixtures')) + window.git = Git.open(project.getPath()) + window.project.on 'path-changed', -> + window.git?.destroy() + window.git = Git.open(window.project.getPath()) + window.resetTimeouts() atom.atomPackageStates = {} atom.loadedPackages = [] @@ -43,13 +48,14 @@ beforeEach -> window.config = new Config() spyOn(config, 'load') spyOn(config, 'save') + config.set "editor.fontFamily", "Courier" config.set "editor.fontSize", 16 config.set "editor.autoIndent", false config.set "core.disabledPackages", ["package-that-throws-an-exception"] # make editor display updates synchronous spyOn(Editor.prototype, 'requestDisplayUpdate').andCallFake -> @updateDisplay() - spyOn(RootView.prototype, 'updateWindowTitle').andCallFake -> + spyOn(RootView.prototype, 'setTitle').andCallFake (@title) -> spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout spyOn(File.prototype, "detectResurrectionAfterDelay").andCallFake -> @detectResurrection() @@ -62,25 +68,34 @@ beforeEach -> spyOn($native, 'writeToPasteboard').andCallFake (text) -> pasteboardContent = text spyOn($native, 'readFromPasteboard').andCallFake -> pasteboardContent + addCustomMatchers(this) + afterEach -> keymap.bindingSets = bindingSetsToRestore keymap.bindingSetsByFirstKeystrokeToRestore = bindingSetsByFirstKeystrokeToRestore if rootView? - rootView.deactivate() + rootView.deactivate?() window.rootView = null if project? project.destroy() window.project = null + if git? + git.destroy() + window.git = null $('#jasmine-content').empty() ensureNoPathSubscriptions() + atom.pendingModals = [[]] + atom.presentingModal = false waits(0) # yield to ui thread to make screen update more frequently -window.loadPackage = (name, options) -> +window.loadPackage = (name, options={}) -> Package = require 'package' packagePath = _.find atom.getPackagePaths(), (packagePath) -> fs.base(packagePath) == name if pack = Package.build(packagePath) pack.load(options) atom.loadedPackages.push(pack) + pack.deferActivation = false if options.activateImmediately + pack.activate() pack # Specs rely on TextMate bundles (but not atom packages) @@ -110,6 +125,23 @@ jasmine.unspy = (object, methodName) -> throw new Error("Not a spy") unless object[methodName].originalValue? object[methodName] = object[methodName].originalValue +addCustomMatchers = (spec) -> + spec.addMatchers + toBeInstanceOf: (expected) -> + notText = if @isNot then " not" else "" + this.message = => "Expected #{jasmine.pp(@actual)} to#{notText} be instance of #{expected.name} class" + @actual instanceof expected + + toHaveLength: (expected) -> + notText = if @isNot then " not" else "" + this.message = => "Expected object with length #{@actual.length} to#{notText} have length #{expected}" + @actual.length == expected + + toExistOnDisk: (expected) -> + notText = this.isNot and " not" or "" + @message = -> return "Expected path '" + @actual + "'" + notText + " to exist." + fs.exists(@actual) + window.keyIdentifierForKey = (key) -> if key.length > 1 # named key key diff --git a/spec/stdlib/child-process-spec.coffee b/spec/stdlib/child-process-spec.coffee index e528aebec..e594d2b7f 100644 --- a/spec/stdlib/child-process-spec.coffee +++ b/spec/stdlib/child-process-spec.coffee @@ -134,11 +134,11 @@ describe 'Child Processes', -> waitsForPromise -> options = - cwd: fixturesProject.getPath() + cwd: "/Applications" stdout: (data) -> output.push(data) stderr: (data) -> ChildProcess.exec("pwd", options) runs -> - expect(output.join('')).toBe "#{fixturesProject.getPath()}\n" + expect(output.join('')).toBe "/Applications\n" diff --git a/spec/stdlib/cson-spec.coffee b/spec/stdlib/cson-spec.coffee index 361eaf330..a1207203c 100644 --- a/spec/stdlib/cson-spec.coffee +++ b/spec/stdlib/cson-spec.coffee @@ -62,10 +62,14 @@ describe "CSON", -> it "formats the undefined value as null", -> expect(CSON.stringify(['a', undefined, 'b'])).toBe "[\n 'a'\n null\n 'b'\n]" + describe "when the array contains an object", -> + it "wraps the object in {}", -> + expect(CSON.stringify([{a:'b', a1: 'b1'}, {c: 'd'}])).toBe "[\n {\n 'a': 'b'\n 'a1': 'b1'\n }\n {\n 'c': 'd'\n }\n]" + describe "when formatting an object", -> describe "when the object is empty", -> - it "returns the empty string", -> - expect(CSON.stringify({})).toBe "" + it "returns {}", -> + expect(CSON.stringify({})).toBe "{}" it "returns formatted CSON", -> expect(CSON.stringify(a: {b: 'c'})).toBe "'a':\n 'b': 'c'" diff --git a/spec/stdlib/fs-spec.coffee b/spec/stdlib/fs-spec.coffee index b0a6fae5f..94445d011 100644 --- a/spec/stdlib/fs-spec.coffee +++ b/spec/stdlib/fs-spec.coffee @@ -86,7 +86,7 @@ describe "fs", -> it "calls fn for every path in the tree at the given path", -> paths = [] onPath = (path) -> - paths.push(fs.join(fixturesDir, path)) + paths.push(path) true fs.traverseTree fixturesDir, onPath, onPath expect(paths).toEqual fs.listTree(fixturesDir) @@ -106,14 +106,16 @@ describe "fs", -> expect(path).not.toMatch /\/dir\// it "returns entries if path is a symlink", -> + symlinkPath = fs.join(fixturesDir, 'symlink-to-dir') symlinkPaths = [] - onSymlinkPath = (path) -> symlinkPaths.push(path) + onSymlinkPath = (path) -> symlinkPaths.push(path.substring(symlinkPath.length + 1)) + regularPath = fs.join(fixturesDir, 'dir') paths = [] - onPath = (path) -> paths.push(path) + onPath = (path) -> paths.push(path.substring(regularPath.length + 1)) - fs.traverseTree(fs.join(fixturesDir, 'symlink-to-dir'), onSymlinkPath, onSymlinkPath) - fs.traverseTree(fs.join(fixturesDir, 'dir'), onPath, onPath) + fs.traverseTree(symlinkPath, onSymlinkPath, onSymlinkPath) + fs.traverseTree(regularPath, onPath, onPath) expect(symlinkPaths).toEqual(paths) diff --git a/spec/stdlib/jquery-extensions-spec.coffee b/spec/stdlib/jquery-extensions-spec.coffee index 6b781370b..b803de4f1 100644 --- a/spec/stdlib/jquery-extensions-spec.coffee +++ b/spec/stdlib/jquery-extensions-spec.coffee @@ -1,4 +1,5 @@ $ = require 'jquery' +_ = require 'underscore' {View, $$} = require 'space-pen' describe 'jQuery extensions', -> @@ -41,7 +42,7 @@ describe 'jQuery extensions', -> element.trigger 'foo' expect(events).toEqual [2,1,3] - describe "$.fn.events() and $.fn.document", -> + describe "$.fn.events() and $.fn.document(...)", -> it "returns a list of all events being listened for on the target node or its ancestors, along with their documentation string", -> view = $$ -> @div id: 'a', => @@ -49,20 +50,18 @@ describe 'jQuery extensions', -> @div id: 'c' @div id: 'd' - view.document - 'a1': "This is event A2" - 'b2': "This is event b2" + view.document 'a1', "This is event A2" + view.document 'b2', "This is event b2" - view.document 'a1': "A1: Waste perfectly-good steak" + view.document 'a1', "A1: Waste perfectly-good steak" view.on 'a1', -> view.on 'a2', -> view.on 'b1', -> # should not appear as a duplicate divB = view.find('#b') - divB.document - 'b1': "B1: Super-sonic bomber" - 'b2': "B2: Looks evil. Kinda is." + divB.document 'b1', "B1: Super-sonic bomber" + divB.document 'b2', "B2: Looks evil. Kinda is." divB.on 'b1', -> divB.on 'b2', -> @@ -76,6 +75,80 @@ describe 'jQuery extensions', -> 'a1': "A1: Waste perfectly-good steak" 'a2': null + describe "$.fn.command(eventName, [selector, options,] handler)", -> + [view, handler] = [] + + beforeEach -> + view = $$ -> + @div class: 'a', => + @div class: 'b' + @div class: 'c' + handler = jasmine.createSpy("commandHandler") + + it "binds the handler to the given event / selector for all argument combinations", -> + view.command 'test:foo', handler + view.trigger 'test:foo' + expect(handler).toHaveBeenCalled() + handler.reset() + + view.command 'test:bar', '.b', handler + view.find('.b').trigger 'test:bar' + view.find('.c').trigger 'test:bar' + expect(handler.callCount).toBe 1 + handler.reset() + + view.command 'test:baz', doc: 'Spaz', handler + view.trigger 'test:baz' + expect(handler).toHaveBeenCalled() + handler.reset() + + view.command 'test:quux', '.c', doc: 'Lorem', handler + view.find('.b').trigger 'test:quux' + view.find('.c').trigger 'test:quux' + expect(handler.callCount).toBe 1 + + it "passes the 'data' option through when binding the event handler", -> + view.command 'test:foo', data: "bar", handler + view.trigger 'test:foo' + expect(handler.argsForCall[0][0].data).toBe 'bar' + + it "sets a custom docstring if the 'doc' option is specified", -> + view.command 'test:foo', doc: "Foo!", handler + expect(view.events()).toEqual 'test:foo': 'Test: Foo!' + + it "capitalizes the 'github' prefix how we like it", -> + view.command 'github:spelling', handler + expect(view.events()).toEqual 'github:spelling': 'GitHub: Spelling' + + describe "$.fn.scrollUp/Down/ToTop/ToBottom", -> + it "scrolls the element in the specified way if possible", -> + view = $$ -> @div => _.times 20, => @div('A') + view.css(height: 100, width: 100, overflow: 'scroll') + view.attachToDom() + + view.scrollUp() + expect(view.scrollTop()).toBe 0 + + view.scrollDown() + expect(view.scrollTop()).toBeGreaterThan 0 + previousScrollTop = view.scrollTop() + view.scrollDown() + expect(view.scrollTop()).toBeGreaterThan previousScrollTop + + view.scrollToBottom() + expect(view.scrollTop()).toBe view.prop('scrollHeight') - 100 + previousScrollTop = view.scrollTop() + view.scrollDown() + expect(view.scrollTop()).toBe previousScrollTop + view.scrollUp() + expect(view.scrollTop()).toBeLessThan previousScrollTop + previousScrollTop = view.scrollTop() + view.scrollUp() + expect(view.scrollTop()).toBeLessThan previousScrollTop + + view.scrollToTop() + expect(view.scrollTop()).toBe 0 + describe "Event.prototype", -> class GrandchildView extends View @content: -> @div class: 'grandchild' diff --git a/src/app/atom-package.coffee b/src/app/atom-package.coffee index 4c3195b0e..8a0e312c9 100644 --- a/src/app/atom-package.coffee +++ b/src/app/atom-package.coffee @@ -6,67 +6,22 @@ $ = require 'jquery' module.exports = class AtomPackage extends Package metadata: null - packageMain: null + mainModule: null + deferActivation: false - load: ({activateImmediately}={}) -> + load: -> try @loadMetadata() @loadKeymaps() @loadStylesheets() - if @metadata.activationEvents and not activateImmediately - @subscribeToActivationEvents() + if @deferActivation = @metadata.activationEvents? + @registerDeferredDeserializers() else - @activatePackageMain() + @requireMainModule() catch e console.warn "Failed to load package named '#{@name}'", e.stack this - disableEventHandlersOnBubblePath: (event) -> - bubblePathEventHandlers = [] - disabledHandler = -> - element = $(event.target) - while element.length - if eventHandlers = element.data('events')?[event.type] - for eventHandler in eventHandlers - eventHandler.disabledHandler = eventHandler.handler - eventHandler.handler = disabledHandler - bubblePathEventHandlers.push(eventHandler) - element = element.parent() - bubblePathEventHandlers - - restoreEventHandlersOnBubblePath: (eventHandlers) -> - for eventHandler in eventHandlers - eventHandler.handler = eventHandler.disabledHandler - delete eventHandler.disabledHandler - - unsubscribeFromActivationEvents: (activateHandler) -> - if _.isArray(@metadata.activationEvents) - rootView.off(event, activateHandler) for event in @metadata.activationEvents - else - rootView.off(event, selector, activateHandler) for event, selector of @metadata.activationEvents - - subscribeToActivationEvents: () -> - activateHandler = (event) => - bubblePathEventHandlers = @disableEventHandlersOnBubblePath(event) - @activatePackageMain() - $(event.target).trigger(event) - @restoreEventHandlersOnBubblePath(bubblePathEventHandlers) - @unsubscribeFromActivationEvents(activateHandler) - - if _.isArray(@metadata.activationEvents) - rootView.command(event, activateHandler) for event in @metadata.activationEvents - else - rootView.command(event, selector, activateHandler) for event, selector of @metadata.activationEvents - - activatePackageMain: -> - mainPath = @path - mainPath = fs.join(mainPath, @metadata.main) if @metadata.main - mainPath = require.resolve(mainPath) - if fs.isFile(mainPath) - @packageMain = require(mainPath) - config.setDefaults(@name, @packageMain.configDefaults) - atom.activateAtomPackage(this) - loadMetadata: -> if metadataPath = fs.resolveExtension(fs.join(@path, 'package'), ['cson', 'json']) @metadata = fs.readObject(metadataPath) @@ -86,3 +41,65 @@ class AtomPackage extends Package stylesheetDirPath = fs.join(@path, 'stylesheets') for stylesheetPath in fs.list(stylesheetDirPath) requireStylesheet(stylesheetPath) + + activate: -> + if @deferActivation + @subscribeToActivationEvents() + else + try + if @requireMainModule() + config.setDefaults(@name, @mainModule.configDefaults) + atom.activateAtomPackage(this) + catch e + console.warn "Failed to activate package named '#{@name}'", e.stack + + requireMainModule: -> + return @mainModule if @mainModule + mainPath = @path + mainPath = fs.join(mainPath, @metadata.main) if @metadata.main + mainPath = require.resolve(mainPath) + @mainModule = require(mainPath) if fs.isFile(mainPath) + + registerDeferredDeserializers: -> + for deserializerName in @metadata.deferredDeserializers ? [] + registerDeferredDeserializer deserializerName, => @requireMainModule() + + subscribeToActivationEvents: () -> + return unless @metadata.activationEvents? + + activateHandler = (event) => + bubblePathEventHandlers = @disableEventHandlersOnBubblePath(event) + @deferActivation = false + @activate() + $(event.target).trigger(event) + @restoreEventHandlersOnBubblePath(bubblePathEventHandlers) + @unsubscribeFromActivationEvents(activateHandler) + + if _.isArray(@metadata.activationEvents) + rootView.command(event, activateHandler) for event in @metadata.activationEvents + else + rootView.command(event, selector, activateHandler) for event, selector of @metadata.activationEvents + + unsubscribeFromActivationEvents: (activateHandler) -> + if _.isArray(@metadata.activationEvents) + rootView.off(event, activateHandler) for event in @metadata.activationEvents + else + rootView.off(event, selector, activateHandler) for event, selector of @metadata.activationEvents + + disableEventHandlersOnBubblePath: (event) -> + bubblePathEventHandlers = [] + disabledHandler = -> + element = $(event.target) + while element.length + if eventHandlers = element.data('events')?[event.type] + for eventHandler in eventHandlers + eventHandler.disabledHandler = eventHandler.handler + eventHandler.handler = disabledHandler + bubblePathEventHandlers.push(eventHandler) + element = element.parent() + bubblePathEventHandlers + + restoreEventHandlersOnBubblePath: (eventHandlers) -> + for eventHandler in eventHandlers + eventHandler.handler = eventHandler.disabledHandler + delete eventHandler.disabledHandler diff --git a/src/app/atom-theme.coffee b/src/app/atom-theme.coffee index 6a3e1e379..04563b3c7 100644 --- a/src/app/atom-theme.coffee +++ b/src/app/atom-theme.coffee @@ -5,10 +5,10 @@ module.exports = class AtomTheme extends Theme loadStylesheet: (stylesheetPath)-> - @stylesheets[stylesheetPath] = fs.read(stylesheetPath) + @stylesheets[stylesheetPath] = window.loadStylesheet(stylesheetPath) load: -> - if fs.extension(@path) is '.css' + if fs.extension(@path) in ['.css', '.less'] @loadStylesheet(@path) else metadataPath = fs.resolveExtension(fs.join(@path, 'package'), ['cson', 'json']) @@ -17,6 +17,6 @@ class AtomTheme extends Theme if stylesheetNames @loadStylesheet(fs.join(@path, name)) for name in stylesheetNames else - @loadStylesheet(stylesheetPath) for stylesheetPath in fs.list(@path, ['.css']) + @loadStylesheet(stylesheetPath) for stylesheetPath in fs.list(@path, ['.css', '.less']) super diff --git a/src/app/atom.coffee b/src/app/atom.coffee index b5149622e..315f8314e 100644 --- a/src/app/atom.coffee +++ b/src/app/atom.coffee @@ -10,21 +10,24 @@ originalSendMessageToBrowserProcess = atom.sendMessageToBrowserProcess _.extend atom, exitWhenDone: window.location.params.exitWhenDone + devMode: window.location.params.devMode loadedThemes: [] pendingBrowserProcessCallbacks: {} loadedPackages: [] activatedAtomPackages: [] atomPackageStates: {} + presentingModal: false + pendingModals: [[]] getPathToOpen: -> @getWindowState('pathToOpen') ? window.location.params.pathToOpen activateAtomPackage: (pack) -> @activatedAtomPackages.push(pack) - pack.packageMain.activate(@atomPackageStates[pack.name] ? {}) + pack.mainModule.activate(@atomPackageStates[pack.name] ? {}) deactivateAtomPackages: -> - pack.packageMain.deactivate?() for pack in @activatedAtomPackages + pack.mainModule.deactivate?() for pack in @activatedAtomPackages @activatedAtomPackages = [] serializeAtomPackages: -> @@ -32,7 +35,7 @@ _.extend atom, for pack in @loadedPackages if pack in @activatedAtomPackages try - packageStates[pack.name] = pack.packageMain.serialize?() + packageStates[pack.name] = pack.mainModule.serialize?() catch e console?.error("Exception serializing '#{pack.name}' package's module\n", e.stack) else @@ -58,6 +61,9 @@ _.extend atom, new LoadTextMatePackagesTask(textMatePackages).start() if textMatePackages.length > 0 + activatePackages: -> + pack.activate() for pack in @loadedPackages + getLoadedPackages: -> _.clone(@loadedPackages) @@ -101,15 +107,50 @@ _.extend atom, @sendMessageToBrowserProcess('newWindow', args) confirm: (message, detailedMessage, buttonLabelsAndCallbacks...) -> - args = [message, detailedMessage] - callbacks = [] - while buttonLabelsAndCallbacks.length - args.push(buttonLabelsAndCallbacks.shift()) - callbacks.push(buttonLabelsAndCallbacks.shift()) - @sendMessageToBrowserProcess('confirm', args, callbacks) + wrapCallback = (callback) => => @dismissModal(callback) + @presentModal => + args = [message, detailedMessage] + callbacks = [] + while buttonLabelsAndCallbacks.length + do => + buttonLabel = buttonLabelsAndCallbacks.shift() + buttonCallback = buttonLabelsAndCallbacks.shift() + args.push(buttonLabel) + callbacks.push(=> @dismissModal(buttonCallback)) + @sendMessageToBrowserProcess('confirm', args, callbacks) showSaveDialog: (callback) -> - @sendMessageToBrowserProcess('showSaveDialog', [], callback) + @presentModal => + @sendMessageToBrowserProcess('showSaveDialog', [], (path) => @dismissModal(callback, path)) + + presentModal: (fn) -> + if @presentingModal + @pushPendingModal(fn) + else + @presentingModal = true + fn() + + dismissModal: (fn, args...) -> + @pendingModals.push([]) # prioritize any modals presented during dismiss callback + fn?(args...) + @presentingModal = false + if fn = @shiftPendingModal() + _.delay (=> @presentModal(fn)), 50 # let view update before next dialog + + pushPendingModal: (fn) -> + # pendingModals is a stack of queues. enqueue to top of stack. + stackSize = @pendingModals.length + @pendingModals[stackSize - 1].push(fn) + + shiftPendingModal: -> + # pop pendingModals stack if its top queue is empty, otherwise shift off the topmost queue + stackSize = @pendingModals.length + currentQueueSize = @pendingModals[stackSize - 1].length + if stackSize > 1 and currentQueueSize == 0 + @pendingModals.pop() + @shiftPendingModal() + else + @pendingModals[stackSize - 1].shift() toggleDevTools: -> @sendMessageToBrowserProcess('toggleDevTools') diff --git a/src/app/buffer-change-operation.coffee b/src/app/buffer-change-operation.coffee index b3ffda35e..cbbf68b0a 100644 --- a/src/app/buffer-change-operation.coffee +++ b/src/app/buffer-change-operation.coffee @@ -73,7 +73,7 @@ class BufferChangeOperation event = { oldRange, newRange, oldText, newText } @updateMarkers(event) @buffer.trigger 'changed', event - @buffer.scheduleStoppedChangingEvent() + @buffer.scheduleModifiedEvents() @resumeMarkerObservation() @buffer.trigger 'markers-updated' diff --git a/src/app/buffer.coffee b/src/app/buffer.coffee index 0cf84f7a7..00d0483e4 100644 --- a/src/app/buffer.coffee +++ b/src/app/buffer.coffee @@ -69,7 +69,7 @@ class Buffer @file.on "removed", => @updateCachedDiskContents() - @trigger "contents-modified", {differsFromDisk: true} + @triggerModifiedStatusChanged(@isModified()) @file.on "moved", => @trigger "path-changed", this @@ -78,6 +78,7 @@ class Buffer @trigger 'will-reload' @updateCachedDiskContents() @setText(@cachedDiskContents) + @triggerModifiedStatusChanged(false) @trigger 'reloaded' updateCachedDiskContents: -> @@ -252,6 +253,7 @@ class Buffer @setPath(path) @cachedDiskContents = @getText() @file.write(@getText()) + @triggerModifiedStatusChanged(false) @trigger 'saved' isModified: -> @@ -419,21 +421,25 @@ class Buffer return match[0][0] != '\t' undefined - getRepo: -> @project?.repo - checkoutHead: -> path = @getPath() return unless path - if @getRepo()?.checkoutHead(path) - @trigger 'git-status-changed' + git?.checkoutHead(path) - scheduleStoppedChangingEvent: -> + scheduleModifiedEvents: -> clearTimeout(@stoppedChangingTimeout) if @stoppedChangingTimeout stoppedChangingCallback = => @stoppedChangingTimeout = null - @trigger 'contents-modified', {differsFromDisk: @isModified()} + modifiedStatus = @isModified() + @trigger 'contents-modified', modifiedStatus + @triggerModifiedStatusChanged(modifiedStatus) @stoppedChangingTimeout = setTimeout(stoppedChangingCallback, @stoppedChangingDelay) + triggerModifiedStatusChanged: (modifiedStatus) -> + return if modifiedStatus is @previousModifiedStatus + @previousModifiedStatus = modifiedStatus + @trigger 'modified-status-changed', modifiedStatus + fileExists: -> @file.exists() diff --git a/src/app/config.coffee b/src/app/config.coffee index 2940b33f4..dc79df9ee 100644 --- a/src/app/config.coffee +++ b/src/app/config.coffee @@ -20,6 +20,7 @@ class Config userPackagesDirPath: userPackagesDirPath defaultSettings: null settings: null + configFileHasErrors: null constructor: -> @defaultSettings = @@ -37,16 +38,16 @@ class Config templateConfigDirPath = fs.resolve(window.resourcePath, 'dot-atom') onConfigDirFile = (path) => - templatePath = fs.join(templateConfigDirPath, path) - configPath = fs.join(@configDirPath, path) - fs.write(configPath, fs.read(templatePath)) + relativePath = path.substring(templateConfigDirPath.length + 1) + configPath = fs.join(@configDirPath, relativePath) + fs.write(configPath, fs.read(path)) fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true) configThemeDirPath = fs.join(@configDirPath, 'themes') onThemeDirFile = (path) -> - templatePath = fs.join(bundledThemesDirPath, path) - configPath = fs.join(configThemeDirPath, path) - fs.write(configPath, fs.read(templatePath)) + relativePath = path.substring(bundledThemesDirPath.length + 1) + configPath = fs.join(configThemeDirPath, relativePath) + fs.write(configPath, fs.read(path)) fs.traverseTree(bundledThemesDirPath, onThemeDirFile, (path) -> true) load: -> @@ -55,8 +56,13 @@ class Config loadUserConfig: -> if fs.exists(@configFilePath) - userConfig = fs.readObject(@configFilePath) - _.extend(@settings, userConfig) + try + userConfig = fs.readObject(@configFilePath) + _.extend(@settings, userConfig) + catch e + @configFileHasErrors = true + console.error "Failed to load user config '#{@configFilePath}'", e.message + console.error e.stack get: (keyPath) -> _.valueForKeyPath(@settings, keyPath) ? @@ -92,6 +98,7 @@ class Config subscription update: -> + return if @configFileHasErrors @save() @trigger 'updated' diff --git a/src/app/edit-session.coffee b/src/app/edit-session.coffee index b160d5468..0104a297f 100644 --- a/src/app/edit-session.coffee +++ b/src/app/edit-session.coffee @@ -14,17 +14,22 @@ module.exports = class EditSession registerDeserializer(this) - @deserialize: (state, project) -> + @deserialize: (state) -> if fs.exists(state.buffer) - session = project.buildEditSessionForPath(state.buffer) + session = project.buildEditSession(state.buffer) else console.warn "Could not build edit session for path '#{state.buffer}' because that file no longer exists" if state.buffer - session = project.buildEditSessionForPath(null) + session = project.buildEditSession(null) session.setScrollTop(state.scrollTop) session.setScrollLeft(state.scrollLeft) session.setCursorScreenPosition(state.cursorScreenPosition) session + @identifiedBy: 'path' + + @deserializesToSameObject: (state, editSession) -> + state.path + scrollTop: 0 scrollLeft: 0 languageMode: null @@ -43,17 +48,40 @@ class EditSession @addCursorAtScreenPosition([0, 0]) @buffer.retain() - @subscribe @buffer, "path-changed", => @trigger "path-changed" + @subscribe @buffer, "path-changed", => + @project.setPath(fs.directory(@getPath())) unless @project.getPath()? + @trigger "title-changed" + @trigger "path-changed" @subscribe @buffer, "contents-conflicted", => @trigger "contents-conflicted" @subscribe @buffer, "markers-updated", => @mergeCursors() + @subscribe @buffer, "modified-status-changed", => @trigger "modified-status-changed" @preserveCursorPositionOnBufferReload() @subscribe @displayBuffer, "changed", (e) => @trigger 'screen-lines-changed', e + @subscribe syntax, 'grammars-loaded', => @reloadGrammar() + + getViewClass: -> + require 'editor' + + getTitle: -> + if path = @getPath() + fs.base(path) + else + 'untitled' + + getLongTitle: -> + if path = @getPath() + fileName = fs.base(path) + directory = fs.base(fs.directory(path)) + "#{fileName} - #{directory}" + else + 'untitled' + destroy: -> - throw new Error("Edit session already destroyed") if @destroyed + return if @destroyed @destroyed = true @unsubscribe() @buffer.release() @@ -130,6 +158,7 @@ class EditSession saveAs: (path) -> @buffer.saveAs(path) getFileExtension: -> @buffer.getExtension() getPath: -> @buffer.getPath() + getUri: -> @getPath() isBufferRowBlank: (bufferRow) -> @buffer.isRowBlank(bufferRow) nextNonBlankBufferRow: (bufferRow) -> @buffer.nextNonBlankRow(bufferRow) getEofBufferPosition: -> @buffer.getEofPosition() @@ -814,11 +843,10 @@ class EditSession getGrammar: -> @languageMode.grammar reloadGrammar: -> - grammarChanged = @languageMode.reloadGrammar() - if grammarChanged + if @languageMode.reloadGrammar() @unfoldAll() @displayBuffer.tokenizedBuffer.resetScreenLines() - grammarChanged + true getDebugSnapshot: -> [ diff --git a/src/app/editor.coffee b/src/app/editor.coffee index 5b5bc6502..a8380d7af 100644 --- a/src/app/editor.coffee +++ b/src/app/editor.coffee @@ -16,7 +16,6 @@ class Editor extends View fontSize: 20 showInvisibles: false showIndentGuide: false - autosave: false autoIndent: true autoIndentOnPaste: false nonWordCharacters: "./\\()\"':,.;<>~!@#$%^&*|+=[]{}`~?-" @@ -49,8 +48,6 @@ class Editor extends View lineCache: null isFocused: false activeEditSession: null - closedEditSessions: null - editSessions: null attached: false lineOverdraw: 10 pendingChanges: null @@ -58,16 +55,13 @@ class Editor extends View newSelections: null redrawOnReattach: false - @deserialize: (state) -> - editor = new Editor(mini: state.mini, deserializing: true) - editSessions = state.editSessions.map (state) -> EditSession.deserialize(state, project) - editor.pushEditSession(editSession) for editSession in editSessions - editor.setActiveEditSessionIndex(state.activeEditSessionIndex) - editor.isFocused = state.isFocused - editor + initialize: (editSessionOrOptions) -> + if editSessionOrOptions instanceof EditSession + editSession = editSessionOrOptions + else + {editSession, @mini} = (editSessionOrOptions ? {}) - initialize: ({editSession, @mini, deserializing} = {}) -> - requireStylesheet 'editor.css' + requireStylesheet 'editor.less' @id = Editor.nextEditorId++ @lineCache = [] @@ -76,8 +70,6 @@ class Editor extends View @handleEvents() @cursorViews = [] @selectionViews = [] - @editSessions = [] - @closedEditSessions = [] @pendingChanges = [] @newCursors = [] @newSelections = [] @@ -91,18 +83,8 @@ class Editor extends View tabLength: 2 softTabs: true ) - else if not deserializing - throw new Error("Editor must be constructed with an 'editSession' or 'mini: true' param") - - serialize: -> - @saveScrollPositionForActiveEditSession() - deserializer: "Editor" - editSessions: @editSessions.map (session) -> session.serialize() - activeEditSessionIndex: @getActiveEditSessionIndex() - isFocused: @isFocused - - copy: -> - Editor.deserialize(@serialize(), rootView) + else + throw new Error("Must supply an EditSession or mini: true") bindKeys: -> editorBindings = @@ -155,9 +137,6 @@ class Editor extends View 'core:select-down': @selectDown 'core:select-to-top': @selectToTop 'core:select-to-bottom': @selectToBottom - 'core:close': @destroyActiveEditSession - 'editor:save': @save - 'editor:save-as': @saveAs 'editor:newline-below': @insertNewlineBelow 'editor:newline-above': @insertNewlineAbove 'editor:toggle-soft-tabs': @toggleSoftTabs @@ -167,32 +146,14 @@ class Editor extends View 'editor:fold-current-row': @foldCurrentRow 'editor:unfold-current-row': @unfoldCurrentRow 'editor:fold-selection': @foldSelection - 'editor:split-left': @splitLeft - 'editor:split-right': @splitRight - 'editor:split-up': @splitUp - 'editor:split-down': @splitDown - 'editor:show-next-buffer': @loadNextEditSession - 'editor:show-buffer-1': => @setActiveEditSessionIndex(0) if @editSessions[0] - 'editor:show-buffer-2': => @setActiveEditSessionIndex(1) if @editSessions[1] - 'editor:show-buffer-3': => @setActiveEditSessionIndex(2) if @editSessions[2] - 'editor:show-buffer-4': => @setActiveEditSessionIndex(3) if @editSessions[3] - 'editor:show-buffer-5': => @setActiveEditSessionIndex(4) if @editSessions[4] - 'editor:show-buffer-6': => @setActiveEditSessionIndex(5) if @editSessions[5] - 'editor:show-buffer-7': => @setActiveEditSessionIndex(6) if @editSessions[6] - 'editor:show-buffer-8': => @setActiveEditSessionIndex(7) if @editSessions[7] - 'editor:show-buffer-9': => @setActiveEditSessionIndex(8) if @editSessions[8] - 'editor:show-previous-buffer': @loadPreviousEditSession 'editor:toggle-line-comments': @toggleLineCommentsInSelection 'editor:log-cursor-scope': @logCursorScope 'editor:checkout-head-revision': @checkoutHead - 'editor:close-other-edit-sessions': @destroyInactiveEditSessions - 'editor:close-all-edit-sessions': @destroyAllEditSessions 'editor:select-grammar': @selectGrammar 'editor:copy-path': @copyPathToPasteboard 'editor:move-line-up': @moveLineUp 'editor:move-line-down': @moveLineDown 'editor:duplicate-line': @duplicateLine - 'editor:undo-close-session': @undoDestroySession 'editor:toggle-indent-guide': => config.set('editor.showIndentGuide', !config.get('editor.showIndentGuide')) 'editor:save-debug-snapshot': @saveDebugSnapshot @@ -343,7 +304,7 @@ class Editor extends View checkoutHead: -> @getBuffer().checkoutHead() setText: (text) -> @getBuffer().setText(text) getText: -> @getBuffer().getText() - getPath: -> @getBuffer().getPath() + getPath: -> @activeEditSession?.getPath() getLineCount: -> @getBuffer().getLineCount() getLastBufferRow: -> @getBuffer().getLastRow() getTextInRange: (range) -> @getBuffer().getTextInRange(range) @@ -367,13 +328,11 @@ class Editor extends View false @hiddenInput.on 'focus', => - rootView?.editorFocused(this) @isFocused = true @addClass 'is-focused' @hiddenInput.on 'focusout', => @isFocused = false - @autosave() if config.get "editor.autosave" @removeClass 'is-focused' @underlayer.on 'click', (e) => @@ -436,10 +395,7 @@ class Editor extends View e.pageX = @renderedLines.offset().left onMouseDown(e) - @subscribe syntax, 'grammars-loaded', => - @reloadGrammar() - for session in @editSessions - session.reloadGrammar() unless session is @activeEditSession + @subscribe syntax, 'grammars-loaded', => @reloadGrammar() @scrollView.on 'scroll', => if @scrollView.scrollLeft() == 0 @@ -481,86 +437,16 @@ class Editor extends View @trigger 'editor:attached', [this] edit: (editSession) -> - index = @editSessions.indexOf(editSession) - index = @pushEditSession(editSession) if index == -1 - @setActiveEditSessionIndex(index) - - pushEditSession: (editSession) -> - index = @editSessions.length - @editSessions.push(editSession) - @closedEditSessions = @closedEditSessions.filter ({path})-> - path isnt editSession.getPath() - editSession.on 'destroyed', => @editSessionDestroyed(editSession) - @trigger 'editor:edit-session-added', [editSession, index] - index - - getBuffer: -> @activeEditSession.buffer - - undoDestroySession: -> - return unless @closedEditSessions.length > 0 - - {path, index} = @closedEditSessions.pop() - rootView.open(path) - activeIndex = @getActiveEditSessionIndex() - @moveEditSessionToIndex(activeIndex, index) if index < activeIndex - - destroyActiveEditSession: -> - @destroyEditSessionIndex(@getActiveEditSessionIndex()) - - destroyEditSessionIndex: (index, callback) -> - return if @mini - - editSession = @editSessions[index] - destroySession = => - path = editSession.getPath() - @closedEditSessions.push({path, index}) if path - editSession.destroy() - callback?(index) - - if editSession.isModified() and not editSession.hasEditors() - @promptToSaveDirtySession(editSession, destroySession) - else - destroySession() - - destroyInactiveEditSessions: -> - destroyIndex = (index) => - index++ if index is @getActiveEditSessionIndex() - @destroyEditSessionIndex(index, destroyIndex) if @editSessions[index] - destroyIndex(0) - - destroyAllEditSessions: -> - destroyIndex = (index) => - @destroyEditSessionIndex(index, destroyIndex) if @editSessions[index] - destroyIndex(0) - - editSessionDestroyed: (editSession) -> - index = @editSessions.indexOf(editSession) - @loadPreviousEditSession() if index is @getActiveEditSessionIndex() and @editSessions.length > 1 - _.remove(@editSessions, editSession) - @trigger 'editor:edit-session-removed', [editSession, index] - @remove() if @editSessions.length is 0 - - loadNextEditSession: -> - nextIndex = (@getActiveEditSessionIndex() + 1) % @editSessions.length - @setActiveEditSessionIndex(nextIndex) - - loadPreviousEditSession: -> - previousIndex = @getActiveEditSessionIndex() - 1 - previousIndex = @editSessions.length - 1 if previousIndex < 0 - @setActiveEditSessionIndex(previousIndex) - - getActiveEditSessionIndex: -> - return index for session, index in @editSessions when session == @activeEditSession - - setActiveEditSessionIndex: (index) -> - throw new Error("Edit session not found") unless @editSessions[index] + return if editSession is @activeEditSession if @activeEditSession - @autosave() if config.get "editor.autosave" @saveScrollPositionForActiveEditSession() @activeEditSession.off(".editor") - @activeEditSession = @editSessions[index] + @activeEditSession = editSession + + return unless @activeEditSession? + @activeEditSession.setVisible(true) @activeEditSession.on "contents-conflicted.editor", => @@ -571,11 +457,18 @@ class Editor extends View @trigger 'editor:path-changed' @trigger 'editor:path-changed' - @trigger 'editor:active-edit-session-changed', [@activeEditSession, index] @resetDisplay() if @attached and @activeEditSession.buffer.isInConflict() - setTimeout(( =>@showBufferConflictAlert(@activeEditSession)), 0) # Display after editSession has a chance to display + _.defer => @showBufferConflictAlert(@activeEditSession) # Display after editSession has a chance to display + + getModel: -> + @activeEditSession + + setModel: (editSession) -> + @edit(editSession) + + getBuffer: -> @activeEditSession.buffer showBufferConflictAlert: (editSession) -> atom.confirm( @@ -585,30 +478,6 @@ class Editor extends View "Cancel" ) - moveEditSessionToIndex: (fromIndex, toIndex) -> - return if fromIndex is toIndex - editSession = @editSessions.splice(fromIndex, 1) - @editSessions.splice(toIndex, 0, editSession[0]) - @trigger 'editor:edit-session-order-changed', [editSession, fromIndex, toIndex] - @setActiveEditSessionIndex(toIndex) - - moveEditSessionToEditor: (fromIndex, toEditor, toIndex) -> - fromEditSession = @editSessions[fromIndex] - toEditSession = fromEditSession.copy() - @destroyEditSessionIndex(fromIndex) - toEditor.edit(toEditSession) - toEditor.moveEditSessionToIndex(toEditor.getActiveEditSessionIndex(), toIndex) - - activateEditSessionForPath: (path) -> - for editSession, index in @editSessions - if editSession.buffer.getPath() == path - @setActiveEditSessionIndex(index) - return @activeEditSession - false - - getOpenBufferPaths: -> - editSession.buffer.getPath() for editSession in @editSessions when editSession.buffer.getPath()? - scrollTop: (scrollTop, options={}) -> return @cachedScrollTop or 0 unless scrollTop? maxScrollTop = @verticalScrollbar.prop('scrollHeight') - @verticalScrollbar.height() @@ -723,22 +592,6 @@ class Editor extends View @removeClass 'soft-wrap' $(window).off 'resize', @_setSoftWrapColumn - save: (session=@activeEditSession, onSuccess) -> - if @getPath() - session.save() - onSuccess?() - else - @saveAs(session, onSuccess) - - saveAs: (session=@activeEditSession, onSuccess) -> - atom.showSaveDialog (path) => - if path - session.saveAs(path) - onSuccess?() - - autosave: -> - @save() if @getPath()? - setFontSize: (fontSize) -> headTag = $("head") styleTag = headTag.find("style.font-size") @@ -781,54 +634,33 @@ class Editor extends View @updateLayerDimensions() @requestDisplayUpdate() - newSplitEditor: (editSession) -> - new Editor { editSession: editSession ? @activeEditSession.copy() } + splitLeft: (items...) -> + @pane()?.splitLeft(items...).activeView - splitLeft: (editSession) -> - @pane()?.splitLeft(@newSplitEditor(editSession)).wrappedView + splitRight: (items...) -> + @pane()?.splitRight(items...).activeView - splitRight: (editSession) -> - @pane()?.splitRight(@newSplitEditor(editSession)).wrappedView + splitUp: (items...) -> + @pane()?.splitUp(items...).activeView - splitUp: (editSession) -> - @pane()?.splitUp(@newSplitEditor(editSession)).wrappedView - - splitDown: (editSession) -> - @pane()?.splitDown(@newSplitEditor(editSession)).wrappedView + splitDown: (items...) -> + @pane()?.splitDown(items...).activeView pane: -> - @parent('.pane').view() - - promptToSaveDirtySession: (session, callback) -> - path = session.getPath() - filename = if path then fs.base(path) else "untitled buffer" - atom.confirm( - "'#{filename}' has changes, do you want to save them?" - "Your changes will be lost if you don't save them" - "Save", => @save(session, callback), - "Cancel", null - "Don't Save", callback - ) + @closest('.pane').view() remove: (selector, keepData) -> return super if keepData or @removed @trigger 'editor:will-be-removed' - if @pane() then @pane().remove() else super + super rootView?.focus() afterRemove: -> @removed = true - @destroyEditSessions() + @activeEditSession?.destroy() $(window).off(".editor-#{@id}") $(document).off(".editor-#{@id}") - getEditSessions: -> - new Array(@editSessions...) - - destroyEditSessions: -> - for session in @getEditSessions() - session.destroy() - getCursorView: (index) -> index ?= @cursorViews.length - 1 @cursorViews[index] @@ -933,7 +765,8 @@ class Editor extends View @pendingDisplayUpdate = false updateDisplay: (options={}) -> - return unless @attached + return unless @attached and @activeEditSession + return if @activeEditSession.destroyed @updateRenderedLines() @highlightCursorLine() @updateCursorViews() diff --git a/src/app/event-emitter.coffee b/src/app/event-emitter.coffee index 8481ca607..3abcb74ac 100644 --- a/src/app/event-emitter.coffee +++ b/src/app/event-emitter.coffee @@ -83,4 +83,3 @@ module.exports = for name, handlers of @eventHandlersByEventName count += handlers.length count - diff --git a/src/app/git.coffee b/src/app/git.coffee index 866e2977f..b9f469b1c 100644 --- a/src/app/git.coffee +++ b/src/app/git.coffee @@ -1,11 +1,14 @@ _ = require 'underscore' +fs = require 'fs' Subscriber = require 'subscriber' +EventEmitter = require 'event-emitter' GitRepository = require 'git-repository' +RepositoryStatusTask = require 'repository-status-task' module.exports = class Git - @open: (path, options) -> + return null unless path try new Git(path, options) catch e @@ -23,12 +26,27 @@ class Git working_dir_typechange: 1 << 10 ignore: 1 << 14 + statuses: null + upstream: null + statusTask: null + constructor: (path, options={}) -> + @statuses = {} + @upstream = {ahead: 0, behind: 0} @repo = GitRepository.open(path) - refreshIndexOnFocus = options.refreshIndexOnFocus ? true - if refreshIndexOnFocus + refreshOnWindowFocus = options.refreshOnWindowFocus ? true + if refreshOnWindowFocus $ = require 'jquery' - @subscribe $(window), 'focus', => @refreshIndex() + @subscribe $(window), 'focus', => + @refreshIndex() + @refreshStatus() + + project?.eachBuffer this, (buffer) => + bufferStatusHandler = => + path = buffer.getPath() + @getPathStatus(path) if path + @subscribe buffer, 'saved', bufferStatusHandler + @subscribe buffer, 'reloaded', bufferStatusHandler getRepo: -> unless @repo? @@ -37,27 +55,40 @@ class Git refreshIndex: -> @getRepo().refreshIndex() - getPath: -> @getRepo().getPath() + getPath: -> + @path ?= fs.absolute(@getRepo().getPath()) destroy: -> + if @statusTask? + @statusTask.abort() + @statusTask.off() + @statusTask = null + @getRepo().destroy() @repo = null @unsubscribe() getWorkingDirectory: -> - repoPath = @getPath() - repoPath?.substring(0, repoPath.length - 6) + @getPath()?.replace(/\/\.git\/?$/, '') getHead: -> @getRepo().getHead() ? '' getPathStatus: (path) -> - pathStatus = @getRepo().getStatus(@relativize(path)) + currentPathStatus = @statuses[path] ? 0 + pathStatus = @getRepo().getStatus(@relativize(path)) ? 0 + if pathStatus > 0 + @statuses[path] = pathStatus + else + delete @statuses[path] + if currentPathStatus isnt pathStatus + @trigger 'status-changed', path, pathStatus + pathStatus isPathIgnored: (path) -> @getRepo().isIgnored(@relativize(path)) - isStatusModified: (status) -> + isStatusModified: (status=0) -> modifiedFlags = @statusFlags.working_dir_modified | @statusFlags.working_dir_delete | @statusFlags.working_dir_typechange | @@ -69,7 +100,7 @@ class Git isPathModified: (path) -> @isStatusModified(@getPathStatus(path)) - isStatusNew: (status) -> + isStatusNew: (status=0) -> newFlags = @statusFlags.working_dir_new | @statusFlags.index_new (status & newFlags) > 0 @@ -93,7 +124,9 @@ class Git return head checkoutHead: (path) -> - @getRepo().checkoutHead(@relativize(path)) + headCheckedOut = @getRepo().checkoutHead(@relativize(path)) + @getPathStatus(path) if headCheckedOut + headCheckedOut getDiffStats: (path) -> @getRepo().getDiffStats(@relativize(path)) ? added: 0, deleted: 0 @@ -101,4 +134,30 @@ class Git isSubmodule: (path) -> @getRepo().isSubmodule(@relativize(path)) + refreshStatus: -> + if @statusTask? + @statusTask.off() + @statusTask.one 'task-completed', => + @statusTask = null + @refreshStatus() + else + @statusTask = new RepositoryStatusTask(this) + @statusTask.one 'task-completed', => + @statusTask = null + @statusTask.start() + + getDirectoryStatus: (directoryPath) -> + directoryPath = "#{directoryPath}/" + directoryStatus = 0 + for path, status of @statuses + directoryStatus |= status if path.indexOf(directoryPath) is 0 + directoryStatus + + getAheadBehindCounts: -> + @getRepo().getAheadBehindCounts() ? ahead: 0, behind: 0 + + getLineDiffs: (path, text) -> + @getRepo().getLineDiffs(@relativize(path), text) ? [] + _.extend Git.prototype, Subscriber +_.extend Git.prototype, EventEmitter diff --git a/src/app/gutter.coffee b/src/app/gutter.coffee index b541595b2..19b46ca0c 100644 --- a/src/app/gutter.coffee +++ b/src/app/gutter.coffee @@ -1,9 +1,5 @@ {View, $$, $$$} = require 'space-pen' - -$ = require 'jquery' -_ = require 'underscore' Range = require 'range' -Point = require 'point' module.exports = class Gutter extends View diff --git a/src/app/keymaps/atom.cson b/src/app/keymaps/atom.cson index 680f25c17..04cc02488 100644 --- a/src/app/keymaps/atom.cson +++ b/src/app/keymaps/atom.cson @@ -1,4 +1,6 @@ 'body': + 'meta-s': 'core:save' + 'meta-S': 'core:save-as' 'enter': 'core:confirm' 'escape': 'core:cancel' 'meta-w': 'core:close' @@ -30,6 +32,26 @@ 'ctrl-tab': 'window:focus-next-pane' 'ctrl-meta-f': 'window:toggle-full-screen' + 'ctrl-|': 'pane:split-right' + 'ctrl-w v': 'pane:split-right' + 'ctrl--': 'pane:split-down' + 'ctrl-w s': 'pane:split-down' + + 'meta-{': 'pane:show-previous-item' + 'meta-}': 'pane:show-next-item' + 'alt-meta-left': 'pane:show-previous-item' + 'alt-meta-right': 'pane:show-next-item' + 'meta-1': 'pane:show-item-1' + 'meta-2': 'pane:show-item-2' + 'meta-3': 'pane:show-item-3' + 'meta-4': 'pane:show-item-4' + 'meta-5': 'pane:show-item-5' + 'meta-6': 'pane:show-item-6' + 'meta-7': 'pane:show-item-7' + 'meta-8': 'pane:show-item-8' + 'meta-9': 'pane:show-item-9' + 'meta-T': 'pane:reopen-closed-item' + '.tool-panel': 'meta-escape': 'tool-panel:unfocus' 'escape': 'core:close' diff --git a/src/app/keymaps/editor.cson b/src/app/keymaps/editor.cson index 467119136..8b192fece 100644 --- a/src/app/keymaps/editor.cson +++ b/src/app/keymaps/editor.cson @@ -1,9 +1,4 @@ -'body': - 'meta-T': 'editor:undo-close-session' - '.editor': - 'meta-s': 'editor:save' - 'meta-S': 'editor:save-as' 'enter': 'editor:newline' 'meta-enter': 'editor:newline-below' 'meta-shift-enter': 'editor:newline-above' @@ -15,26 +10,9 @@ 'ctrl-{': 'editor:fold-all' 'ctrl-}': 'editor:unfold-all' 'alt-meta-ctrl-f': 'editor:fold-selection' - 'ctrl-|': 'editor:split-right' - 'ctrl-w v': 'editor:split-right' - 'ctrl--': 'editor:split-down' - 'ctrl-w s': 'editor:split-down' 'shift-tab': 'editor:outdent-selected-rows' 'meta-[': 'editor:outdent-selected-rows' 'meta-]': 'editor:indent-selected-rows' - 'meta-{': 'editor:show-previous-buffer' - 'meta-}': 'editor:show-next-buffer' - 'alt-meta-left': 'editor:show-previous-buffer' - 'alt-meta-right': 'editor:show-next-buffer' - 'meta-1': 'editor:show-buffer-1' - 'meta-2': 'editor:show-buffer-2' - 'meta-3': 'editor:show-buffer-3' - 'meta-4': 'editor:show-buffer-4' - 'meta-5': 'editor:show-buffer-5' - 'meta-6': 'editor:show-buffer-6' - 'meta-7': 'editor:show-buffer-7' - 'meta-8': 'editor:show-buffer-8' - 'meta-9': 'editor:show-buffer-9' 'meta-/': 'editor:toggle-line-comments' 'ctrl-W': 'editor:select-word' 'meta-alt-p': 'editor:log-cursor-scope' diff --git a/src/app/pane-grid.coffee b/src/app/pane-axis.coffee similarity index 95% rename from src/app/pane-grid.coffee rename to src/app/pane-axis.coffee index 3d54fd64d..9aa4fda81 100644 --- a/src/app/pane-grid.coffee +++ b/src/app/pane-axis.coffee @@ -2,7 +2,7 @@ $ = require 'jquery' {View} = require 'space-pen' module.exports = -class PaneGrid extends View +class PaneAxis extends View @deserialize: ({children}) -> childViews = children.map (child) -> deserialize(child) new this(childViews) diff --git a/src/app/pane-column.coffee b/src/app/pane-column.coffee index f00c7ed23..43ba40cbb 100644 --- a/src/app/pane-column.coffee +++ b/src/app/pane-column.coffee @@ -1,9 +1,9 @@ $ = require 'jquery' _ = require 'underscore' -PaneGrid = require 'pane-grid' +PaneAxis = require 'pane-axis' module.exports = -class PaneColumn extends PaneGrid +class PaneColumn extends PaneAxis @content: -> @div class: 'column' diff --git a/src/app/pane-container.coffee b/src/app/pane-container.coffee new file mode 100644 index 000000000..2118af2b0 --- /dev/null +++ b/src/app/pane-container.coffee @@ -0,0 +1,119 @@ +{View} = require 'space-pen' +Pane = require 'pane' +$ = require 'jquery' + +module.exports = +class PaneContainer extends View + registerDeserializer(this) + + @deserialize: ({root}) -> + container = new PaneContainer + container.append(deserialize(root)) if root + container.removeEmptyPanes() + container + + @content: -> + @div id: 'panes' + + initialize: -> + @destroyedItemStates = [] + + serialize: -> + deserializer: 'PaneContainer' + root: @getRoot()?.serialize() + + focusNextPane: -> + panes = @getPanes() + if panes.length > 1 + currentIndex = panes.indexOf(@getFocusedPane()) + nextIndex = (currentIndex + 1) % panes.length + panes[nextIndex].focus() + true + else + false + + makeNextPaneActive: -> + panes = @getPanes() + currentIndex = panes.indexOf(@getActivePane()) + nextIndex = (currentIndex + 1) % panes.length + panes[nextIndex].makeActive() + + reopenItem: -> + if lastItemState = @destroyedItemStates.pop() + if activePane = @getActivePane() + activePane.showItem(deserialize(lastItemState)) + true + else + @append(new Pane(deserialize(lastItemState))) + + itemDestroyed: (item) -> + state = item.serialize?() + state.uri ?= item.getUri?() + @destroyedItemStates.push(state) if state? + + itemAdded: (item) -> + itemUri = item.getUri?() + @destroyedItemStates = @destroyedItemStates.filter (itemState) -> + itemState.uri isnt itemUri + + getRoot: -> + @children().first().view() + + saveAll: -> + pane.saveItems() for pane in @getPanes() + + confirmClose: -> + deferred = $.Deferred() + modifiedItems = [] + for pane in @getPanes() + modifiedItems.push(item) for item in pane.getItems() when item.isModified?() + + cancel = => deferred.reject() + saveNextModifiedItem = => + if modifiedItems.length == 0 + deferred.resolve() + else + item = modifiedItems.pop() + @paneAtIndex(0).promptToSaveItem item, saveNextModifiedItem, cancel + + saveNextModifiedItem() + deferred.promise() + + getPanes: -> + @find('.pane').views() + + indexOfPane: (pane) -> + @getPanes().indexOf(pane.view()) + + paneAtIndex: (index) -> + @getPanes()[index] + + eachPane: (callback) -> + callback(pane) for pane in @getPanes() + paneAttached = (e) -> callback($(e.target).view()) + @on 'pane:attached', paneAttached + cancel: => @off 'pane:attached', paneAttached + + getFocusedPane: -> + @find('.pane:has(:focus)').view() + + getActivePane: -> + @find('.pane.active').view() ? @find('.pane:first').view() + + getActivePaneItem: -> + @getActivePane()?.activeItem + + getActiveView: -> + @getActivePane()?.activeView + + adjustPaneDimensions: -> + if root = @getRoot() + root.css(width: '100%', height: '100%', top: 0, left: 0) + root.adjustDimensions() + + removeEmptyPanes: -> + for pane in @getPanes() when pane.getItems().length == 0 + pane.remove() + + afterAttach: -> + @adjustPaneDimensions() diff --git a/src/app/pane-row.coffee b/src/app/pane-row.coffee index c729e0b9a..ce7a09f82 100644 --- a/src/app/pane-row.coffee +++ b/src/app/pane-row.coffee @@ -1,9 +1,9 @@ $ = require 'jquery' _ = require 'underscore' -PaneGrid = require 'pane-grid' +PaneAxis = require 'pane-axis' module.exports = -class PaneRow extends PaneGrid +class PaneRow extends PaneAxis @content: -> @div class: 'row' diff --git a/src/app/pane.coffee b/src/app/pane.coffee index 5b0e630f6..3bd9c9f4c 100644 --- a/src/app/pane.coffee +++ b/src/app/pane.coffee @@ -1,4 +1,6 @@ {View} = require 'space-pen' +$ = require 'jquery' +_ = require 'underscore' PaneRow = require 'pane-row' PaneColumn = require 'pane-column' @@ -6,58 +8,321 @@ module.exports = class Pane extends View @content: (wrappedView) -> @div class: 'pane', => - @subview 'wrappedView', wrappedView if wrappedView + @div class: 'item-views', outlet: 'itemViews' - @deserialize: ({wrappedView}) -> - new Pane(deserialize(wrappedView)) + @deserialize: ({items, focused, activeItemUri}) -> + deserializedItems = _.compact(items.map((item) -> deserialize(item))) + pane = new Pane(deserializedItems...) + pane.showItemForUri(activeItemUri) if activeItemUri + pane.focusOnAttach = true if focused + pane + + activeItem: null + items: null + + initialize: (@items...) -> + @viewsByClassName = {} + @showItem(@items[0]) if @items.length > 0 + + @command 'core:close', @destroyActiveItem + @command 'core:save', @saveActiveItem + @command 'core:save-as', @saveActiveItemAs + @command 'pane:save-items', @saveItems + @command 'pane:show-next-item', @showNextItem + @command 'pane:show-previous-item', @showPreviousItem + + @command 'pane:show-item-1', => @showItemAtIndex(0) + @command 'pane:show-item-2', => @showItemAtIndex(1) + @command 'pane:show-item-3', => @showItemAtIndex(2) + @command 'pane:show-item-4', => @showItemAtIndex(3) + @command 'pane:show-item-5', => @showItemAtIndex(4) + @command 'pane:show-item-6', => @showItemAtIndex(5) + @command 'pane:show-item-7', => @showItemAtIndex(6) + @command 'pane:show-item-8', => @showItemAtIndex(7) + @command 'pane:show-item-9', => @showItemAtIndex(8) + + @command 'pane:split-left', => @splitLeft() + @command 'pane:split-right', => @splitRight() + @command 'pane:split-up', => @splitUp() + @command 'pane:split-down', => @splitDown() + @command 'pane:close', => @destroyItems() + @command 'pane:close-other-items', => @destroyInactiveItems() + @on 'focus', => @activeView?.focus(); false + @on 'focusin', => @makeActive() + @on 'focusout', => @autosaveActiveItem() + + afterAttach: (onDom) -> + if @focusOnAttach and onDom + @focusOnAttach = null + @focus() + + return if @attached + @attached = true + @trigger 'pane:attached' + + makeActive: -> + for pane in @getContainer().getPanes() when pane isnt this + pane.makeInactive() + wasActive = @isActive() + @addClass('active') + @trigger 'pane:became-active' unless wasActive + + makeInactive: -> + @removeClass('active') + + isActive: -> + @hasClass('active') + + getNextPane: -> + panes = @getContainer()?.getPanes() + return unless panes.length > 1 + nextIndex = (panes.indexOf(this) + 1) % panes.length + panes[nextIndex] + + getItems: -> + new Array(@items...) + + showNextItem: => + index = @getActiveItemIndex() + if index < @items.length - 1 + @showItemAtIndex(index + 1) + else + @showItemAtIndex(0) + + showPreviousItem: => + index = @getActiveItemIndex() + if index > 0 + @showItemAtIndex(index - 1) + else + @showItemAtIndex(@items.length - 1) + + getActiveItemIndex: -> + @items.indexOf(@activeItem) + + showItemAtIndex: (index) -> + @showItem(@itemAtIndex(index)) + + itemAtIndex: (index) -> + @items[index] + + showItem: (item) -> + return if !item? or item is @activeItem + + if @activeItem + @activeItem.off? 'title-changed', @activeItemTitleChanged + @autosaveActiveItem() + + isFocused = @is(':has(:focus)') + @addItem(item) + item.on? 'title-changed', @activeItemTitleChanged + view = @viewForItem(item) + @itemViews.children().not(view).hide() + @itemViews.append(view) unless view.parent().is(@itemViews) + view.show() + view.focus() if isFocused + @activeItem = item + @activeView = view + @trigger 'pane:active-item-changed', [item] + + activeItemTitleChanged: => + @trigger 'pane:active-item-title-changed' + + addItem: (item) -> + return if _.include(@items, item) + index = @getActiveItemIndex() + 1 + @items.splice(index, 0, item) + @getContainer().itemAdded(item) + @trigger 'pane:item-added', [item, index] + item + + destroyActiveItem: => + @destroyItem(@activeItem) + false + + destroyItem: (item) -> + container = @getContainer() + reallyDestroyItem = => + @removeItem(item) + container.itemDestroyed(item) + item.destroy?() + + @autosaveItem(item) + + if item.isModified?() + @promptToSaveItem(item, reallyDestroyItem) + else + reallyDestroyItem() + + destroyItems: -> + @destroyItem(item) for item in @getItems() + + destroyInactiveItems: -> + @destroyItem(item) for item in @getItems() when item isnt @activeItem + + promptToSaveItem: (item, nextAction, cancelAction) -> + uri = item.getUri() + atom.confirm( + "'#{item.getTitle?() ? item.getUri()}' has changes, do you want to save them?" + "Your changes will be lost if close this item without saving." + "Save", => @saveItem(item, nextAction) + "Cancel", cancelAction + "Don't Save", nextAction + ) + + saveActiveItem: => + @saveItem(@activeItem) + + saveActiveItemAs: => + @saveItemAs(@activeItem) + + saveItem: (item, nextAction) -> + if item.getUri?() + item.save() + nextAction?() + else + @saveItemAs(item, nextAction) + + saveItemAs: (item, nextAction) -> + return unless item.saveAs? + atom.showSaveDialog (path) => + if path + item.saveAs(path) + nextAction?() + + saveItems: => + @saveItem(item) for item in @getItems() + + autosaveActiveItem: -> + @autosaveItem(@activeItem) + + autosaveItem: (item) -> + @saveItem(item) if config.get('core.autosave') and item.getUri?() + + removeItem: (item) -> + index = @items.indexOf(item) + return if index == -1 + + @showNextItem() if item is @activeItem and @items.length > 1 + _.remove(@items, item) + @cleanupItemView(item) + @trigger 'pane:item-removed', [item, index] + + moveItem: (item, newIndex) -> + oldIndex = @items.indexOf(item) + @items.splice(oldIndex, 1) + @items.splice(newIndex, 0, item) + @trigger 'pane:item-moved', [item, newIndex] + + moveItemToPane: (item, pane, index) -> + @removeItem(item) + pane.addItem(item, index) + + itemForUri: (uri) -> + _.detect @items, (item) -> item.getUri?() is uri + + showItemForUri: (uri) -> + @showItem(@itemForUri(uri)) + + cleanupItemView: (item) -> + if item instanceof $ + viewToRemove = item + else + viewClass = item.getViewClass() + otherItemsForView = @items.filter (i) -> i.getViewClass?() is viewClass + unless otherItemsForView.length + viewToRemove = @viewsByClassName[viewClass.name] + viewToRemove?.setModel(null) + delete @viewsByClassName[viewClass.name] + + if @items.length > 0 + viewToRemove?.remove() + else + @remove() + + viewForItem: (item) -> + if item instanceof $ + item + else + viewClass = item.getViewClass() + if view = @viewsByClassName[viewClass.name] + view.setModel(item) + else + view = @viewsByClassName[viewClass.name] = new viewClass(item) + view + + viewForActiveItem: -> + @viewForItem(@activeItem) serialize: -> deserializer: "Pane" - wrappedView: @wrappedView?.serialize() + focused: @is(':has(:focus)') + activeItemUri: @activeItem.getUri?() if typeof @activeItem.serialize is 'function' + items: _.compact(@getItems().map (item) -> item.serialize?()) adjustDimensions: -> # do nothing - horizontalGridUnits: -> - 1 + horizontalGridUnits: -> 1 - verticalGridUnits: -> - 1 + verticalGridUnits: -> 1 - splitUp: (view) -> - @split(view, 'column', 'before') + splitUp: (items...) -> + @split(items, 'column', 'before') - splitDown: (view) -> - @split(view, 'column', 'after') + splitDown: (items...) -> + @split(items, 'column', 'after') - splitLeft: (view) -> - @split(view, 'row', 'before') + splitLeft: (items...) -> + @split(items, 'row', 'before') - splitRight: (view) -> - @split(view, 'row', 'after') + splitRight: (items...) -> + @split(items, 'row', 'after') - split: (view, axis, side) -> + split: (items, axis, side) -> unless @parent().hasClass(axis) @buildPaneAxis(axis) .insertBefore(this) .append(@detach()) - pane = new Pane(view) + items = [@copyActiveItem()] unless items.length + pane = new Pane(items...) this[side](pane) - rootView.adjustPaneDimensions() - view.focus?() + @getContainer().adjustPaneDimensions() + pane.focus() pane - remove: (selector, keepData) -> - return super if keepData - # find parent elements before removing from dom - parentAxis = @parent('.row, .column') - super - if parentAxis.children().length == 1 - sibling = parentAxis.children().detach() - parentAxis.replaceWith(sibling) - rootView.adjustPaneDimensions() - buildPaneAxis: (axis) -> switch axis when 'row' then new PaneRow when 'column' then new PaneColumn + + getContainer: -> + @closest('#panes').view() + + copyActiveItem: -> + deserialize(@activeItem.serialize()) + + remove: (selector, keepData) -> + return super if keepData + + # find parent elements before removing from dom + container = @getContainer() + parentAxis = @parent('.row, .column') + + if @is(':has(:focus)') + container.focusNextPane() or rootView?.focus() + else if @isActive() + container.makeNextPaneActive() + + super + + if parentAxis.children().length == 1 + sibling = parentAxis.children() + siblingFocused = sibling.is(':has(:focus)') + sibling.detach() + parentAxis.replaceWith(sibling) + sibling.focus() if siblingFocused + container.adjustPaneDimensions() + container.trigger 'pane:removed', [this] + + afterRemove: -> + item.destroy?() for item in @getItems() diff --git a/src/app/project.coffee b/src/app/project.coffee index 9b8aa380d..fac99ce8d 100644 --- a/src/app/project.coffee +++ b/src/app/project.coffee @@ -7,7 +7,6 @@ EditSession = require 'edit-session' EventEmitter = require 'event-emitter' Directory = require 'directory' ChildProcess = require 'child-process' -Git = require 'git' module.exports = class Project @@ -35,8 +34,6 @@ class Project grammarOverridesByPath: @grammarOverridesByPath destroy: -> - @repo?.destroy() - @repo = null editSession.destroy() for editSession in @getEditSessions() addGrammarOverrideForPath: (path, grammar) -> @@ -60,10 +57,8 @@ class Project if path? directory = if fs.isDirectory(path) then path else fs.directory(path) @rootDirectory = new Directory(directory) - @repo = Git.open(path) else @rootDirectory = null - @repo = null @trigger "path-changed" @@ -85,7 +80,7 @@ class Project @ignoreRepositoryPath(path) ignoreRepositoryPath: (path) -> - config.get("core.hideGitIgnoredFiles") and @repo?.isPathIgnored(fs.join(@getPath(), path)) + config.get("core.hideGitIgnoredFiles") and git?.isPathIgnored(fs.join(@getPath(), path)) resolve: (filePath) -> filePath = fs.join(@getPath(), filePath) unless filePath[0] == '/' @@ -100,10 +95,10 @@ class Project getSoftWrap: -> @softWrap setSoftWrap: (@softWrap) -> - buildEditSessionForPath: (filePath, editSessionOptions={}) -> - @buildEditSession(@bufferForPath(filePath), editSessionOptions) + buildEditSession: (filePath, editSessionOptions={}) -> + @buildEditSessionForBuffer(@bufferForPath(filePath), editSessionOptions) - buildEditSession: (buffer, editSessionOptions) -> + buildEditSessionForBuffer: (buffer, editSessionOptions) -> options = _.extend(@defaultEditSessionOptions(), editSessionOptions) options.project = this options.buffer = buffer @@ -133,9 +128,15 @@ class Project buffers.push editSession.buffer buffers - eachBuffer: (callback) -> + eachBuffer: (args...) -> + subscriber = args.shift() if args.length > 1 + callback = args.shift() + callback(buffer) for buffer in @getBuffers() - @on 'buffer-created', (buffer) -> callback(buffer) + if subscriber + subscriber.subscribe this, 'buffer-created', (buffer) -> callback(buffer) + else + @on 'buffer-created', (buffer) -> callback(buffer) bufferForPath: (filePath) -> if filePath? diff --git a/src/app/repository-status-handler.coffee b/src/app/repository-status-handler.coffee new file mode 100644 index 000000000..5503396ce --- /dev/null +++ b/src/app/repository-status-handler.coffee @@ -0,0 +1,18 @@ +Git = require 'git' +fs = require 'fs' + +module.exports = + loadStatuses: (path) -> + repo = Git.open(path) + if repo? + workingDirectoryPath = repo.getWorkingDirectory() + statuses = {} + for path, status of repo.getRepo().getStatuses() + statuses[fs.join(workingDirectoryPath, path)] = status + upstream = repo.getAheadBehindCounts() + repo.destroy() + else + upstream = {} + statuses = {} + + callTaskMethod('statusesLoaded', {statuses, upstream}) diff --git a/src/app/repository-status-task.coffee b/src/app/repository-status-task.coffee new file mode 100644 index 000000000..0e5f746a5 --- /dev/null +++ b/src/app/repository-status-task.coffee @@ -0,0 +1,17 @@ +Task = require 'task' +_ = require 'underscore' + +module.exports = +class RepositoryStatusTask extends Task + constructor: (@repo) -> + super('repository-status-handler') + + started: -> + @callWorkerMethod('loadStatuses', @repo.getPath()) + + statusesLoaded: ({statuses, upstream}) -> + @done() + statusesUnchanged = _.isEqual(statuses, @repo.statuses) and _.isEqual(upstream, @repo.upstream) + @repo.statuses = statuses + @repo.upstream = upstream + @repo.trigger 'statuses-changed' unless statusesUnchanged diff --git a/src/app/root-view.coffee b/src/app/root-view.coffee index 558143889..9c2eaae40 100644 --- a/src/app/root-view.coffee +++ b/src/app/root-view.coffee @@ -10,28 +10,29 @@ Project = require 'project' Pane = require 'pane' PaneColumn = require 'pane-column' PaneRow = require 'pane-row' +PaneContainer = require 'pane-container' +EditSession = require 'edit-session' module.exports = class RootView extends View registerDeserializers(this, Pane, PaneRow, PaneColumn, Editor) + @version: 1 + @configDefaults: ignoredNames: [".git", ".svn", ".DS_Store"] disabledPackages: [] - @content: -> + @content: ({panes}={}) -> @div id: 'root-view', => @div id: 'horizontal', outlet: 'horizontal', => @div id: 'vertical', outlet: 'vertical', => - @div id: 'panes', outlet: 'panes' + @subview 'panes', panes ? new PaneContainer - @deserialize: ({ panesViewState, packageStates, projectPath }) -> - atom.atomPackageStates = packageStates ? {} - rootView = new RootView - rootView.setRootPane(deserialize(panesViewState)) if panesViewState - rootView - - title: null + @deserialize: ({ panes, packages, projectPath }) -> + atom.atomPackageStates = packages ? {} + panes = deserialize(panes) if panes?.deserializer is 'PaneContainer' + new RootView({panes}) initialize: -> @command 'toggle-dev-tools', => atom.toggleDevTools() @@ -39,12 +40,11 @@ class RootView extends View @subscribe $(window), 'focus', (e) => @handleFocus(e) if document.activeElement is document.body - @on 'root-view:active-path-changed', (e, path) => - if path - project.setPath(path) unless project.getRootDirectory() - @setTitle(fs.base(path)) - else - @setTitle("untitled") + project.on 'path-changed', => @updateTitle() + @on 'pane:became-active', => @updateTitle() + @on 'pane:active-item-changed', '.active.pane', => @updateTitle() + @on 'pane:removed', => @updateTitle() unless @getActivePane() + @on 'pane:active-item-title-changed', '.active.pane', => @updateTitle() @command 'window:increase-font-size', => config.set("editor.fontSize", config.get("editor.fontSize") + 1) @@ -53,26 +53,34 @@ class RootView extends View fontSize = config.get "editor.fontSize" config.set("editor.fontSize", fontSize - 1) if fontSize > 1 - @command 'window:focus-next-pane', => @focusNextPane() @command 'window:save-all', => @saveAll() @command 'window:toggle-invisibles', => config.set("editor.showInvisibles", !config.get("editor.showInvisibles")) @command 'window:toggle-ignored-files', => config.set("core.hideGitIgnoredFiles", not config.core.hideGitIgnoredFiles) + @command 'window:toggle-auto-indent', => config.set("editor.autoIndent", !config.get("editor.autoIndent")) + @command 'window:toggle-auto-indent-on-paste', => config.set("editor.autoIndentOnPaste", !config.get("editor.autoIndentOnPaste")) + @command 'pane:reopen-closed-item', => + @panes.reopenItem() + serialize: -> + version: RootView.version deserializer: 'RootView' - panesViewState: @panes.children().view()?.serialize() - packageStates: atom.serializeAtomPackages() + panes: @panes.serialize() + packages: atom.serializeAtomPackages() + + confirmClose: -> + @panes.confirmClose() handleFocus: (e) -> - if @getActiveEditor() - @getActiveEditor().focus() + if @getActivePane() + @getActivePane().focus() false else @setTitle(null) @@ -92,118 +100,57 @@ class RootView extends View open: (path, options = {}) -> changeFocus = options.changeFocus ? true - allowActiveEditorChange = options.allowActiveEditorChange ? false - - unless editSession = @openInExistingEditor(path, allowActiveEditorChange, changeFocus) - editSession = project.buildEditSessionForPath(path) - editor = new Editor({editSession}) - pane = new Pane(editor) - @panes.append(pane) - if changeFocus - editor.focus() + path = project.resolve(path) if path? + if activePane = @getActivePane() + if editSession = activePane.itemForUri(path) + activePane.showItem(editSession) else - @makeEditorActive(editor, changeFocus) + editSession = project.buildEditSession(path) + activePane.showItem(editSession) + else + editSession = project.buildEditSession(path) + activePane = new Pane(editSession) + @panes.append(activePane) + activePane.focus() if changeFocus editSession - openInExistingEditor: (path, allowActiveEditorChange, changeFocus) -> - if activeEditor = @getActiveEditor() - activeEditor.focus() if changeFocus - - path = project.resolve(path) if path - - if editSession = activeEditor.activateEditSessionForPath(path) - return editSession - - if allowActiveEditorChange - for editor in @getEditors() - if editSession = editor.activateEditSessionForPath(path) - @makeEditorActive(editor, changeFocus) - return editSession - - editSession = project.buildEditSessionForPath(path) - activeEditor.edit(editSession) - editSession - - editorFocused: (editor) -> - @makeEditorActive(editor) if @panes.containsElement(editor) - - makeEditorActive: (editor, focus) -> - if focus - editor.focus() - return - - previousActiveEditor = @panes.find('.editor.active').view() - previousActiveEditor?.removeClass('active').off('.root-view') - editor.addClass('active') - - if not editor.mini - editor.on 'editor:path-changed.root-view', => - @trigger 'root-view:active-path-changed', editor.getPath() - if not previousActiveEditor or editor.getPath() != previousActiveEditor.getPath() - @trigger 'root-view:active-path-changed', editor.getPath() - - activeKeybindings: -> - keymap.bindingsForElement(document.activeElement) - - getTitle: -> - @title or "untitled" + updateTitle: -> + if projectPath = project.getPath() + if item = @getActivePaneItem() + @setTitle("#{item.getTitle?() ? 'untitled'} - #{projectPath}") + else + @setTitle(projectPath) + else + @setTitle('untitled') setTitle: (title) -> - projectPath = project.getPath() - if not projectPath - @title = "untitled" - else if title - @title = "#{title} – #{projectPath}" - else - @title = projectPath - - @updateWindowTitle() - - updateWindowTitle: -> - document.title = @title + document.title = title getEditors: -> - @panes.find('.pane > .editor').map(-> $(this).view()).toArray() + @panes.find('.pane > .item-views > .editor').map(-> $(this).view()).toArray() getModifiedBuffers: -> modifiedBuffers = [] - for editor in @getEditors() - for session in editor.editSessions - modifiedBuffers.push session.buffer if session.buffer.isModified() - + for pane in @getPanes() + for item in pane.getItems() when item instanceof EditSession + modifiedBuffers.push item.buffer if item.buffer.isModified() modifiedBuffers getOpenBufferPaths: -> _.uniq(_.flatten(@getEditors().map (editor) -> editor.getOpenBufferPaths())) - getActiveEditor: -> - if (editor = @panes.find('.editor.active')).length - editor.view() - else - @panes.find('.editor:first').view() + getActivePane: -> + @panes.getActivePane() - getActiveEditSession: -> - @getActiveEditor()?.activeEditSession + getActivePaneItem: -> + @panes.getActivePaneItem() - focusNextPane: -> - panes = @panes.find('.pane') - currentIndex = panes.toArray().indexOf(@getFocusedPane()[0]) - nextIndex = (currentIndex + 1) % panes.length - panes.eq(nextIndex).view().wrappedView.focus() + getActiveView: -> + @panes.getActiveView() - getFocusedPane: -> - @panes.find('.pane:has(:focus)') - - setRootPane: (pane) -> - @panes.empty() - @panes.append(pane) - @adjustPaneDimensions() - - adjustPaneDimensions: -> - rootPane = @panes.children().first().view() - rootPane?.css(width: '100%', height: '100%', top: 0, left: 0) - rootPane?.adjustDimensions() + focusNextPane: -> @panes.focusNextPane() + getFocusedPane: -> @panes.getFocusedPane() remove: -> editor.remove() for editor in @getEditors() @@ -211,7 +158,16 @@ class RootView extends View super saveAll: -> - editor.save() for editor in @getEditors() + @panes.saveAll() + + eachPane: (callback) -> + @panes.eachPane(callback) + + getPanes: -> + @panes.getPanes() + + indexOfPane: (pane) -> + @panes.indexOfPane(pane) eachEditor: (callback) -> callback(editor) for editor in @getEditors() @@ -223,10 +179,3 @@ class RootView extends View eachBuffer: (callback) -> project.eachBuffer(callback) - indexOfPane: (pane) -> - index = -1 - for p, idx in @panes.find('.pane') - if pane.is(p) - index = idx - break - index diff --git a/src/app/screen-line.coffee b/src/app/screen-line.coffee index 0c8822499..739d23eff 100644 --- a/src/app/screen-line.coffee +++ b/src/app/screen-line.coffee @@ -12,6 +12,8 @@ class ScreenLine new ScreenLine({@tokens, @ruleStack, @bufferRows, @startBufferColumn, @fold}) clipScreenColumn: (column, options={}) -> + return 0 if @tokens.length == 0 + { skipAtomicTokens } = options column = Math.min(column, @getMaxScreenColumn()) diff --git a/src/app/select-list.coffee b/src/app/select-list.coffee index 297c3fa35..e2441deff 100644 --- a/src/app/select-list.coffee +++ b/src/app/select-list.coffee @@ -20,7 +20,7 @@ class SelectList extends View cancelling: false initialize: -> - requireStylesheet 'select-list.css' + requireStylesheet 'select-list.less' @miniEditor.getBuffer().on 'changed', => @schedulePopulateList() @miniEditor.on 'focusout', => @cancel() unless @cancelling diff --git a/src/app/selection.coffee b/src/app/selection.coffee index 62a513984..9377498a2 100644 --- a/src/app/selection.coffee +++ b/src/app/selection.coffee @@ -164,7 +164,7 @@ class Selection if options.autoIndent if text == '\n' @editSession.autoIndentBufferRow(newBufferRange.end.row) - else + else if /\S/.test(text) @editSession.autoDecreaseIndentForRow(newBufferRange.start.row) newBufferRange diff --git a/src/app/sortable-list.coffee b/src/app/sortable-list.coffee index 08d727646..ebdc675a1 100644 --- a/src/app/sortable-list.coffee +++ b/src/app/sortable-list.coffee @@ -14,7 +14,9 @@ class SortableList extends View @on 'drop', '.sortable', @onDrop onDragStart: (event) => - return false if !@shouldAllowDrag(event) + unless @shouldAllowDrag(event) + event.preventDefault() + return el = @getSortableElement(event) el.addClass 'is-dragging' @@ -45,9 +47,8 @@ class SortableList extends View true getDroppedElement: (event) -> - idx = event.originalEvent.dataTransfer.getData 'sortable-index' - @find ".sortable:eq(#{idx})" + index = event.originalEvent.dataTransfer.getData('sortable-index') + @find(".sortable:eq(#{index})") getSortableElement: (event) -> - el = $(event.target) - if !el.hasClass('sortable') then el.closest('.sortable') else el + $(event.target).closest('.sortable') diff --git a/src/app/text-mate-package.coffee b/src/app/text-mate-package.coffee index c4901cc02..2929e1883 100644 --- a/src/app/text-mate-package.coffee +++ b/src/app/text-mate-package.coffee @@ -31,6 +31,8 @@ class TextMatePackage extends Package console.warn "Failed to load package at '#{@path}'", e.stack this + activate: -> # no-op + getGrammars: -> @grammars readGrammars: -> diff --git a/src/app/theme.coffee b/src/app/theme.coffee index b16a7ef1c..ba35e23e1 100644 --- a/src/app/theme.coffee +++ b/src/app/theme.coffee @@ -11,7 +11,7 @@ class Theme if fs.exists(name) path = name else - path = fs.resolve(config.themeDirPaths..., name, ['', '.tmTheme', '.css']) + path = fs.resolve(config.themeDirPaths..., name, ['', '.tmTheme', '.css', 'less']) throw new Error("No theme exists named '#{name}'") unless path diff --git a/src/app/window.coffee b/src/app/window.coffee index d814ba22c..bba97a751 100644 --- a/src/app/window.coffee +++ b/src/app/window.coffee @@ -1,11 +1,13 @@ fs = require 'fs' $ = require 'jquery' ChildProcess = require 'child-process' +{less} = require 'less' require 'jquery-extensions' require 'underscore-extensions' require 'space-pen-extensions' deserializers = {} +deferredDeserializers = {} # This method is called in any window needing a general environment, including specs window.setUpEnvironment = -> @@ -23,17 +25,17 @@ window.setUpEnvironment = -> $(document).on 'keydown', keymap.handleKeyEvent keymap.bindDefaultKeys() - requireStylesheet 'reset.css' - requireStylesheet 'atom.css' - requireStylesheet 'tabs.css' - requireStylesheet 'tree-view.css' - requireStylesheet 'status-bar.css' - requireStylesheet 'command-panel.css' - requireStylesheet 'fuzzy-finder.css' - requireStylesheet 'overlay.css' - requireStylesheet 'popover-list.css' - requireStylesheet 'notification.css' - requireStylesheet 'markdown.css' + requireStylesheet 'reset.less' + requireStylesheet 'atom.less' + requireStylesheet 'tabs.less' + requireStylesheet 'tree-view.less' + requireStylesheet 'status-bar.less' + requireStylesheet 'command-panel.less' + requireStylesheet 'fuzzy-finder.less' + requireStylesheet 'overlay.less' + requireStylesheet 'popover-list.less' + requireStylesheet 'notification.less' + requireStylesheet 'markdown.less' if nativeStylesheetPath = require.resolve("#{platform}.css") requireStylesheet(nativeStylesheetPath) @@ -42,16 +44,21 @@ window.setUpEnvironment = -> window.startup = -> if fs.isDirectory('/opt/boxen') installAtomCommand('/opt/boxen/bin/atom') - else + else if fs.isDirectory('/opt/github') installAtomCommand('/opt/github/bin/atom') + else if fs.isDirectory('/usr/local') + installAtomCommand('/usr/local/bin/atom') + else + console.warn "Failed to install `atom` binary" handleWindowEvents() config.load() atom.loadTextPackage() - buildProjectAndRootView() keymap.loadBundledKeymaps() atom.loadThemes() atom.loadPackages() + buildProjectAndRootView() + atom.activatePackages() keymap.loadUserKeymaps() atom.requireUserInitScript() $(window).on 'beforeunload', -> shutdown(); false @@ -65,9 +72,11 @@ window.shutdown = -> rootView: rootView.serialize() rootView.deactivate() project.destroy() + git?.destroy() $(window).off('focus blur before') window.rootView = null window.project = null + window.git = null window.installAtomCommand = (commandPath) -> return if fs.exists(commandPath) @@ -78,15 +87,15 @@ window.installAtomCommand = (commandPath) -> ChildProcess.exec("chmod u+x '#{commandPath}'") window.handleWindowEvents = -> - $(window).on 'core:close', => window.close() - $(window).command 'window:close', => window.close() $(window).command 'window:toggle-full-screen', => atom.toggleFullScreen() $(window).on 'focus', -> $("body").removeClass('is-blurred') $(window).on 'blur', -> $("body").addClass('is-blurred') + $(window).command 'window:close', => confirmClose() window.buildProjectAndRootView = -> RootView = require 'root-view' Project = require 'project' + Git = require 'git' pathToOpen = atom.getPathToOpen() windowState = atom.getRootViewStateForPath(pathToOpen) ? {} @@ -98,15 +107,31 @@ window.buildProjectAndRootView = -> $(rootViewParentSelector).append(rootView) + window.git = Git.open(project.getPath()) + project.on 'path-changed', -> + window.git?.destroy() + window.git = Git.open(project.getPath()) + window.stylesheetElementForId = (id) -> $("head style[id='#{id}']") window.requireStylesheet = (path) -> if fullPath = require.resolve(path) - window.applyStylesheet(fullPath, fs.read(fullPath)) - unless fullPath + content = window.loadStylesheet(fullPath) + window.applyStylesheet(fullPath, content) + else + console.log "bad", path throw new Error("Could not find a file at path '#{path}'") +window.loadStylesheet = (path) -> + content = fs.read(path) + if fs.extension(path) == '.less' + (new less.Parser).parse content, (e, tree) -> + throw new Error(e.message, file, e.line) if e + content = tree.toCSS() + + content + window.removeStylesheet = (path) -> unless fullPath = require.resolve(path) throw new Error("Could not find a file at path '#{path}'") @@ -139,8 +164,23 @@ window.registerDeserializers = (args...) -> window.registerDeserializer = (klass) -> deserializers[klass.name] = klass +window.registerDeferredDeserializer = (name, fn) -> + deferredDeserializers[name] = fn + +window.unregisterDeserializer = (klass) -> + delete deserializers[klass.name] + window.deserialize = (state) -> - deserializers[state?.deserializer]?.deserialize(state) + if deserializer = getDeserializer(state) + return if deserializer.version? and deserializer.version isnt state.version + deserializer.deserialize(state) + +window.getDeserializer = (state) -> + name = state?.deserializer + if deferredDeserializers[name] + deferredDeserializers[name]() + delete deferredDeserializers[name] + deserializers[name] window.measure = (description, fn) -> start = new Date().getTime() @@ -148,3 +188,7 @@ window.measure = (description, fn) -> result = new Date().getTime() - start console.log description, result value + + +confirmClose = -> + rootView.confirmClose().done -> window.close() \ No newline at end of file diff --git a/src/packages/autocomplete/spec/autocomplete-spec.coffee b/src/packages/autocomplete/spec/autocomplete-spec.coffee index 8d019d69b..9bfa9af7f 100644 --- a/src/packages/autocomplete/spec/autocomplete-spec.coffee +++ b/src/packages/autocomplete/spec/autocomplete-spec.coffee @@ -17,8 +17,8 @@ describe "Autocomplete", -> autocompletePackage = window.loadPackage("autocomplete") expect(AutocompleteView.prototype.initialize).not.toHaveBeenCalled() - leftEditor = rootView.getActiveEditor() - rightEditor = rootView.getActiveEditor().splitRight() + leftEditor = rootView.getActiveView() + rightEditor = leftEditor.splitRight() leftEditor.trigger 'autocomplete:attach' expect(leftEditor.find('.autocomplete')).toExist() @@ -40,7 +40,7 @@ describe "AutocompleteView", -> beforeEach -> window.rootView = new RootView - editor = new Editor(editSession: fixturesProject.buildEditSessionForPath('sample.js')) + editor = new Editor(editSession: project.buildEditSession('sample.js')) window.loadPackage('autocomplete') autocomplete = new AutocompleteView(editor) miniEditor = autocomplete.miniEditor diff --git a/src/packages/autocomplete/stylesheets/autocomplete.css b/src/packages/autocomplete/stylesheets/autocomplete.less similarity index 100% rename from src/packages/autocomplete/stylesheets/autocomplete.css rename to src/packages/autocomplete/stylesheets/autocomplete.less diff --git a/src/packages/autoflow/spec/autoflow-spec.coffee b/src/packages/autoflow/spec/autoflow-spec.coffee index d717bcf85..190ac238c 100644 --- a/src/packages/autoflow/spec/autoflow-spec.coffee +++ b/src/packages/autoflow/spec/autoflow-spec.coffee @@ -7,7 +7,7 @@ describe "Autoflow package", -> window.rootView = new RootView rootView.open() window.loadPackage 'autoflow' - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() config.set('editor.preferredLineLength', 30) describe "autoflow:reflow-paragraph", -> diff --git a/src/packages/bracket-matcher/spec/bracket-matcher-spec.coffee b/src/packages/bracket-matcher/spec/bracket-matcher-spec.coffee index ff56fec57..099be0926 100644 --- a/src/packages/bracket-matcher/spec/bracket-matcher-spec.coffee +++ b/src/packages/bracket-matcher/spec/bracket-matcher-spec.coffee @@ -8,7 +8,7 @@ describe "bracket matching", -> rootView.open('sample.js') window.loadPackage('bracket-matcher') rootView.attachToDom() - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() editSession = editor.activeEditSession buffer = editSession.buffer diff --git a/src/packages/bracket-matcher/stylesheets/bracket-matcher.css b/src/packages/bracket-matcher/stylesheets/bracket-matcher.less similarity index 100% rename from src/packages/bracket-matcher/stylesheets/bracket-matcher.css rename to src/packages/bracket-matcher/stylesheets/bracket-matcher.less diff --git a/src/packages/command-logger/spec/command-logger-spec.coffee b/src/packages/command-logger/spec/command-logger-spec.coffee index 7210c171f..8feec78bd 100644 --- a/src/packages/command-logger/spec/command-logger-spec.coffee +++ b/src/packages/command-logger/spec/command-logger-spec.coffee @@ -7,9 +7,9 @@ describe "CommandLogger", -> beforeEach -> window.rootView = new RootView rootView.open('sample.js') - commandLogger = window.loadPackage('command-logger').packageMain + commandLogger = window.loadPackage('command-logger').mainModule commandLogger.eventLog = {} - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() describe "when a command is triggered", -> it "records the number of times the command is triggered", -> diff --git a/src/packages/command-logger/stylesheets/command-logger.css b/src/packages/command-logger/stylesheets/command-logger.less similarity index 100% rename from src/packages/command-logger/stylesheets/command-logger.css rename to src/packages/command-logger/stylesheets/command-logger.less diff --git a/src/packages/command-palette/spec/command-palette-spec.coffee b/src/packages/command-palette/spec/command-palette-spec.coffee index 2b115bc68..414c53996 100644 --- a/src/packages/command-palette/spec/command-palette-spec.coffee +++ b/src/packages/command-palette/spec/command-palette-spec.coffee @@ -19,8 +19,8 @@ describe "CommandPalette", -> describe "when command-palette:toggle is triggered on the root view", -> it "shows a list of all valid command descriptions, names, and keybindings for the previously focused element", -> - keyBindings = _.losslessInvert(keymap.bindingsForElement(rootView.getActiveEditor())) - for eventName, description of rootView.getActiveEditor().events() + keyBindings = _.losslessInvert(keymap.bindingsForElement(rootView.getActiveView())) + for eventName, description of rootView.getActiveView().events() eventLi = palette.list.children("[data-event-name='#{eventName}']") if description expect(eventLi).toExist() @@ -32,7 +32,7 @@ describe "CommandPalette", -> expect(eventLi).not.toExist() it "displays all commands registerd on the window", -> - editorEvents = rootView.getActiveEditor().events() + editorEvents = rootView.getActiveView().events() windowEvents = $(window).events() expect(_.isEmpty(windowEvents)).toBeFalsy() for eventName, description of windowEvents @@ -60,19 +60,19 @@ describe "CommandPalette", -> expect(palette.hasParent()).toBeTruthy() palette.trigger 'command-palette:toggle' expect(palette.hasParent()).toBeFalsy() - expect(rootView.getActiveEditor().isFocused).toBeTruthy() + expect(rootView.getActiveView().isFocused).toBeTruthy() describe "when the command palette is cancelled", -> it "focuses the root view and detaches the command palette", -> expect(palette.hasParent()).toBeTruthy() palette.cancel() expect(palette.hasParent()).toBeFalsy() - expect(rootView.getActiveEditor().isFocused).toBeTruthy() + expect(rootView.getActiveView().isFocused).toBeTruthy() describe "when an command selection is confirmed", -> it "detaches the palette, then focuses the previously focused element and emits the selected command on it", -> eventHandler = jasmine.createSpy 'eventHandler' - activeEditor = rootView.getActiveEditor() + activeEditor = rootView.getActiveView() {eventName} = palette.array[5] activeEditor.preempt eventName, eventHandler diff --git a/src/packages/command-panel/lib/command-panel-view.coffee b/src/packages/command-panel/lib/command-panel-view.coffee index 3ede83809..2430c7ae0 100644 --- a/src/packages/command-panel/lib/command-panel-view.coffee +++ b/src/packages/command-panel/lib/command-panel-view.coffee @@ -120,7 +120,7 @@ class CommandPanelView extends View @errorMessages.empty() try - @commandInterpreter.eval(command, rootView.getActiveEditSession()).done ({operationsToPreview, errorMessages}) => + @commandInterpreter.eval(command, rootView.getActivePaneItem()).done ({operationsToPreview, errorMessages}) => @loadingMessage.hide() @history.push(command) @historyIndex = @history.length @@ -155,12 +155,12 @@ class CommandPanelView extends View @miniEditor.setText(@history[@historyIndex] or '') repeatRelativeAddress: -> - @commandInterpreter.repeatRelativeAddress(rootView.getActiveEditSession()) + @commandInterpreter.repeatRelativeAddress(rootView.getActivePaneItem()) repeatRelativeAddressInReverse: -> - @commandInterpreter.repeatRelativeAddressInReverse(rootView.getActiveEditSession()) + @commandInterpreter.repeatRelativeAddressInReverse(rootView.getActivePaneItem()) setSelectionAsLastRelativeAddress: -> - selection = rootView.getActiveEditor().getSelectedText() + selection = rootView.getActiveView().getSelectedText() regex = _.escapeRegExp(selection) @commandInterpreter.lastRelativeAddress = new CompositeCommand([new RegexAddress(regex)]) diff --git a/src/packages/command-panel/spec/command-interpreter-spec.coffee b/src/packages/command-panel/spec/command-interpreter-spec.coffee index 289d87709..c4602b59f 100644 --- a/src/packages/command-panel/spec/command-interpreter-spec.coffee +++ b/src/packages/command-panel/spec/command-interpreter-spec.coffee @@ -6,12 +6,11 @@ EditSession = require 'edit-session' _ = require 'underscore' describe "CommandInterpreter", -> - [project, interpreter, editSession, buffer] = [] + [interpreter, editSession, buffer] = [] beforeEach -> - project = new Project(fixturesProject.resolve('dir/')) - interpreter = new CommandInterpreter(fixturesProject) - editSession = fixturesProject.buildEditSessionForPath('sample.js') + interpreter = new CommandInterpreter(project) + editSession = project.buildEditSession('sample.js') buffer = editSession.buffer afterEach -> @@ -418,7 +417,7 @@ describe "CommandInterpreter", -> describe "X x/regex/", -> it "returns selection operations for all regex matches in all the project's files", -> editSession.destroy() - project = new Project(fixturesProject.resolve('dir/')) + project.setPath(project.resolve('dir')) interpreter = new CommandInterpreter(project) operationsToPreview = null @@ -428,7 +427,7 @@ describe "CommandInterpreter", -> runs -> expect(operationsToPreview.length).toBeGreaterThan 3 for operation in operationsToPreview - editSession = project.buildEditSessionForPath(operation.getPath()) + editSession = project.buildEditSession(operation.getPath()) editSession.setSelectedBufferRange(operation.execute(editSession)) expect(editSession.getSelectedText()).toMatch /a+/ editSession.destroy() diff --git a/src/packages/command-panel/spec/command-panel-spec.coffee b/src/packages/command-panel/spec/command-panel-spec.coffee index e6005c4be..c6d9f7997 100644 --- a/src/packages/command-panel/spec/command-panel-spec.coffee +++ b/src/packages/command-panel/spec/command-panel-spec.coffee @@ -3,15 +3,15 @@ CommandPanelView = require 'command-panel/lib/command-panel-view' _ = require 'underscore' describe "CommandPanel", -> - [editor, buffer, commandPanel] = [] + [editSession, buffer, commandPanel] = [] beforeEach -> window.rootView = new RootView rootView.open('sample.js') rootView.enableKeymap() - editor = rootView.getActiveEditor() - buffer = editor.activeEditSession.buffer - commandPanelMain = window.loadPackage('command-panel', activateImmediately: true).packageMain + editSession = rootView.getActivePaneItem() + buffer = editSession.buffer + commandPanelMain = window.loadPackage('command-panel', activateImmediately: true).mainModule commandPanel = commandPanelMain.commandPanelView commandPanel.history = [] commandPanel.historyIndex = 0 @@ -219,41 +219,41 @@ describe "CommandPanel", -> it "repeats the last search command if there is one", -> rootView.trigger 'command-panel:repeat-relative-address' - editor.setCursorScreenPosition([4, 0]) + editSession.setCursorScreenPosition([4, 0]) commandPanel.execute("/current") - expect(editor.getSelection().getBufferRange()).toEqual [[5,6], [5,13]] + expect(editSession.getSelectedBufferRange()).toEqual [[5,6], [5,13]] rootView.trigger 'command-panel:repeat-relative-address' - expect(editor.getSelection().getBufferRange()).toEqual [[6,6], [6,13]] + expect(editSession.getSelectedBufferRange()).toEqual [[6,6], [6,13]] commandPanel.execute('s/r/R/g') rootView.trigger 'command-panel:repeat-relative-address' - expect(editor.getSelection().getBufferRange()).toEqual [[6,34], [6,41]] + expect(editSession.getSelectedBufferRange()).toEqual [[6,34], [6,41]] commandPanel.execute('0') commandPanel.execute('/sort/ s/r/R/') # this contains a substitution... won't be repeated rootView.trigger 'command-panel:repeat-relative-address' - expect(editor.getSelection().getBufferRange()).toEqual [[3,31], [3,38]] + expect(editSession.getSelectedBufferRange()).toEqual [[3,31], [3,38]] describe "when command-panel:repeat-relative-address-in-reverse is triggered on the root view", -> it "it repeats the last relative address in the reverse direction", -> rootView.trigger 'command-panel:repeat-relative-address-in-reverse' - editor.setCursorScreenPosition([6, 0]) + editSession.setCursorScreenPosition([6, 0]) commandPanel.execute("/current") - expect(editor.getSelection().getBufferRange()).toEqual [[6,6], [6,13]] + expect(editSession.getSelectedBufferRange()).toEqual [[6,6], [6,13]] rootView.trigger 'command-panel:repeat-relative-address-in-reverse' - expect(editor.getSelection().getBufferRange()).toEqual [[5,6], [5,13]] + expect(editSession.getSelectedBufferRange()).toEqual [[5,6], [5,13]] describe "when command-panel:set-selection-as-regex-address is triggered on the root view", -> it "sets the @lastRelativeAddress to a RegexAddress of the current selection", -> rootView.open(require.resolve('fixtures/sample.js')) - rootView.getActiveEditor().setSelectedBufferRange([[1,21],[1,28]]) + rootView.getActivePaneItem().setSelectedBufferRange([[1,21],[1,28]]) commandInterpreter = commandPanel.commandInterpreter expect(commandInterpreter.lastRelativeAddress).toBeUndefined() @@ -267,7 +267,7 @@ describe "CommandPanel", -> commandPanel.miniEditor.setText("foo") commandPanel.miniEditor.setCursorBufferPosition([0, 0]) - rootView.getActiveEditor().trigger "command-panel:find-in-file" + rootView.getActiveView().trigger "command-panel:find-in-file" expect(commandPanel.attach).toHaveBeenCalled() expect(commandPanel.parent).not.toBeEmpty() expect(commandPanel.miniEditor.getText()).toBe "/" @@ -297,8 +297,8 @@ describe "CommandPanel", -> describe "when the command returns operations to be previewed", -> beforeEach -> + rootView.getActivePane().remove() rootView.attachToDom() - editor.remove() rootView.trigger 'command-panel:toggle' waitsForPromise -> commandPanel.execute('X x/quicksort/') @@ -350,16 +350,14 @@ describe "CommandPanel", -> expect(commandPanel).toBeVisible() expect(commandPanel.errorMessages).not.toBeVisible() - describe "when the command contains an escaped character", -> it "executes the command with the escaped character (instead of as a backslash followed by the character)", -> rootView.trigger 'command-panel:toggle' editSession = rootView.open(require.resolve 'fixtures/sample-with-tabs.coffee') - editor.edit(editSession) commandPanel.miniEditor.setText "/\\tsell" commandPanel.miniEditor.hiddenInput.trigger keydownEvent('enter') - expect(editor.getSelectedBufferRange()).toEqual [[3,1],[3,6]] + expect(editSession.getSelectedBufferRange()).toEqual [[3,1],[3,6]] describe "when move-up and move-down are triggerred on the editor", -> it "navigates forward and backward through the command history", -> @@ -470,11 +468,11 @@ describe "CommandPanel", -> previewList.trigger 'core:confirm' - editSession = rootView.getActiveEditSession() + editSession = rootView.getActivePaneItem() expect(editSession.buffer.getPath()).toBe project.resolve(operation.getPath()) expect(editSession.getSelectedBufferRange()).toEqual operation.getBufferRange() expect(editSession.getSelectedBufferRange()).toEqual operation.getBufferRange() - expect(editor.isScreenRowVisible(editor.getCursorScreenRow())).toBeTruthy() + expect(rootView.getActiveView().isScreenRowVisible(editSession.getCursorScreenRow())).toBeTruthy() expect(previewList.focus).toHaveBeenCalled() expect(executeHandler).not.toHaveBeenCalled() @@ -496,7 +494,7 @@ describe "CommandPanel", -> previewList.find('li.operation:eq(4) span').mousedown() expect(previewList.getSelectedOperation()).toBe operation - editSession = rootView.getActiveEditSession() + editSession = rootView.getActivePaneItem() expect(editSession.buffer.getPath()).toBe project.resolve(operation.getPath()) expect(editSession.getSelectedBufferRange()).toEqual operation.getBufferRange() expect(previewList.focus).toHaveBeenCalled() diff --git a/src/packages/editor-stats/spec/editor-stats-spec.coffee b/src/packages/editor-stats/spec/editor-stats-spec.coffee index 5d8708e42..9ce2360a2 100644 --- a/src/packages/editor-stats/spec/editor-stats-spec.coffee +++ b/src/packages/editor-stats/spec/editor-stats-spec.coffee @@ -17,7 +17,7 @@ describe "EditorStats", -> beforeEach -> window.rootView = new RootView rootView.open('sample.js') - editorStats = window.loadPackage('editor-stats').packageMain.stats + editorStats = window.loadPackage('editor-stats').mainModule.stats describe "when a keyup event is triggered", -> beforeEach -> diff --git a/src/packages/editor-stats/stylesheets/editor-stats.css b/src/packages/editor-stats/stylesheets/editor-stats.css deleted file mode 100644 index a7d5e3ff2..000000000 --- a/src/packages/editor-stats/stylesheets/editor-stats.css +++ /dev/null @@ -1,45 +0,0 @@ -.editor-stats-wrapper { - padding: 5px; - box-sizing: border-box; - border-top: 1px solid rgba(255, 255, 255, 0.05); - z-index: 9999; -} - -.editor-stats { - height: 50px; - width: 100%; - background: #1d1f21; - border: 1px solid rgba(0, 0, 0, 0.3); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - border-right: 1px solid rgba(255, 255, 255, 0.1); -} - -.editor-stats rect.bar { - fill: rgba(255, 255, 255, 0.2); - shape-rendering: crispedges; -} - -.editor-stats rect.bar.max { - fill: rgba(0, 163, 255, 1); -} - -.editor-stats text { - font-size: 10px; - fill: rgba(255, 255, 255, 0.2); - font-family: Courier; -} - -.editor-stats .minor text { - display: none; -} - -.editor-stats line { - stroke: #ccc; - stroke-opacity: 0.05; - stroke-width: 1px; - shape-rendering: crispedges; -} - -.editor-stats path.domain { - fill: none; -} diff --git a/src/packages/editor-stats/stylesheets/editor-stats.less b/src/packages/editor-stats/stylesheets/editor-stats.less new file mode 100644 index 000000000..26efaafde --- /dev/null +++ b/src/packages/editor-stats/stylesheets/editor-stats.less @@ -0,0 +1,45 @@ +.editor-stats-wrapper { + padding: 5px; + box-sizing: border-box; + border-top: 1px solid rgba(255, 255, 255, 0.05); + z-index: 9999; +} + +.editor-stats { + height: 50px; + width: 100%; + background: #1d1f21; + border: 1px solid rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-right: 1px solid rgba(255, 255, 255, 0.1); + + .bar { + fill: rgba(255, 255, 255, 0.2); + shape-rendering: crispedges; + + &.max { + fill: rgba(0, 163, 255, 1); + } + } + + text { + font-size: 10px; + fill: rgba(255, 255, 255, 0.2); + font-family: Courier; + } + + .minor text { + display: none; + } + + line { + stroke: #ccc; + stroke-opacity: 0.05; + stroke-width: 1px; + shape-rendering: crispedges; + } + + path.domain { + display: none; + } +} diff --git a/src/packages/fuzzy-finder/keymaps/fuzzy-finder.cson b/src/packages/fuzzy-finder/keymaps/fuzzy-finder.cson index c4267c9e2..60a302d3d 100644 --- a/src/packages/fuzzy-finder/keymaps/fuzzy-finder.cson +++ b/src/packages/fuzzy-finder/keymaps/fuzzy-finder.cson @@ -2,3 +2,4 @@ 'meta-t': 'fuzzy-finder:toggle-file-finder' 'meta-b': 'fuzzy-finder:toggle-buffer-finder' 'ctrl-.': 'fuzzy-finder:find-under-cursor' + 'meta-B': 'fuzzy-finder:toggle-git-status-finder' diff --git a/src/packages/fuzzy-finder/lib/fuzzy-finder-view.coffee b/src/packages/fuzzy-finder/lib/fuzzy-finder-view.coffee index 8b5c56e7e..d12f6f29a 100644 --- a/src/packages/fuzzy-finder/lib/fuzzy-finder-view.coffee +++ b/src/packages/fuzzy-finder/lib/fuzzy-finder-view.coffee @@ -22,23 +22,29 @@ class FuzzyFinderView extends SelectList @subscribe $(window), 'focus', => @reloadProjectPaths = true @observeConfig 'fuzzy-finder.ignoredNames', => @reloadProjectPaths = true - rootView.eachEditor (editor) -> - editor.activeEditSession.lastOpened = (new Date) - 1 - editor.on 'editor:active-edit-session-changed', (e, editSession, index) -> - editSession.lastOpened = (new Date) - 1 + rootView.eachPane (pane) -> + pane.activeItem.lastOpened = (new Date) - 1 + pane.on 'pane:active-item-changed', (e, item) -> item.lastOpened = (new Date) - 1 - @miniEditor.command 'editor:split-left', => - @splitOpenPath (editor, session) -> editor.splitLeft(session) - @miniEditor.command 'editor:split-right', => - @splitOpenPath (editor, session) -> editor.splitRight(session) - @miniEditor.command 'editor:split-down', => - @splitOpenPath (editor, session) -> editor.splitDown(session) - @miniEditor.command 'editor:split-up', => - @splitOpenPath (editor, session) -> editor.splitUp(session) + @miniEditor.command 'pane:split-left', => + @splitOpenPath (pane, session) -> pane.splitLeft(session) + @miniEditor.command 'pane:split-right', => + @splitOpenPath (pane, session) -> pane.splitRight(session) + @miniEditor.command 'pane:split-down', => + @splitOpenPath (pane, session) -> pane.splitDown(session) + @miniEditor.command 'pane:split-up', => + @splitOpenPath (pane, session) -> pane.splitUp(session) itemForElement: (path) -> $$ -> @li => + if git? + status = git.statuses[project.resolve(path)] + if git.isStatusNew(status) + @div class: 'status new' + else if git.isStatusModified(status) + @div class: 'status modified' + ext = fs.extension(path) if fs.isReadmePath(path) typeClass = 'readme-name' @@ -52,6 +58,7 @@ class FuzzyFinderView extends SelectList typeClass = 'binary-name' else typeClass = 'text-name' + @span fs.base(path), class: "file label #{typeClass}" if folder = fs.directory(path) @span " - #{folder}/", class: 'directory' @@ -62,10 +69,8 @@ class FuzzyFinderView extends SelectList splitOpenPath: (fn) -> path = @getSelectedElement() return unless path - - editor = rootView.getActiveEditor() - if editor - fn(editor, project.buildEditSessionForPath(path)) + if pane = rootView.getActivePane() + fn(pane, project.buildEditSession(path)) else @openPath(path) @@ -95,13 +100,22 @@ class FuzzyFinderView extends SelectList @populateOpenBufferPaths() @attach() if @paths?.length + toggleGitFinder: -> + if @hasParent() + @cancel() + else + return unless project.getPath()? and git? + @allowActiveEditorChange = false + @populateGitStatusPaths() + @attach() + findUnderCursor: -> if @hasParent() @cancel() else return unless project.getPath()? @allowActiveEditorChange = false - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() currentWord = editor.getWordUnderCursor(wordRegex: @filenameRegex) if currentWord.length == 0 @@ -118,6 +132,13 @@ class FuzzyFinderView extends SelectList @attach() @miniEditor.setText(currentWord) + populateGitStatusPaths: -> + projectRelativePaths = [] + for path, status of git.statuses + continue unless fs.isFile(path) + projectRelativePaths.push(project.relativize(path)) + @setArray(projectRelativePaths) + populateProjectPaths: (options = {}) -> if @projectPaths?.length > 0 listedItems = @@ -153,7 +174,7 @@ class FuzzyFinderView extends SelectList editSession.getPath()? editSessions = _.sortBy editSessions, (editSession) => - if editSession is rootView.getActiveEditSession() + if editSession is rootView.getActivePaneItem() 0 else -(editSession.lastOpened or 1) diff --git a/src/packages/fuzzy-finder/lib/fuzzy-finder.coffee b/src/packages/fuzzy-finder/lib/fuzzy-finder.coffee index a3de8b204..d90ff497d 100644 --- a/src/packages/fuzzy-finder/lib/fuzzy-finder.coffee +++ b/src/packages/fuzzy-finder/lib/fuzzy-finder.coffee @@ -12,6 +12,8 @@ module.exports = @createView().toggleBufferFinder() rootView.command 'fuzzy-finder:find-under-cursor', => @createView().findUnderCursor() + rootView.command 'fuzzy-finder:toggle-git-status-finder', => + @createView().toggleGitFinder() if project.getPath()? LoadPathsTask = require 'fuzzy-finder/lib/load-paths-task' @@ -33,9 +35,10 @@ module.exports = createView: -> unless @fuzzyFinderView + @loadPathsTask?.abort() FuzzyFinderView = require 'fuzzy-finder/lib/fuzzy-finder-view' @fuzzyFinderView = new FuzzyFinderView() - if @projectPaths? and not @fuzzyFinderView.projectPaths? + if @projectPaths?.length > 0 and not @fuzzyFinderView.projectPaths? @fuzzyFinderView.projectPaths = @projectPaths @fuzzyFinderView.reloadProjectPaths = false @fuzzyFinderView diff --git a/src/packages/fuzzy-finder/lib/load-paths-handler.coffee b/src/packages/fuzzy-finder/lib/load-paths-handler.coffee index 723f302d1..ac7c338dc 100644 --- a/src/packages/fuzzy-finder/lib/load-paths-handler.coffee +++ b/src/packages/fuzzy-finder/lib/load-paths-handler.coffee @@ -5,7 +5,7 @@ module.exports = loadPaths: (rootPath, ignoredNames, excludeGitIgnoredPaths) -> if excludeGitIgnoredPaths Git = require 'git' - repo = Git.open(rootPath, refreshIndexOnFocus: false) + repo = Git.open(rootPath, refreshOnWindowFocus: false) paths = [] isIgnored = (path) -> @@ -13,8 +13,10 @@ module.exports = return true if _.contains(ignoredNames, segment) repo?.isPathIgnored(fs.join(rootPath, path)) onFile = (path) -> + path = path.substring(rootPath.length + 1) paths.push(path) unless isIgnored(path) onDirectory = (path) -> + path = path.substring(rootPath.length + 1) not isIgnored(path) fs.traverseTree(rootPath, onFile, onDirectory) diff --git a/src/packages/fuzzy-finder/spec/fuzzy-finder-spec.coffee b/src/packages/fuzzy-finder/spec/fuzzy-finder-spec.coffee index 43d7065f1..bde11cca9 100644 --- a/src/packages/fuzzy-finder/spec/fuzzy-finder-spec.coffee +++ b/src/packages/fuzzy-finder/spec/fuzzy-finder-spec.coffee @@ -13,7 +13,7 @@ describe 'FuzzyFinder', -> window.rootView = new RootView rootView.open('sample.js') rootView.enableKeymap() - finderView = window.loadPackage("fuzzy-finder").packageMain.createView() + finderView = window.loadPackage("fuzzy-finder").mainModule.createView() describe "file-finder behavior", -> describe "toggling", -> @@ -21,8 +21,8 @@ describe 'FuzzyFinder', -> it "shows the FuzzyFinder or hides it and returns focus to the active editor if it already showing", -> rootView.attachToDom() expect(rootView.find('.fuzzy-finder')).not.toExist() - rootView.find('.editor').trigger 'editor:split-right' - [editor1, editor2] = rootView.find('.editor').map -> $(this).view() + rootView.getActiveView().splitRight() + [editor1, editor2] = rootView.getEditors() expect(rootView.find('.fuzzy-finder')).not.toExist() rootView.trigger 'fuzzy-finder:toggle-file-finder' @@ -72,13 +72,13 @@ describe 'FuzzyFinder', -> describe "when a path selection is confirmed", -> it "opens the file associated with that path in the editor", -> rootView.attachToDom() - editor1 = rootView.getActiveEditor() + editor1 = rootView.getActiveView() editor2 = editor1.splitRight() - expect(rootView.getActiveEditor()).toBe editor2 + expect(rootView.getActiveView()).toBe editor2 rootView.trigger 'fuzzy-finder:toggle-file-finder' finderView.confirmed('dir/a') - expectedPath = fixturesProject.resolve('dir/a') + expectedPath = project.resolve('dir/a') expect(finderView.hasParent()).toBeFalsy() expect(editor1.getPath()).not.toBe expectedPath @@ -88,26 +88,26 @@ describe 'FuzzyFinder', -> describe "when the selected path isn't a file that exists", -> it "leaves the the tree view open, doesn't open the path in the editor, and displays an error", -> rootView.attachToDom() - path = rootView.getActiveEditor().getPath() + path = rootView.getActiveView().getPath() rootView.trigger 'fuzzy-finder:toggle-file-finder' finderView.confirmed('dir/this/is/not/a/file.txt') expect(finderView.hasParent()).toBeTruthy() - expect(rootView.getActiveEditor().getPath()).toBe path + expect(rootView.getActiveView().getPath()).toBe path expect(finderView.find('.error').text().length).toBeGreaterThan 0 advanceClock(2000) expect(finderView.find('.error').text().length).toBe 0 describe "buffer-finder behavior", -> describe "toggling", -> - describe "when the active editor contains edit sessions for buffers with paths", -> + describe "when there are pane items with paths", -> beforeEach -> rootView.open('sample.txt') - it "shows the FuzzyFinder or hides it, returning focus to the active editor if", -> + it "shows the FuzzyFinder if it isn't showing, or hides it and returns focus to the active editor", -> rootView.attachToDom() expect(rootView.find('.fuzzy-finder')).not.toExist() - rootView.find('.editor').trigger 'editor:split-right' - [editor1, editor2] = rootView.find('.editor').map -> $(this).view() + rootView.getActiveView().splitRight() + [editor1, editor2] = rootView.getEditors() rootView.trigger 'fuzzy-finder:toggle-buffer-finder' expect(rootView.find('.fuzzy-finder')).toExist() @@ -122,26 +122,17 @@ describe 'FuzzyFinder', -> rootView.trigger 'fuzzy-finder:toggle-buffer-finder' expect(finderView.miniEditor.getText()).toBe '' - it "lists the paths of the current open buffers by most recently modified", -> + it "lists the paths of the current items, sorted by most recently opened but with the current item last", -> rootView.attachToDom() rootView.open 'sample-with-tabs.coffee' rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - children = finderView.list.children('li') - expect(children.get(0).outerText).toBe "sample.txt" - expect(children.get(1).outerText).toBe "sample.js" - expect(children.get(2).outerText).toBe "sample-with-tabs.coffee" + expect(_.pluck(finderView.list.children('li'), 'outerText')).toEqual ['sample.txt', 'sample.js', 'sample-with-tabs.coffee'] + rootView.trigger 'fuzzy-finder:toggle-buffer-finder' rootView.open 'sample.txt' rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - children = finderView.list.children('li') - expect(children.get(0).outerText).toBe "sample-with-tabs.coffee" - expect(children.get(1).outerText).toBe "sample.js" - expect(children.get(2).outerText).toBe "sample.txt" - expect(finderView.list.children('li').length).toBe 3 - expect(finderView.list.find("li:contains(sample.js)")).toExist() - expect(finderView.list.find("li:contains(sample.txt)")).toExist() - expect(finderView.list.find("li:contains(sample-with-tabs.coffee)")).toExist() + expect(_.pluck(finderView.list.children('li'), 'outerText')).toEqual ['sample-with-tabs.coffee', 'sample.js', 'sample.txt'] expect(finderView.list.children().first()).toHaveClass 'selected' it "serializes the list of paths and their last opened time", -> @@ -151,29 +142,26 @@ describe 'FuzzyFinder', -> rootView.trigger 'fuzzy-finder:toggle-buffer-finder' rootView.open() - states = rootView.serialize().packageStates + states = rootView.serialize().packages states = _.map states['fuzzy-finder'], (path, time) -> [ path, time ] states = _.sortBy states, (path, time) -> -time paths = [ 'sample-with-tabs.coffee', 'sample.txt', 'sample.js' ] + for [time, path] in states expect(_.last path.split '/').toBe paths.shift() expect(time).toBeGreaterThan 50000 - describe "when the active editor only contains edit sessions for anonymous buffers", -> + describe "when there are only panes with anonymous items", -> it "does not open", -> - editor = rootView.getActiveEditor() - editor.edit(project.buildEditSessionForPath()) - editor.loadPreviousEditSession() - editor.destroyActiveEditSession() - expect(editor.getOpenBufferPaths().length).toBe 0 + rootView.getActivePane().remove() + rootView.open() rootView.trigger 'fuzzy-finder:toggle-buffer-finder' expect(rootView.find('.fuzzy-finder')).not.toExist() - describe "when there is no active editor", -> + describe "when there are no pane items", -> it "does not open", -> - rootView.getActiveEditor().destroyActiveEditSession() - expect(rootView.getActiveEditor()).toBeUndefined() + rootView.getActivePane().remove() rootView.trigger 'fuzzy-finder:toggle-buffer-finder' expect(rootView.find('.fuzzy-finder')).not.toExist() @@ -182,16 +170,16 @@ describe 'FuzzyFinder', -> beforeEach -> rootView.attachToDom() - editor1 = rootView.getActiveEditor() + editor1 = rootView.getActiveView() editor2 = editor1.splitRight() - expect(rootView.getActiveEditor()).toBe editor2 + expect(rootView.getActiveView()).toBe editor2 rootView.open('sample.txt') - editor2.loadPreviousEditSession() + editor2.trigger 'pane:show-previous-item' rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - describe "when there is an edit session for the confirmed path in the active editor", -> - it "switches the active editor to the edit session for the selected path", -> - expectedPath = fixturesProject.resolve('sample.txt') + describe "when the active pane has an item for the selected path", -> + it "switches to the item for the selected path", -> + expectedPath = project.resolve('sample.txt') finderView.confirmed('sample.txt') expect(finderView.hasParent()).toBeFalsy() @@ -199,28 +187,55 @@ describe 'FuzzyFinder', -> expect(editor2.getPath()).toBe expectedPath expect(editor2.isFocused).toBeTruthy() - describe "when there is NO edit session for the confirmed path on the active editor, but there is one on another editor", -> - it "focuses the editor that contains an edit session for the selected path", -> + describe "when the active pane does not have an item for the selected path", -> + it "adds a new item to the active pane for the selcted path", -> rootView.trigger 'fuzzy-finder:toggle-buffer-finder' editor1.focus() rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - expect(rootView.getActiveEditor()).toBe editor1 + expect(rootView.getActiveView()).toBe editor1 - expectedPath = fixturesProject.resolve('sample.txt') + expectedPath = project.resolve('sample.txt') finderView.confirmed('sample.txt') expect(finderView.hasParent()).toBeFalsy() - expect(editor1.getPath()).not.toBe expectedPath - expect(editor2.getPath()).toBe expectedPath - expect(editor2.isFocused).toBeTruthy() + expect(editor1.getPath()).toBe expectedPath + expect(editor1.isFocused).toBeTruthy() + + describe "git-status-finder behavior", -> + [originalText, originalPath, newPath] = [] + + beforeEach -> + editor = rootView.getActiveView() + originalText = editor.getText() + originalPath = editor.getPath() + fs.write(originalPath, 'making a change for the better') + git.getPathStatus(originalPath) + + newPath = project.resolve('newsample.js') + fs.write(newPath, '') + git.getPathStatus(newPath) + + afterEach -> + fs.write(originalPath, originalText) + fs.remove(newPath) if fs.exists(newPath) + + it "displays all new and modified paths", -> + expect(rootView.find('.fuzzy-finder')).not.toExist() + rootView.trigger 'fuzzy-finder:toggle-git-status-finder' + expect(rootView.find('.fuzzy-finder')).toExist() + + expect(finderView.find('.file').length).toBe 2 + + expect(finderView.find('.status.modified').length).toBe 1 + expect(finderView.find('.status.new').length).toBe 1 describe "common behavior between file and buffer finder", -> describe "when the fuzzy finder is cancelled", -> describe "when an editor is open", -> it "detaches the finder and focuses the previously focused element", -> rootView.attachToDom() - activeEditor = rootView.getActiveEditor() + activeEditor = rootView.getActiveView() activeEditor.focus() rootView.trigger 'fuzzy-finder:toggle-file-finder' @@ -237,7 +252,7 @@ describe 'FuzzyFinder', -> describe "when no editors are open", -> it "detaches the finder and focuses the previously focused element", -> rootView.attachToDom() - rootView.getActiveEditor().destroyActiveEditSession() + rootView.getActivePane().remove() inputView = $$ -> @input() rootView.append(inputView) @@ -323,7 +338,7 @@ describe 'FuzzyFinder', -> editor = null beforeEach -> - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() rootView.attachToDom() it "opens the fuzzy finder window when there are multiple matches", -> @@ -376,52 +391,89 @@ describe 'FuzzyFinder', -> runs -> expect(finderView.find('.error').text().length).toBeGreaterThan 0 - describe "opening a path into a split", -> + it "opens the path by splitting the active editor left", -> + expect(rootView.getPanes().length).toBe 1 + pane = rootView.getActivePane() + spyOn(pane, "splitLeft").andCallThrough() + + rootView.trigger 'fuzzy-finder:toggle-buffer-finder' + path = finderView.getSelectedElement() + finderView.miniEditor.trigger 'pane:split-left' + + expect(rootView.getPanes().length).toBe 2 + expect(pane.splitLeft).toHaveBeenCalled() + expect(rootView.getActiveView().getPath()).toBe project.resolve(path) + + it "opens the path by splitting the active editor right", -> + expect(rootView.getPanes().length).toBe 1 + pane = rootView.getActivePane() + spyOn(pane, "splitRight").andCallThrough() + + rootView.trigger 'fuzzy-finder:toggle-buffer-finder' + path = finderView.getSelectedElement() + finderView.miniEditor.trigger 'pane:split-right' + + expect(rootView.getPanes().length).toBe 2 + expect(pane.splitRight).toHaveBeenCalled() + expect(rootView.getActiveView().getPath()).toBe project.resolve(path) + + it "opens the path by splitting the active editor up", -> + expect(rootView.getPanes().length).toBe 1 + pane = rootView.getActivePane() + spyOn(pane, "splitUp").andCallThrough() + + rootView.trigger 'fuzzy-finder:toggle-buffer-finder' + path = finderView.getSelectedElement() + finderView.miniEditor.trigger 'pane:split-up' + + expect(rootView.getPanes().length).toBe 2 + expect(pane.splitUp).toHaveBeenCalled() + expect(rootView.getActiveView().getPath()).toBe project.resolve(path) + + it "opens the path by splitting the active editor down", -> + expect(rootView.getPanes().length).toBe 1 + pane = rootView.getActivePane() + spyOn(pane, "splitDown").andCallThrough() + + rootView.trigger 'fuzzy-finder:toggle-buffer-finder' + path = finderView.getSelectedElement() + finderView.miniEditor.trigger 'pane:split-down' + + expect(rootView.getPanes().length).toBe 2 + expect(pane.splitDown).toHaveBeenCalled() + expect(rootView.getActiveView().getPath()).toBe project.resolve(path) + + describe "git status decorations", -> + [originalText, originalPath, editor, newPath] = [] + beforeEach -> - rootView.attachToDom() + editor = rootView.getActiveView() + originalText = editor.getText() + originalPath = editor.getPath() + newPath = project.resolve('newsample.js') + fs.write(newPath, '') - describe "when an editor is active", -> - it "opens the path by splitting the active editor left", -> - editor = rootView.getActiveEditor() - spyOn(editor, "splitLeft").andCallThrough() - expect(rootView.find('.editor').length).toBe 1 - rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - finderView.miniEditor.trigger 'editor:split-left' - expect(rootView.find('.editor').length).toBe 2 - expect(editor.splitLeft).toHaveBeenCalled() - expect(rootView.getActiveEditor()).not.toBe editor - expect(rootView.getActiveEditor().getPath()).toBe editor.getPath() + afterEach -> + fs.write(originalPath, originalText) + fs.remove(newPath) if fs.exists(newPath) - it "opens the path by splitting the active editor right", -> - editor = rootView.getActiveEditor() - spyOn(editor, "splitRight").andCallThrough() - expect(rootView.find('.editor').length).toBe 1 - rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - finderView.miniEditor.trigger 'editor:split-right' - expect(rootView.find('.editor').length).toBe 2 - expect(editor.splitRight).toHaveBeenCalled() - expect(rootView.getActiveEditor()).not.toBe editor - expect(rootView.getActiveEditor().getPath()).toBe editor.getPath() + describe "when a modified file is shown in the list", -> + it "displays the modified icon", -> + editor.setText('modified') + editor.activeEditSession.save() + git.getPathStatus(editor.getPath()) - it "opens the path by splitting the active editor down", -> - editor = rootView.getActiveEditor() - spyOn(editor, "splitDown").andCallThrough() - expect(rootView.find('.editor').length).toBe 1 rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - finderView.miniEditor.trigger 'editor:split-down' - expect(rootView.find('.editor').length).toBe 2 - expect(editor.splitDown).toHaveBeenCalled() - expect(rootView.getActiveEditor()).not.toBe editor - expect(rootView.getActiveEditor().getPath()).toBe editor.getPath() + expect(finderView.find('.status.modified').length).toBe 1 + expect(finderView.find('.status.modified').closest('li').find('.file').text()).toBe 'sample.js' + + + describe "when a new file is shown in the list", -> + it "displays the new icon", -> + rootView.open('newsample.js') + git.getPathStatus(editor.getPath()) - it "opens the path by splitting the active editor up", -> - editor = rootView.getActiveEditor() - spyOn(editor, "splitUp").andCallThrough() - expect(rootView.find('.editor').length).toBe 1 rootView.trigger 'fuzzy-finder:toggle-buffer-finder' - finderView.miniEditor.trigger 'editor:split-up' - expect(rootView.find('.editor').length).toBe 2 - expect(editor.splitUp).toHaveBeenCalled() - expect(rootView.getActiveEditor()).not.toBe editor - expect(rootView.getActiveEditor().getPath()).toBe editor.getPath() + expect(finderView.find('.status.new').length).toBe 1 + expect(finderView.find('.status.new').closest('li').find('.file').text()).toBe 'newsample.js' diff --git a/src/packages/gfm.tmbundle/spec/gfm-spec.coffee b/src/packages/gfm.tmbundle/spec/gfm-spec.coffee index db307ab65..97bc98300 100644 --- a/src/packages/gfm.tmbundle/spec/gfm-spec.coffee +++ b/src/packages/gfm.tmbundle/spec/gfm-spec.coffee @@ -136,6 +136,6 @@ describe "GitHub Flavored Markdown grammar", -> describe "auto indent", -> it "indents newlines entered after list lines", -> config.set('editor.autoIndent', true) - editSession = fixturesProject.buildEditSessionForPath('gfm.md') + editSession = project.buildEditSession('gfm.md') editSession.insertNewlineBelow() expect(editSession.buffer.lineForRow(1)).toBe ' ' diff --git a/src/packages/gists/lib/gists.coffee b/src/packages/gists/lib/gists.coffee index 4cc241fcd..6df96a01d 100644 --- a/src/packages/gists/lib/gists.coffee +++ b/src/packages/gists/lib/gists.coffee @@ -9,7 +9,7 @@ class Gists rootView.command 'gist:create', '.editor', => @createGist() createGist: (editor) -> - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() return unless editor? gist = { public: false, files: {} } diff --git a/src/packages/gists/spec/gists-spec.coffee b/src/packages/gists/spec/gists-spec.coffee index 430b727ad..3091f8ed6 100644 --- a/src/packages/gists/spec/gists-spec.coffee +++ b/src/packages/gists/spec/gists-spec.coffee @@ -8,7 +8,7 @@ describe "Gists package", -> window.rootView = new RootView rootView.open('sample.js') window.loadPackage('gists') - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() spyOn($, 'ajax') describe "when gist:create is triggered on an editor", -> diff --git a/src/packages/go-to-line/lib/go-to-line-view.coffee b/src/packages/go-to-line/lib/go-to-line-view.coffee index 1f24afd39..d1da9b764 100644 --- a/src/packages/go-to-line/lib/go-to-line-view.coffee +++ b/src/packages/go-to-line/lib/go-to-line-view.coffee @@ -38,7 +38,7 @@ class GoToLineView extends View confirm: -> lineNumber = @miniEditor.getText() - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() @detach() @@ -51,5 +51,5 @@ class GoToLineView extends View attach: -> @previouslyFocusedElement = $(':focus') rootView.append(this) - @message.text("Enter a line number 1-#{rootView.getActiveEditor().getLineCount()}") + @message.text("Enter a line number 1-#{rootView.getActiveView().getLineCount()}") @miniEditor.focus() diff --git a/src/packages/go-to-line/spec/go-to-line-spec.coffee b/src/packages/go-to-line/spec/go-to-line-spec.coffee index fe0c8b7df..bddef9426 100644 --- a/src/packages/go-to-line/spec/go-to-line-spec.coffee +++ b/src/packages/go-to-line/spec/go-to-line-spec.coffee @@ -8,7 +8,7 @@ describe 'GoToLine', -> window.rootView = new RootView rootView.open('sample.js') rootView.enableKeymap() - editor = rootView.getActiveEditor() + editor = rootView.getActiveView() goToLine = GoToLineView.activate() editor.setCursorBufferPosition([1,0]) diff --git a/src/packages/markdown-preview/keymaps/markdown-preview.cson b/src/packages/markdown-preview/keymaps/markdown-preview.cson index d98f00093..af52d2f0c 100644 --- a/src/packages/markdown-preview/keymaps/markdown-preview.cson +++ b/src/packages/markdown-preview/keymaps/markdown-preview.cson @@ -1,5 +1,2 @@ '.editor': - 'ctrl-m': 'markdown-preview:toggle' - -'.markdown-preview': - 'ctrl-m': 'markdown-preview:toggle' + 'ctrl-m': 'markdown-preview:show' diff --git a/src/packages/markdown-preview/lib/markdown-preview-view.coffee b/src/packages/markdown-preview/lib/markdown-preview-view.coffee index 0560030a0..a65c09eba 100644 --- a/src/packages/markdown-preview/lib/markdown-preview-view.coffee +++ b/src/packages/markdown-preview/lib/markdown-preview-view.coffee @@ -1,49 +1,36 @@ -ScrollView = require 'scroll-view' fs = require 'fs' $ = require 'jquery' +ScrollView = require 'scroll-view' {$$$} = require 'space-pen' module.exports = class MarkdownPreviewView extends ScrollView - @activate: -> - @instance = new MarkdownPreviewView + registerDeserializer(this) + + @deserialize: ({path}) -> + new MarkdownPreviewView(project.bufferForPath(path)) @content: -> - @div class: 'markdown-preview', tabindex: -1, => - @div class: 'markdown-body', outlet: 'markdownBody' + @div class: 'markdown-preview', tabindex: -1 - initialize: -> + initialize: (@buffer) -> super + @fetchRenderedMarkdown() + @on 'core:move-up', => @scrollUp() + @on 'core:move-down', => @scrollDown() - rootView.command 'markdown-preview:toggle', => @toggle() - @on 'blur', => @detach() unless document.activeElement is this[0] - @command 'core:cancel', => @detach() + serialize: -> + deserializer: 'MarkdownPreviewView' + path: @buffer.getPath() - toggle: -> - if @hasParent() - @detach() - else - @attach() + getTitle: -> + "Markdown Preview – #{@buffer.getBaseName()}" - attach: -> - return unless @isMarkdownEditor() - rootView.append(this) - @markdownBody.html(@getLoadingHtml()) - @loadHtml() - @focus() + getUri: -> + "markdown-preview:#{@buffer.getPath()}" - detach: -> - return if @detaching - @detaching = true - super - rootView.focus() - @detaching = false - - getActiveText: -> - rootView.getActiveEditor()?.getText() - - getErrorHtml: (error) -> - $$$ -> + setErrorHtml: -> + @html $$$ -> @h2 'Previewing Markdown Failed' @h3 'Possible Reasons' @ul => @@ -52,29 +39,18 @@ class MarkdownPreviewView extends ScrollView @a 'github.com', href: 'https://github.com' @span '.' - getLoadingHtml: -> - $$$ -> - @div class: 'markdown-spinner', 'Loading Markdown...' + setLoading: -> + @html($$$ -> @div class: 'markdown-spinner', 'Loading Markdown...') - loadHtml: (text) -> - payload = - mode: 'markdown' - text: @getActiveText() - request = + fetchRenderedMarkdown: (text) -> + @setLoading() + $.ajax url: 'https://api.github.com/markdown' type: 'POST' dataType: 'html' contentType: 'application/json; charset=UTF-8' - data: JSON.stringify(payload) - success: (html) => @setHtml(html) - error: (jqXhr, error) => @setHtml(@getErrorHtml(error)) - $.ajax(request) - - setHtml: (html) -> - @markdownBody.html(html) if @hasParent() - - isMarkdownEditor: (path) -> - editor = rootView.getActiveEditor() - return unless editor? - return true if editor.getGrammar().scopeName is 'source.gfm' - path and fs.isMarkdownExtension(fs.extension(path)) + data: JSON.stringify + mode: 'markdown' + text: @buffer.getText() + success: (html) => @html(html) + error: => @setErrorHtml() diff --git a/src/packages/markdown-preview/lib/markdown-preview.coffee b/src/packages/markdown-preview/lib/markdown-preview.coffee new file mode 100644 index 000000000..b30bbb621 --- /dev/null +++ b/src/packages/markdown-preview/lib/markdown-preview.coffee @@ -0,0 +1,25 @@ +EditSession = require 'edit-session' +MarkdownPreviewView = require 'markdown-preview/lib/markdown-preview-view' + +module.exports = + activate: -> + rootView.command 'markdown-preview:show', '.editor', => @show() + + show: -> + activePane = rootView.getActivePane() + item = activePane.activeItem + + if not item instanceof EditSession + console.warn("Can not render markdown for #{item.getUri()}") + return + + editSession = item + if nextPane = activePane.getNextPane() + if preview = nextPane.itemForUri("markdown-preview:#{editSession.getPath()}") + nextPane.showItem(preview) + preview.fetchRenderedMarkdown() + else + nextPane.showItem(new MarkdownPreviewView(editSession.buffer)) + else + activePane.splitRight(new MarkdownPreviewView(editSession.buffer)) + activePane.focus() \ No newline at end of file diff --git a/src/packages/markdown-preview/package.cson b/src/packages/markdown-preview/package.cson index deea08f07..29925172d 100644 --- a/src/packages/markdown-preview/package.cson +++ b/src/packages/markdown-preview/package.cson @@ -1,3 +1,4 @@ -'main': 'lib/markdown-preview-view' +'main': 'lib/markdown-preview' 'activationEvents': - 'markdown-preview:toggle': '.editor' + 'markdown-preview:show': '.editor' +'deferredDeserializers': ['MarkdownPreviewView'] diff --git a/src/packages/markdown-preview/spec/markdown-preview-spec.coffee b/src/packages/markdown-preview/spec/markdown-preview-spec.coffee index a195f907d..9a7ed6e33 100644 --- a/src/packages/markdown-preview/spec/markdown-preview-spec.coffee +++ b/src/packages/markdown-preview/spec/markdown-preview-spec.coffee @@ -1,84 +1,67 @@ -$ = require 'jquery' RootView = require 'root-view' -MarkdownPreview = require 'markdown-preview/lib/markdown-preview-view' -_ = require 'underscore' +MarkdownPreviewView = require 'markdown-preview/lib/markdown-preview-view' +{$$} = require 'space-pen' -describe "MarkdownPreview", -> +describe "MarkdownPreview package", -> beforeEach -> project.setPath(project.resolve('markdown')) window.rootView = new RootView - window.loadPackage("markdown-preview") - spyOn(MarkdownPreview.prototype, 'loadHtml') + window.loadPackage("markdown-preview", activateImmediately: true) + spyOn(MarkdownPreviewView.prototype, 'fetchRenderedMarkdown') - describe "markdown-preview:toggle event", -> - it "toggles on/off a preview for a .md file", -> - rootView.open('file.md') - editor = rootView.getActiveEditor() - expect(rootView.find('.markdown-preview')).not.toExist() - editor.trigger('markdown-preview:toggle') + describe "markdown-preview:show", -> + beforeEach -> + rootView.open("file.markdown") - markdownPreviewView = rootView.find('.markdown-preview')?.view() - expect(rootView.find('.markdown-preview')).toExist() - expect(markdownPreviewView.loadHtml).toHaveBeenCalled() - markdownPreviewView.trigger('markdown-preview:toggle') - expect(rootView.find('.markdown-preview')).not.toExist() + describe "when the active item is an edit session", -> + beforeEach -> + rootView.attachToDom() - it "displays a preview for a .markdown file", -> - rootView.open('file.markdown') - editor = rootView.getActiveEditor() - expect(rootView.find('.markdown-preview')).not.toExist() - editor.trigger('markdown-preview:toggle') - expect(rootView.find('.markdown-preview')).toExist() - markdownPreviewView = rootView.find('.markdown-preview')?.view() - expect(markdownPreviewView.loadHtml).toHaveBeenCalled() + describe "when a preview item has not been created for the edit session's uri", -> + describe "when there is more than one pane", -> + it "shows a markdown preview for the current buffer on the next pane", -> + rootView.getActivePane().splitRight() + [pane1, pane2] = rootView.getPanes() + pane1.focus() - it "displays a preview for a file with the source.gfm grammar scope", -> - gfmGrammar = _.find syntax.grammars, (grammar) -> grammar.scopeName is 'source.gfm' - rootView.open('file.js') - editor = rootView.getActiveEditor() - project.addGrammarOverrideForPath(editor.getPath(), gfmGrammar) - editor.reloadGrammar() - expect(rootView.find('.markdown-preview')).not.toExist() - editor.trigger('markdown-preview:toggle') - expect(rootView.find('.markdown-preview')).toExist() - markdownPreviewView = rootView.find('.markdown-preview')?.view() - expect(markdownPreviewView.loadHtml).toHaveBeenCalled() + rootView.getActiveView().trigger 'markdown-preview:show' - it "does not display a preview for non-markdown file", -> - rootView.open('file.js') - editor = rootView.getActiveEditor() - expect(rootView.find('.markdown-preview')).not.toExist() - editor.trigger('markdown-preview:toggle') - expect(rootView.find('.markdown-preview')).not.toExist() - expect(MarkdownPreview.prototype.loadHtml).not.toHaveBeenCalled() + preview = pane2.activeItem + expect(preview).toBeInstanceOf(MarkdownPreviewView) + expect(preview.buffer).toBe rootView.getActivePaneItem().buffer + expect(pane1).toMatchSelector(':has(:focus)') - describe "core:cancel event", -> - it "removes markdown preview", -> - rootView.open('file.md') - editor = rootView.getActiveEditor() - expect(rootView.find('.markdown-preview')).not.toExist() - editor.trigger('markdown-preview:toggle') + describe "when there is only one pane", -> + it "splits the current pane to the right with a markdown preview for the current buffer", -> + expect(rootView.getPanes()).toHaveLength 1 - markdownPreviewView = rootView.find('.markdown-preview')?.view() - expect(markdownPreviewView).toExist() - markdownPreviewView.trigger('core:cancel') - expect(rootView.find('.markdown-preview')).not.toExist() + rootView.getActiveView().trigger 'markdown-preview:show' - describe "when the editor receives focus", -> - it "removes the markdown preview view", -> - rootView.attachToDom() - rootView.open('file.md') - editor = rootView.getActiveEditor() - expect(rootView.find('.markdown-preview')).not.toExist() - editor.trigger('markdown-preview:toggle') + expect(rootView.getPanes()).toHaveLength 2 + [pane1, pane2] = rootView.getPanes() - markdownPreviewView = rootView.find('.markdown-preview') - editor.focus() - expect(markdownPreviewView).toExist() - expect(rootView.find('.markdown-preview')).not.toExist() + expect(pane2.items).toHaveLength 1 + preview = pane2.activeItem + expect(preview).toBeInstanceOf(MarkdownPreviewView) + expect(preview.buffer).toBe rootView.getActivePaneItem().buffer + expect(pane1).toMatchSelector(':has(:focus)') - describe "when no editor is open", -> - it "does not attach", -> - expect(rootView.getActiveEditor()).toBeFalsy() - rootView.trigger('markdown-preview:toggle') - expect(rootView.find('.markdown-preview')).not.toExist() + describe "when a preview item has already been created for the edit session's uri", -> + it "updates and shows the existing preview item if it isn't displayed", -> + rootView.getActiveView().trigger 'markdown-preview:show' + [pane1, pane2] = rootView.getPanes() + pane2.focus() + expect(rootView.getActivePane()).toBe pane2 + preview = pane2.activeItem + expect(preview).toBeInstanceOf(MarkdownPreviewView) + rootView.open() + expect(pane2.activeItem).not.toBe preview + pane1.focus() + + preview.fetchRenderedMarkdown.reset() + rootView.getActiveView().trigger 'markdown-preview:show' + expect(preview.fetchRenderedMarkdown).toHaveBeenCalled() + expect(rootView.getPanes()).toHaveLength 2 + expect(pane2.getItems()).toHaveLength 2 + expect(pane2.activeItem).toBe preview + expect(pane1).toMatchSelector(':has(:focus)') diff --git a/src/packages/markdown-preview/spec/markdown-preview-view-spec.coffee b/src/packages/markdown-preview/spec/markdown-preview-view-spec.coffee new file mode 100644 index 000000000..42cac9b84 --- /dev/null +++ b/src/packages/markdown-preview/spec/markdown-preview-view-spec.coffee @@ -0,0 +1,39 @@ +MarkdownPreviewView = require 'markdown-preview/lib/markdown-preview-view' +$ = require 'jquery' +{$$$} = require 'space-pen' + +describe "MarkdownPreviewView", -> + [buffer, preview] = [] + + beforeEach -> + spyOn($, 'ajax') + project.setPath(project.resolve('markdown')) + buffer = project.bufferForPath('file.markdown') + preview = new MarkdownPreviewView(buffer) + + afterEach -> + buffer.release() + + describe "on construction", -> + ajaxArgs = null + + beforeEach -> + ajaxArgs = $.ajax.argsForCall[0][0] + + it "shows a loading spinner and fetches the rendered markdown", -> + expect(preview.find('.markdown-spinner')).toExist() + expect($.ajax).toHaveBeenCalled() + + expect(JSON.parse(ajaxArgs.data).text).toBe buffer.getText() + + ajaxArgs.success($$$ -> @div "WWII", class: 'private-ryan') + expect(preview.find(".private-ryan")).toExist() + + it "shows an error message on error", -> + ajaxArgs.error() + expect(preview.text()).toContain "Failed" + + describe "serialization", -> + it "reassociates with the same buffer when deserialized", -> + newPreview = deserialize(preview.serialize()) + expect(newPreview.buffer).toBe buffer diff --git a/src/packages/markdown-preview/stylesheets/markdown-preview.css b/src/packages/markdown-preview/stylesheets/markdown-preview.css deleted file mode 100644 index 1138dc1b7..000000000 --- a/src/packages/markdown-preview/stylesheets/markdown-preview.css +++ /dev/null @@ -1,438 +0,0 @@ -.markdown-preview { - font-family: "Helvetica Neue", Helvetica, sans-serif; - font-size: 14px; - line-height: 1.6; - position: absolute; - width: 100%; - height: 100%; - top: 0px; - left: 0px; - background-color: #fff; - overflow: auto; - z-index: 3; - box-sizing: border-box; - padding: 20px; -} - -.markdown-body { - min-width: 680px; -} - -.markdown-body pre, -.markdown-body code, -.markdown-body tt { - font-size: 12px; - font-family: Consolas, "Liberation Mono", Courier, monospace; -} - -.markdown-body a { - color: #4183c4; -} - -.markdown-body ol > li { - list-style-type: decimal; -} - -.markdown-body ul > li { - list-style-type: disc; -} - -.markdown-spinner { - margin: auto; - background-image: url(images/octocat-spinner-128.gif); - background-repeat: no-repeat; - background-size: 64px; - background-position: top center; - padding-top: 70px; - text-align: center; -} - - -/* this code below was copied from https://github.com/assets/stylesheets/primer/components/markdown.css */ -/* we really need to get primer in here somehow. */ -.markdown-body { - font-size: 14px; - line-height: 1.6; - overflow: hidden; } - .markdown-body > *:first-child { - margin-top: 0 !important; } - .markdown-body > *:last-child { - margin-bottom: 0 !important; } - .markdown-body a.absent { - color: #c00; } - .markdown-body a.anchor { - display: block; - padding-left: 30px; - margin-left: -30px; - cursor: pointer; - position: absolute; - top: 0; - left: 0; - bottom: 0; } - .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { - margin: 20px 0 10px; - padding: 0; - font-weight: bold; - -webkit-font-smoothing: antialiased; - cursor: text; - position: relative; } - .markdown-body h1 .mini-icon-link, .markdown-body h2 .mini-icon-link, .markdown-body h3 .mini-icon-link, .markdown-body h4 .mini-icon-link, .markdown-body h5 .mini-icon-link, .markdown-body h6 .mini-icon-link { - display: none; - color: #000; } - .markdown-body h1:hover a.anchor, .markdown-body h2:hover a.anchor, .markdown-body h3:hover a.anchor, .markdown-body h4:hover a.anchor, .markdown-body h5:hover a.anchor, .markdown-body h6:hover a.anchor { - text-decoration: none; - line-height: 1; - padding-left: 0; - margin-left: -22px; - top: 15%; } - .markdown-body h1:hover a.anchor .mini-icon-link, .markdown-body h2:hover a.anchor .mini-icon-link, .markdown-body h3:hover a.anchor .mini-icon-link, .markdown-body h4:hover a.anchor .mini-icon-link, .markdown-body h5:hover a.anchor .mini-icon-link, .markdown-body h6:hover a.anchor .mini-icon-link { - display: inline-block; } - .markdown-body h1 tt, .markdown-body h1 code, .markdown-body h2 tt, .markdown-body h2 code, .markdown-body h3 tt, .markdown-body h3 code, .markdown-body h4 tt, .markdown-body h4 code, .markdown-body h5 tt, .markdown-body h5 code, .markdown-body h6 tt, .markdown-body h6 code { - font-size: inherit; } - .markdown-body h1 { - font-size: 28px; - color: #000; } - .markdown-body h2 { - font-size: 24px; - border-bottom: 1px solid #ccc; - color: #000; } - .markdown-body h3 { - font-size: 18px; } - .markdown-body h4 { - font-size: 16px; } - .markdown-body h5 { - font-size: 14px; } - .markdown-body h6 { - color: #777; - font-size: 14px; } - .markdown-body p, - .markdown-body blockquote, - .markdown-body ul, .markdown-body ol, .markdown-body dl, - .markdown-body table, - .markdown-body pre { - margin: 15px 0; } - .markdown-body hr { - background: transparent url("https://a248.e.akamai.net/assets.github.com/assets/primer/markdown/dirty-shade-0e7d81b119cc9beae17b0c98093d121fa0050a74.png") repeat-x 0 0; - border: 0 none; - color: #ccc; - height: 4px; - padding: 0; } - .markdown-body > h2:first-child, .markdown-body > h1:first-child, .markdown-body > h1:first-child + h2, .markdown-body > h3:first-child, .markdown-body > h4:first-child, .markdown-body > h5:first-child, .markdown-body > h6:first-child { - margin-top: 0; - padding-top: 0; } - .markdown-body a:first-child h1, .markdown-body a:first-child h2, .markdown-body a:first-child h3, .markdown-body a:first-child h4, .markdown-body a:first-child h5, .markdown-body a:first-child h6 { - margin-top: 0; - padding-top: 0; } - .markdown-body h1 + p, - .markdown-body h2 + p, - .markdown-body h3 + p, - .markdown-body h4 + p, - .markdown-body h5 + p, - .markdown-body h6 + p { - margin-top: 0; } - .markdown-body li p.first { - display: inline-block; } - .markdown-body ul, .markdown-body ol { - padding-left: 30px; } - .markdown-body ul.no-list, .markdown-body ol.no-list { - list-style-type: none; - padding: 0; } - .markdown-body ul li > :first-child, - .markdown-body ul li ul:first-of-type, .markdown-body ol li > :first-child, - .markdown-body ol li ul:first-of-type { - margin-top: 0px; } - .markdown-body ul ul, - .markdown-body ul ol, - .markdown-body ol ol, - .markdown-body ol ul { - margin-bottom: 0; } - .markdown-body dl { - padding: 0; } - .markdown-body dl dt { - font-size: 14px; - font-weight: bold; - font-style: italic; - padding: 0; - margin: 15px 0 5px; } - .markdown-body dl dt:first-child { - padding: 0; } - .markdown-body dl dt > :first-child { - margin-top: 0px; } - .markdown-body dl dt > :last-child { - margin-bottom: 0px; } - .markdown-body dl dd { - margin: 0 0 15px; - padding: 0 15px; } - .markdown-body dl dd > :first-child { - margin-top: 0px; } - .markdown-body dl dd > :last-child { - margin-bottom: 0px; } - .markdown-body blockquote { - border-left: 4px solid #DDD; - padding: 0 15px; - color: #777; } - .markdown-body blockquote > :first-child { - margin-top: 0px; } - .markdown-body blockquote > :last-child { - margin-bottom: 0px; } - .markdown-body table th { - font-weight: bold; } - .markdown-body table th, .markdown-body table td { - border: 1px solid #ccc; - padding: 6px 13px; } - .markdown-body table tr { - border-top: 1px solid #ccc; - background-color: #fff; } - .markdown-body table tr:nth-child(2n) { - background-color: #f8f8f8; } - .markdown-body img { - max-width: 100%; - -moz-box-sizing: border-box; - box-sizing: border-box; } - .markdown-body span.frame { - display: block; - overflow: hidden; } - .markdown-body span.frame > span { - border: 1px solid #ddd; - display: block; - float: left; - overflow: hidden; - margin: 13px 0 0; - padding: 7px; - width: auto; } - .markdown-body span.frame span img { - display: block; - float: left; } - .markdown-body span.frame span span { - clear: both; - color: #333; - display: block; - padding: 5px 0 0; } - .markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; } - .markdown-body span.align-center > span { - display: block; - overflow: hidden; - margin: 13px auto 0; - text-align: center; } - .markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; } - .markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; } - .markdown-body span.align-right > span { - display: block; - overflow: hidden; - margin: 13px 0 0; - text-align: right; } - .markdown-body span.align-right span img { - margin: 0; - text-align: right; } - .markdown-body span.float-left { - display: block; - margin-right: 13px; - overflow: hidden; - float: left; } - .markdown-body span.float-left span { - margin: 13px 0 0; } - .markdown-body span.float-right { - display: block; - margin-left: 13px; - overflow: hidden; - float: right; } - .markdown-body span.float-right > span { - display: block; - overflow: hidden; - margin: 13px auto 0; - text-align: right; } - .markdown-body code, .markdown-body tt { - margin: 0 2px; - padding: 0px 5px; - border: 1px solid #eaeaea; - background-color: #f8f8f8; - border-radius: 3px; } - .markdown-body code { - white-space: nowrap; } - .markdown-body pre > code { - margin: 0; - padding: 0; - white-space: pre; - border: none; - background: transparent; } - .markdown-body .highlight pre, .markdown-body pre { - background-color: #f8f8f8; - border: 1px solid #ccc; - font-size: 13px; - line-height: 19px; - overflow: auto; - padding: 6px 10px; - border-radius: 3px; } - .markdown-body pre code, .markdown-body pre tt { - margin: 0; - padding: 0; - background-color: transparent; - border: none; } - -/* this code was copied from https://github.com/assets/stylesheets/primer/components/pygments.css */ -/* the .markdown-body class was then added to all rules */ -.markdown-body .highlight { - background: #ffffff; } - .markdown-body .highlight .c { - color: #999988; - font-style: italic; } - .markdown-body .highlight .err { - color: #a61717; - background-color: #e3d2d2; } - .markdown-body .highlight .k { - font-weight: bold; } - .markdown-body .highlight .o { - font-weight: bold; } - .markdown-body .highlight .cm { - color: #999988; - font-style: italic; } - .markdown-body .highlight .cp { - color: #999999; - font-weight: bold; } - .markdown-body .highlight .c1 { - color: #999988; - font-style: italic; } - .markdown-body .highlight .cs { - color: #999999; - font-weight: bold; - font-style: italic; } - .markdown-body .highlight .gd { - color: #000000; - background-color: #ffdddd; } - .markdown-body .highlight .gd .x { - color: #000000; - background-color: #ffaaaa; } - .markdown-body .highlight .ge { - font-style: italic; } - .markdown-body .highlight .gr { - color: #aa0000; } - .markdown-body .highlight .gh { - color: #999999; } - .markdown-body .highlight .gi { - color: #000000; - background-color: #ddffdd; } - .markdown-body .highlight .gi .x { - color: #000000; - background-color: #aaffaa; } - .markdown-body .highlight .go { - color: #888888; } - .markdown-body .highlight .gp { - color: #555555; } - .markdown-body .highlight .gs { - font-weight: bold; } - .markdown-body .highlight .gu { - color: #800080; - font-weight: bold; } - .markdown-body .highlight .gt { - color: #aa0000; } - .markdown-body .highlight .kc { - font-weight: bold; } - .markdown-body .highlight .kd { - font-weight: bold; } - .markdown-body .highlight .kn { - font-weight: bold; } - .markdown-body .highlight .kp { - font-weight: bold; } - .markdown-body .highlight .kr { - font-weight: bold; } - .markdown-body .highlight .kt { - color: #445588; - font-weight: bold; } - .markdown-body .highlight .m { - color: #009999; } - .markdown-body .highlight .s { - color: #d14; } - .markdown-body .highlight .na { - color: #008080; } - .markdown-body .highlight .nb { - color: #0086B3; } - .markdown-body .highlight .nc { - color: #445588; - font-weight: bold; } - .markdown-body .highlight .no { - color: #008080; } - .markdown-body .highlight .ni { - color: #800080; } - .markdown-body .highlight .ne { - color: #990000; - font-weight: bold; } - .markdown-body .highlight .nf { - color: #990000; - font-weight: bold; } - .markdown-body .highlight .nn { - color: #555555; } - .markdown-body .highlight .nt { - color: #000080; } - .markdown-body .highlight .nv { - color: #008080; } - .markdown-body .highlight .ow { - font-weight: bold; } - .markdown-body .highlight .w { - color: #bbbbbb; } - .markdown-body .highlight .mf { - color: #009999; } - .markdown-body .highlight .mh { - color: #009999; } - .markdown-body .highlight .mi { - color: #009999; } - .markdown-body .highlight .mo { - color: #009999; } - .markdown-body .highlight .sb { - color: #d14; } - .markdown-body .highlight .sc { - color: #d14; } - .markdown-body .highlight .sd { - color: #d14; } - .markdown-body .highlight .s2 { - color: #d14; } - .markdown-body .highlight .se { - color: #d14; } - .markdown-body .highlight .sh { - color: #d14; } - .markdown-body .highlight .si { - color: #d14; } - .markdown-body .highlight .sx { - color: #d14; } - .markdown-body .highlight .sr { - color: #009926; } - .markdown-body .highlight .s1 { - color: #d14; } - .markdown-body .highlight .ss { - color: #990073; } - .markdown-body .highlight .bp { - color: #999999; } - .markdown-body .highlight .vc { - color: #008080; } - .markdown-body .highlight .vg { - color: #008080; } - .markdown-body .highlight .vi { - color: #008080; } - .markdown-body .highlight .il { - color: #009999; } - .markdown-body .highlight .gc { - color: #999; - background-color: #EAF2F5; } - -.type-csharp .markdown-body .highlight .k { - color: #0000FF; } -.type-csharp .markdown-body .highlight .kt { - color: #0000FF; } -.type-csharp .markdown-body .highlight .nf { - color: #000000; - font-weight: normal; } -.type-csharp .markdown-body .highlight .nc { - color: #2B91AF; } -.type-csharp .markdown-body .highlight .nn { - color: #000000; } -.type-csharp .markdown-body .highlight .s { - color: #A31515; } -.type-csharp .markdown-body .highlight .sc { - color: #A31515; } diff --git a/src/packages/markdown-preview/stylesheets/markdown-preview.less b/src/packages/markdown-preview/stylesheets/markdown-preview.less new file mode 100644 index 000000000..608389a88 --- /dev/null +++ b/src/packages/markdown-preview/stylesheets/markdown-preview.less @@ -0,0 +1,403 @@ +.markdown-preview { + font-family: "Helvetica Neue", Helvetica, sans-serif; + font-size: 14px; + line-height: 1.6; + background-color: #fff; + overflow: scroll; + box-sizing: border-box; + padding: 20px; +} + +.markdown-spinner { + margin: auto; + background-image: url(images/octocat-spinner-128.gif); + background-repeat: no-repeat; + background-size: 64px; + background-position: top center; + padding-top: 70px; + text-align: center; +} + +// This is styling for generic markdownized text. Anything you put in a +// container with .markdown-preview on it should render generally well. It also +// includes some GitHub Flavored Markdown specific styling (like @mentions) +.markdown-preview { + pre, + code, + tt { + font-size: 12px; + font-family: Consolas, "Liberation Mono", Courier, monospace; + } + + a { + color: #4183c4; + } + + ol > li { + list-style-type: decimal; + } + + ul > li { + list-style-type: disc; + } + + & > *:first-child { + margin-top: 0 !important; + } + + & > *:last-child { + margin-bottom: 0 !important; + } + + // Link Colors + a.absent { + color: #c00; + } + + a.anchor { + display: block; + padding-left: 30px; + margin-left: -30px; + cursor: pointer; + position: absolute; + top: 0; + left: 0; + bottom: 0; + } + + // Headings + h1, h2, h3, h4, h5, h6 { + margin: 20px 0 10px; + padding: 0; + font-weight: bold; + -webkit-font-smoothing: antialiased; + cursor: text; + position: relative; + + .mini-icon-link { + display: none; + color: #000; + } + + &:hover a.anchor { + text-decoration: none; + line-height: 1; + padding-left: 0; + margin-left: -22px; + top: 15%; + + .mini-icon-link { + display: inline-block; + } + } + tt, code { + font-size: inherit; + } + } + + h1 { + font-size: 28px; + color: #000; + } + + h2 { + font-size: 24px; + border-bottom: 1px solid #ccc; + color: #000; + } + + h3 { + font-size: 18px; + } + + h4 { + font-size: 16px; + } + + h5 { + font-size: 14px; + } + + h6 { + color: #777; + font-size: 14px; + } + + p, + blockquote, + ul, ol, dl, + table, + pre { + margin: 15px 0; + } + + hr { + background: transparent; + border: 0 none; + color: #ccc; + height: 4px; + padding: 0; + } + + & > h2:first-child, + & > h1:first-child, + & > h1:first-child + h2, + & > h3:first-child, + & > h4:first-child, + & > h5:first-child, + & > h6:first-child { + margin-top: 0; + padding-top: 0; + } + + // fixes margin on shit like: + // + //

The Heading

+ a:first-child { + h1, h2, h3, h4, h5, h6 { + margin-top: 0; + padding-top: 0; + } + } + + h1 + p, + h2 + p, + h3 + p, + h4 + p, + h5 + p, + h6 + p { + margin-top: 0; + } + + // ReST first graf in nested list + li p.first { + display: inline-block; + } + + // Lists, Blockquotes & Such + ul, ol { + padding-left: 30px; + + &.no-list { + list-style-type: none; + padding: 0; + } + + li > :first-child, + li ul:first-of-type { + margin-top: 0px; + } + } + + ul ul, + ul ol, + ol ol, + ol ul { + margin-bottom: 0; + } + + dl { + padding: 0; + } + + dl dt { + font-size: 14px; + font-weight: bold; + font-style: italic; + padding: 0; + margin: 15px 0 5px; + + &:first-child { + padding: 0; + } + & > :first-child { + margin-top: 0px; + } + + & > :last-child { + margin-bottom: 0px; + } + } + + dl dd { + margin: 0 0 15px; + padding: 0 15px; + & > :first-child { + margin-top: 0px; + } + + & > :last-child { + margin-bottom: 0px; + } + } + + blockquote { + border-left: 4px solid #DDD; + padding: 0 15px; + color: #777; + + & > :first-child { + margin-top: 0px; + } + + & > :last-child { + margin-bottom: 0px; + } + } + + // Tables + table { + + th { + font-weight: bold; + } + + th, td { + border: 1px solid #ccc; + padding: 6px 13px; + } + + tr { + border-top: 1px solid #ccc; + background-color: #fff; + + &:nth-child(2n) { + background-color: #f8f8f8; + } + } + } + + // Images & Stuff + img { + max-width: 100%; + @include box-sizing(); + } + + // Gollum Image Tags + + // Framed + span.frame { + display: block; + overflow: hidden; + + & > span { + border: 1px solid #ddd; + display: block; + float: left; + overflow: hidden; + margin: 13px 0 0; + padding: 7px; + width: auto; + } + + span img { + display: block; + float: left; + } + + span span { + clear: both; + color: #333; + display: block; + padding: 5px 0 0; + } + } + + span.align-center { + display: block; + overflow: hidden; + clear: both; + + & > span { + display: block; + overflow: hidden; + margin: 13px auto 0; + text-align: center; + } + + span img { + margin: 0 auto; + text-align: center; + } + } + + span.align-right { + display: block; + overflow: hidden; + clear: both; + + & > span { + display: block; + overflow: hidden; + margin: 13px 0 0; + text-align: right; + } + + span img { + margin: 0; + text-align: right; + } + } + + span.float-left { + display: block; + margin-right: 13px; + overflow: hidden; + float: left; + + span { + margin: 13px 0 0; + } + } + + span.float-right { + display: block; + margin-left: 13px; + overflow: hidden; + float: right; + + & > span { + display: block; + overflow: hidden; + margin: 13px auto 0; + text-align: right; + } + } + + // Inline code snippets + code, tt { + margin: 0 2px; + padding: 0px 5px; + border: 1px solid #eaeaea; + background-color: #f8f8f8; + border-radius:3px; + } + + code { white-space: nowrap; } + + // Code tags within code blocks (
s)
+  pre > code {
+    margin: 0;
+    padding: 0;
+    white-space: pre;
+    border: none;
+    background: transparent;
+  }
+
+  .highlight pre, pre {
+    background-color: #f8f8f8;
+    border: 1px solid #ccc;
+    font-size: 13px;
+    line-height: 19px;
+    overflow: auto;
+    padding: 6px 10px;
+    border-radius:3px;
+  }
+
+  pre code, pre tt {
+    margin: 0;
+    padding: 0;
+    background-color: transparent;
+    border: none;
+  }
+}
diff --git a/src/packages/markdown-preview/stylesheets/pygments.less b/src/packages/markdown-preview/stylesheets/pygments.less
new file mode 100644
index 000000000..f3faab07a
--- /dev/null
+++ b/src/packages/markdown-preview/stylesheets/pygments.less
@@ -0,0 +1,201 @@
+.highlight  {
+  background: #ffffff;
+
+  // Comment
+  .c { color: #999988; font-style: italic }
+
+  // Error
+  .err { color: #a61717; background-color: #e3d2d2 }
+
+  // Keyword
+  .k { font-weight: bold }
+
+  // Operator
+  .o { font-weight: bold }
+
+  // Comment.Multiline
+  .cm { color: #999988; font-style: italic }
+
+  // Comment.Preproc
+  .cp { color: #999999; font-weight: bold }
+
+  // Comment.Single
+  .c1 { color: #999988; font-style: italic }
+
+  // Comment.Special
+  .cs { color: #999999; font-weight: bold; font-style: italic }
+
+  // Generic.Deleted
+  .gd { color: #000000; background-color: #ffdddd }
+
+  // Generic.Deleted.Specific
+  .gd .x { color: #000000; background-color: #ffaaaa }
+
+  // Generic.Emph
+  .ge { font-style: italic }
+
+  // Generic.Error
+  .gr { color: #aa0000 }
+
+  // Generic.Heading
+  .gh { color: #999999 }
+
+  // Generic.Inserted
+  .gi { color: #000000; background-color: #ddffdd }
+
+  // Generic.Inserted.Specific
+  .gi .x { color: #000000; background-color: #aaffaa }
+
+  // Generic.Output
+  .go { color: #888888 }
+
+  // Generic.Prompt
+  .gp { color: #555555 }
+
+  // Generic.Strong
+  .gs { font-weight: bold }
+
+  // Generic.Subheading
+  .gu { color: #800080; font-weight: bold; }
+
+  // Generic.Traceback
+  .gt { color: #aa0000 }
+
+  // Keyword.Constant
+  .kc { font-weight: bold }
+
+  // Keyword.Declaration
+  .kd { font-weight: bold }
+
+  // Keyword.Namespace
+  .kn { font-weight: bold }
+
+  // Keyword.Pseudo
+  .kp { font-weight: bold }
+
+  // Keyword.Reserved
+  .kr { font-weight: bold }
+
+  // Keyword.Type
+  .kt { color: #445588; font-weight: bold }
+
+  // Literal.Number
+  .m { color: #009999 }
+
+  // Literal.String
+  .s { color: #d14 }
+
+  // Name
+  .n { color: #333333 }
+
+  // Name.Attribute
+  .na { color: #008080 }
+
+  // Name.Builtin
+  .nb { color: #0086B3 }
+
+  // Name.Class
+  .nc { color: #445588; font-weight: bold }
+
+  // Name.Constant
+  .no { color: #008080 }
+
+  // Name.Entity
+  .ni { color: #800080 }
+
+  // Name.Exception
+  .ne { color: #990000; font-weight: bold }
+
+  // Name.Function
+  .nf { color: #990000; font-weight: bold }
+
+  // Name.Namespace
+  .nn { color: #555555 }
+
+  // Name.Tag
+  .nt { color: #000080 }
+
+  // Name.Variable
+  .nv { color: #008080 }
+
+  // Operator.Word
+  .ow { font-weight: bold }
+
+  // Text.Whitespace
+  .w { color: #bbbbbb }
+
+  // Literal.Number.Float
+  .mf { color: #009999 }
+
+  // Literal.Number.Hex
+  .mh { color: #009999 }
+
+  // Literal.Number.Integer
+  .mi { color: #009999 }
+
+  // Literal.Number.Oct
+  .mo { color: #009999 }
+
+  // Literal.String.Backtick
+  .sb { color: #d14 }
+
+  // Literal.String.Char
+  .sc { color: #d14 }
+
+  // Literal.String.Doc
+  .sd { color: #d14 }
+
+  // Literal.String.Double
+  .s2 { color: #d14 }
+
+  // Literal.String.Escape
+  .se { color: #d14 }
+
+  // Literal.String.Heredoc
+  .sh { color: #d14 }
+
+  // Literal.String.Interpol
+  .si { color: #d14 }
+
+  // Literal.String.Other
+  .sx { color: #d14 }
+
+  // Literal.String.Regex
+  .sr { color: #009926 }
+
+  // Literal.String.Single
+  .s1 { color: #d14 }
+
+  // Literal.String.Symbol
+  .ss { color: #990073 }
+
+  // Name.Builtin.Pseudo
+  .bp { color: #999999 }
+
+  // Name.Variable.Class
+  .vc { color: #008080 }
+
+  // Name.Variable.Global
+  .vg { color: #008080 }
+
+  // Name.Variable.Instance
+  .vi { color: #008080 }
+
+  // Literal.Number.Integer.Long
+  .il { color: #009999 }
+
+  .gc {
+    color: #999;
+    background-color: #EAF2F5;
+  }
+}
+
+.type-csharp .highlight {
+  .k { color: #0000FF }
+  .kt { color: #0000FF }
+  .nf { color: #000000; font-weight: normal }
+  .nc { color: #2B91AF }
+  .nn { color: #000000 }
+  .s { color: #A31515 }
+  .sc { color: #A31515 }
+}
diff --git a/src/packages/package-generator/lib/package-generator-view.coffee b/src/packages/package-generator/lib/package-generator-view.coffee
index cc3f8100d..a38315446 100644
--- a/src/packages/package-generator/lib/package-generator-view.coffee
+++ b/src/packages/package-generator/lib/package-generator-view.coffee
@@ -62,6 +62,7 @@ class PackageGeneratorView extends View
     for path in fs.listTree(templatePath)
       relativePath = path.replace(templatePath, "")
       relativePath = relativePath.replace(/^\//, '')
+      relativePath = relativePath.replace(/\.template$/, '')
       relativePath = @replacePackageNamePlaceholders(relativePath, packageName)
 
       sourcePath = fs.join(@getPackagePath(), relativePath)
@@ -73,7 +74,7 @@ class PackageGeneratorView extends View
         fs.write(sourcePath, content)
 
   replacePackageNamePlaceholders: (string, packageName) ->
-    placeholderRegex = /##(?:(package-name)|([pP]ackageName)|(package_name))##/g
+    placeholderRegex = /__(?:(package-name)|([pP]ackageName)|(package_name))__/g
     string = string.replace placeholderRegex, (match, dash, camel, underscore) ->
       if dash
         _.dasherize(packageName)
diff --git a/src/packages/package-generator/spec/package-generator-spec.coffee b/src/packages/package-generator/spec/package-generator-spec.coffee
index 690443457..c9dd0c727 100644
--- a/src/packages/package-generator/spec/package-generator-spec.coffee
+++ b/src/packages/package-generator/spec/package-generator-spec.coffee
@@ -21,11 +21,11 @@ describe 'Package Generator', ->
       rootView.trigger("package-generator:generate")
       packageGeneratorView = rootView.find(".package-generator").view()
       expect(packageGeneratorView.miniEditor.isFocused).toBeTruthy()
-      expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+      expect(rootView.getActiveView().isFocused).toBeFalsy()
 
       packageGeneratorView.trigger("core:cancel")
       expect(packageGeneratorView.hasParent()).toBeFalsy()
-      expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+      expect(rootView.getActiveView().isFocused).toBeTruthy()
 
   describe "when a package is generated", ->
     [packageName, packagePath] = []
@@ -37,12 +37,6 @@ describe 'Package Generator', ->
       packagePath = "/tmp/atom-packages/#{packageName}"
       fs.remove(packagePath) if fs.exists(packagePath)
 
-      @addMatchers
-        toExistOnDisk: (expected) ->
-          notText = this.isNot and " not" or ""
-          @message = -> return "Expected path '" + @actual + "'" + notText + " to exist."
-          fs.exists(@actual)
-
     afterEach ->
       fs.remove(packagePath) if fs.exists(packagePath)
 
@@ -58,6 +52,7 @@ describe 'Package Generator', ->
       expect(fs.join(fs.directory(packagePath), "camel-case-is-for-the-birds")).toExistOnDisk()
 
     it "correctly lays out the package files and closes the package generator view", ->
+      rootView.attachToDom()
       rootView.trigger("package-generator:generate")
       packageGeneratorView = rootView.find(".package-generator").view()
       expect(packageGeneratorView.hasParent()).toBeTruthy()
@@ -73,7 +68,7 @@ describe 'Package Generator', ->
       expect("#{packagePath}/stylesheets/#{packageName}.css").toExistOnDisk()
 
       expect(packageGeneratorView.hasParent()).toBeFalsy()
-      expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+      expect(rootView.getActiveView().isFocused).toBeTruthy()
 
     it "replaces instances of packageName placeholders in template files", ->
       rootView.trigger("package-generator:generate")
diff --git a/src/packages/package-generator/stylesheets/package-generator.css b/src/packages/package-generator/stylesheets/package-generator.less
similarity index 100%
rename from src/packages/package-generator/stylesheets/package-generator.css
rename to src/packages/package-generator/stylesheets/package-generator.less
diff --git a/src/packages/package-generator/template/keymaps/##package-name##.cson b/src/packages/package-generator/template/keymaps/__package-name__.cson.template
similarity index 51%
rename from src/packages/package-generator/template/keymaps/##package-name##.cson
rename to src/packages/package-generator/template/keymaps/__package-name__.cson.template
index c85d84e8a..b3213a6a9 100644
--- a/src/packages/package-generator/template/keymaps/##package-name##.cson
+++ b/src/packages/package-generator/template/keymaps/__package-name__.cson.template
@@ -1,3 +1,3 @@
 # DOCUMENT: link to keymap documentation
 'body':
-  'meta-alt-ctrl-o': '##package-name##:toggle'
\ No newline at end of file
+  'meta-alt-ctrl-o': '__package-name__:toggle'
diff --git a/src/packages/package-generator/template/lib/##package-name##.coffee b/src/packages/package-generator/template/lib/##package-name##.coffee
deleted file mode 100644
index 3f36f47f5..000000000
--- a/src/packages/package-generator/template/lib/##package-name##.coffee
+++ /dev/null
@@ -1,13 +0,0 @@
-##PackageName##View = require '##package-name##/lib/##package-name##-view'
-
-module.exports =
-  ##packageName##View: null
-
-  activate: (state) ->
-    @##packageName##View = new ##PackageName##View(state.##packageName##ViewState)
-
-  deactivate: ->
-    @##packageName##View.destroy()
-
-  serialize: ->
-    ##packageName##ViewState: @##packageName##View.serialize()
diff --git a/src/packages/package-generator/template/lib/##package-name##-view.coffee b/src/packages/package-generator/template/lib/__package-name__-view.coffee.template
similarity index 56%
rename from src/packages/package-generator/template/lib/##package-name##-view.coffee
rename to src/packages/package-generator/template/lib/__package-name__-view.coffee.template
index 681e3668f..215b8fdbf 100644
--- a/src/packages/package-generator/template/lib/##package-name##-view.coffee
+++ b/src/packages/package-generator/template/lib/__package-name__-view.coffee.template
@@ -1,13 +1,13 @@
 {$$, View} = require 'space-pen'
 
 module.exports =
-class ##PackageName##View extends View
+class __PackageName__View extends View
   @content: ->
-    @div class: '##package-name## overlay from-top', =>
-      @div "The ##PackageName## package is Alive! It's ALIVE!", class: "message"
+    @div class: '__package-name__ overlay from-top', =>
+      @div "The __PackageName__ package is Alive! It's ALIVE!", class: "message"
 
   initialize: (serializeState) ->
-    rootView.command "##package-name##:toggle", => @toggle()
+    rootView.command "__package-name__:toggle", => @toggle()
 
   # Returns an object that can be retrieved when package is activated
   serialize: ->
@@ -17,7 +17,7 @@ class ##PackageName##View extends View
     @detach()
 
   toggle: ->
-    console.log "##PackageName##View was toggled!"
+    console.log "__PackageName__View was toggled!"
     if @hasParent()
       @detach()
     else
diff --git a/src/packages/package-generator/template/lib/__package-name__.coffee.template b/src/packages/package-generator/template/lib/__package-name__.coffee.template
new file mode 100644
index 000000000..e619c5b84
--- /dev/null
+++ b/src/packages/package-generator/template/lib/__package-name__.coffee.template
@@ -0,0 +1,13 @@
+__PackageName__View = require '__package-name__/lib/__package-name__-view'
+
+module.exports =
+  __packageName__View: null
+
+  activate: (state) ->
+    @__packageName__View = new __PackageName__View(state.__packageName__ViewState)
+
+  deactivate: ->
+    @__packageName__View.destroy()
+
+  serialize: ->
+    __packageName__ViewState: @__packageName__View.serialize()
diff --git a/src/packages/package-generator/template/package.cson b/src/packages/package-generator/template/package.cson
index 4f80472b5..7374ea007 100644
--- a/src/packages/package-generator/template/package.cson
+++ b/src/packages/package-generator/template/package.cson
@@ -1,2 +1,2 @@
-'main': 'lib/##package-name##'
-'activationEvents': ['##package-name##:toggle']
\ No newline at end of file
+'main': 'lib/__package-name__'
+'activationEvents': ['__package-name__:toggle']
diff --git a/src/packages/package-generator/template/spec/##package-name##-spec.coffee b/src/packages/package-generator/template/spec/##package-name##-spec.coffee
deleted file mode 100644
index afb95afa2..000000000
--- a/src/packages/package-generator/template/spec/##package-name##-spec.coffee
+++ /dev/null
@@ -1,5 +0,0 @@
-##PackageName## = require '##package-name##/lib/##package-name##'
-
-describe "##PackageName##", ->
-  it "has one valid test", ->
-    expect("life").toBe "easy"
diff --git a/src/packages/package-generator/template/spec/##package-name##-view-spec.coffee b/src/packages/package-generator/template/spec/##package-name##-view-spec.coffee
deleted file mode 100644
index e1e4cbf40..000000000
--- a/src/packages/package-generator/template/spec/##package-name##-view-spec.coffee
+++ /dev/null
@@ -1,21 +0,0 @@
-##PackageName##View = require '##package-name##/lib/##package-name##-view'
-RootView = require 'root-view'
-
-# This spec is focused because it starts with an `f`. Remove the `f`
-# to unfocus the spec.
-#
-# Press meta-alt-ctrl-s to run the specs
-fdescribe "##PackageName##View", ->
-  ##packageName## = null
-
-  beforeEach ->
-    window.rootView = new RootView
-    ##packageName## = window.loadPackage('##packageName##', activateImmediately: true)
-
-  describe "when the ##package-name##:toggle event is triggered", ->
-    it "attaches and then detaches the view", ->
-      expect(rootView.find('.##package-name##')).not.toExist()
-      rootView.trigger '##package-name##:toggle'
-      expect(rootView.find('.##package-name##')).toExist()
-      rootView.trigger '##package-name##:toggle'
-      expect(rootView.find('.##package-name##')).not.toExist()
diff --git a/src/packages/package-generator/template/spec/__package-name__-spec.coffee.template b/src/packages/package-generator/template/spec/__package-name__-spec.coffee.template
new file mode 100644
index 000000000..3bc7b1091
--- /dev/null
+++ b/src/packages/package-generator/template/spec/__package-name__-spec.coffee.template
@@ -0,0 +1,5 @@
+__PackageName__ = require '__package-name__/lib/__package-name__'
+
+describe "__PackageName__", ->
+  it "has one valid test", ->
+    expect("life").toBe "easy"
diff --git a/src/packages/package-generator/template/spec/__package-name__-view-spec.coffee.template b/src/packages/package-generator/template/spec/__package-name__-view-spec.coffee.template
new file mode 100644
index 000000000..a8b62caf7
--- /dev/null
+++ b/src/packages/package-generator/template/spec/__package-name__-view-spec.coffee.template
@@ -0,0 +1,21 @@
+__PackageName__View = require '__package-name__/lib/__package-name__-view'
+RootView = require 'root-view'
+
+# This spec is focused because it starts with an `f`. Remove the `f`
+# to unfocus the spec.
+#
+# Press meta-alt-ctrl-s to run the specs
+fdescribe "__PackageName__View", ->
+  __packageName__ = null
+
+  beforeEach ->
+    window.rootView = new RootView
+    __packageName__ = window.loadPackage('__packageName__', activateImmediately: true)
+
+  describe "when the __package-name__:toggle event is triggered", ->
+    it "attaches and then detaches the view", ->
+      expect(rootView.find('.__package-name__')).not.toExist()
+      rootView.trigger '__package-name__:toggle'
+      expect(rootView.find('.__package-name__')).toExist()
+      rootView.trigger '__package-name__:toggle'
+      expect(rootView.find('.__package-name__')).not.toExist()
diff --git a/src/packages/package-generator/template/stylesheets/##package-name##.css b/src/packages/package-generator/template/stylesheets/##package-name##.css
deleted file mode 100644
index 4562c6786..000000000
--- a/src/packages/package-generator/template/stylesheets/##package-name##.css
+++ /dev/null
@@ -1,2 +0,0 @@
-.##package-name## {
-}
\ No newline at end of file
diff --git a/src/packages/package-generator/template/stylesheets/__package-name__.css.template b/src/packages/package-generator/template/stylesheets/__package-name__.css.template
new file mode 100644
index 000000000..f6fe86ba8
--- /dev/null
+++ b/src/packages/package-generator/template/stylesheets/__package-name__.css.template
@@ -0,0 +1,2 @@
+.__package-name__ {
+}
diff --git a/src/packages/snippets/spec/snippets-spec.coffee b/src/packages/snippets/spec/snippets-spec.coffee
index c6e997d35..b77b2d80b 100644
--- a/src/packages/snippets/spec/snippets-spec.coffee
+++ b/src/packages/snippets/spec/snippets-spec.coffee
@@ -21,8 +21,8 @@ describe "Snippets extension", ->
 
     window.loadPackage("snippets")
 
-    editor = rootView.getActiveEditor()
-    editSession = rootView.getActiveEditSession()
+    editor = rootView.getActiveView()
+    editSession = rootView.getActivePaneItem()
     buffer = editor.getBuffer()
     rootView.simulateDomAttachment()
     rootView.enableKeymap()
@@ -300,7 +300,7 @@ describe "Snippets extension", ->
       jasmine.unspy(LoadSnippetsTask.prototype, 'loadTextMateSnippets')
       snippets.loaded = false
       task = new LoadSnippetsTask(snippets)
-      task.packages = [Package.build(fixturesProject.resolve('packages/package-with-a-cson-grammar.tmbundle'))]
+      task.packages = [Package.build(project.resolve('packages/package-with-a-cson-grammar.tmbundle'))]
       task.start()
 
       waitsFor "CSON snippets to load", 5000, -> snippets.loaded
diff --git a/src/packages/spell-check/lib/misspelling-view.coffee b/src/packages/spell-check/lib/misspelling-view.coffee
index eeb537671..ba57e4164 100644
--- a/src/packages/spell-check/lib/misspelling-view.coffee
+++ b/src/packages/spell-check/lib/misspelling-view.coffee
@@ -39,6 +39,11 @@ class MisspellingView extends View
   getScreenRange: ->
     new Range(@startPosition, @endPosition)
 
+  unsubscribe: ->
+    super
+
+    @editSession.destroyMarker(@marker)
+
   containsCursor: ->
     cursor = @editor.getCursorScreenPosition()
     @getScreenRange().containsPoint(cursor, exclusive: false)
diff --git a/src/packages/spell-check/spec/spell-check-spec.coffee b/src/packages/spell-check/spec/spell-check-spec.coffee
index abdb98ac3..0756dbdeb 100644
--- a/src/packages/spell-check/spec/spell-check-spec.coffee
+++ b/src/packages/spell-check/spec/spell-check-spec.coffee
@@ -7,9 +7,9 @@ describe "Spell check", ->
     window.rootView = new RootView
     rootView.open('sample.js')
     config.set('spell-check.grammars', [])
-    window.loadPackage('spell-check')
+    window.loadPackage('spell-check', activateImmediately: true)
     rootView.attachToDom()
-    editor = rootView.getActiveEditor()
+    editor = rootView.getActiveView()
 
   it "decorates all misspelled words", ->
     editor.setText("This middle of thiss sentencts has issues.")
@@ -96,3 +96,19 @@ describe "Spell check", ->
           expect(editor.find('.corrections').length).toBe 1
           expect(editor.find('.corrections li').length).toBe 0
           expect(editor.find('.corrections .error').text()).toBe "No corrections found"
+
+  describe "when the edit session is destroyed", ->
+    it "destroys all misspelling markers", ->
+      editor.setText("mispelling")
+      config.set('spell-check.grammars', ['source.js'])
+
+      waitsFor ->
+        editor.find('.misspelling').length > 0
+
+      runs ->
+        expect(editor.find('.misspelling').length).toBe 1
+        view = editor.find('.misspelling').view()
+        buffer = editor.getBuffer()
+        expect(buffer.getMarkerPosition(view.marker)).not.toBeUndefined()
+        editor.remove()
+        expect(buffer.getMarkerPosition(view.marker)).toBeUndefined()
diff --git a/src/packages/spell-check/stylesheets/spell-check.css b/src/packages/spell-check/stylesheets/spell-check.less
similarity index 100%
rename from src/packages/spell-check/stylesheets/spell-check.css
rename to src/packages/spell-check/stylesheets/spell-check.less
diff --git a/src/packages/status-bar/lib/status-bar-view.coffee b/src/packages/status-bar/lib/status-bar-view.coffee
index ffd51486b..f58d4dba0 100644
--- a/src/packages/status-bar/lib/status-bar-view.coffee
+++ b/src/packages/status-bar/lib/status-bar-view.coffee
@@ -17,6 +17,8 @@ class StatusBarView extends View
       @span class: 'git-branch', outlet: 'branchArea', =>
         @span class: 'octicons branch-icon'
         @span class: 'branch-label', outlet: 'branchLabel'
+        @span class: 'octicons commits-ahead-label', outlet: 'commitsAhead'
+        @span class: 'octicons commits-behind-label', outlet: 'commitsBehind'
         @span class: 'git-status', outlet: 'gitStatusIcon'
       @span class: 'file-info', =>
         @span class: 'current-path', outlet: 'currentPath'
@@ -32,18 +34,21 @@ class StatusBarView extends View
 
     @updateCursorPositionText()
     @subscribe @editor, 'cursor:moved', => @updateCursorPositionText()
-    @subscribe $(window), 'focus', => @updateStatusBar()
     @subscribe @grammarName, 'click', => @editor.trigger 'editor:select-grammar'
     @subscribe @editor, 'editor:grammar-changed', => @updateGrammarText()
+    if git?
+      @subscribe git, 'status-changed', (path, status) =>
+        @updateStatusBar() if path is @buffer?.getPath()
+      @subscribe git, 'statuses-changed', =>
+        @updateStatusBar()
 
     @subscribeToBuffer()
 
   subscribeToBuffer: ->
     @buffer?.off '.status-bar'
     @buffer = @editor.getBuffer()
-    @buffer.on 'contents-modified.status-bar', (e) => @updateBufferHasModifiedText(e.differsFromDisk)
+    @buffer.on 'modified-status-changed.status-bar', (isModified) => @updateBufferHasModifiedText(isModified)
     @buffer.on 'saved.status-bar', => @updateStatusBar()
-    @buffer.on 'git-status-changed.status-bar', => @updateStatusBar()
     @updateStatusBar()
 
   updateStatusBar: ->
@@ -55,8 +60,8 @@ class StatusBarView extends View
   updateGrammarText: ->
     @grammarName.text(@editor.getGrammar().name)
 
-  updateBufferHasModifiedText: (differsFromDisk)->
-    if differsFromDisk
+  updateBufferHasModifiedText: (isModified)->
+    if isModified
       @bufferModified.text('*') unless @isModified
       @isModified = true
     else
@@ -68,7 +73,7 @@ class StatusBarView extends View
     @branchArea.hide()
     return unless path
 
-    head = @buffer.getRepo()?.getShortHead() or ''
+    head = git?.getShortHead() or ''
     @branchLabel.text(head)
     @branchArea.show() if head
 
@@ -78,10 +83,19 @@ class StatusBarView extends View
     return unless path
 
     @gitStatusIcon.addClass('git-status octicons')
-    git = @buffer.getRepo()
-    return unless git
+    return unless git?
 
-    status = git.getPathStatus(path)
+    if git.upstream.ahead > 0
+      @commitsAhead.text(git.upstream.ahead).show()
+    else
+      @commitsAhead.hide()
+
+    if git.upstream.behind > 0
+      @commitsBehind.text(git.upstream.behind).show()
+    else
+      @commitsBehind.hide()
+
+    status = git.statuses[path]
     if git.isStatusModified(status)
       @gitStatusIcon.addClass('modified-status-icon')
       stats = git.getDiffStats(path)
diff --git a/src/packages/status-bar/spec/status-bar-spec.coffee b/src/packages/status-bar/spec/status-bar-spec.coffee
index 177a2b6a2..f7a88cf4d 100644
--- a/src/packages/status-bar/spec/status-bar-spec.coffee
+++ b/src/packages/status-bar/spec/status-bar-spec.coffee
@@ -12,7 +12,7 @@ describe "StatusBar", ->
     rootView.open('sample.js')
     rootView.simulateDomAttachment()
     StatusBar.activate()
-    editor = rootView.getActiveEditor()
+    editor = rootView.getActiveView()
     statusBar = rootView.find('.status-bar').view()
     buffer = editor.getBuffer()
 
@@ -63,7 +63,7 @@ describe "StatusBar", ->
       editor.insertText("\n")
       advanceClock(buffer.stoppedChangingDelay)
       expect(statusBar.bufferModified.text()).toBe '*'
-      editor.save()
+      editor.getBuffer().save()
       expect(statusBar.bufferModified.text()).toBe ''
 
     it "disables the buffer modified indicator if the content matches again", ->
@@ -130,6 +130,8 @@ describe "StatusBar", ->
       path = require.resolve('fixtures/git/working-dir/file.txt')
       newPath = fs.join(require.resolve('fixtures/git/working-dir'), 'new.txt')
       fs.write(newPath, "I'm new here")
+      git.getPathStatus(path)
+      git.getPathStatus(newPath)
       originalPathText = fs.read(path)
       rootView.attachToDom()
 
@@ -139,6 +141,7 @@ describe "StatusBar", ->
 
     it "displays the modified icon for a changed file", ->
       fs.write(path, "i've changed for the worse")
+      git.getPathStatus(path)
       rootView.open(path)
       expect(statusBar.gitStatusIcon).toHaveClass('modified-status-icon')
 
@@ -150,24 +153,18 @@ describe "StatusBar", ->
       rootView.open(newPath)
       expect(statusBar.gitStatusIcon).toHaveClass('new-status-icon')
 
-    it "updates when a git-status-changed event occurs", ->
+    it "updates when a status-changed event occurs", ->
       fs.write(path, "i've changed for the worse")
+      git.getPathStatus(path)
       rootView.open(path)
       expect(statusBar.gitStatusIcon).toHaveClass('modified-status-icon')
       fs.write(path, originalPathText)
-      rootView.getActiveEditor().getBuffer().trigger 'git-status-changed'
-      expect(statusBar.gitStatusIcon).not.toHaveClass('modified-status-icon')
-
-    it "updates when the window receives focus", ->
-      fs.write(path, "i've changed for the worse")
-      rootView.open(path)
-      expect(statusBar.gitStatusIcon).toHaveClass('modified-status-icon')
-      fs.write(path, originalPathText)
-      $(window).trigger 'focus'
+      git.getPathStatus(path)
       expect(statusBar.gitStatusIcon).not.toHaveClass('modified-status-icon')
 
     it "displays the diff stat for modified files", ->
       fs.write(path, "i've changed for the worse")
+      git.getPathStatus(path)
       rootView.open(path)
       expect(statusBar.gitStatusIcon).toHaveText('+1,-1')
 
diff --git a/src/packages/strip-trailing-whitespace/spec/strip-trailing-whitespace-spec.coffee b/src/packages/strip-trailing-whitespace/spec/strip-trailing-whitespace-spec.coffee
index f76f5b38d..17277b059 100644
--- a/src/packages/strip-trailing-whitespace/spec/strip-trailing-whitespace-spec.coffee
+++ b/src/packages/strip-trailing-whitespace/spec/strip-trailing-whitespace-spec.coffee
@@ -12,7 +12,7 @@ describe "StripTrailingWhitespace", ->
 
     window.loadPackage('strip-trailing-whitespace')
     rootView.focus()
-    editor = rootView.getActiveEditor()
+    editor = rootView.getActiveView()
 
   afterEach ->
     fs.remove(path) if fs.exists(path)
@@ -23,7 +23,7 @@ describe "StripTrailingWhitespace", ->
 
     # works for buffers that are already open when extension is initialized
     editor.insertText("foo   \nbar\t   \n\nbaz")
-    editor.save()
+    editor.getBuffer().save()
     expect(editor.getText()).toBe "foo\nbar\n\nbaz"
 
     # works for buffers that are opened after extension is initialized
@@ -47,25 +47,25 @@ describe "StripTrailingWhitespace", ->
 
     it "adds a trailing newline when there is no trailing newline", ->
       editor.insertText "foo"
-      editor.save()
+      editor.getBuffer().save()
       expect(editor.getText()).toBe "foo\n"
 
     it "removes extra trailing newlines and only keeps one", ->
       editor.insertText "foo\n\n\n\n"
-      editor.save()
+      editor.getBuffer().save()
       expect(editor.getText()).toBe "foo\n"
 
     it "leaves a buffer with a single trailing newline untouched", ->
       editor.insertText "foo\nbar\n"
-      editor.save()
+      editor.getBuffer().save()
       expect(editor.getText()).toBe "foo\nbar\n"
 
     it "leaves an empty buffer untouched", ->
       editor.insertText ""
-      editor.save()
+      editor.getBuffer().save()
       expect(editor.getText()).toBe ""
 
     it "leaves a buffer that is a single newline untouched", ->
       editor.insertText "\n"
-      editor.save()
+      editor.getBuffer().save()
       expect(editor.getText()).toBe "\n"
diff --git a/src/packages/symbols-view/lib/symbols-view.coffee b/src/packages/symbols-view/lib/symbols-view.coffee
index fb87d78de..10f347b66 100644
--- a/src/packages/symbols-view/lib/symbols-view.coffee
+++ b/src/packages/symbols-view/lib/symbols-view.coffee
@@ -43,7 +43,7 @@ class SymbolsView extends SelectList
   populateFileSymbols: ->
     tags = []
     callback = (tag) -> tags.push tag
-    path = rootView.getActiveEditor().getPath()
+    path = rootView.getActiveView().getPath()
     @list.empty()
     @setLoading("Generating symbols...")
     new TagGenerator(path, callback).generate().done =>
@@ -91,7 +91,7 @@ class SymbolsView extends SelectList
     @moveToPosition(position) if position
 
   moveToPosition: (position) ->
-    editor = rootView.getActiveEditor()
+    editor = rootView.getActiveView()
     editor.scrollToBufferPosition(position, center: true)
     editor.setCursorBufferPosition(position)
     editor.moveCursorToFirstCharacterOfLine()
@@ -111,7 +111,7 @@ class SymbolsView extends SelectList
       return new Point(index, 0) if pattern is $.trim(line)
 
   goToDeclaration: ->
-    editor = rootView.getActiveEditor()
+    editor = rootView.getActiveView()
     matches = TagReader.find(editor)
     return unless matches.length
 
diff --git a/src/packages/symbols-view/spec/symbols-view-spec.coffee b/src/packages/symbols-view/spec/symbols-view-spec.coffee
index dcc5c5556..30d9f69be 100644
--- a/src/packages/symbols-view/spec/symbols-view-spec.coffee
+++ b/src/packages/symbols-view/spec/symbols-view-spec.coffee
@@ -19,7 +19,7 @@ describe "SymbolsView", ->
   describe "when tags can be generated for a file", ->
     it "initially displays all JavaScript functions with line numbers", ->
       rootView.open('sample.js')
-      rootView.getActiveEditor().trigger "symbols-view:toggle-file-symbols"
+      rootView.getActiveView().trigger "symbols-view:toggle-file-symbols"
       symbolsView = rootView.find('.symbols-view').view()
       expect(symbolsView.find('.loading')).toHaveText 'Generating symbols...'
 
@@ -39,7 +39,7 @@ describe "SymbolsView", ->
 
     it "displays error when no tags match text in mini-editor", ->
       rootView.open('sample.js')
-      rootView.getActiveEditor().trigger "symbols-view:toggle-file-symbols"
+      rootView.getActiveView().trigger "symbols-view:toggle-file-symbols"
       symbolsView = rootView.find('.symbols-view').view()
 
       waitsFor ->
@@ -66,7 +66,7 @@ describe "SymbolsView", ->
   describe "when tags can't be generated for a file", ->
     it "shows an error message when no matching tags are found", ->
       rootView.open('sample.txt')
-      rootView.getActiveEditor().trigger "symbols-view:toggle-file-symbols"
+      rootView.getActiveView().trigger "symbols-view:toggle-file-symbols"
       symbolsView = rootView.find('.symbols-view').view()
       setErrorSpy = spyOn(symbolsView, "setError").andCallThrough()
 
@@ -93,14 +93,14 @@ describe "SymbolsView", ->
 
     runs ->
       rootView.open('sample.js')
-      expect(rootView.getActiveEditor().getCursorBufferPosition()).toEqual [0,0]
+      expect(rootView.getActiveView().getCursorBufferPosition()).toEqual [0,0]
       expect(rootView.find('.symbols-view')).not.toExist()
       symbolsView = SymbolsView.activate()
       symbolsView.setArray(tags)
       symbolsView.attach()
       expect(rootView.find('.symbols-view')).toExist()
       symbolsView.confirmed(tags[1])
-      expect(rootView.getActiveEditor().getCursorBufferPosition()).toEqual [1,2]
+      expect(rootView.getActiveView().getCursorBufferPosition()).toEqual [1,2]
 
   describe "TagGenerator", ->
     it "generates tags for all JavaScript functions", ->
@@ -136,29 +136,29 @@ describe "SymbolsView", ->
   describe "go to declaration", ->
     it "doesn't move the cursor when no declaration is found", ->
       rootView.open("tagged.js")
-      editor = rootView.getActiveEditor()
+      editor = rootView.getActiveView()
       editor.setCursorBufferPosition([0,2])
       editor.trigger 'symbols-view:go-to-declaration'
       expect(editor.getCursorBufferPosition()).toEqual [0,2]
 
     it "moves the cursor to the declaration", ->
       rootView.open("tagged.js")
-      editor = rootView.getActiveEditor()
+      editor = rootView.getActiveView()
       editor.setCursorBufferPosition([6,24])
       editor.trigger 'symbols-view:go-to-declaration'
       expect(editor.getCursorBufferPosition()).toEqual [2,0]
 
     it "displays matches when more than one exists and opens the selected match", ->
       rootView.open("tagged.js")
-      editor = rootView.getActiveEditor()
+      editor = rootView.getActiveView()
       editor.setCursorBufferPosition([8,14])
       editor.trigger 'symbols-view:go-to-declaration'
       symbolsView = rootView.find('.symbols-view').view()
       expect(symbolsView.list.children('li').length).toBe 2
       expect(symbolsView).toBeVisible()
       symbolsView.confirmed(symbolsView.array[0])
-      expect(rootView.getActiveEditor().getPath()).toBe project.resolve("tagged-duplicate.js")
-      expect(rootView.getActiveEditor().getCursorBufferPosition()).toEqual [0,4]
+      expect(rootView.getActiveView().getPath()).toBe project.resolve("tagged-duplicate.js")
+      expect(rootView.getActiveView().getCursorBufferPosition()).toEqual [0,4]
 
     describe "when the tag is in a file that doesn't exist", ->
       renamedPath = null
@@ -173,7 +173,7 @@ describe "SymbolsView", ->
 
       it "doesn't display the tag", ->
         rootView.open("tagged.js")
-        editor = rootView.getActiveEditor()
+        editor = rootView.getActiveView()
         editor.setCursorBufferPosition([8,14])
         editor.trigger 'symbols-view:go-to-declaration'
         symbolsView = rootView.find('.symbols-view').view()
diff --git a/src/packages/tabs/lib/tab-bar-view.coffee b/src/packages/tabs/lib/tab-bar-view.coffee
new file mode 100644
index 000000000..fbbab1330
--- /dev/null
+++ b/src/packages/tabs/lib/tab-bar-view.coffee
@@ -0,0 +1,98 @@
+$ = require 'jquery'
+_ = require 'underscore'
+SortableList = require 'sortable-list'
+TabView = require './tab-view'
+
+module.exports =
+class TabBarView extends SortableList
+  @content: ->
+    @ul class: "tabs #{@viewClass()}"
+
+  initialize: (@pane) ->
+    super
+
+    @paneContainer = @pane.getContainer()
+    @addTabForItem(item) for item in @pane.getItems()
+
+    @pane.on 'pane:item-added', (e, item, index) => @addTabForItem(item, index)
+    @pane.on 'pane:item-moved', (e, item, index) => @moveItemTabToIndex(item, index)
+    @pane.on 'pane:item-removed', (e, item) => @removeTabForItem(item)
+    @pane.on 'pane:active-item-changed', => @updateActiveTab()
+
+    @updateActiveTab()
+
+    @on 'click', '.tab', (e) =>
+      tab = $(e.target).closest('.tab').view()
+      @pane.showItem(tab.item)
+      @pane.focus()
+
+    @on 'click', '.tab .close-icon', (e) =>
+      tab = $(e.target).closest('.tab').view()
+      @pane.destroyItem(tab.item)
+      false
+
+    @pane.prepend(this)
+
+  addTabForItem: (item, index) ->
+    @insertTabAtIndex(new TabView(item, @pane), index)
+
+  moveItemTabToIndex: (item, index) ->
+    tab = @tabForItem(item)
+    tab.detach()
+    @insertTabAtIndex(tab, index)
+
+  insertTabAtIndex: (tab, index) ->
+    followingTab = @tabAtIndex(index) if index?
+    if followingTab
+      tab.insertBefore(followingTab)
+    else
+      @append(tab)
+
+  removeTabForItem: (item) ->
+    @tabForItem(item).remove()
+
+  getTabs: ->
+    @children('.tab').toArray().map (elt) -> $(elt).view()
+
+  tabAtIndex: (index) ->
+    @children(".tab:eq(#{index})").view()
+
+  tabForItem: (item) ->
+    _.detect @getTabs(), (tab) -> tab.item is item
+
+  setActiveTab: (tabView) ->
+    unless tabView.hasClass('active')
+      @find(".tab.active").removeClass('active')
+      tabView.addClass('active')
+
+  updateActiveTab: ->
+    @setActiveTab(@tabForItem(@pane.activeItem))
+
+  shouldAllowDrag: ->
+    (@paneContainer.getPanes().length > 1) or (@pane.getItems().length > 1)
+
+  onDragStart: (event) =>
+    super
+    pane = $(event.target).closest('.pane')
+    paneIndex = @paneContainer.indexOfPane(pane)
+    event.originalEvent.dataTransfer.setData 'from-pane-index', paneIndex
+
+  onDrop: (event) =>
+    super
+
+    dataTransfer  = event.originalEvent.dataTransfer
+    fromIndex     = parseInt(dataTransfer.getData('sortable-index'))
+    fromPaneIndex = parseInt(dataTransfer.getData('from-pane-index'))
+    fromPane      = @paneContainer.paneAtIndex(fromPaneIndex)
+    toIndex       = @getSortableElement(event).index()
+    toPane        = $(event.target).closest('.pane').view()
+    draggedTab    = fromPane.find(".tabs .sortable:eq(#{fromIndex})").view()
+    item          = draggedTab.item
+
+    if toPane is fromPane
+      toIndex++ if fromIndex > toIndex
+      toPane.moveItem(item, toIndex)
+    else
+      fromPane.moveItemToPane(item, toPane, toIndex)
+    toPane.showItem(item)
+    toPane.focus()
diff --git a/src/packages/tabs/lib/tab-view.coffee b/src/packages/tabs/lib/tab-view.coffee
index 166233094..9bfee397e 100644
--- a/src/packages/tabs/lib/tab-view.coffee
+++ b/src/packages/tabs/lib/tab-view.coffee
@@ -1,100 +1,55 @@
 $ = require 'jquery'
-SortableList = require 'sortable-list'
-Tab = require './tab'
+{View} = require 'space-pen'
+fs = require 'fs'
 
 module.exports =
-class TabView extends SortableList
-  @activate: ->
-    rootView.eachEditor (editor) =>
-      @prependToEditorPane(editor) if editor.attached
-
-  @prependToEditorPane: (editor) ->
-    if pane = editor.pane()
-      pane.prepend(new TabView(editor))
-
+class TabView extends View
   @content: ->
-    @ul class: "tabs #{@viewClass()}"
+    @li class: 'tab sortable', =>
+      @span class: 'title', outlet: 'title'
+      @span class: 'close-icon'
 
-  initialize: (@editor) ->
-    super
+  initialize: (@item, @pane) ->
+    @item.on? 'title-changed', => @updateTitle()
+    @item.on? 'modified-status-changed', => @updateModifiedStatus()
+    @updateTitle()
+    @updateModifiedStatus()
 
-    @addTabForEditSession(editSession) for editSession in @editor.editSessions
+  updateTitle: ->
+    return if @updatingTitle
+    @updatingTitle = true
 
-    @setActiveTab(@editor.getActiveEditSessionIndex())
-    @editor.on 'editor:active-edit-session-changed', (e, editSession, index) => @setActiveTab(index)
-    @editor.on 'editor:edit-session-added', (e, editSession) => @addTabForEditSession(editSession)
-    @editor.on 'editor:edit-session-removed', (e, editSession, index) => @removeTabAtIndex(index)
-    @editor.on 'editor:edit-session-order-changed', (e, editSession, fromIndex, toIndex) =>
-      fromTab = @find(".tab:eq(#{fromIndex})")
-      toTab = @find(".tab:eq(#{toIndex})")
-      fromTab.detach()
-      if fromIndex < toIndex
-        fromTab.insertAfter(toTab)
-      else
-        fromTab.insertBefore(toTab)
+    title = @item.getTitle()
+    useLongTitle = false
+    for tab in @getSiblingTabs()
+      if tab.item.getTitle() is title
+        tab.updateTitle()
+        useLongTitle = true
+    title = @item.getLongTitle?() ? title if useLongTitle
 
-    @on 'click', '.tab', (e) =>
-      @editor.setActiveEditSessionIndex($(e.target).closest('.tab').index())
-      @editor.focus()
+    @title.text(title)
+    @updatingTitle = false
 
-    @on 'click', '.tab .close-icon', (e) =>
-      index = $(e.target).closest('.tab').index()
-      @editor.destroyEditSessionIndex(index)
-      false
+  getSiblingTabs: ->
+    @siblings('.tab').views()
 
-  addTabForEditSession: (editSession) ->
-    @append(new Tab(editSession, @editor))
-
-  setActiveTab: (index) ->
-    @find(".tab.active").removeClass('active')
-    @find(".tab:eq(#{index})").addClass('active')
-
-  removeTabAtIndex: (index) ->
-    @find(".tab:eq(#{index})").remove()
-
-  containsEditSession: (editor, editSession) ->
-    for session in editor.editSessions
-      return true if editSession.getPath() is session.getPath()
-
-  shouldAllowDrag: (event) ->
-    panes = rootView.find('.pane')
-    !(panes.length == 1 && panes.find('.sortable').length == 1)
-
-  onDragStart: (event) =>
-    super
-
-    pane = $(event.target).closest('.pane')
-    paneIndex = rootView.indexOfPane(pane)
-    event.originalEvent.dataTransfer.setData 'from-pane-index', paneIndex
-
-  onDrop: (event) =>
-    super
-
-    droppedNearTab = @getSortableElement(event)
-    transfer = event.originalEvent.dataTransfer
-    previousDraggedTabIndex = transfer.getData 'sortable-index'
-
-    fromPaneIndex = ~~transfer.getData 'from-pane-index'
-    toPaneIndex   = rootView.indexOfPane($(event.target).closest('.pane'))
-    fromPane      = $(rootView.find('.pane')[fromPaneIndex])
-    fromEditor    = fromPane.find('.editor').view()
-    draggedTab    = fromPane.find(".#{TabView.viewClass()} .sortable:eq(#{previousDraggedTabIndex})")
-
-    if draggedTab.is(droppedNearTab)
-      fromEditor.focus()
-      return
-
-    if fromPaneIndex == toPaneIndex
-      droppedNearTab = @getSortableElement(event)
-      fromIndex = draggedTab.index()
-      toIndex = droppedNearTab.index()
-      toIndex++ if fromIndex > toIndex
-      fromEditor.moveEditSessionToIndex(fromIndex, toIndex)
-      fromEditor.focus()
+  updateModifiedStatus: ->
+    if @item.isModified?()
+      @addClass('modified') unless @isModified
+      @isModified = true
     else
-      toEditor = rootView.find(".pane:eq(#{toPaneIndex}) > .editor").view()
-      if @containsEditSession(toEditor, fromEditor.editSessions[draggedTab.index()])
-        fromEditor.focus()
-      else
-        fromEditor.moveEditSessionToEditor(draggedTab.index(), toEditor, droppedNearTab.index() + 1)
-        toEditor.focus()
+      @removeClass('modified') if @isModified
+      @isModified = false
+
+  updateFileName: ->
+    fileNameText = @editSession.buffer.getBaseName()
+    if fileNameText?
+      duplicates = @editor.getEditSessions().filter (session) -> fileNameText is session.buffer.getBaseName()
+      if duplicates.length > 1
+        directory = fs.base(fs.directory(@editSession.getPath()))
+        fileNameText = "#{fileNameText} - #{directory}" if directory
+    else
+      fileNameText = 'untitled'
+
+    @fileName.text(fileNameText)
+    @fileName.attr('title', @editSession.getPath())
diff --git a/src/packages/tabs/lib/tab.coffee b/src/packages/tabs/lib/tab.coffee
deleted file mode 100644
index bcc055f1d..000000000
--- a/src/packages/tabs/lib/tab.coffee
+++ /dev/null
@@ -1,41 +0,0 @@
-{View} = require 'space-pen'
-fs = require 'fs'
-
-module.exports =
-class Tab extends View
-  @content: (editSession) ->
-    @li class: 'tab sortable', =>
-      @span class: 'file-name', outlet: 'fileName'
-      @span class: 'close-icon'
-
-  initialize: (@editSession, @editor) ->
-    @buffer = @editSession.buffer
-    @subscribe @buffer, 'path-changed', => @updateFileName()
-    @subscribe @buffer, 'contents-modified', => @updateModifiedStatus()
-    @subscribe @buffer, 'saved', => @updateModifiedStatus()
-    @subscribe @buffer, 'git-status-changed', => @updateModifiedStatus()
-    @subscribe @editor, 'editor:edit-session-added', => @updateFileName()
-    @subscribe @editor, 'editor:edit-session-removed', => @updateFileName()
-    @updateFileName()
-    @updateModifiedStatus()
-
-  updateModifiedStatus: ->
-    if @buffer.isModified()
-      @toggleClass('file-modified') unless @isModified
-      @isModified = true
-    else
-      @removeClass('file-modified') if @isModified
-      @isModified = false
-
-  updateFileName: ->
-    fileNameText = @editSession.buffer.getBaseName()
-    if fileNameText?
-      duplicates = @editor.getEditSessions().filter (session) -> fileNameText is session.buffer.getBaseName()
-      if duplicates.length > 1
-        directory = fs.base(fs.directory(@editSession.getPath()))
-        fileNameText = "#{fileNameText} - #{directory}" if directory
-    else
-      fileNameText = 'untitled'
-
-    @fileName.text(fileNameText)
-    @fileName.attr('title', @editSession.getPath())
diff --git a/src/packages/tabs/lib/tabs.coffee b/src/packages/tabs/lib/tabs.coffee
new file mode 100644
index 000000000..ba2da6b3a
--- /dev/null
+++ b/src/packages/tabs/lib/tabs.coffee
@@ -0,0 +1,5 @@
+TabBarView = require './tab-bar-view'
+
+module.exports =
+  activate: ->
+    rootView.eachPane (pane) => new TabBarView(pane)
diff --git a/src/packages/tabs/package.cson b/src/packages/tabs/package.cson
index 0e40dfd74..1c24d65ba 100644
--- a/src/packages/tabs/package.cson
+++ b/src/packages/tabs/package.cson
@@ -1 +1 @@
-'main': 'lib/tab-view'
+'main': 'lib/tabs'
diff --git a/src/packages/tabs/spec/tabs-spec.coffee b/src/packages/tabs/spec/tabs-spec.coffee
index 00efe0324..d8d32e390 100644
--- a/src/packages/tabs/spec/tabs-spec.coffee
+++ b/src/packages/tabs/spec/tabs-spec.coffee
@@ -1,231 +1,256 @@
 $ = require 'jquery'
 _ = require 'underscore'
 RootView = require 'root-view'
+Pane = require 'pane'
+PaneContainer = require 'pane-container'
+TabBarView = require 'tabs/lib/tab-bar-view'
 fs = require 'fs'
+{View} = require 'space-pen'
 
-describe "TabView", ->
-  [editor, buffer, tabs] = []
-
+describe "Tabs package main", ->
   beforeEach ->
     window.rootView = new RootView
     rootView.open('sample.js')
-    rootView.open('sample.txt')
-    rootView.simulateDomAttachment()
     window.loadPackage("tabs")
-    editor = rootView.getActiveEditor()
-    tabs = rootView.find('.tabs').view()
 
-  describe "@activate", ->
-    it "appends a status bear to all existing and new editors", ->
+  describe ".activate()", ->
+    it "appends a tab bar all existing and new panes", ->
       expect(rootView.panes.find('.pane').length).toBe 1
       expect(rootView.panes.find('.pane > .tabs').length).toBe 1
-      editor.splitRight()
+      rootView.getActivePane().splitRight()
       expect(rootView.find('.pane').length).toBe 2
       expect(rootView.panes.find('.pane > .tabs').length).toBe 2
 
-  describe ".initialize()", ->
-    it "creates a tab for each edit session on the editor to which the tab-strip belongs", ->
-      expect(editor.editSessions.length).toBe 2
-      expect(tabs.find('.tab').length).toBe 2
+describe "TabBarView", ->
+  [item1, item2, editSession1, pane, tabBar] = []
 
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe editor.editSessions[0].buffer.getBaseName()
-      expect(tabs.find('.tab:eq(1) .file-name').text()).toBe editor.editSessions[1].buffer.getBaseName()
+  class TestView extends View
+    @deserialize: ({title, longTitle}) -> new TestView(title, longTitle)
+    @content: (title) -> @div title
+    initialize: (@title, @longTitle) ->
+    getTitle: -> @title
+    getLongTitle: -> @longTitle
+    serialize: -> { deserializer: 'TestView', @title, @longTitle }
 
-    it "highlights the tab for the current active edit session", ->
-      expect(editor.getActiveEditSessionIndex()).toBe 1
-      expect(tabs.find('.tab:eq(1)')).toHaveClass 'active'
+  beforeEach ->
+    registerDeserializer(TestView)
+    item1 = new TestView('Item 1')
+    item2 = new TestView('Item 2')
+    editSession1 = project.buildEditSession('sample.js')
+    paneContainer = new PaneContainer
+    pane = new Pane(item1, editSession1, item2)
+    pane.showItem(item2)
+    paneContainer.append(pane)
+    tabBar = new TabBarView(pane)
 
-    it "sets the title on each tab to be the full path of the edit session", ->
-      expect(tabs.find('.tab:eq(0) .file-name').attr('title')).toBe editor.editSessions[0].getPath()
-      expect(tabs.find('.tab:eq(1) .file-name').attr('title')).toBe editor.editSessions[1].getPath()
+  afterEach ->
+    unregisterDeserializer(TestView)
 
-  describe "when the active edit session changes", ->
-    it "highlights the tab for the newly-active edit session", ->
-      editor.setActiveEditSessionIndex(0)
-      expect(tabs.find('.active').length).toBe 1
-      expect(tabs.find('.tab:eq(0)')).toHaveClass 'active'
+  describe ".initialize(pane)", ->
+    it "creates a tab for each item on the tab bar's parent pane", ->
+      expect(pane.getItems().length).toBe 3
+      expect(tabBar.find('.tab').length).toBe 3
 
-      editor.setActiveEditSessionIndex(1)
-      expect(tabs.find('.active').length).toBe 1
-      expect(tabs.find('.tab:eq(1)')).toHaveClass 'active'
+      expect(tabBar.find('.tab:eq(0) .title').text()).toBe item1.getTitle()
+      expect(tabBar.find('.tab:eq(1) .title').text()).toBe editSession1.getTitle()
+      expect(tabBar.find('.tab:eq(2) .title').text()).toBe item2.getTitle()
 
-  describe "when a new edit session is created", ->
-    it "adds a tab for the new edit session", ->
-      rootView.open('two-hundred.txt')
-      expect(tabs.find('.tab').length).toBe 3
-      expect(tabs.find('.tab:eq(2) .file-name').text()).toBe 'two-hundred.txt'
+    it "highlights the tab for the active pane item", ->
+      expect(tabBar.find('.tab:eq(2)')).toHaveClass 'active'
 
-    describe "when the edit session's buffer has an undefined path", ->
-      it "makes the tab text 'untitled'", ->
-        rootView.open()
-        expect(tabs.find('.tab').length).toBe 3
-        expect(tabs.find('.tab:eq(2) .file-name').text()).toBe 'untitled'
+  describe "when the active pane item changes", ->
+    it "highlights the tab for the new active pane item", ->
+      pane.showItem(item1)
+      expect(tabBar.find('.active').length).toBe 1
+      expect(tabBar.find('.tab:eq(0)')).toHaveClass 'active'
 
-      it "removes the tab's title", ->
-        rootView.open()
-        expect(tabs.find('.tab').length).toBe 3
-        expect(tabs.find('.tab:eq(2) .file-name').attr('title')).toBeUndefined()
+      pane.showItem(item2)
+      expect(tabBar.find('.active').length).toBe 1
+      expect(tabBar.find('.tab:eq(2)')).toHaveClass 'active'
 
-  describe "when an edit session is removed", ->
-    it "removes the tab for the removed edit session", ->
-      editor.setActiveEditSessionIndex(0)
-      editor.destroyActiveEditSession()
-      expect(tabs.find('.tab').length).toBe 1
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe 'sample.txt'
+  describe "when a new item is added to the pane", ->
+    it "adds a tab for the new item at the same index as the item in the pane", ->
+      pane.showItem(item1)
+      item3 = new TestView('Item 3')
+      pane.showItem(item3)
+      expect(tabBar.find('.tab').length).toBe 4
+      expect(tabBar.tabAtIndex(1).find('.title')).toHaveText 'Item 3'
+
+    it "adds the 'modified' class to the new tab if the item is initially modified", ->
+      editSession2 = project.buildEditSession('sample.txt')
+      editSession2.insertText('x')
+      pane.showItem(editSession2)
+      expect(tabBar.tabForItem(editSession2)).toHaveClass 'modified'
+
+  describe "when an item is removed from the pane", ->
+    it "removes the item's tab from the tab bar", ->
+      pane.removeItem(item2)
+      expect(tabBar.getTabs().length).toBe 2
+      expect(tabBar.find('.tab:contains(Item 2)')).not.toExist()
 
   describe "when a tab is clicked", ->
-    it "activates the associated edit session", ->
-      expect(editor.getActiveEditSessionIndex()).toBe 1
-      tabs.find('.tab:eq(0)').click()
-      expect(editor.getActiveEditSessionIndex()).toBe 0
-      tabs.find('.tab:eq(1)').click()
-      expect(editor.getActiveEditSessionIndex()).toBe 1
+    it "shows the associated item on the pane and focuses the pane", ->
+      spyOn(pane, 'focus')
 
-    it "focuses the associated editor", ->
-      rootView.attachToDom()
-      expect(editor).toMatchSelector ":has(:focus)"
-      editor.splitRight()
-      expect(editor).not.toMatchSelector ":has(:focus)"
-      tabs.find('.tab:eq(0)').click()
-      expect(editor).toMatchSelector ":has(:focus)"
+      tabBar.tabAtIndex(0).click()
+      expect(pane.activeItem).toBe pane.getItems()[0]
 
-  describe "when a file name associated with a tab changes", ->
-    [buffer, oldPath, newPath] = []
+      tabBar.tabAtIndex(2).click()
+      expect(pane.activeItem).toBe pane.getItems()[2]
 
-    beforeEach ->
-      buffer = editor.editSessions[0].buffer
-      oldPath = "/tmp/file-to-rename.txt"
-      newPath = "/tmp/renamed-file.txt"
-      fs.write(oldPath, "this old path")
-      rootView.open(oldPath)
+      expect(pane.focus.callCount).toBe 2
 
-    afterEach ->
-      fs.remove(newPath) if fs.exists(newPath)
+  describe "when a tab's close icon is clicked", ->
+    it "destroys the tab's item on the pane", ->
+      tabBar.tabForItem(editSession1).find('.close-icon').click()
+      expect(pane.getItems().length).toBe 2
+      expect(pane.getItems().indexOf(editSession1)).toBe -1
+      expect(editSession1.destroyed).toBeTruthy()
+      expect(tabBar.getTabs().length).toBe 2
+      expect(tabBar.find('.tab:contains(sample.js)')).not.toExist()
 
-    it "updates the file name in the tab", ->
-      tabFileName = tabs.find('.tab:eq(2) .file-name')
-      expect(tabFileName).toExist()
-      editor.setActiveEditSessionIndex(0)
-      fs.move(oldPath, newPath)
-      waitsFor "file to be renamed", ->
-        tabFileName.text() == "renamed-file.txt"
+  describe "when a tab item's title changes", ->
+    it "updates the title of the item's tab", ->
+      editSession1.buffer.setPath('/this/is-a/test.txt')
+      expect(tabBar.tabForItem(editSession1)).toHaveText 'test.txt'
 
-  describe "when the close icon is clicked", ->
-    it "closes the selected non-active edit session", ->
-      activeSession = editor.activeEditSession
-      expect(editor.getActiveEditSessionIndex()).toBe 1
-      tabs.find('.tab .close-icon:eq(0)').click()
-      expect(editor.getActiveEditSessionIndex()).toBe 0
-      expect(editor.activeEditSession).toBe activeSession
+  describe "when two tabs have the same title", ->
+    it "displays the long title on the tab if it's available from the item", ->
+      item1.title = "Old Man"
+      item1.longTitle = "Grumpy Old Man"
+      item1.trigger 'title-changed'
+      item2.title = "Old Man"
+      item2.longTitle = "Jolly Old Man"
+      item2.trigger 'title-changed'
 
-    it "closes the selected active edit session", ->
-      firstSession = editor.getEditSessions()[0]
-      expect(editor.getActiveEditSessionIndex()).toBe 1
-      tabs.find('.tab .close-icon:eq(1)').click()
-      expect(editor.getActiveEditSessionIndex()).toBe 0
-      expect(editor.activeEditSession).toBe firstSession
+      expect(tabBar.tabForItem(item1)).toHaveText "Grumpy Old Man"
+      expect(tabBar.tabForItem(item2)).toHaveText "Jolly Old Man"
 
-  describe "when two tabs have the same file name", ->
-    [tempPath] = []
+      item2.longTitle = undefined
+      item2.trigger 'title-changed'
 
-    beforeEach ->
-      tempPath = '/tmp/sample.js'
-      fs.write(tempPath, 'sample')
+      expect(tabBar.tabForItem(item1)).toHaveText "Grumpy Old Man"
+      expect(tabBar.tabForItem(item2)).toHaveText "Old Man"
 
-    afterEach ->
-      fs.remove(tempPath) if fs.exists(tempPath)
+  describe "when a tab item's modified status changes", ->
+    it "adds or removes the 'modified' class to the tab based on the status", ->
+      tab = tabBar.tabForItem(editSession1)
+      expect(editSession1.isModified()).toBeFalsy()
+      expect(tab).not.toHaveClass 'modified'
 
-    it "displays the parent folder name after the file name", ->
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe 'sample.js'
-      rootView.open(tempPath)
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe 'sample.js - fixtures'
-      expect(tabs.find('.tab:last .file-name').text()).toBe 'sample.js - tmp'
-      editor.destroyActiveEditSession()
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe 'sample.js'
+      editSession1.insertText('x')
+      advanceClock(editSession1.buffer.stoppedChangingDelay)
+      expect(editSession1.isModified()).toBeTruthy()
+      expect(tab).toHaveClass 'modified'
 
-  describe "when an editor:edit-session-order-changed event is triggered", ->
-    it "updates the order of the tabs to match the new edit session order", ->
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js"
-      expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt"
+      editSession1.undo()
+      advanceClock(editSession1.buffer.stoppedChangingDelay)
+      expect(editSession1.isModified()).toBeFalsy()
+      expect(tab).not.toHaveClass 'modified'
 
-      editor.moveEditSessionToIndex(0, 1)
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.txt"
-      expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.js"
-
-      editor.moveEditSessionToIndex(1, 0)
-      expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js"
-      expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt"
+  describe "when a pane item moves to a new index", ->
+    it "updates the order of the tabs to match the new item order", ->
+      expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "sample.js", "Item 2"]
+      pane.moveItem(item2, 1)
+      expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "Item 2", "sample.js"]
+      pane.moveItem(editSession1, 0)
+      expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["sample.js", "Item 1", "Item 2"]
+      pane.moveItem(item1, 2)
+      expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["sample.js", "Item 2", "Item 1"]
 
   describe "dragging and dropping tabs", ->
-    describe "when the tab is dropped onto itself", ->
-      it "doesn't move the edit session and focuses the editor", ->
-        expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt"
+    buildDragEvents = (dragged, dropTarget) ->
+      dataTransfer =
+        data: {}
+        setData: (key, value) -> @data[key] = value
+        getData: (key) -> @data[key]
 
-        sortableElement = [tabs.find('.tab:eq(0)')]
-        spyOn(tabs, 'getSortableElement').andCallFake -> sortableElement[0]
-        event = $.Event()
-        event.target = tabs[0]
-        event.originalEvent =
-          dataTransfer:
-            data: {}
-            setData: (key, value) -> @data[key] = value
-            getData: (key) -> @data[key]
+      dragStartEvent = $.Event()
+      dragStartEvent.target = dragged[0]
+      dragStartEvent.originalEvent = { dataTransfer }
 
-        editor.hiddenInput.focusout()
-        tabs.onDragStart(event)
-        tabs.onDrop(event)
+      dropEvent = $.Event()
+      dropEvent.target = dropTarget[0]
+      dropEvent.originalEvent = { dataTransfer }
 
-        expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js"
-        expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt"
-        expect(editor.isFocused).toBeTruthy()
+      [dragStartEvent, dropEvent]
 
-    describe "when a tab is dragged from and dropped onto the same editor", ->
-      it "moves the edit session, updates the order of the tabs, and focuses the editor", ->
-        expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.js"
-        expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.txt"
+    describe "when a tab is dragged within the same pane", ->
+      describe "when it is dropped on tab that's later in the list", ->
+        it "moves the tab and its item, shows the tab's item, and focuses the pane", ->
+          expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "sample.js", "Item 2"]
+          expect(pane.getItems()).toEqual [item1, editSession1, item2]
+          expect(pane.activeItem).toBe item2
+          spyOn(pane, 'focus')
 
-        sortableElement = [tabs.find('.tab:eq(0)')]
-        spyOn(tabs, 'getSortableElement').andCallFake -> sortableElement[0]
-        event = $.Event()
-        event.target = tabs[0]
-        event.originalEvent =
-          dataTransfer:
-            data: {}
-            setData: (key, value) -> @data[key] = value
-            getData: (key) -> @data[key]
+          [dragStartEvent, dropEvent] = buildDragEvents(tabBar.tabAtIndex(0), tabBar.tabAtIndex(1))
+          tabBar.onDragStart(dragStartEvent)
+          tabBar.onDrop(dropEvent)
 
-        editor.hiddenInput.focusout()
-        tabs.onDragStart(event)
-        sortableElement = [tabs.find('.tab:eq(1)')]
-        tabs.onDrop(event)
+          expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["sample.js", "Item 1", "Item 2"]
+          expect(pane.getItems()).toEqual [editSession1, item1, item2]
+          expect(pane.activeItem).toBe item1
+          expect(pane.focus).toHaveBeenCalled()
 
-        expect(tabs.find('.tab:eq(0) .file-name').text()).toBe "sample.txt"
-        expect(tabs.find('.tab:eq(1) .file-name').text()).toBe "sample.js"
-        expect(editor.isFocused).toBeTruthy()
+      describe "when it is dropped on a tab that's earlier in the list", ->
+        it "moves the tab and its item, shows the tab's item, and focuses the pane", ->
+          expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "sample.js", "Item 2"]
+          expect(pane.getItems()).toEqual [item1, editSession1, item2]
+          expect(pane.activeItem).toBe item2
+          spyOn(pane, 'focus')
 
-    describe "when a tab is dragged from one editor and dropped onto another editor", ->
-      it "moves the edit session, updates the order of the tabs, and focuses the destination editor", ->
-        leftTabs = tabs
-        rightEditor = editor.splitRight()
-        rightTabs = rootView.find('.tabs:last').view()
+          [dragStartEvent, dropEvent] = buildDragEvents(tabBar.tabAtIndex(2), tabBar.tabAtIndex(0))
+          tabBar.onDragStart(dragStartEvent)
+          tabBar.onDrop(dropEvent)
 
-        sortableElement = [leftTabs.find('.tab:eq(0)')]
-        spyOn(tabs, 'getSortableElement').andCallFake -> sortableElement[0]
-        event = $.Event()
-        event.target = leftTabs
-        event.originalEvent =
-          dataTransfer:
-            data: {}
-            setData: (key, value) -> @data[key] = value
-            getData: (key) -> @data[key]
+          expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "Item 2", "sample.js"]
+          expect(pane.getItems()).toEqual [item1, item2, editSession1]
+          expect(pane.activeItem).toBe item2
+          expect(pane.focus).toHaveBeenCalled()
 
-        rightEditor.hiddenInput.focusout()
-        tabs.onDragStart(event)
+      describe "when it is dropped on itself", ->
+        it "doesn't move the tab or item, but does make it the active item and focuses the pane", ->
+          expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "sample.js", "Item 2"]
+          expect(pane.getItems()).toEqual [item1, editSession1, item2]
+          expect(pane.activeItem).toBe item2
+          spyOn(pane, 'focus')
 
-        event.target = rightTabs
-        sortableElement = [rightTabs.find('.tab:eq(0)')]
-        tabs.onDrop(event)
+          [dragStartEvent, dropEvent] = buildDragEvents(tabBar.tabAtIndex(0), tabBar.tabAtIndex(0))
+          tabBar.onDragStart(dragStartEvent)
+          tabBar.onDrop(dropEvent)
 
-        expect(rightTabs.find('.tab:eq(0) .file-name').text()).toBe "sample.txt"
-        expect(rightTabs.find('.tab:eq(1) .file-name').text()).toBe "sample.js"
-        expect(rightEditor.isFocused).toBeTruthy()
+          expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "sample.js", "Item 2"]
+          expect(pane.getItems()).toEqual [item1, editSession1, item2]
+          expect(pane.activeItem).toBe item1
+          expect(pane.focus).toHaveBeenCalled()
+
+    describe "when a tab is dragged to a different pane", ->
+      [pane2, tabBar2, item2b] = []
+
+      beforeEach ->
+        pane2 = pane.splitRight()
+        [item2b] = pane2.getItems()
+        tabBar2 = new TabBarView(pane2)
+
+      it "removes the tab and item from their original pane and moves them to the target pane", ->
+        expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["Item 1", "sample.js", "Item 2"]
+        expect(pane.getItems()).toEqual [item1, editSession1, item2]
+        expect(pane.activeItem).toBe item2
+
+        expect(tabBar2.getTabs().map (tab) -> tab.text()).toEqual ["Item 2"]
+        expect(pane2.getItems()).toEqual [item2b]
+        expect(pane2.activeItem).toBe item2b
+        spyOn(pane2, 'focus')
+
+        [dragStartEvent, dropEvent] = buildDragEvents(tabBar.tabAtIndex(0), tabBar2.tabAtIndex(0))
+        tabBar.onDragStart(dragStartEvent)
+        tabBar.onDrop(dropEvent)
+
+        expect(tabBar.getTabs().map (tab) -> tab.text()).toEqual ["sample.js", "Item 2"]
+        expect(pane.getItems()).toEqual [editSession1, item2]
+        expect(pane.activeItem).toBe item2
+
+        expect(tabBar2.getTabs().map (tab) -> tab.text()).toEqual ["Item 2", "Item 1"]
+        expect(pane2.getItems()).toEqual [item2b, item1]
+        expect(pane2.activeItem).toBe item1
+        expect(pane2.focus).toHaveBeenCalled()
diff --git a/src/packages/tree-view/lib/directory-view.coffee b/src/packages/tree-view/lib/directory-view.coffee
index 29bd8e767..c6f561c1d 100644
--- a/src/packages/tree-view/lib/directory-view.coffee
+++ b/src/packages/tree-view/lib/directory-view.coffee
@@ -22,22 +22,40 @@ class DirectoryView extends View
     @expand() if isExpanded
     @disclosureArrow.on 'click', => @toggleExpansion()
 
-    repo = @project.repo
     iconClass = 'directory-icon'
-    if repo?
+    if git?
       path = @directory.getPath()
       if parent
-        @directoryName.addClass('ignored') if repo.isPathIgnored(path)
-        iconClass = 'submodule-icon' if repo.isSubmodule(path)
+        if git.isSubmodule(path)
+          iconClass = 'submodule-icon'
+        else
+          @subscribe git, 'status-changed', (path, status) =>
+            @updateStatus() if path.substring("#{@getPath()}/") is 0
+          @subscribe git, 'statuses-changed', =>
+            @updateStatus()
+          @updateStatus()
       else
-        iconClass = 'repository-icon' if path is repo.getWorkingDirectory()
+        iconClass = 'repository-icon' if path is git.getWorkingDirectory()
+
     @directoryName.addClass(iconClass)
 
+  updateStatus: ->
+    @removeClass('ignored modified new')
+    path = @directory.getPath()
+    if git.isPathIgnored(path)
+      @addClass('ignored')
+    else
+      status = git.getDirectoryStatus(path)
+      if git.isStatusModified(status)
+        @addClass('modified')
+      else if git.isStatusNew(status)
+        @addClass('new')
+
   getPath: ->
     @directory.path
 
   isPathIgnored: (path) ->
-    config.get("core.hideGitIgnoredFiles") and @project.repo?.isPathIgnored(path)
+    config.get("core.hideGitIgnoredFiles") and git?.isPathIgnored(path)
 
   buildEntries: ->
     @unwatchDescendantEntries()
diff --git a/src/packages/tree-view/lib/file-view.coffee b/src/packages/tree-view/lib/file-view.coffee
index 0b29d9ee5..f8d7f5115 100644
--- a/src/packages/tree-view/lib/file-view.coffee
+++ b/src/packages/tree-view/lib/file-view.coffee
@@ -14,8 +14,6 @@ class FileView extends View
   file: null
 
   initialize: ({@file, @project} = {}) ->
-    @subscribe $(window), 'focus', => @updateStatus()
-
     extension = fs.extension(@getPath())
     if fs.isReadmePath(@getPath())
       @fileName.addClass('readme-icon')
@@ -30,21 +28,26 @@ class FileView extends View
     else
       @fileName.addClass('text-icon')
 
+    if git?
+      @subscribe git, 'status-changed', (path, status) =>
+        @updateStatus() if path is @getPath()
+      @subscribe git, 'statuses-changed', =>
+        @updateStatus()
+
     @updateStatus()
 
   updateStatus: ->
     @removeClass('ignored modified new')
-    repo = @project.repo
-    return unless repo?
+    return unless git?
 
     path = @getPath()
-    if repo.isPathIgnored(path)
+    if git.isPathIgnored(path)
       @addClass('ignored')
     else
-      status = repo.getPathStatus(path)
-      if repo.isStatusModified(status)
+      status = git.statuses[path]
+      if git.isStatusModified(status)
         @addClass('modified')
-      else if repo.isStatusNew(status)
+      else if git.isStatusNew(status)
         @addClass('new')
 
   getPath: ->
diff --git a/src/packages/tree-view/lib/tree-view.coffee b/src/packages/tree-view/lib/tree-view.coffee
index ee4ebf0c3..5a6ea480f 100644
--- a/src/packages/tree-view/lib/tree-view.coffee
+++ b/src/packages/tree-view/lib/tree-view.coffee
@@ -40,7 +40,7 @@ class TreeView extends ScrollView
       else
         @selectActiveFile()
 
-    rootView.on 'root-view:active-path-changed', => @selectActiveFile()
+    rootView.on 'pane:active-item-changed pane:became-active', => @selectActiveFile()
     project.on 'path-changed', => @updateRoot()
     @observeConfig 'core.hideGitIgnoredFiles', => @updateRoot()
 
@@ -98,7 +98,7 @@ class TreeView extends ScrollView
         @openSelectedEntry(false) if entry instanceof FileView
       when 2
         if entry.is('.selected.file')
-          rootView.getActiveEditor().focus()
+          rootView.getActiveView().focus()
         else if entry.is('.selected.directory')
           entry.toggleExpansion()
 
@@ -119,6 +119,7 @@ class TreeView extends ScrollView
 
   updateRoot: ->
     @root?.remove()
+
     if rootDirectory = project.getRootDirectory()
       @root = new DirectoryView(directory: rootDirectory, isExpanded: true, project: project)
       @treeViewList.append(@root)
@@ -126,14 +127,16 @@ class TreeView extends ScrollView
       @root = null
 
   selectActiveFile: ->
-    activeFilePath = rootView.getActiveEditor()?.getPath()
-    @selectEntryForPath(activeFilePath) if activeFilePath
+    if activeFilePath = rootView.getActiveView()?.getPath?()
+      @selectEntryForPath(activeFilePath)
+    else
+      @deselect()
 
   revealActiveFile: ->
     @attach()
     @focus()
 
-    return unless activeFilePath = rootView.getActiveEditor()?.getPath()
+    return unless activeFilePath = rootView.getActiveView()?.getPath()
 
     activePathComponents = project.relativize(activeFilePath).split('/')
     currentPath = project.getPath().replace(/\/$/, '')
@@ -289,9 +292,12 @@ class TreeView extends ScrollView
     return false unless entry.get(0)
     entry = entry.view() unless entry instanceof View
     @selectedPath = entry.getPath()
-    @treeViewList.find('.selected').removeClass('selected')
+    @deselect()
     entry.addClass('selected')
 
+  deselect: ->
+    @treeViewList.find('.selected').removeClass('selected')
+
   scrollTop: (top) ->
     if top
       @treeViewList.scrollTop(top)
diff --git a/src/packages/tree-view/lib/tree.coffee b/src/packages/tree-view/lib/tree.coffee
index cd43e4467..df3cb45c3 100644
--- a/src/packages/tree-view/lib/tree.coffee
+++ b/src/packages/tree-view/lib/tree.coffee
@@ -2,7 +2,7 @@ module.exports =
   treeView: null
 
   activate: (@state) ->
-    @state.attached ?= true unless rootView.getActiveEditSession()
+    @state.attached ?= true unless rootView.getActivePaneItem()
 
     @createView() if @state.attached
     rootView.command 'tree-view:toggle', => @createView().toggle()
diff --git a/src/packages/tree-view/spec/tree-view-spec.coffee b/src/packages/tree-view/spec/tree-view-spec.coffee
index 73c91d24b..d48f5577c 100644
--- a/src/packages/tree-view/spec/tree-view-spec.coffee
+++ b/src/packages/tree-view/spec/tree-view-spec.coffee
@@ -1,4 +1,5 @@
 $ = require 'jquery'
+{$$} = require 'space-pen'
 _ = require 'underscore'
 TreeView = require 'tree-view/lib/tree-view'
 RootView = require 'root-view'
@@ -49,7 +50,7 @@ describe "TreeView", ->
         rootView.deactivate()
         window.rootView = new RootView()
         rootView.open()
-        treeView = window.loadPackage("tree-view").packageMain.createView()
+        treeView = window.loadPackage("tree-view").mainModule.createView()
 
       it "does not attach to the root view or create a root node when initialized", ->
         expect(treeView.hasParent()).toBeFalsy()
@@ -65,7 +66,7 @@ describe "TreeView", ->
 
       describe "when the project is assigned a path because a new buffer is saved", ->
         it "creates a root directory view but does not attach to the root view", ->
-          rootView.getActiveEditSession().saveAs("/tmp/test.txt")
+          rootView.getActivePaneItem().saveAs("/tmp/test.txt")
           expect(treeView.hasParent()).toBeFalsy()
           expect(treeView.root.getPath()).toBe require.resolve('/tmp')
           expect(treeView.root.parent()).toMatchSelector(".tree-view")
@@ -75,13 +76,13 @@ describe "TreeView", ->
         rootView.deactivate()
         window.rootView = new RootView
         rootView.open('tree-view.js')
-        treeView = window.loadPackage("tree-view").packageMain.createView()
+        treeView = window.loadPackage("tree-view").mainModule.createView()
         expect(treeView.hasParent()).toBeFalsy()
         expect(treeView.root).toExist()
 
     describe "when the root view is opened to a directory", ->
       it "attaches to the root view", ->
-        treeView = window.loadPackage("tree-view").packageMain.createView()
+        treeView = window.loadPackage("tree-view").mainModule.createView()
         expect(treeView.hasParent()).toBeTruthy()
         expect(treeView.root).toExist()
 
@@ -174,14 +175,14 @@ describe "TreeView", ->
     describe "if the current file has no path", ->
       it "shows and focuses the tree view, but does not attempt to select a specific file", ->
         rootView.open()
-        expect(rootView.getActiveEditSession().getPath()).toBeUndefined()
+        expect(rootView.getActivePaneItem().getPath()).toBeUndefined()
         rootView.trigger 'tree-view:reveal-active-file'
         expect(treeView.hasParent()).toBeTruthy()
         expect(treeView.focus).toHaveBeenCalled()
 
     describe "if there is no editor open", ->
       it "shows and focuses the tree view, but does not attempt to select a specific file", ->
-        expect(rootView.getActiveEditSession()).toBeUndefined()
+        expect(rootView.getActivePaneItem()).toBeUndefined()
         rootView.trigger 'tree-view:reveal-active-file'
         expect(treeView.hasParent()).toBeTruthy()
         expect(treeView.focus).toHaveBeenCalled()
@@ -195,7 +196,7 @@ describe "TreeView", ->
       treeView.trigger 'tool-panel:unfocus'
       expect(treeView).toBeVisible()
       expect(treeView.find(".tree-view")).not.toMatchSelector(':focus')
-      expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+      expect(rootView.getActiveView().isFocused).toBeTruthy()
 
   describe "when core:close is triggered on the tree view", ->
     it "detaches the TreeView, focuses the RootView and does not bubble the core:close event", ->
@@ -262,28 +263,28 @@ describe "TreeView", ->
 
   describe "when a file is single-clicked", ->
     it "selects the files and opens it in the active editor, without changing focus", ->
-      expect(rootView.getActiveEditor()).toBeUndefined()
+      expect(rootView.getActiveView()).toBeUndefined()
 
       sampleJs.trigger clickEvent(originalEvent: { detail: 1 })
       expect(sampleJs).toHaveClass 'selected'
-      expect(rootView.getActiveEditor().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.js')
-      expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+      expect(rootView.getActiveView().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.js')
+      expect(rootView.getActiveView().isFocused).toBeFalsy()
 
       sampleTxt.trigger clickEvent(originalEvent: { detail: 1 })
       expect(sampleTxt).toHaveClass 'selected'
       expect(treeView.find('.selected').length).toBe 1
-      expect(rootView.getActiveEditor().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.txt')
-      expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+      expect(rootView.getActiveView().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.txt')
+      expect(rootView.getActiveView().isFocused).toBeFalsy()
 
   describe "when a file is double-clicked", ->
     it "selects the file and opens it in the active editor on the first click, then changes focus to the active editor on the second", ->
       sampleJs.trigger clickEvent(originalEvent: { detail: 1 })
       expect(sampleJs).toHaveClass 'selected'
-      expect(rootView.getActiveEditor().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.js')
-      expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+      expect(rootView.getActiveView().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.js')
+      expect(rootView.getActiveView().isFocused).toBeFalsy()
 
       sampleJs.trigger clickEvent(originalEvent: { detail: 2 })
-      expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+      expect(rootView.getActiveView().isFocused).toBeTruthy()
 
   describe "when a directory is single-clicked", ->
     it "is selected", ->
@@ -299,26 +300,32 @@ describe "TreeView", ->
       expect(subdir).toHaveClass 'selected'
       subdir.trigger clickEvent(originalEvent: { detail: 2 })
       expect(subdir).toHaveClass 'expanded'
-      expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+      expect(rootView.getActiveView().isFocused).toBeFalsy()
 
-  describe "when a new file is opened in the active editor", ->
-    it "is selected in the tree view if the file's entry visible", ->
-      sampleJs.click()
-      rootView.open(require.resolve('fixtures/tree-view/tree-view.txt'))
+  describe "when the active item changes on the active pane", ->
+    describe "when the item has a path", ->
+      it "selects the entry with that path in the tree view if it is visible", ->
+        sampleJs.click()
+        rootView.open(require.resolve('fixtures/tree-view/tree-view.txt'))
 
-      expect(sampleTxt).toHaveClass 'selected'
-      expect(treeView.find('.selected').length).toBe 1
+        expect(sampleTxt).toHaveClass 'selected'
+        expect(treeView.find('.selected').length).toBe 1
 
-    it "selected a file's parent dir if the file's entry is not visible", ->
-      rootView.open(require.resolve('fixtures/tree-view/dir1/sub-dir1/sub-file1'))
+      it "selects the path's parent dir if its entry is not visible", ->
+        rootView.open('dir1/sub-dir1/sub-file1')
+        dirView = treeView.root.find('.directory:contains(dir1)').view()
+        expect(dirView).toHaveClass 'selected'
 
-      dirView = treeView.root.find('.directory:contains(dir1)').view()
-      expect(dirView).toHaveClass 'selected'
+    describe "when the item has no path", ->
+      it "deselects the previously selected entry", ->
+        sampleJs.click()
+        rootView.getActivePane().showItem($$ -> @div('hello'))
+        expect(rootView.find('.selected')).not.toExist()
 
   describe "when a different editor becomes active", ->
     it "selects the file in that is open in that editor", ->
       sampleJs.click()
-      leftEditor = rootView.getActiveEditor()
+      leftEditor = rootView.getActiveView()
       rightEditor = leftEditor.splitRight()
       sampleTxt.click()
 
@@ -569,8 +576,8 @@ describe "TreeView", ->
         it "opens the file in the editor and focuses it", ->
           treeView.root.find('.file:contains(tree-view.js)').click()
           treeView.root.trigger 'tree-view:open-selected-entry'
-          expect(rootView.getActiveEditor().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.js')
-          expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+          expect(rootView.getActiveView().getPath()).toBe require.resolve('fixtures/tree-view/tree-view.js')
+          expect(rootView.getActiveView().isFocused).toBeTruthy()
 
       describe "when a directory is selected", ->
         it "expands or collapses the directory", ->
@@ -586,7 +593,7 @@ describe "TreeView", ->
       describe "when nothing is selected", ->
         it "does nothing", ->
           treeView.root.trigger 'tree-view:open-selected-entry'
-          expect(rootView.getActiveEditor()).toBeUndefined()
+          expect(rootView.getActiveView()).toBeUndefined()
 
   describe "file modification", ->
     [dirView, fileView, rootDirPath, dirPath, filePath] = []
@@ -650,7 +657,7 @@ describe "TreeView", ->
               expect(fs.exists(newPath)).toBeTruthy()
               expect(fs.isFile(newPath)).toBeTruthy()
               expect(addDialog.parent()).not.toExist()
-              expect(rootView.getActiveEditor().getPath()).toBe newPath
+              expect(rootView.getActiveView().getPath()).toBe newPath
 
               waitsFor "tree view to be updated", ->
                 dirView.entries.find("> .file").length > 1
@@ -680,9 +687,9 @@ describe "TreeView", ->
               expect(fs.exists(newPath)).toBeTruthy()
               expect(fs.isDirectory(newPath)).toBeTruthy()
               expect(addDialog.parent()).not.toExist()
-              expect(rootView.getActiveEditor().getPath()).not.toBe newPath
+              expect(rootView.getActiveView().getPath()).not.toBe newPath
               expect(treeView.find(".tree-view")).toMatchSelector(':focus')
-              expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+              expect(rootView.getActiveView().isFocused).toBeFalsy()
               expect(dirView.find('.directory.selected:contains(new)').length).toBe(1)
 
             it "selects the created directory", ->
@@ -693,9 +700,9 @@ describe "TreeView", ->
               expect(fs.exists(newPath)).toBeTruthy()
               expect(fs.isDirectory(newPath)).toBeTruthy()
               expect(addDialog.parent()).not.toExist()
-              expect(rootView.getActiveEditor().getPath()).not.toBe newPath
+              expect(rootView.getActiveView().getPath()).not.toBe newPath
               expect(treeView.find(".tree-view")).toMatchSelector(':focus')
-              expect(rootView.getActiveEditor().isFocused).toBeFalsy()
+              expect(rootView.getActiveView().isFocused).toBeFalsy()
               expect(dirView.find('.directory.selected:contains(new2)').length).toBe(1)
 
           describe "when a file or directory already exists at the given path", ->
@@ -722,7 +729,7 @@ describe "TreeView", ->
             rootView.attachToDom()
             rootView.focus()
             expect(addDialog.parent()).not.toExist()
-            expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+            expect(rootView.getActiveView().isFocused).toBeTruthy()
 
       describe "when a directory is selected", ->
         it "opens an add dialog with the directory's path populated", ->
@@ -839,7 +846,7 @@ describe "TreeView", ->
             rootView.attachToDom()
             rootView.focus()
             expect(moveDialog.parent()).not.toExist()
-            expect(rootView.getActiveEditor().isFocused).toBeTruthy()
+            expect(rootView.getActiveView().isFocused).toBeTruthy()
 
       describe "when a file is selected that's name starts with a '.'", ->
         [dotFilePath, dotFileView, moveDialog] = []
@@ -927,36 +934,48 @@ describe "TreeView", ->
       expect(treeView.find('.file:contains(tree-view.js)').length).toBe 1
 
   describe "Git status decorations", ->
-    [ignoreFile, modifiedFile, originalFileContent] = []
+    [ignoreFile, newFile, modifiedFile, originalFileContent] = []
 
     beforeEach ->
       config.set "core.hideGitIgnoredFiles", false
       ignoreFile = fs.join(require.resolve('fixtures/tree-view'), '.gitignore')
       fs.write(ignoreFile, 'tree-view.js')
-      modifiedFile = fs.join(require.resolve('fixtures/tree-view'), 'tree-view.txt')
+      git.getPathStatus(ignoreFile)
+
+      newFile = fs.join(require.resolve('fixtures/tree-view/dir2'), 'new2')
+      fs.write(newFile, '')
+      git.getPathStatus(newFile)
+
+      modifiedFile = fs.join(require.resolve('fixtures/tree-view/dir1'), 'file1')
       originalFileContent = fs.read(modifiedFile)
       fs.write modifiedFile, 'ch ch changes'
+      git.getPathStatus(modifiedFile)
+
       treeView.updateRoot()
+      treeView.root.entries.find('.directory:contains(dir1)').view().expand()
+      treeView.root.entries.find('.directory:contains(dir2)').view().expand()
 
     afterEach ->
       fs.remove(ignoreFile) if fs.exists(ignoreFile)
+      fs.remove(newFile) if fs.exists(newFile)
       fs.write modifiedFile, originalFileContent
 
     describe "when a file is modified", ->
       it "adds a custom style", ->
-        expect(treeView.find('.file:contains(tree-view.txt)')).toHaveClass 'modified'
+        expect(treeView.find('.file:contains(file1)')).toHaveClass 'modified'
 
-      describe "when the window gains focus after the contents are restored to a clean state", ->
-        it "removes the custom style", ->
-          expect(treeView.find('.file:contains(tree-view.txt)')).toHaveClass 'modified'
-          fs.write modifiedFile, originalFileContent
-          $(window).trigger 'focus'
-          expect(treeView.find('.file:contains(tree-view.txt)')).not.toHaveClass 'modified'
+    describe "when a directory if modified", ->
+      it "adds a custom style", ->
+        expect(treeView.find('.directory:contains(dir1)')).toHaveClass 'modified'
 
     describe "when a file is new", ->
       it "adds a custom style", ->
         expect(treeView.find('.file:contains(.gitignore)')).toHaveClass 'new'
 
+    describe "when a directory is new", ->
+      it "adds a custom style", ->
+        expect(treeView.find('.directory:contains(dir2)')).toHaveClass 'new'
+
     describe "when a file is ignored", ->
       it "adds a custom style", ->
         expect(treeView.find('.file:contains(tree-view.js)')).toHaveClass 'ignored'
diff --git a/src/packages/tree-view/stylesheets/tree-view.css b/src/packages/tree-view/stylesheets/tree-view.css
deleted file mode 100644
index 723b85f2e..000000000
--- a/src/packages/tree-view/stylesheets/tree-view.css
+++ /dev/null
@@ -1,57 +0,0 @@
-.tree-view-wrapper {
-  position: relative;
-  height: 100%;
-  cursor: default;
-  -webkit-user-select: none;
-  min-width: 50px;
-  z-index: 2;
-}
-
-.tree-view {
-  position: relative;
-  cursor: default;
-  -webkit-user-select: none;
-  overflow: auto;
-  height: 100%;
-}
-
-.tree-view-wrapper .tree-view-resizer {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  width: 10px;
-  cursor: col-resize;
-  z-index: 3;
-}
-
-.tree-view .entry {
-  text-wrap: none;
-  white-space: nowrap;
-}
-
-.tree-view .entry > .header,
-.tree-view .entry > .name {
-  z-index: 1;
-  position: relative;
-  display: inline-block;
-}
-
-.tree-view .selected > .highlight {
-  position: absolute;
-  left: 0;
-  right: 0;
-  height: 24px;
-}
-
-.tree-view .disclosure-arrow {
-  display: inline-block;
-}
-
-.tree-view-dialog {
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  z-index: 99;
-}
diff --git a/src/packages/tree-view/stylesheets/tree-view.less b/src/packages/tree-view/stylesheets/tree-view.less
new file mode 100644
index 000000000..33c9db860
--- /dev/null
+++ b/src/packages/tree-view/stylesheets/tree-view.less
@@ -0,0 +1,57 @@
+.tree-view-wrapper {
+  position: relative;
+  height: 100%;
+  cursor: default;
+  -webkit-user-select: none;
+  min-width: 50px;
+  z-index: 2;
+
+  .tree-view-resizer {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    width: 10px;
+    cursor: col-resize;
+    z-index: 3;
+  }
+}
+
+.tree-view {
+  position: relative;
+  cursor: default;
+  -webkit-user-select: none;
+  overflow: auto;
+  height: 100%;
+
+  .entry {
+    text-wrap: none;
+    white-space: nowrap;
+
+    & > .header,
+    > .name {
+      z-index: 1;
+      position: relative;
+      display: inline-block;
+    }
+  }
+
+  .selected > .highlight {
+    position: absolute;
+    left: 0;
+    right: 0;
+    height: 24px;
+  }
+
+  .disclosure-arrow {
+    display: inline-block;
+  }
+}
+
+.tree-view-dialog {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 99;
+}
diff --git a/src/packages/wrap-guide/spec/wrap-guide-spec.coffee b/src/packages/wrap-guide/spec/wrap-guide-spec.coffee
index 706032321..ff14f7c27 100644
--- a/src/packages/wrap-guide/spec/wrap-guide-spec.coffee
+++ b/src/packages/wrap-guide/spec/wrap-guide-spec.coffee
@@ -8,9 +8,10 @@ describe "WrapGuide", ->
     rootView.open('sample.js')
     window.loadPackage('wrap-guide')
     rootView.attachToDom()
-    editor = rootView.getActiveEditor()
+    editor = rootView.getActiveView()
     wrapGuide = rootView.find('.wrap-guide').view()
     editor.width(editor.charWidth * wrapGuide.getDefaultColumn() * 2)
+    editor.trigger 'resize'
 
   describe "@initialize", ->
     it "appends a wrap guide to all existing and new editors", ->
@@ -31,7 +32,8 @@ describe "WrapGuide", ->
     it "updates the wrap guide position", ->
       initial = wrapGuide.position().left
       expect(initial).toBeGreaterThan(0)
-      rootView.trigger('window:increase-font-size')
+      fontSize = config.get("editor.fontSize")
+      config.set("editor.fontSize", fontSize * 2)
       expect(wrapGuide.position().left).toBeGreaterThan(initial)
       expect(wrapGuide).toBeVisible()
 
diff --git a/src/packages/wrap-guide/stylesheets/wrap-guide.css b/src/packages/wrap-guide/stylesheets/wrap-guide.less
similarity index 100%
rename from src/packages/wrap-guide/stylesheets/wrap-guide.css
rename to src/packages/wrap-guide/stylesheets/wrap-guide.less
diff --git a/src/stdlib/cson.coffee b/src/stdlib/cson.coffee
index 63158f006..84832f35f 100644
--- a/src/stdlib/cson.coffee
+++ b/src/stdlib/cson.coffee
@@ -1,3 +1,4 @@
+require 'underscore-extensions'
 _ = require 'underscore'
 
 module.exports =
@@ -21,7 +22,8 @@ module.exports =
 
     cson = '[\n'
     for value in array
-      cson += @stringifyIndent(indentLevel + 2)
+      indent = @stringifyIndent(indentLevel + 2)
+      cson += indent
       if _.isString(value)
         cson += @stringifyString(value)
       else if _.isBoolean(value)
@@ -33,13 +35,15 @@ module.exports =
       else if _.isArray(value)
         cson += @stringifyArray(value, indentLevel + 2)
       else if _.isObject(value)
-        cson += @stringifyObject(value, indentLevel + 2)
+        cson += "{\n#{@stringifyObject(value, indentLevel + 4)}\n#{indent}}"
       else
         throw new Error("Unrecognized type for array value: #{value}")
       cson += '\n'
     "#{cson}#{@stringifyIndent(indentLevel)}]"
 
   stringifyObject: (object, indentLevel=0) ->
+    return '{}' if _.isEmpty(object)
+
     cson = ''
     prefix = ''
     for key, value of object
diff --git a/src/stdlib/fs.coffee b/src/stdlib/fs.coffee
index cac22e5fe..5d9ffe226 100644
--- a/src/stdlib/fs.coffee
+++ b/src/stdlib/fs.coffee
@@ -63,11 +63,11 @@ module.exports =
     paths = []
     if extensions
       onPath = (path) =>
-        paths.push(@join(rootPath, path)) if _.contains(extensions, @extension(path))
+        paths.push(path) if _.contains(extensions, @extension(path))
         false
     else
       onPath = (path) =>
-        paths.push(@join(rootPath, path))
+        paths.push(path)
         false
     @traverseTree(rootPath, onPath, onPath)
     paths
@@ -75,7 +75,7 @@ module.exports =
   listTree: (rootPath) ->
     paths = []
     onPath = (path) =>
-      paths.push(@join(rootPath, path))
+      paths.push(path)
       true
     @traverseTree(rootPath, onPath, onPath)
     paths
diff --git a/src/stdlib/git-repository.coffee b/src/stdlib/git-repository.coffee
index 7a52403b4..4a1291e8b 100644
--- a/src/stdlib/git-repository.coffee
+++ b/src/stdlib/git-repository.coffee
@@ -10,9 +10,12 @@ class GitRepository
   getHead: $git.getHead
   getPath: $git.getPath
   getStatus: $git.getStatus
+  getStatuses: $git.getStatuses
   isIgnored: $git.isIgnored
   checkoutHead: $git.checkoutHead
   getDiffStats: $git.getDiffStats
   isSubmodule: $git.isSubmodule
   refreshIndex: $git.refreshIndex
   destroy: $git.destroy
+  getAheadBehindCounts: $git.getAheadBehindCounts
+  getLineDiffs: $git.getLineDiffs
diff --git a/src/stdlib/jquery-extensions.coffee b/src/stdlib/jquery-extensions.coffee
index 089363577..70d24fc3a 100644
--- a/src/stdlib/jquery-extensions.coffee
+++ b/src/stdlib/jquery-extensions.coffee
@@ -7,6 +7,12 @@ $.fn.scrollBottom = (newValue) ->
   else
     @scrollTop() + @height()
 
+$.fn.scrollDown = ->
+  @scrollTop(@scrollTop() + $(window).height() / 20)
+
+$.fn.scrollUp = ->
+  @scrollTop(@scrollTop() - $(window).height() / 20)
+
 $.fn.scrollToTop = ->
   @scrollTop(0)
 
@@ -53,7 +59,9 @@ $.fn.trueHeight = ->
 $.fn.trueWidth = ->
   this[0].getBoundingClientRect().width
 
-$.fn.document = (eventDescriptions) ->
+$.fn.document = (eventName, docString) ->
+  eventDescriptions = {}
+  eventDescriptions[eventName] = docString
   @data('documentation', {}) unless @data('documentation')
   _.extend(@data('documentation'), eventDescriptions)
 
@@ -69,12 +77,20 @@ $.fn.events = ->
   else
     events
 
-$.fn.command = (args...) ->
-  eventName = args[0]
-  documentation = {}
-  documentation[eventName] = _.humanizeEventName(eventName)
-  @document(documentation)
-  @on(args...)
+$.fn.command = (eventName, selector, options, handler) ->
+  if not options?
+    handler  = selector
+    selector = null
+  else if not handler?
+    handler = options
+    options = null
+
+  if selector? and typeof(selector) is 'object'
+    options  = selector
+    selector = null
+
+  @document(eventName, _.humanizeEventName(eventName, options?["doc"]))
+  @on(eventName, selector, options?['data'], handler)
 
 $.fn.iconSize = (size) ->
   @width(size).height(size).css('font-size', size)
diff --git a/src/stdlib/require.coffee b/src/stdlib/require.coffee
index d7e8b87dd..42f0f05b3 100644
--- a/src/stdlib/require.coffee
+++ b/src/stdlib/require.coffee
@@ -66,6 +66,13 @@ exts =
     evaluated = exts.js(file, compiled)
     $native.write(cacheFilePath, compiled) if writeToCache
     evaluated
+  less: (file) ->
+    output = ""
+    (new less.Parser).parse __read(file), (e, tree) ->
+      throw new Error(e.message, file, e.line) if e
+      output = tree.toCSS()
+    output
+
 
 getPath = (path) ->
   path = resolve(path)
diff --git a/src/stdlib/task.coffee b/src/stdlib/task.coffee
index 1210f4999..eb3e2f3f5 100644
--- a/src/stdlib/task.coffee
+++ b/src/stdlib/task.coffee
@@ -1,3 +1,6 @@
+_ = require 'underscore'
+EventEmitter = require 'event-emitter'
+
 module.exports =
 class Task
   aborted: false
@@ -24,7 +27,7 @@ class Task
   error: -> console.error(arguments...)
 
   startWorker: ->
-    @callWorkerMethod 'start'
+    @callWorkerMethod 'start',
       globals:
         resourcePath: window.resourcePath
         navigator:
@@ -49,3 +52,6 @@ class Task
     @abort()
     @worker?.terminate()
     @worker = null
+    @trigger 'task-completed'
+
+_.extend Task.prototype, EventEmitter
diff --git a/src/stdlib/underscore-extensions.coffee b/src/stdlib/underscore-extensions.coffee
index df5865b24..09c79c4f5 100644
--- a/src/stdlib/underscore-extensions.coffee
+++ b/src/stdlib/underscore-extensions.coffee
@@ -52,16 +52,20 @@ _.mixin
     regex = RegExp('[' + specials.join('\\') + ']', 'g')
     string.replace(regex, "\\$&");
 
-  humanizeEventName: (eventName) ->
-    if /:/.test(eventName)
-      [namespace, name] = eventName.split(':')
-      return "#{@humanizeEventName(namespace)}: #{@humanizeEventName(name)}"
+  humanizeEventName: (eventName, eventDoc) ->
+    [namespace, event]  = eventName.split(':')
+    return _.capitalize(namespace) unless event?
 
-    words = eventName.split('-')
-    words.map(_.capitalize).join(' ')
+    namespaceDoc = _.undasherize(namespace)
+    eventDoc ?= _.undasherize(event)
+
+    "#{namespaceDoc}: #{eventDoc}"
 
   capitalize: (word) ->
-    word[0].toUpperCase() + word[1..]
+    if word.toLowerCase() is 'github'
+      'GitHub'
+    else
+      word[0].toUpperCase() + word[1..]
 
   pluralize: (count=0, singular, plural=singular+'s') ->
     if count is 1
@@ -80,6 +84,9 @@ _.mixin
       else
         "-"
 
+  undasherize: (string) ->
+    string.split('-').map(_.capitalize).join(' ')
+
   underscore: (string) ->
     string = string[0].toLowerCase() + string[1..]
     string.replace /([A-Z])|(-)/g, (m, letter, dash) ->
diff --git a/static/atom.css b/static/atom.css
deleted file mode 100644
index bfe56f43b..000000000
--- a/static/atom.css
+++ /dev/null
@@ -1,75 +0,0 @@
-html, body {
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-}
-
-#root-view {
-  height: 100%;
-  overflow: hidden;
-  position: relative;
-}
-
-#root-view #horizontal {
-  display: -webkit-flex;
-  height: 100%;
-}
-
-#root-view #vertical {
-  display: -webkit-flex;
-  -webkit-flex: 1;
-  -webkit-flex-flow: column;
-}
-
-#root-view #panes {
-  position: relative;
-  -webkit-flex: 1;
-}
-
-#root-view #panes .column {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  overflow-y: hidden;
-}
-
-#root-view #panes .row {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  overflow-x: hidden;
-}
-
-#root-view #panes .pane {
-  position: absolute;
-  display: -webkit-flex;
-  -webkit-flex-flow: column;
-  top: 0;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  box-sizing: border-box;
-}
-
-@font-face {
-  font-family: 'Octicons Regular';
-  src: url("octicons-regular-webfont.woff") format("woff");
-  font-weight: normal;
-  font-style: normal;
-}
-
-.is-loading {
-  background-image: url(images/spinner.svg);
-  background-repeat: no-repeat;
-  width: 14px;
-  height: 14px;
-  opacity: 0.5;
-  background-size: contain;
-  position: relative;
-  display: inline-block;
-  padding-left: 19px;
-}
\ No newline at end of file
diff --git a/static/atom.less b/static/atom.less
new file mode 100644
index 000000000..f58c62d76
--- /dev/null
+++ b/static/atom.less
@@ -0,0 +1,86 @@
+html, body {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+#root-view {
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+
+  #horizontal {
+    display: -webkit-flex;
+    height: 100%;
+  }
+
+  #vertical {
+    display: -webkit-flex;
+    -webkit-flex: 1;
+    -webkit-flex-flow: column;
+  }
+}
+
+#panes {
+  position: relative;
+  -webkit-flex: 1;
+
+  .column {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    overflow-y: hidden;
+  }
+
+  .row {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    overflow-x: hidden;
+  }
+
+  .pane {
+    position: absolute;
+    display: -webkit-flex;
+    -webkit-flex-flow: column;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    box-sizing: border-box;
+  }
+
+  .pane .item-views {
+    -webkit-flex: 1;
+    display: -webkit-flex;
+    -webkit-flex-flow: column;
+  }
+
+  .pane .item-views > * {
+    -webkit-flex: 1;
+    min-height: 0;
+  }
+}
+
+@font-face {
+  font-family: 'Octicons Regular';
+  src: url("octicons-regular-webfont.woff") format("woff");
+  font-weight: normal;
+  font-style: normal;
+}
+
+.is-loading {
+  background-image: url(images/spinner.svg);
+  background-repeat: no-repeat;
+  width: 14px;
+  height: 14px;
+  opacity: 0.5;
+  background-size: contain;
+  position: relative;
+  display: inline-block;
+  padding-left: 19px;
+}
diff --git a/static/command-panel.css b/static/command-panel.css
deleted file mode 100644
index 8f2098259..000000000
--- a/static/command-panel.css
+++ /dev/null
@@ -1,134 +0,0 @@
-.command-panel {
-  position: relative;
-  padding: 0;
-}
-
-.command-panel .is-loading {
-  display: block;
-  margin: 0 auto 10px auto;
-  width: 100px;
-  background-color: #111;
-  background-size: auto;
-  background-position: 5px 5px;
-  padding: 5px 5px 10px 30px;
-  border-radius: 3px;
-  border: 1px solid rgba(255, 255, 255,  0.1);
-  border-top: 1px solid rgba(0, 0, 0, 1);
-  border-left: 1px solid rgba(0, 0, 0, 1);
-}
-
-.command-panel .preview-count {
-  display: inline-block;
-  margin-top: 4px;
-  font-size: 11px;
-}
-
-.command-panel .preview-list {
-  max-height: 300px;
-  overflow: auto;
-  margin: 0 0 10px 0;
-  position: relative;
-  cursor: default;
-}
-
-.command-panel .header:after {
-  content: ".";
-  display: block;
-  visibility: hidden;
-  clear: both;
-  height: 0;
-}
-
-.command-panel .expand-collapse {
-  float: right;
-}
-
-.command-panel .expand-collapse li {
-  display: inline-block;
-  cursor: pointer;
-  font-size: 11px;
-  margin-left: 5px;
-  padding: 5px 10px;
-  border-radius: 3px;
-}
-
-.command-panel .preview-count,
-.command-panel .expand-collapse {
-  -webkit-user-select: none;
-}
-
-.command-panel .preview-list .path {
-  position: relative;
-  -webkit-user-select: none;
-}
-
-.command-panel .preview-list .path-details:before {
-  font-family: 'Octicons Regular';
-  font-size: 12px;
-  width: 12px;
-  height: 12px;
-  margin-right: 5px;
-  margin-left: 5px;
-  -webkit-font-smoothing: antialiased;
-  content: "\f05b";
-  position: relative;
-  top: 0;
-}
-
-.command-panel .preview-list .is-collapsed .path-details:before {
-  content: "\f05a";
-}
-
-.command-panel .preview-list .path-name:before {
-  font-family: 'Octicons Regular';
-  font-size: 16px;
-  width: 16px;
-  height: 16px;
-  margin-right: 5px;
-  -webkit-font-smoothing: antialiased;
-  content: "\f011";
-  position: relative;
-  top: 1px;
-}
-
-.command-panel .preview-list .path.readme .path-name:before {
-  content: "\f007";
-}
-
-.command-panel .preview-list .operation {
-  padding-top: 2px;
-  padding-bottom: 2px;
-  padding-left: 10px;
-}
-
-.command-panel .preview-list .line-number {
-  margin-right: 1ex;
-  text-align: right;
-  display: inline-block;
-}
-
-.command-panel .preview-list .path-match-number {
-  padding-left: 8px;
-}
-
-.command-panel .preview-list .preview {
-  word-break: break-all;
-}
-
-.command-panel .preview-list .preview .match {
-  -webkit-border-radius: 2px;
-  padding: 1px;
-}
-
-.command-panel .prompt-and-editor .editor {
-  position: relative;
-}
-
-.command-panel .prompt-and-editor {
-  display: -webkit-flex;
-}
-
-.error-messages {
-  padding: 5px 1em;
-  color: white;
-}
diff --git a/static/command-panel.less b/static/command-panel.less
new file mode 100644
index 000000000..15a133ec7
--- /dev/null
+++ b/static/command-panel.less
@@ -0,0 +1,132 @@
+.command-panel {
+  position: relative;
+  padding: 0;
+
+  .is-loading {
+    display: block;
+    margin: 0 auto 10px auto;
+    width: 100px;
+    background-color: #111111;
+    background-size: auto;
+    background-position: 5px 5px;
+    padding: 5px 5px 10px 30px;
+    border-radius: 3px;
+    border: 1px solid rgba(255,255,255,0.1);
+    border-top: 1px solid rgba(0,0,0,1);
+    border-left: 1px solid rgba(0,0,0,1);
+  }
+
+  .preview-count {
+    display: inline-block;
+    margin-top: 4px;
+    font-size: 11px;
+    -webkit-user-select: none;
+  }
+
+  .preview-list {
+    max-height: 300px;
+    overflow: auto;
+    margin: 0 0 10px 0;
+    position: relative;
+    cursor: default;
+
+    .path {
+      position: relative;
+      -webkit-user-select: none;
+    }
+
+    .path-details:before {
+      font-family: 'Octicons Regular';
+      font-size: 12px;
+      width: 12px;
+      height: 12px;
+      margin-right: 5px;
+      margin-left: 5px;
+      -webkit-font-smoothing: antialiased;
+      content: "\f05b";
+      position: relative;
+      top: 0;
+    }
+
+    .is-collapsed .path-details:before {
+      content: "\f05a";
+    }
+
+    .path-name:before {
+      font-family: 'Octicons Regular';
+      font-size: 16px;
+      width: 16px;
+      height: 16px;
+      margin-right: 5px;
+      -webkit-font-smoothing: antialiased;
+      content: "\f011";
+      position: relative;
+      top: 1px;
+    }
+
+    .path.readme .path-name:before {
+      content: "\f007";
+    }
+
+    .operation {
+      padding-top: 2px;
+      padding-bottom: 2px;
+      padding-left: 10px;
+    }
+
+    .line-number {
+      margin-right: 1ex;
+      text-align: right;
+      display: inline-block;
+    }
+
+    .path-match-number {
+      padding-left: 8px;
+    }
+
+    .preview {
+      word-break: break-all;
+
+      .match {
+        -webkit-border-radius: 2px;
+        padding: 1px;
+      }
+    }
+  }
+
+  .header:after {
+    content: ".";
+    display: block;
+    visibility: hidden;
+    clear: both;
+    height: 0;
+  }
+
+  .expand-collapse {
+    float: right;
+    -webkit-user-select: none;
+
+    li {
+      display: inline-block;
+      cursor: pointer;
+      font-size: 11px;
+      margin-left: 5px;
+      padding: 5px 10px;
+      border-radius: 3px;
+    }
+  }
+
+  .prompt-and-editor {
+    display: -webkit-flex;
+
+    .editor {
+      position: relative;
+      -webkit-flex: 1;
+    }
+  }
+}
+
+.error-messages {
+  padding: 5px 1em;
+  color: white;
+}
diff --git a/static/editor.css b/static/editor.less
similarity index 99%
rename from static/editor.css
rename to static/editor.less
index 1b6c35a74..c88b3f1f2 100644
--- a/static/editor.css
+++ b/static/editor.less
@@ -6,7 +6,6 @@
   z-index: 0;
   font-family: Inconsolata, Monaco, Courier;
   line-height: 1.3;
-  -webkit-flex: 1;
 }
 
 .editor.mini {
diff --git a/static/fuzzy-finder.css b/static/fuzzy-finder.less
similarity index 64%
rename from static/fuzzy-finder.css
rename to static/fuzzy-finder.less
index 410269890..3c8a4f3f1 100644
--- a/static/fuzzy-finder.css
+++ b/static/fuzzy-finder.less
@@ -16,6 +16,29 @@
   color: #9d9d9d;
 }
 
+.fuzzy-finder .status {
+  font-family: 'Octicons Regular';
+  font-size: 16px;
+  width: 16px;
+  height: 16px;
+  margin-left: 5px;
+  -webkit-font-smoothing: antialiased;
+  color: #9d9d9d;
+  float: right;
+}
+
+.fuzzy-finder .status.new:before {
+  position: relative;
+  top: 3px;
+  content: "\f06b";
+}
+
+.fuzzy-finder .status.modified:before {
+  position: relative;
+  top: 3px;
+  content: "\f06d";
+}
+
 .fuzzy-finder .file.text-name:before {
   content: "\f011";
 }
diff --git a/static/jasmine.css b/static/jasmine.less
similarity index 100%
rename from static/jasmine.css
rename to static/jasmine.less
diff --git a/static/markdown.css b/static/markdown.css
deleted file mode 100644
index c41f233ed..000000000
--- a/static/markdown.css
+++ /dev/null
@@ -1,23 +0,0 @@
-.source.gfm {
-  -webkit-font-smoothing: antialiased;
-}
-
-.gfm .markup.heading {
-  font-weight: bold;
-}
-
-.gfm .bold {
-  font-weight: bold;
-}
-
-.gfm .italic {
-  font-style: italic;
-}
-
-.gfm .comment.quote {
-  font-style: italic;
-}
-
-.gfm .raw {
-  -webkit-font-smoothing: subpixel-antialiased;
-}
\ No newline at end of file
diff --git a/static/markdown.less b/static/markdown.less
new file mode 100644
index 000000000..e97b8ce25
--- /dev/null
+++ b/static/markdown.less
@@ -0,0 +1,25 @@
+.source {
+  .gfm {
+    -webkit-font-smoothing: antialiased;
+
+    .markup.heading {
+      font-weight: bold;
+    }
+
+    .bold {
+      font-weight: bold;
+    }
+
+    .italic {
+      font-style: italic;
+    }
+
+    .comment.quote {
+      font-style: italic;
+    }
+
+    .raw {
+      -webkit-font-smoothing: subpixel-antialiased;
+    }
+  }
+}
diff --git a/static/notification.css b/static/notification.less
similarity index 100%
rename from static/notification.css
rename to static/notification.less
diff --git a/static/overlay.css b/static/overlay.less
similarity index 100%
rename from static/overlay.css
rename to static/overlay.less
diff --git a/static/popover-list.css b/static/popover-list.less
similarity index 100%
rename from static/popover-list.css
rename to static/popover-list.less
diff --git a/static/reset.css b/static/reset.less
similarity index 100%
rename from static/reset.css
rename to static/reset.less
diff --git a/static/select-list.css b/static/select-list.less
similarity index 100%
rename from static/select-list.css
rename to static/select-list.less
diff --git a/static/status-bar.css b/static/status-bar.less
similarity index 77%
rename from static/status-bar.css
rename to static/status-bar.less
index 4cc898c41..f30dd48c8 100644
--- a/static/status-bar.css
+++ b/static/status-bar.less
@@ -5,6 +5,7 @@
   position: relative;
   -webkit-user-select: none;
   cursor: default;
+  overflow: hidden;
 }
 
 .status-bar .git-branch {
@@ -55,3 +56,17 @@
 .status-bar .new-status-icon:before {
   content: "\f26b";
 }
+
+.status-bar .commits-behind-label:before {
+  margin-top: -3px;
+  margin-left: 3px;
+  margin-right: 1px;
+  content: "\f03f";
+}
+
+.status-bar .commits-ahead-label:before {
+  margin-top: -3px;
+  margin-left: 3px;
+  margin-right: 1px;
+  content: "\f03d";
+}
diff --git a/static/tabs.css b/static/tabs.less
similarity index 89%
rename from static/tabs.css
rename to static/tabs.less
index 29133287a..bb262bba1 100644
--- a/static/tabs.css
+++ b/static/tabs.less
@@ -23,7 +23,7 @@
   -webkit-flex: 2;
 }
 
-.tab .file-name {
+.tab .title {
   display: block;
   overflow: hidden;
   white-space: nowrap;
@@ -52,7 +52,7 @@
   color: #fff;
 }
 
-.tab.file-modified .close-icon {
+.tab.modified .close-icon {
   top: 11px;
   width: 5px;
   height: 5px;
@@ -61,11 +61,11 @@
   border-radius: 12px;
 }
 
-.tab.file-modified .close-icon:before {
+.tab.modified .close-icon:before {
   content: "";
 }
 
-.tab.file-modified:hover .close-icon {
+.tab.modified:hover .close-icon {
   border: none;
   width: 12px;
   height: 12px;
@@ -73,7 +73,7 @@
   top: 5px;
 }
 
-.tab.file-modified:hover .close-icon:before {
+.tab.modified:hover .close-icon:before {
   content: "\f081";
   color: #66a6ff;
 }
diff --git a/static/tree-view.css b/static/tree-view.less
similarity index 100%
rename from static/tree-view.css
rename to static/tree-view.less
diff --git a/themes/atom-dark-ui/select-list.css b/themes/atom-dark-ui/select-list.css
index 789ac5313..41740ab83 100644
--- a/themes/atom-dark-ui/select-list.css
+++ b/themes/atom-dark-ui/select-list.css
@@ -38,3 +38,11 @@
 .select-list ol .selected .directory {
   color: #ccc;
 }
+
+.select-list .modified {
+  color: #f78a46;
+}
+
+.select-list .new {
+  color: #5293d8;
+}
diff --git a/themes/atom-dark-ui/tree-view.css b/themes/atom-dark-ui/tree-view.css
index 26851216e..f1d8a9796 100644
--- a/themes/atom-dark-ui/tree-view.css
+++ b/themes/atom-dark-ui/tree-view.css
@@ -29,7 +29,7 @@
   text-shadow: 0 -1px 0 #7E4521;
 }
 
-.tree-view .directory .header {
+.tree-view .directory {
   color: #bebebe;
 }
 
diff --git a/themes/atom-light-ui/select-list.css b/themes/atom-light-ui/select-list.css
index 2bd0a4e9d..54a8934aa 100644
--- a/themes/atom-light-ui/select-list.css
+++ b/themes/atom-light-ui/select-list.css
@@ -31,3 +31,11 @@
 .select-list ol .selected .directory {
   color: #333;
 }
+
+.select-list .modified {
+  color: #f78a46;
+}
+
+.select-list .new {
+  color: #5293d8;
+}
diff --git a/themes/atom-light-ui/tree-view.css b/themes/atom-light-ui/tree-view.css
index ff18a5990..5ab39df36 100644
--- a/themes/atom-light-ui/tree-view.css
+++ b/themes/atom-light-ui/tree-view.css
@@ -34,7 +34,7 @@
   text-shadow: 0 1px 0 #000;
 }
 
-.tree-view .directory .header {
+.tree-view .directory {
   color: #262626;
 }
 
@@ -46,7 +46,6 @@
   color: #7e8692;
 }
 
-
 .tree-view .entry:hover,
 .tree-view .directory .header:hover .name,
 .tree-view .directory .header:hover .disclosure-arrow  {
@@ -83,4 +82,4 @@
 
 .tree-view-dialog .prompt {
   color: #333;
-}
\ No newline at end of file
+}
diff --git a/vendor/less.js b/vendor/less.js
new file mode 100644
index 000000000..59629068d
--- /dev/null
+++ b/vendor/less.js
@@ -0,0 +1,5078 @@
+// Modified
+//
+// Added
+//     module.exports.less = window.less = less = {}
+//     less.tree = tree = {}
+//     less.mode = 'browser'
+//
+// LESS - Leaner CSS v1.4.0
+// http://lesscss.org
+//
+// Copyright (c) 2009-2013, Alexis Sellier
+// Licensed under the Apache 2.0 License.
+//
+(function (window, undefined) {
+//
+// Stub out `require` in the browser
+//
+function require(arg) {
+    return window.less[arg.split('/')[1]];
+};
+
+// ecma-5.js
+//
+// -- kriskowal Kris Kowal Copyright (C) 2009-2010 MIT License
+// -- tlrobinson Tom Robinson
+// dantman Daniel Friesen
+
+//
+// Array
+//
+if (!Array.isArray) {
+    Array.isArray = function(obj) {
+        return Object.prototype.toString.call(obj) === "[object Array]" ||
+               (obj instanceof Array);
+    };
+}
+if (!Array.prototype.forEach) {
+    Array.prototype.forEach =  function(block, thisObject) {
+        var len = this.length >>> 0;
+        for (var i = 0; i < len; i++) {
+            if (i in this) {
+                block.call(thisObject, this[i], i, this);
+            }
+        }
+    };
+}
+if (!Array.prototype.map) {
+    Array.prototype.map = function(fun /*, thisp*/) {
+        var len = this.length >>> 0;
+        var res = new Array(len);
+        var thisp = arguments[1];
+
+        for (var i = 0; i < len; i++) {
+            if (i in this) {
+                res[i] = fun.call(thisp, this[i], i, this);
+            }
+        }
+        return res;
+    };
+}
+if (!Array.prototype.filter) {
+    Array.prototype.filter = function (block /*, thisp */) {
+        var values = [];
+        var thisp = arguments[1];
+        for (var i = 0; i < this.length; i++) {
+            if (block.call(thisp, this[i])) {
+                values.push(this[i]);
+            }
+        }
+        return values;
+    };
+}
+if (!Array.prototype.reduce) {
+    Array.prototype.reduce = function(fun /*, initial*/) {
+        var len = this.length >>> 0;
+        var i = 0;
+
+        // no value to return if no initial value and an empty array
+        if (len === 0 && arguments.length === 1) throw new TypeError();
+
+        if (arguments.length >= 2) {
+            var rv = arguments[1];
+        } else {
+            do {
+                if (i in this) {
+                    rv = this[i++];
+                    break;
+                }
+                // if array contains no values, no initial value to return
+                if (++i >= len) throw new TypeError();
+            } while (true);
+        }
+        for (; i < len; i++) {
+            if (i in this) {
+                rv = fun.call(null, rv, this[i], i, this);
+            }
+        }
+        return rv;
+    };
+}
+if (!Array.prototype.indexOf) {
+    Array.prototype.indexOf = function (value /*, fromIndex */ ) {
+        var length = this.length;
+        var i = arguments[1] || 0;
+
+        if (!length)     return -1;
+        if (i >= length) return -1;
+        if (i < 0)       i += length;
+
+        for (; i < length; i++) {
+            if (!Object.prototype.hasOwnProperty.call(this, i)) { continue }
+            if (value === this[i]) return i;
+        }
+        return -1;
+    };
+}
+
+//
+// Object
+//
+if (!Object.keys) {
+    Object.keys = function (object) {
+        var keys = [];
+        for (var name in object) {
+            if (Object.prototype.hasOwnProperty.call(object, name)) {
+                keys.push(name);
+            }
+        }
+        return keys;
+    };
+}
+
+//
+// String
+//
+if (!String.prototype.trim) {
+    String.prototype.trim = function () {
+        return String(this).replace(/^\s\s*/, '').replace(/\s\s*$/, '');
+    };
+}
+var less, tree, charset;
+
+module.exports.less = window.less = less = {}
+less.tree = tree = {}
+less.mode = 'browser'
+//
+// less.js - parser
+//
+//    A relatively straight-forward predictive parser.
+//    There is no tokenization/lexing stage, the input is parsed
+//    in one sweep.
+//
+//    To make the parser fast enough to run in the browser, several
+//    optimization had to be made:
+//
+//    - Matching and slicing on a huge input is often cause of slowdowns.
+//      The solution is to chunkify the input into smaller strings.
+//      The chunks are stored in the `chunks` var,
+//      `j` holds the current chunk index, and `current` holds
+//      the index of the current chunk in relation to `input`.
+//      This gives us an almost 4x speed-up.
+//
+//    - In many cases, we don't need to match individual tokens;
+//      for example, if a value doesn't hold any variables, operations
+//      or dynamic references, the parser can effectively 'skip' it,
+//      treating it as a literal.
+//      An example would be '1px solid #000' - which evaluates to itself,
+//      we don't need to know what the individual components are.
+//      The drawback, of course is that you don't get the benefits of
+//      syntax-checking on the CSS. This gives us a 50% speed-up in the parser,
+//      and a smaller speed-up in the code-gen.
+//
+//
+//    Token matching is done with the `$` function, which either takes
+//    a terminal string or regexp, or a non-terminal function to call.
+//    It also takes care of moving all the indices forwards.
+//
+//
+less.Parser = function Parser(env) {
+    var input,       // LeSS input string
+        i,           // current index in `input`
+        j,           // current chunk
+        temp,        // temporarily holds a chunk's state, for backtracking
+        memo,        // temporarily holds `i`, when backtracking
+        furthest,    // furthest index the parser has gone to
+        chunks,      // chunkified input
+        current,     // index of current chunk, in `input`
+        parser;
+
+    var that = this;
+
+    // Top parser on an import tree must be sure there is one "env"
+    // which will then be passed around by reference.
+    if (!(env instanceof tree.parseEnv)) {
+        env = new tree.parseEnv(env);
+    }
+
+    if (!env.currentDirectory && env.filename) {
+        // only works for node, only used for node
+        env.currentDirectory = env.filename.replace(/[^\/\\]*$/, "");
+    }
+
+    // This function is called after all files
+    // have been imported through `@import`.
+    var finish = function () {};
+
+    var imports = this.imports = {
+        paths: env.paths || [],  // Search paths, when importing
+        queue: [],               // Files which haven't been imported yet
+        files: env.files,        // Holds the imported parse trees
+        contents: env.contents,  // Holds the imported file contents
+        mime:  env.mime,         // MIME type of .less files
+        error: null,             // Error in parsing/evaluating an import
+        push: function (path, callback) {
+            var that = this;
+            this.queue.push(path);
+
+            //
+            // Import a file asynchronously
+            //
+            less.Parser.importer(path, this.paths, function (e, root, fullPath) {
+                that.queue.splice(that.queue.indexOf(path), 1); // Remove the path from the queue
+
+                var imported = fullPath in that.files;
+
+                that.files[fullPath] = root;                        // Store the root
+
+                if (e && !that.error) { that.error = e; }
+
+                callback(e, root, imported);
+
+                if (that.queue.length === 0) { finish(that.error); }       // Call `finish` if we're done importing
+            }, env);
+        }
+    };
+
+    function save()    { temp = chunks[j], memo = i, current = i; }
+    function restore() { chunks[j] = temp, i = memo, current = i; }
+
+    function sync() {
+        if (i > current) {
+            chunks[j] = chunks[j].slice(i - current);
+            current = i;
+        }
+    }
+    function isWhitespace(c) {
+        // Could change to \s?
+        var code = c.charCodeAt(0);
+        return code === 32 || code === 10 || code === 9;
+    }
+    //
+    // Parse from a token, regexp or string, and move forward if match
+    //
+    function $(tok) {
+        var match, args, length, index, k;
+
+        //
+        // Non-terminal
+        //
+        if (tok instanceof Function) {
+            return tok.call(parser.parsers);
+        //
+        // Terminal
+        //
+        //     Either match a single character in the input,
+        //     or match a regexp in the current chunk (chunk[j]).
+        //
+        } else if (typeof(tok) === 'string') {
+            match = input.charAt(i) === tok ? tok : null;
+            length = 1;
+            sync ();
+        } else {
+            sync ();
+
+            if (match = tok.exec(chunks[j])) {
+                length = match[0].length;
+            } else {
+                return null;
+            }
+        }
+
+        // The match is confirmed, add the match length to `i`,
+        // and consume any extra white-space characters (' ' || '\n')
+        // which come after that. The reason for this is that LeSS's
+        // grammar is mostly white-space insensitive.
+        //
+        if (match) {
+            skipWhitespace(length);
+
+            if(typeof(match) === 'string') {
+                return match;
+            } else {
+                return match.length === 1 ? match[0] : match;
+            }
+        }
+    }
+
+    function skipWhitespace(length) {
+        var oldi = i, oldj = j,
+            endIndex = i + chunks[j].length,
+            mem = i += length;
+
+        while (i < endIndex) {
+            if (! isWhitespace(input.charAt(i))) { break }
+            i++;
+        }
+        chunks[j] = chunks[j].slice(length + (i - mem));
+        current = i;
+
+        if (chunks[j].length === 0 && j < chunks.length - 1) { j++ }
+
+        return oldi !== i || oldj !== j;
+    }
+
+    function expect(arg, msg) {
+        var result = $(arg);
+        if (! result) {
+            error(msg || (typeof(arg) === 'string' ? "expected '" + arg + "' got '" + input.charAt(i) + "'"
+                                                   : "unexpected token"));
+        } else {
+            return result;
+        }
+    }
+
+    function error(msg, type) {
+        var e = new Error(msg);
+        e.index = i;
+        e.type = type || 'Syntax';
+        throw e;
+    }
+
+    // Same as $(), but don't change the state of the parser,
+    // just return the match.
+    function peek(tok) {
+        if (typeof(tok) === 'string') {
+            return input.charAt(i) === tok;
+        } else {
+            if (tok.test(chunks[j])) {
+                return true;
+            } else {
+                return false;
+            }
+        }
+    }
+
+    function getInput(e, env) {
+        if (e.filename && env.filename && (e.filename !== env.filename)) {
+            return parser.imports.contents[e.filename];
+        } else {
+            return input;
+        }
+    }
+
+    function getLocation(index, input) {
+        for (var n = index, column = -1;
+                 n >= 0 && input.charAt(n) !== '\n';
+                 n--) { column++ }
+
+        return { line:   typeof(index) === 'number' ? (input.slice(0, index).match(/\n/g) || "").length : null,
+                 column: column };
+    }
+
+    function getFileName(e) {
+        if(less.mode === 'browser' || less.mode === 'rhino')
+            return e.filename;
+        else
+            return require('path').resolve(e.filename);
+    }
+
+    function getDebugInfo(index, inputStream, e) {
+        return {
+            lineNumber: getLocation(index, inputStream).line + 1,
+            fileName: getFileName(e)
+        };
+    }
+
+    function LessError(e, env) {
+        var input = getInput(e, env),
+            loc = getLocation(e.index, input),
+            line = loc.line,
+            col  = loc.column,
+            lines = input.split('\n');
+
+        this.type = e.type || 'Syntax';
+        this.message = e.message;
+        this.filename = e.filename || env.filename;
+        this.index = e.index;
+        this.line = typeof(line) === 'number' ? line + 1 : null;
+        this.callLine = e.call && (getLocation(e.call, input).line + 1);
+        this.callExtract = lines[getLocation(e.call, input).line];
+        this.stack = e.stack;
+        this.column = col;
+        this.extract = [
+            lines[line - 1],
+            lines[line],
+            lines[line + 1]
+        ];
+    }
+
+    this.env = env = env || {};
+
+    // The optimization level dictates the thoroughness of the parser,
+    // the lower the number, the less nodes it will create in the tree.
+    // This could matter for debugging, or if you want to access
+    // the individual nodes in the tree.
+    this.optimization = ('optimization' in this.env) ? this.env.optimization : 1;
+
+    this.env.filename = this.env.filename || null;
+
+    //
+    // The Parser
+    //
+    return parser = {
+
+        imports: imports,
+        //
+        // Parse an input string into an abstract syntax tree,
+        // call `callback` when done.
+        //
+        parse: function (str, callback) {
+            var root, start, end, zone, line, lines, buff = [], c, error = null;
+
+            i = j = current = furthest = 0;
+            input = str.replace(/\r\n/g, '\n');
+
+            // Remove potential UTF Byte Order Mark
+            input = input.replace(/^\uFEFF/, '');
+
+            // Split the input into chunks.
+            chunks = (function (chunks) {
+                var j = 0,
+                    skip = /(?:@\{[\w-]+\}|[^"'`\{\}\/\(\)\\])+/g,
+                    comment = /\/\*(?:[^*]|\*+[^\/*])*\*+\/|\/\/.*/g,
+                    string = /"((?:[^"\\\r\n]|\\.)*)"|'((?:[^'\\\r\n]|\\.)*)'|`((?:[^`]|\\.)*)`/g,
+                    level = 0,
+                    match,
+                    chunk = chunks[0],
+                    inParam;
+
+                for (var i = 0, c, cc; i < input.length;) {
+                    skip.lastIndex = i;
+                    if (match = skip.exec(input)) {
+                        if (match.index === i) {
+                            i += match[0].length;
+                            chunk.push(match[0]);
+                        }
+                    }
+                    c = input.charAt(i);
+                    comment.lastIndex = string.lastIndex = i;
+
+                    if (match = string.exec(input)) {
+                        if (match.index === i) {
+                            i += match[0].length;
+                            chunk.push(match[0]);
+                            continue;
+                        }
+                    }
+
+                    if (!inParam && c === '/') {
+                        cc = input.charAt(i + 1);
+                        if (cc === '/' || cc === '*') {
+                            if (match = comment.exec(input)) {
+                                if (match.index === i) {
+                                    i += match[0].length;
+                                    chunk.push(match[0]);
+                                    continue;
+                                }
+                            }
+                        }
+                    }
+
+                    switch (c) {
+                        case '{': if (! inParam) { level ++;        chunk.push(c);                           break }
+                        case '}': if (! inParam) { level --;        chunk.push(c); chunks[++j] = chunk = []; break }
+                        case '(': if (! inParam) { inParam = true;  chunk.push(c);                           break }
+                        case ')': if (  inParam) { inParam = false; chunk.push(c);                           break }
+                        default:                                    chunk.push(c);
+                    }
+
+                    i++;
+                }
+                if (level != 0) {
+                    error = new(LessError)({
+                        index: i-1,
+                        type: 'Parse',
+                        message: (level > 0) ? "missing closing `}`" : "missing opening `{`",
+                        filename: env.filename
+                    }, env);
+                }
+
+                return chunks.map(function (c) { return c.join('') });;
+            })([[]]);
+
+            if (error) {
+                return callback(new(LessError)(error, env));
+            }
+
+            // Start with the primary rule.
+            // The whole syntax tree is held under a Ruleset node,
+            // with the `root` property set to true, so no `{}` are
+            // output. The callback is called when the input is parsed.
+            try {
+                root = new(tree.Ruleset)([], $(this.parsers.primary));
+                root.root = true;
+            } catch (e) {
+                return callback(new(LessError)(e, env));
+            }
+
+            root.toCSS = (function (evaluate) {
+                var line, lines, column;
+
+                return function (options, variables) {
+                    options = options || {};
+                    var importError,
+                        evalEnv = new tree.evalEnv(options);
+
+                    //
+                    // Allows setting variables with a hash, so:
+                    //
+                    //   `{ color: new(tree.Color)('#f01') }` will become:
+                    //
+                    //   new(tree.Rule)('@color',
+                    //     new(tree.Value)([
+                    //       new(tree.Expression)([
+                    //         new(tree.Color)('#f01')
+                    //       ])
+                    //     ])
+                    //   )
+                    //
+                    if (typeof(variables) === 'object' && !Array.isArray(variables)) {
+                        variables = Object.keys(variables).map(function (k) {
+                            var value = variables[k];
+
+                            if (! (value instanceof tree.Value)) {
+                                if (! (value instanceof tree.Expression)) {
+                                    value = new(tree.Expression)([value]);
+                                }
+                                value = new(tree.Value)([value]);
+                            }
+                            return new(tree.Rule)('@' + k, value, false, 0);
+                        });
+                        evalEnv.frames = [new(tree.Ruleset)(null, variables)];
+                    }
+
+                    try {
+                        var css = evaluate.call(this, evalEnv)
+                                          .toCSS([], {
+                                compress: options.compress || false,
+                                dumpLineNumbers: env.dumpLineNumbers,
+                                strictUnits: options.strictUnits === false ? false : true});
+                    } catch (e) {
+                        throw new(LessError)(e, env);
+                    }
+
+                    if (options.yuicompress && less.mode === 'node') {
+                        return require('ycssmin').cssmin(css);
+                    } else if (options.compress) {
+                        return css.replace(/(\s)+/g, "$1");
+                    } else {
+                        return css;
+                    }
+                };
+            })(root.eval);
+
+            // If `i` is smaller than the `input.length - 1`,
+            // it means the parser wasn't able to parse the whole
+            // string, so we've got a parsing error.
+            //
+            // We try to extract a \n delimited string,
+            // showing the line where the parse error occured.
+            // We split it up into two parts (the part which parsed,
+            // and the part which didn't), so we can color them differently.
+            if (i < input.length - 1) {
+                i = furthest;
+                lines = input.split('\n');
+                line = (input.slice(0, i).match(/\n/g) || "").length + 1;
+
+                for (var n = i, column = -1; n >= 0 && input.charAt(n) !== '\n'; n--) { column++ }
+
+                error = {
+                    type: "Parse",
+                    message: "Unrecognised input",
+                    index: i,
+                    filename: env.filename,
+                    line: line,
+                    column: column,
+                    extract: [
+                        lines[line - 2],
+                        lines[line - 1],
+                        lines[line]
+                    ]
+                };
+            }
+
+            finish = function (e) {
+                e = error || e || parser.imports.error;
+
+                if (e) {
+                    if (!(e instanceof LessError)) {
+                        e = new(LessError)(e, env);
+                    }
+
+                    callback(e);
+                }
+                else {
+                    callback(null, root);
+                }
+            };
+
+            if (this.imports.queue.length === 0) {
+                finish();
+            }
+        },
+
+        //
+        // Here in, the parsing rules/functions
+        //
+        // The basic structure of the syntax tree generated is as follows:
+        //
+        //   Ruleset ->  Rule -> Value -> Expression -> Entity
+        //
+        // Here's some LESS code:
+        //
+        //    .class {
+        //      color: #fff;
+        //      border: 1px solid #000;
+        //      width: @w + 4px;
+        //      > .child {...}
+        //    }
+        //
+        // And here's what the parse tree might look like:
+        //
+        //     Ruleset (Selector '.class', [
+        //         Rule ("color",  Value ([Expression [Color #fff]]))
+        //         Rule ("border", Value ([Expression [Dimension 1px][Keyword "solid"][Color #000]]))
+        //         Rule ("width",  Value ([Expression [Operation "+" [Variable "@w"][Dimension 4px]]]))
+        //         Ruleset (Selector [Element '>', '.child'], [...])
+        //     ])
+        //
+        //  In general, most rules will try to parse a token with the `$()` function, and if the return
+        //  value is truly, will return a new node, of the relevant type. Sometimes, we need to check
+        //  first, before parsing, that's when we use `peek()`.
+        //
+        parsers: {
+            //
+            // The `primary` rule is the *entry* and *exit* point of the parser.
+            // The rules here can appear at any level of the parse tree.
+            //
+            // The recursive nature of the grammar is an interplay between the `block`
+            // rule, which represents `{ ... }`, the `ruleset` rule, and this `primary` rule,
+            // as represented by this simplified grammar:
+            //
+            //     primary  →  (ruleset | rule)+
+            //     ruleset  →  selector+ block
+            //     block    →  '{' primary '}'
+            //
+            // Only at one point is the primary rule not called from the
+            // block rule: at the root level.
+            //
+            primary: function () {
+                var node, root = [];
+
+                while ((node = $(this.extendRule) || $(this.mixin.definition) || $(this.rule)    ||  $(this.ruleset) ||
+                               $(this.mixin.call)       || $(this.comment) ||  $(this.directive))
+                               || $(/^[\s\n]+/) || $(/^;+/)) {
+                    node && root.push(node);
+                }
+                return root;
+            },
+
+            // We create a Comment node for CSS comments `/* */`,
+            // but keep the LeSS comments `//` silent, by just skipping
+            // over them.
+            comment: function () {
+                var comment;
+
+                if (input.charAt(i) !== '/') return;
+
+                if (input.charAt(i + 1) === '/') {
+                    return new(tree.Comment)($(/^\/\/.*/), true);
+                } else if (comment = $(/^\/\*(?:[^*]|\*+[^\/*])*\*+\/\n?/)) {
+                    return new(tree.Comment)(comment);
+                }
+            },
+
+            //
+            // Entities are tokens which can be found inside an Expression
+            //
+            entities: {
+                //
+                // A string, which supports escaping " and '
+                //
+                //     "milky way" 'he\'s the one!'
+                //
+                quoted: function () {
+                    var str, j = i, e;
+
+                    if (input.charAt(j) === '~') { j++, e = true } // Escaped strings
+                    if (input.charAt(j) !== '"' && input.charAt(j) !== "'") return;
+
+                    e && $('~');
+
+                    if (str = $(/^"((?:[^"\\\r\n]|\\.)*)"|'((?:[^'\\\r\n]|\\.)*)'/)) {
+                        return new(tree.Quoted)(str[0], str[1] || str[2], e);
+                    }
+                },
+
+                //
+                // A catch-all word, such as:
+                //
+                //     black border-collapse
+                //
+                keyword: function () {
+                    var k;
+
+                    if (k = $(/^[_A-Za-z-][_A-Za-z0-9-]*/)) {
+                        if (tree.colors.hasOwnProperty(k)) {
+                            // detect named color
+                            return new(tree.Color)(tree.colors[k].slice(1));
+                        } else {
+                            return new(tree.Keyword)(k);
+                        }
+                    }
+                },
+
+                //
+                // A function call
+                //
+                //     rgb(255, 0, 255)
+                //
+                // We also try to catch IE's `alpha()`, but let the `alpha` parser
+                // deal with the details.
+                //
+                // The arguments are parsed with the `entities.arguments` parser.
+                //
+                call: function () {
+                    var name, nameLC, args, alpha_ret, index = i;
+
+                    if (! (name = /^([\w-]+|%|progid:[\w\.]+)\(/.exec(chunks[j]))) return;
+
+                    name = name[1];
+                    nameLC = name.toLowerCase();
+
+                    if (nameLC === 'url') { return null }
+                    else                  { i += name.length }
+
+                    if (nameLC === 'alpha') {
+                        alpha_ret = $(this.alpha);
+                        if(typeof alpha_ret !== 'undefined') {
+                            return alpha_ret;
+                        }
+                    }
+
+                    $('('); // Parse the '(' and consume whitespace.
+
+                    args = $(this.entities.arguments);
+
+                    if (! $(')')) {
+                        return;
+                    }
+
+                    if (name) { return new(tree.Call)(name, args, index, env.filename, env.rootpath, env.currentDirectory); }
+                },
+                arguments: function () {
+                    var args = [], arg;
+
+                    while (arg = $(this.entities.assignment) || $(this.expression)) {
+                        args.push(arg);
+                        if (! $(',')) { break }
+                    }
+                    return args;
+                },
+                literal: function () {
+                    return $(this.entities.dimension) ||
+                           $(this.entities.color) ||
+                           $(this.entities.quoted) ||
+                           $(this.entities.unicodeDescriptor);
+                },
+
+                // Assignments are argument entities for calls.
+                // They are present in ie filter properties as shown below.
+                //
+                //     filter: progid:DXImageTransform.Microsoft.Alpha( *opacity=50* )
+                //
+
+                assignment: function () {
+                    var key, value;
+                    if ((key = $(/^\w+(?=\s?=)/i)) && $('=') && (value = $(this.entity))) {
+                        return new(tree.Assignment)(key, value);
+                    }
+                },
+
+                //
+                // Parse url() tokens
+                //
+                // We use a specific rule for urls, because they don't really behave like
+                // standard function calls. The difference is that the argument doesn't have
+                // to be enclosed within a string, so it can't be parsed as an Expression.
+                //
+                url: function () {
+                    var value;
+
+                    if (input.charAt(i) !== 'u' || !$(/^url\(/)) return;
+                    value = $(this.entities.quoted)  || $(this.entities.variable) ||
+                            $(/^(?:(?:\\[\(\)'"])|[^\(\)'"])+/) || "";
+
+                    expect(')');
+
+                    return new(tree.URL)((value.value != null || value instanceof tree.Variable)
+                                        ? value : new(tree.Anonymous)(value), env.rootpath);
+                },
+
+                //
+                // A Variable entity, such as `@fink`, in
+                //
+                //     width: @fink + 2px
+                //
+                // We use a different parser for variable definitions,
+                // see `parsers.variable`.
+                //
+                variable: function () {
+                    var name, index = i;
+
+                    if (input.charAt(i) === '@' && (name = $(/^@@?[\w-]+/))) {
+                        return new(tree.Variable)(name, index, env.filename);
+                    }
+                },
+
+                // A variable entity useing the protective {} e.g. @{var}
+                variableCurly: function () {
+                    var name, curly, index = i;
+
+                    if (input.charAt(i) === '@' && (curly = $(/^@\{([\w-]+)\}/))) {
+                        return new(tree.Variable)("@" + curly[1], index, env.filename);
+                    }
+                },
+
+                //
+                // A Hexadecimal color
+                //
+                //     #4F3C2F
+                //
+                // `rgb` and `hsl` colors are parsed through the `entities.call` parser.
+                //
+                color: function () {
+                    var rgb;
+
+                    if (input.charAt(i) === '#' && (rgb = $(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})/))) {
+                        return new(tree.Color)(rgb[1]);
+                    }
+                },
+
+                //
+                // A Dimension, that is, a number and a unit
+                //
+                //     0.5em 95%
+                //
+                dimension: function () {
+                    var value, c = input.charCodeAt(i);
+                    //Is the first char of the dimension 0-9, '.', '+' or '-'
+                    if ((c > 57 || c < 43) || c === 47 || c == 44) return;
+
+                    if (value = $(/^([+-]?\d*\.?\d+)(%|[a-z]+)?/)) {
+                        return new(tree.Dimension)(value[1], value[2]);
+                    }
+                },
+
+                //
+                // A unicode descriptor, as is used in unicode-range
+                //
+                // U+0??  or U+00A1-00A9
+                //
+                unicodeDescriptor: function () {
+                    var ud;
+
+                    if (ud = $(/^U\+[0-9a-fA-F?]+(\-[0-9a-fA-F?]+)?/)) {
+                        return new(tree.UnicodeDescriptor)(ud[0]);
+                    }
+                },
+
+                //
+                // JavaScript code to be evaluated
+                //
+                //     `window.location.href`
+                //
+                javascript: function () {
+                    var str, j = i, e;
+
+                    if (input.charAt(j) === '~') { j++, e = true } // Escaped strings
+                    if (input.charAt(j) !== '`') { return }
+
+                    e && $('~');
+
+                    if (str = $(/^`([^`]*)`/)) {
+                        return new(tree.JavaScript)(str[1], i, e);
+                    }
+                }
+            },
+
+            //
+            // The variable part of a variable definition. Used in the `rule` parser
+            //
+            //     @fink:
+            //
+            variable: function () {
+                var name;
+
+                if (input.charAt(i) === '@' && (name = $(/^(@[\w-]+)\s*:/))) { return name[1] }
+            },
+
+            //
+            // extend syntax - used to extend selectors
+            //
+            extend: function(isRule) {
+                var elements = [], e, args, index = i;
+
+                if (!$(isRule ? /^&:extend\(/ : /^:extend\(/)) { return; }
+
+                while (e = $(/^[#.](?:[\w-]|\\(?:[a-fA-F0-9]{1,6} ?|[^a-fA-F0-9]))+/)) {
+                    elements.push(new(tree.Element)(null, e, i));
+                }
+
+                expect(/^\)/);
+
+                if (isRule) {
+                    expect(/^;/);
+                }
+
+                return new(tree.Extend)(elements, index);
+            },
+
+            //
+            // extendRule - used in a rule to extend all the parent selectors
+            //
+            extendRule: function() {
+                return this.extend(true);
+            },
+
+            //
+            // Mixins
+            //
+            mixin: {
+                //
+                // A Mixin call, with an optional argument list
+                //
+                //     #mixins > .square(#fff);
+                //     .rounded(4px, black);
+                //     .button;
+                //
+                // The `while` loop is there because mixins can be
+                // namespaced, but we only support the child and descendant
+                // selector for now.
+                //
+                call: function () {
+                    var elements = [], e, c, argsSemiColon = [], argsComma = [], args, delim, arg, nameLoop, expressions, isSemiColonSeperated, expressionContainsNamed, index = i, s = input.charAt(i), name, value, important = false;
+
+                    if (s !== '.' && s !== '#') { return }
+
+                    save(); // stop us absorbing part of an invalid selector
+
+                    while (e = $(/^[#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/)) {
+                        elements.push(new(tree.Element)(c, e, i));
+                        c = $('>');
+                    }
+                    if ($('(')) {
+                        expressions = [];
+                        while (arg = $(this.expression)) {
+                            nameLoop = null;
+                            arg.throwAwayComments();
+                            value = arg;
+
+                            // Variable
+                            if (arg.value.length == 1) {
+                                var val = arg.value[0];
+                                if (val instanceof tree.Variable) {
+                                    if ($(':')) {
+                                        if (expressions.length > 0) {
+                                            if (isSemiColonSeperated) {
+                                                error("Cannot mix ; and , as delimiter types");
+                                            }
+                                            expressionContainsNamed = true;
+                                        }
+                                        value = expect(this.expression);
+                                        nameLoop = (name = val.name);
+                                    }
+                                }
+                            }
+
+                            expressions.push(value);
+
+                            argsComma.push({ name: nameLoop, value: value });
+
+                            if ($(',')) {
+                                continue;
+                            }
+
+                            if ($(';') || isSemiColonSeperated) {
+
+                                if (expressionContainsNamed) {
+                                    error("Cannot mix ; and , as delimiter types");
+                                }
+
+                                isSemiColonSeperated = true;
+
+                                if (expressions.length > 1) {
+                                    value = new(tree.Value)(expressions);
+                                }
+                                argsSemiColon.push({ name: name, value: value });
+
+                                name = null;
+                                expressions = [];
+                                expressionContainsNamed = false;
+                            }
+                        }
+
+                        expect(')');
+                    }
+
+                    args = isSemiColonSeperated ? argsSemiColon : argsComma;
+
+                    if ($(this.important)) {
+                        important = true;
+                    }
+
+                    if (elements.length > 0 && ($(';') || peek('}'))) {
+                        return new(tree.mixin.Call)(elements, args, index, env.filename, important);
+                    }
+
+                    restore();
+                },
+
+                //
+                // A Mixin definition, with a list of parameters
+                //
+                //     .rounded (@radius: 2px, @color) {
+                //        ...
+                //     }
+                //
+                // Until we have a finer grained state-machine, we have to
+                // do a look-ahead, to make sure we don't have a mixin call.
+                // See the `rule` function for more information.
+                //
+                // We start by matching `.rounded (`, and then proceed on to
+                // the argument list, which has optional default values.
+                // We store the parameters in `params`, with a `value` key,
+                // if there is a value, such as in the case of `@radius`.
+                //
+                // Once we've got our params list, and a closing `)`, we parse
+                // the `{...}` block.
+                //
+                definition: function () {
+                    var name, params = [], match, ruleset, param, value, cond, variadic = false;
+                    if ((input.charAt(i) !== '.' && input.charAt(i) !== '#') ||
+                        peek(/^[^{]*\}/)) return;
+
+                    save();
+
+                    if (match = $(/^([#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/)) {
+                        name = match[1];
+
+                        do {
+                            $(this.comment);
+                            if (input.charAt(i) === '.' && $(/^\.{3}/)) {
+                                variadic = true;
+                                params.push({ variadic: true });
+                                break;
+                            } else if (param = $(this.entities.variable) || $(this.entities.literal)
+                                                                         || $(this.entities.keyword)) {
+                                // Variable
+                                if (param instanceof tree.Variable) {
+                                    if ($(':')) {
+                                        value = expect(this.expression, 'expected expression');
+                                        params.push({ name: param.name, value: value });
+                                    } else if ($(/^\.{3}/)) {
+                                        params.push({ name: param.name, variadic: true });
+                                        variadic = true;
+                                        break;
+                                    } else {
+                                        params.push({ name: param.name });
+                                    }
+                                } else {
+                                    params.push({ value: param });
+                                }
+                            } else {
+                                break;
+                            }
+                        } while ($(',') || $(';'))
+
+                        // .mixincall("@{a}");
+                        // looks a bit like a mixin definition.. so we have to be nice and restore
+                        if (!$(')')) {
+                            furthest = i;
+                            restore();
+                        }
+
+                        $(this.comment);
+
+                        if ($(/^when/)) { // Guard
+                            cond = expect(this.conditions, 'expected condition');
+                        }
+
+                        ruleset = $(this.block);
+
+                        if (ruleset) {
+                            return new(tree.mixin.Definition)(name, params, ruleset, cond, variadic);
+                        } else {
+                            restore();
+                        }
+                    }
+                }
+            },
+
+            //
+            // Entities are the smallest recognized token,
+            // and can be found inside a rule's value.
+            //
+            entity: function () {
+                return $(this.entities.literal) || $(this.entities.variable) || $(this.entities.url) ||
+                       $(this.entities.call)    || $(this.entities.keyword)  ||$(this.entities.javascript) ||
+                       $(this.comment);
+            },
+
+            //
+            // A Rule terminator. Note that we use `peek()` to check for '}',
+            // because the `block` rule will be expecting it, but we still need to make sure
+            // it's there, if ';' was ommitted.
+            //
+            end: function () {
+                return $(';') || peek('}');
+            },
+
+            //
+            // IE's alpha function
+            //
+            //     alpha(opacity=88)
+            //
+            alpha: function () {
+                var value;
+
+                if (! $(/^\(opacity=/i)) return;
+                if (value = $(/^\d+/) || $(this.entities.variable)) {
+                    expect(')');
+                    return new(tree.Alpha)(value);
+                }
+            },
+
+            //
+            // A Selector Element
+            //
+            //     div
+            //     + h1
+            //     #socks
+            //     input[type="text"]
+            //
+            // Elements are the building blocks for Selectors,
+            // they are made out of a `Combinator` (see combinator rule),
+            // and an element name, such as a tag a class, or `*`.
+            //
+            element: function () {
+                var e, t, c, v;
+
+                c = $(this.combinator);
+
+                e = $(/^(?:\d+\.\d+|\d+)%/) || $(/^(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/) ||
+                    $('*') || $('&') || $(this.attribute) || $(/^\([^()@]+\)/) || $(/^[\.#](?=@)/) || $(this.entities.variableCurly);
+
+                if (! e) {
+                    if ($('(')) {
+                        if ((v = (//$(this.entities.variableCurly) ||
+                                $(this.selector))) &&
+                                $(')')) {
+                            e = new(tree.Paren)(v);
+                        }
+                    }
+                }
+
+                if (e) { return new(tree.Element)(c, e, i) }
+            },
+
+            //
+            // Combinators combine elements together, in a Selector.
+            //
+            // Because our parser isn't white-space sensitive, special care
+            // has to be taken, when parsing the descendant combinator, ` `,
+            // as it's an empty space. We have to check the previous character
+            // in the input, to see if it's a ` ` character. More info on how
+            // we deal with this in *combinator.js*.
+            //
+            combinator: function () {
+                var match, c = input.charAt(i);
+
+                if (c === '>' || c === '+' || c === '~' || c === '|') {
+                    i++;
+                    while (input.charAt(i).match(/\s/)) { i++ }
+                    return new(tree.Combinator)(c);
+                } else if (input.charAt(i - 1).match(/\s/)) {
+                    return new(tree.Combinator)(" ");
+                } else {
+                    return new(tree.Combinator)(null);
+                }
+            },
+
+            //
+            // A CSS Selector
+            //
+            //     .class > div + h1
+            //     li a:hover
+            //
+            // Selectors are made out of one or more Elements, see above.
+            //
+            selector: function () {
+                var sel, e, elements = [], c, match, extend;
+
+                while ((extend = $(this.extend)) || (e = $(this.element))) {
+                    if (!e) {
+                        break;
+                    }
+                    c = input.charAt(i);
+                    elements.push(e)
+                    e = null;
+                    if (c === '{' || c === '}' || c === ';' || c === ',' || c === ')') { break }
+                }
+
+                if (elements.length > 0) { return new(tree.Selector)(elements, extend) }
+                if (extend) { error("Extend must be used to extend a selector"); }
+            },
+            attribute: function () {
+                var attr = '', key, val, op;
+
+                if (! $('[')) return;
+
+                if (key = $(/^(?:[_A-Za-z0-9-]|\\.)+/) || $(this.entities.quoted)) {
+                    if ((op = $(/^[|~*$^]?=/)) &&
+                        (val = $(this.entities.quoted) || $(/^[\w-]+/))) {
+                        attr = [key, op, val.toCSS ? val.toCSS() : val].join('');
+                    } else { attr = key }
+                }
+
+                if (! $(']')) return;
+
+                if (attr) { return "[" + attr + "]" }
+            },
+
+            //
+            // The `block` rule is used by `ruleset` and `mixin.definition`.
+            // It's a wrapper around the `primary` rule, with added `{}`.
+            //
+            block: function () {
+                var content;
+                if ($('{') && (content = $(this.primary)) && $('}')) {
+                    return content;
+                }
+            },
+
+            //
+            // div, .class, body > p {...}
+            //
+            ruleset: function () {
+                var selectors = [], s, rules, match, debugInfo;
+
+                save();
+
+                if (env.dumpLineNumbers)
+                    debugInfo = getDebugInfo(i, input, env);
+
+                while (s = $(this.selector)) {
+                    selectors.push(s);
+                    $(this.comment);
+                    if (! $(',')) { break }
+                    $(this.comment);
+                }
+
+                if (selectors.length > 0 && (rules = $(this.block))) {
+                    var ruleset = new(tree.Ruleset)(selectors, rules, env.strictImports);
+                    if (env.dumpLineNumbers)
+                        ruleset.debugInfo = debugInfo;
+                    return ruleset;
+                } else {
+                    // Backtrack
+                    furthest = i;
+                    restore();
+                }
+            },
+            rule: function () {
+                var name, value, c = input.charAt(i), important, match;
+                save();
+
+                if (c === '.' || c === '#' || c === '&') { return }
+
+                if (name = $(this.variable) || $(this.property)) {
+                    if (!env.compress && (name.charAt(0) != '@') && (match = /^([^@+\/'"*`(;{}-]*);/.exec(chunks[j]))) {
+                        i += match[0].length - 1;
+                        value = new(tree.Anonymous)(match[1]);
+                    } else {
+                        value = $(this.value);
+                    }
+                    important = $(this.important);
+
+                    if (value && $(this.end)) {
+                        return new(tree.Rule)(name, value, important, memo);
+                    } else {
+                        furthest = i;
+                        restore();
+                    }
+                }
+            },
+
+            //
+            // An @import directive
+            //
+            //     @import "lib";
+            //
+            // Depending on our environemnt, importing is done differently:
+            // In the browser, it's an XHR request, in Node, it would be a
+            // file-system operation. The function used for importing is
+            // stored in `import`, which we pass to the Import constructor.
+            //
+            "import": function () {
+                var path, features, index = i;
+
+                save();
+
+                var dir = $(/^@import(?:-(once|multiple))?\s+/);
+
+                if (dir && (path = $(this.entities.quoted) || $(this.entities.url))) {
+                    features = $(this.mediaFeatures);
+                    if ($(';')) {
+                        features = features && new(tree.Value)(features);
+                        var importOnce = dir[1] !== 'multiple';
+                        return new(tree.Import)(path, imports, features, importOnce, index, env.rootpath);
+                    }
+                }
+
+                restore();
+            },
+
+            mediaFeature: function () {
+                var e, p, nodes = [];
+
+                do {
+                    if (e = $(this.entities.keyword)) {
+                        nodes.push(e);
+                    } else if ($('(')) {
+                        p = $(this.property);
+                        e = $(this.value);
+                        if ($(')')) {
+                            if (p && e) {
+                                nodes.push(new(tree.Paren)(new(tree.Rule)(p, e, null, i, true)));
+                            } else if (e) {
+                                nodes.push(new(tree.Paren)(e));
+                            } else {
+                                return null;
+                            }
+                        } else { return null }
+                    }
+                } while (e);
+
+                if (nodes.length > 0) {
+                    return new(tree.Expression)(nodes);
+                }
+            },
+
+            mediaFeatures: function () {
+                var e, features = [];
+
+                do {
+                  if (e = $(this.mediaFeature)) {
+                      features.push(e);
+                      if (! $(',')) { break }
+                  } else if (e = $(this.entities.variable)) {
+                      features.push(e);
+                      if (! $(',')) { break }
+                  }
+                } while (e);
+
+                return features.length > 0 ? features : null;
+            },
+
+            media: function () {
+                var features, rules, media, debugInfo;
+
+                if (env.dumpLineNumbers)
+                    debugInfo = getDebugInfo(i, input, env);
+
+                if ($(/^@media/)) {
+                    features = $(this.mediaFeatures);
+
+                    if (rules = $(this.block)) {
+                        media = new(tree.Media)(rules, features);
+                        if(env.dumpLineNumbers)
+                            media.debugInfo = debugInfo;
+                        return media;
+                    }
+                }
+            },
+
+            //
+            // A CSS Directive
+            //
+            //     @charset "utf-8";
+            //
+            directive: function () {
+                var name, value, rules, identifier, e, nodes, nonVendorSpecificName,
+                    hasBlock, hasIdentifier, hasExpression;
+
+                if (input.charAt(i) !== '@') return;
+
+                if (value = $(this['import']) || $(this.media)) {
+                    return value;
+                }
+
+                save();
+
+                name = $(/^@[a-z-]+/);
+
+                if (!name) return;
+
+                nonVendorSpecificName = name;
+                if (name.charAt(1) == '-' && name.indexOf('-', 2) > 0) {
+                    nonVendorSpecificName = "@" + name.slice(name.indexOf('-', 2) + 1);
+                }
+
+                switch(nonVendorSpecificName) {
+                    case "@font-face":
+                        hasBlock = true;
+                        break;
+                    case "@viewport":
+                    case "@top-left":
+                    case "@top-left-corner":
+                    case "@top-center":
+                    case "@top-right":
+                    case "@top-right-corner":
+                    case "@bottom-left":
+                    case "@bottom-left-corner":
+                    case "@bottom-center":
+                    case "@bottom-right":
+                    case "@bottom-right-corner":
+                    case "@left-top":
+                    case "@left-middle":
+                    case "@left-bottom":
+                    case "@right-top":
+                    case "@right-middle":
+                    case "@right-bottom":
+                        hasBlock = true;
+                        break;
+                    case "@page":
+                    case "@document":
+                    case "@supports":
+                    case "@keyframes":
+                        hasBlock = true;
+                        hasIdentifier = true;
+                        break;
+                    case "@namespace":
+                        hasExpression = true;
+                        break;
+                }
+
+                if (hasIdentifier) {
+                    name += " " + ($(/^[^{]+/) || '').trim();
+                }
+
+                if (hasBlock)
+                {
+                    if (rules = $(this.block)) {
+                        return new(tree.Directive)(name, rules);
+                    }
+                } else {
+                    if ((value = hasExpression ? $(this.expression) : $(this.entity)) && $(';')) {
+                        var directive = new(tree.Directive)(name, value);
+                        if (env.dumpLineNumbers) {
+                            directive.debugInfo = getDebugInfo(i, input, env);
+                        }
+                        return directive;
+                    }
+                }
+
+                restore();
+            },
+
+            //
+            // A Value is a comma-delimited list of Expressions
+            //
+            //     font-family: Baskerville, Georgia, serif;
+            //
+            // In a Rule, a Value represents everything after the `:`,
+            // and before the `;`.
+            //
+            value: function () {
+                var e, expressions = [], important;
+
+                while (e = $(this.expression)) {
+                    expressions.push(e);
+                    if (! $(',')) { break }
+                }
+
+                if (expressions.length > 0) {
+                    return new(tree.Value)(expressions);
+                }
+            },
+            important: function () {
+                if (input.charAt(i) === '!') {
+                    return $(/^! *important/);
+                }
+            },
+            sub: function () {
+                var a, e;
+
+                if ($('(')) {
+                    if (a = $(this.addition)) {
+                        e = new(tree.Expression)([a]);
+                        expect(')');
+                        e.parens = true;
+                        return e;
+                    }
+                }
+            },
+            multiplication: function () {
+                var m, a, op, operation, isSpaced, expression = [];
+                if (m = $(this.operand)) {
+                    isSpaced = isWhitespace(input.charAt(i - 1));
+                    while (!peek(/^\/[*\/]/) && (op = ($('/') || $('*')))) {
+                        if (a = $(this.operand)) {
+                            m.parensInOp = true;
+                            a.parensInOp = true;
+                            operation = new(tree.Operation)(op, [operation || m, a], isSpaced);
+                            isSpaced = isWhitespace(input.charAt(i - 1));
+                        } else {
+                            break;
+                        }
+                    }
+                    return operation || m;
+                }
+            },
+            addition: function () {
+                var m, a, op, operation, isSpaced;
+                if (m = $(this.multiplication)) {
+                    isSpaced = isWhitespace(input.charAt(i - 1));
+                    while ((op = $(/^[-+]\s+/) || (!isSpaced && ($('+') || $('-')))) &&
+                           (a = $(this.multiplication))) {
+                        m.parensInOp = true;
+                        a.parensInOp = true;
+                        operation = new(tree.Operation)(op, [operation || m, a], isSpaced);
+                        isSpaced = isWhitespace(input.charAt(i - 1));
+                    }
+                    return operation || m;
+                }
+            },
+            conditions: function () {
+                var a, b, index = i, condition;
+
+                if (a = $(this.condition)) {
+                    while ($(',') && (b = $(this.condition))) {
+                        condition = new(tree.Condition)('or', condition || a, b, index);
+                    }
+                    return condition || a;
+                }
+            },
+            condition: function () {
+                var a, b, c, op, index = i, negate = false;
+
+                if ($(/^not/)) { negate = true }
+                expect('(');
+                if (a = $(this.addition) || $(this.entities.keyword) || $(this.entities.quoted)) {
+                    if (op = $(/^(?:>=|=<|[<=>])/)) {
+                        if (b = $(this.addition) || $(this.entities.keyword) || $(this.entities.quoted)) {
+                            c = new(tree.Condition)(op, a, b, index, negate);
+                        } else {
+                            error('expected expression');
+                        }
+                    } else {
+                        c = new(tree.Condition)('=', a, new(tree.Keyword)('true'), index, negate);
+                    }
+                    expect(')');
+                    return $(/^and/) ? new(tree.Condition)('and', c, $(this.condition)) : c;
+                }
+            },
+
+            //
+            // An operand is anything that can be part of an operation,
+            // such as a Color, or a Variable
+            //
+            operand: function () {
+                var negate, p = input.charAt(i + 1);
+
+                if (input.charAt(i) === '-' && (p === '@' || p === '(')) { negate = $('-') }
+                var o = $(this.sub) || $(this.entities.dimension) ||
+                        $(this.entities.color) || $(this.entities.variable) ||
+                        $(this.entities.call);
+
+                if (negate) {
+                    o.parensInOp = true;
+                    o = new(tree.Negative)(o);
+                }
+
+                return o;
+            },
+
+            //
+            // Expressions either represent mathematical operations,
+            // or white-space delimited Entities.
+            //
+            //     1px solid black
+            //     @var * 2
+            //
+            expression: function () {
+                var e, delim, entities = [], d;
+
+                while (e = $(this.addition) || $(this.entity)) {
+                    entities.push(e);
+                    // operations do not allow keyword "/" dimension (e.g. small/20px) so we support that here
+                    if (!peek(/^\/[\/*]/) && (delim = $('/'))) {
+                        entities.push(new(tree.Anonymous)(delim));
+                    }
+                }
+                if (entities.length > 0) {
+                    return new(tree.Expression)(entities);
+                }
+            },
+            property: function () {
+                var name;
+
+                if (name = $(/^(\*?-?[_a-z0-9-]+)\s*:/)) {
+                    return name[1];
+                }
+            }
+        }
+    };
+};
+
+if (less.mode === 'browser' || less.mode === 'rhino') {
+    //
+    // Used by `@import` directives
+    //
+    less.Parser.importer = function (path, paths, callback, env) {
+        if (!/^([a-z-]+:)?\//.test(path) && paths.length > 0) {
+            path = paths[0] + path;
+        }
+        // We pass `true` as 3rd argument, to force the reload of the import.
+        // This is so we can get the syntax tree as opposed to just the CSS output,
+        // as we need this to evaluate the current stylesheet.
+        loadStyleSheet(env.toSheet(path),
+            function (e, root, data, sheet, _, path) {
+                callback.call(null, e, root, path);
+            }, true);
+    };
+}
+
+(function (tree) {
+
+tree.functions = {
+    rgb: function (r, g, b) {
+        return this.rgba(r, g, b, 1.0);
+    },
+    rgba: function (r, g, b, a) {
+        var rgb = [r, g, b].map(function (c) { return scaled(c, 256); });
+        a = number(a);
+        return new(tree.Color)(rgb, a);
+    },
+    hsl: function (h, s, l) {
+        return this.hsla(h, s, l, 1.0);
+    },
+    hsla: function (h, s, l, a) {
+        h = (number(h) % 360) / 360;
+        s = number(s); l = number(l); a = number(a);
+
+        var m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s;
+        var m1 = l * 2 - m2;
+
+        return this.rgba(hue(h + 1/3) * 255,
+                         hue(h)       * 255,
+                         hue(h - 1/3) * 255,
+                         a);
+
+        function hue(h) {
+            h = h < 0 ? h + 1 : (h > 1 ? h - 1 : h);
+            if      (h * 6 < 1) return m1 + (m2 - m1) * h * 6;
+            else if (h * 2 < 1) return m2;
+            else if (h * 3 < 2) return m1 + (m2 - m1) * (2/3 - h) * 6;
+            else                return m1;
+        }
+    },
+
+    hsv: function(h, s, v) {
+        return this.hsva(h, s, v, 1.0);
+    },
+
+    hsva: function(h, s, v, a) {
+        h = ((number(h) % 360) / 360) * 360;
+        s = number(s); v = number(v); a = number(a);
+
+        var i, f;
+        i = Math.floor((h / 60) % 6);
+        f = (h / 60) - i;
+
+        var vs = [v,
+                  v * (1 - s),
+                  v * (1 - f * s),
+                  v * (1 - (1 - f) * s)];
+        var perm = [[0, 3, 1],
+                    [2, 0, 1],
+                    [1, 0, 3],
+                    [1, 2, 0],
+                    [3, 1, 0],
+                    [0, 1, 2]];
+
+        return this.rgba(vs[perm[i][0]] * 255,
+                         vs[perm[i][1]] * 255,
+                         vs[perm[i][2]] * 255,
+                         a);
+    },
+
+    hue: function (color) {
+        return new(tree.Dimension)(Math.round(color.toHSL().h));
+    },
+    saturation: function (color) {
+        return new(tree.Dimension)(Math.round(color.toHSL().s * 100), '%');
+    },
+    lightness: function (color) {
+        return new(tree.Dimension)(Math.round(color.toHSL().l * 100), '%');
+    },
+    hsvhue: function(color) {
+        return new(tree.Dimension)(Math.round(color.toHSV().h));
+    },
+    hsvsaturation: function (color) {
+        return new(tree.Dimension)(Math.round(color.toHSV().s * 100), '%');
+    },
+    hsvvalue: function (color) {
+        return new(tree.Dimension)(Math.round(color.toHSV().v * 100), '%');
+    },
+    red: function (color) {
+        return new(tree.Dimension)(color.rgb[0]);
+    },
+    green: function (color) {
+        return new(tree.Dimension)(color.rgb[1]);
+    },
+    blue: function (color) {
+        return new(tree.Dimension)(color.rgb[2]);
+    },
+    alpha: function (color) {
+        return new(tree.Dimension)(color.toHSL().a);
+    },
+    luma: function (color) {
+        return new(tree.Dimension)(Math.round(color.luma() * color.alpha * 100), '%');
+    },
+    saturate: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.s += amount.value / 100;
+        hsl.s = clamp(hsl.s);
+        return hsla(hsl);
+    },
+    desaturate: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.s -= amount.value / 100;
+        hsl.s = clamp(hsl.s);
+        return hsla(hsl);
+    },
+    lighten: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.l += amount.value / 100;
+        hsl.l = clamp(hsl.l);
+        return hsla(hsl);
+    },
+    darken: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.l -= amount.value / 100;
+        hsl.l = clamp(hsl.l);
+        return hsla(hsl);
+    },
+    fadein: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.a += amount.value / 100;
+        hsl.a = clamp(hsl.a);
+        return hsla(hsl);
+    },
+    fadeout: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.a -= amount.value / 100;
+        hsl.a = clamp(hsl.a);
+        return hsla(hsl);
+    },
+    fade: function (color, amount) {
+        var hsl = color.toHSL();
+
+        hsl.a = amount.value / 100;
+        hsl.a = clamp(hsl.a);
+        return hsla(hsl);
+    },
+    spin: function (color, amount) {
+        var hsl = color.toHSL();
+        var hue = (hsl.h + amount.value) % 360;
+
+        hsl.h = hue < 0 ? 360 + hue : hue;
+
+        return hsla(hsl);
+    },
+    //
+    // Copyright (c) 2006-2009 Hampton Catlin, Nathan Weizenbaum, and Chris Eppstein
+    // http://sass-lang.com
+    //
+    mix: function (color1, color2, weight) {
+        if (!weight) {
+            weight = new(tree.Dimension)(50);
+        }
+        var p = weight.value / 100.0;
+        var w = p * 2 - 1;
+        var a = color1.toHSL().a - color2.toHSL().a;
+
+        var w1 = (((w * a == -1) ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
+        var w2 = 1 - w1;
+
+        var rgb = [color1.rgb[0] * w1 + color2.rgb[0] * w2,
+                   color1.rgb[1] * w1 + color2.rgb[1] * w2,
+                   color1.rgb[2] * w1 + color2.rgb[2] * w2];
+
+        var alpha = color1.alpha * p + color2.alpha * (1 - p);
+
+        return new(tree.Color)(rgb, alpha);
+    },
+    greyscale: function (color) {
+        return this.desaturate(color, new(tree.Dimension)(100));
+    },
+    contrast: function (color, dark, light, threshold) {
+        // filter: contrast(3.2);
+        // should be kept as is, so check for color
+        if (!color.rgb) {
+            return null;
+        }
+        if (typeof light === 'undefined') {
+            light = this.rgba(255, 255, 255, 1.0);
+        }
+        if (typeof dark === 'undefined') {
+            dark = this.rgba(0, 0, 0, 1.0);
+        }
+        //Figure out which is actually light and dark!
+        if (dark.luma() > light.luma()) {
+            var t = light;
+            light = dark;
+            dark = t;
+        }
+        if (typeof threshold === 'undefined') {
+            threshold = 0.43;
+        } else {
+            threshold = number(threshold);
+        }
+        if ((color.luma() * color.alpha) < threshold) {
+            return light;
+        } else {
+            return dark;
+        }
+    },
+    e: function (str) {
+        return new(tree.Anonymous)(str instanceof tree.JavaScript ? str.evaluated : str);
+    },
+    escape: function (str) {
+        return new(tree.Anonymous)(encodeURI(str.value).replace(/=/g, "%3D").replace(/:/g, "%3A").replace(/#/g, "%23").replace(/;/g, "%3B").replace(/\(/g, "%28").replace(/\)/g, "%29"));
+    },
+    '%': function (quoted /* arg, arg, ...*/) {
+        var args = Array.prototype.slice.call(arguments, 1),
+            str = quoted.value;
+
+        for (var i = 0; i < args.length; i++) {
+            str = str.replace(/%[sda]/i, function(token) {
+                var value = token.match(/s/i) ? args[i].value : args[i].toCSS();
+                return token.match(/[A-Z]$/) ? encodeURIComponent(value) : value;
+            });
+        }
+        str = str.replace(/%%/g, '%');
+        return new(tree.Quoted)('"' + str + '"', str);
+    },
+    unit: function (val, unit) {
+        return new(tree.Dimension)(val.value, unit ? unit.toCSS() : "");
+    },
+    convert: function (val, unit) {
+        return val.convertTo(unit.value);
+    },
+    round: function (n, f) {
+        var fraction = typeof(f) === "undefined" ? 0 : f.value;
+        return this._math(function(num) { return num.toFixed(fraction); }, null, n);
+    },
+    pi: function () {
+        return new(tree.Dimension)(Math.PI);
+    },
+    mod: function(a, b) {
+        return new(tree.Dimension)(a.value % b.value, a.unit);
+    },
+    pow: function(x, y) {
+        if (typeof x === "number" && typeof y === "number") {
+            x = new(tree.Dimension)(x);
+            y = new(tree.Dimension)(y);
+        } else if (!(x instanceof tree.Dimension) || !(y instanceof tree.Dimension)) {
+            throw { type: "Argument", message: "arguments must be numbers" };
+        }
+
+        return new(tree.Dimension)(Math.pow(x.value, y.value), x.unit);
+    },
+    _math: function (fn, unit, n) {
+        if (n instanceof tree.Dimension) {
+            return new(tree.Dimension)(fn(parseFloat(n.value)), unit == null ? n.unit : unit);
+        } else if (typeof(n) === 'number') {
+            return fn(n);
+        } else {
+            throw { type: "Argument", message: "argument must be a number" };
+        }
+    },
+    argb: function (color) {
+        return new(tree.Anonymous)(color.toARGB());
+
+    },
+    percentage: function (n) {
+        return new(tree.Dimension)(n.value * 100, '%');
+    },
+    color: function (n) {
+        if (n instanceof tree.Quoted) {
+            return new(tree.Color)(n.value.slice(1));
+        } else {
+            throw { type: "Argument", message: "argument must be a string" };
+        }
+    },
+    iscolor: function (n) {
+        return this._isa(n, tree.Color);
+    },
+    isnumber: function (n) {
+        return this._isa(n, tree.Dimension);
+    },
+    isstring: function (n) {
+        return this._isa(n, tree.Quoted);
+    },
+    iskeyword: function (n) {
+        return this._isa(n, tree.Keyword);
+    },
+    isurl: function (n) {
+        return this._isa(n, tree.URL);
+    },
+    ispixel: function (n) {
+        return (n instanceof tree.Dimension) && n.unit.is('px') ? tree.True : tree.False;
+    },
+    ispercentage: function (n) {
+        return (n instanceof tree.Dimension) && n.unit.is('%') ? tree.True : tree.False;
+    },
+    isem: function (n) {
+        return (n instanceof tree.Dimension) && n.unit.is('em') ? tree.True : tree.False;
+    },
+    _isa: function (n, Type) {
+        return (n instanceof Type) ? tree.True : tree.False;
+    },
+
+    /* Blending modes */
+
+    multiply: function(color1, color2) {
+        var r = color1.rgb[0] * color2.rgb[0] / 255;
+        var g = color1.rgb[1] * color2.rgb[1] / 255;
+        var b = color1.rgb[2] * color2.rgb[2] / 255;
+        return this.rgb(r, g, b);
+    },
+    screen: function(color1, color2) {
+        var r = 255 - (255 - color1.rgb[0]) * (255 - color2.rgb[0]) / 255;
+        var g = 255 - (255 - color1.rgb[1]) * (255 - color2.rgb[1]) / 255;
+        var b = 255 - (255 - color1.rgb[2]) * (255 - color2.rgb[2]) / 255;
+        return this.rgb(r, g, b);
+    },
+    overlay: function(color1, color2) {
+        var r = color1.rgb[0] < 128 ? 2 * color1.rgb[0] * color2.rgb[0] / 255 : 255 - 2 * (255 - color1.rgb[0]) * (255 - color2.rgb[0]) / 255;
+        var g = color1.rgb[1] < 128 ? 2 * color1.rgb[1] * color2.rgb[1] / 255 : 255 - 2 * (255 - color1.rgb[1]) * (255 - color2.rgb[1]) / 255;
+        var b = color1.rgb[2] < 128 ? 2 * color1.rgb[2] * color2.rgb[2] / 255 : 255 - 2 * (255 - color1.rgb[2]) * (255 - color2.rgb[2]) / 255;
+        return this.rgb(r, g, b);
+    },
+    softlight: function(color1, color2) {
+        var t = color2.rgb[0] * color1.rgb[0] / 255;
+        var r = t + color1.rgb[0] * (255 - (255 - color1.rgb[0]) * (255 - color2.rgb[0]) / 255 - t) / 255;
+        t = color2.rgb[1] * color1.rgb[1] / 255;
+        var g = t + color1.rgb[1] * (255 - (255 - color1.rgb[1]) * (255 - color2.rgb[1]) / 255 - t) / 255;
+        t = color2.rgb[2] * color1.rgb[2] / 255;
+        var b = t + color1.rgb[2] * (255 - (255 - color1.rgb[2]) * (255 - color2.rgb[2]) / 255 - t) / 255;
+        return this.rgb(r, g, b);
+    },
+    hardlight: function(color1, color2) {
+        var r = color2.rgb[0] < 128 ? 2 * color2.rgb[0] * color1.rgb[0] / 255 : 255 - 2 * (255 - color2.rgb[0]) * (255 - color1.rgb[0]) / 255;
+        var g = color2.rgb[1] < 128 ? 2 * color2.rgb[1] * color1.rgb[1] / 255 : 255 - 2 * (255 - color2.rgb[1]) * (255 - color1.rgb[1]) / 255;
+        var b = color2.rgb[2] < 128 ? 2 * color2.rgb[2] * color1.rgb[2] / 255 : 255 - 2 * (255 - color2.rgb[2]) * (255 - color1.rgb[2]) / 255;
+        return this.rgb(r, g, b);
+    },
+    difference: function(color1, color2) {
+        var r = Math.abs(color1.rgb[0] - color2.rgb[0]);
+        var g = Math.abs(color1.rgb[1] - color2.rgb[1]);
+        var b = Math.abs(color1.rgb[2] - color2.rgb[2]);
+        return this.rgb(r, g, b);
+    },
+    exclusion: function(color1, color2) {
+        var r = color1.rgb[0] + color2.rgb[0] * (255 - color1.rgb[0] - color1.rgb[0]) / 255;
+        var g = color1.rgb[1] + color2.rgb[1] * (255 - color1.rgb[1] - color1.rgb[1]) / 255;
+        var b = color1.rgb[2] + color2.rgb[2] * (255 - color1.rgb[2] - color1.rgb[2]) / 255;
+        return this.rgb(r, g, b);
+    },
+    average: function(color1, color2) {
+        var r = (color1.rgb[0] + color2.rgb[0]) / 2;
+        var g = (color1.rgb[1] + color2.rgb[1]) / 2;
+        var b = (color1.rgb[2] + color2.rgb[2]) / 2;
+        return this.rgb(r, g, b);
+    },
+    negation: function(color1, color2) {
+        var r = 255 - Math.abs(255 - color2.rgb[0] - color1.rgb[0]);
+        var g = 255 - Math.abs(255 - color2.rgb[1] - color1.rgb[1]);
+        var b = 255 - Math.abs(255 - color2.rgb[2] - color1.rgb[2]);
+        return this.rgb(r, g, b);
+    },
+    tint: function(color, amount) {
+        return this.mix(this.rgb(255,255,255), color, amount);
+    },
+    shade: function(color, amount) {
+        return this.mix(this.rgb(0, 0, 0), color, amount);
+    },
+    extract: function(values, index) {
+        index = index.value - 1; // (1-based index)
+        return values.value[index];
+    },
+
+    "data-uri": function(mimetypeNode, filePathNode) {
+
+        if (typeof window !== 'undefined') {
+            return new tree.URL(filePathNode || mimetypeNode, this.rootpath).eval(this.env);
+        }
+
+        var mimetype = mimetypeNode.value;
+        var filePath = (filePathNode && filePathNode.value);
+
+        var fs = require("fs"),
+            path = require("path"),
+            useBase64 = false;
+
+        if (arguments.length < 2) {
+            filePath = mimetype;
+        }
+
+        if (this.currentDirectory && this.env.isPathRelative(filePath)) {
+            filePath = path.join(this.currentDirectory, filePath);
+        }
+
+        // detect the mimetype if not given
+        if (arguments.length < 2) {
+            var mime;
+            try {
+                mime = require('mime');
+            } catch (ex) {
+                mime = tree._mime;
+            }
+
+            mimetype = mime.lookup(filePath);
+
+            // use base 64 unless it's an ASCII or UTF-8 format
+            var charset = mime.charsets.lookup(mimetype);
+            useBase64 = ['US-ASCII', 'UTF-8'].indexOf(charset) < 0;
+            if (useBase64) mimetype += ';base64';
+        }
+        else {
+            useBase64 = /;base64$/.test(mimetype)
+        }
+
+        var buf = fs.readFileSync(filePath);
+
+        // IE8 cannot handle a data-uri larger than 32KB. If this is exceeded
+        // and the --ieCompat flag is enabled, return a normal url() instead.
+        var DATA_URI_MAX_KB = 32,
+            fileSizeInKB = parseInt((buf.length / 1024), 10);
+        if (fileSizeInKB >= DATA_URI_MAX_KB) {
+            // the url() must be relative, not an absolute file path
+            filePath = path.relative(this.currentDirectory, filePath);
+
+            if (this.env.ieCompat !== false) {
+                if (!this.env.silent) {
+                    console.warn("Skipped data-uri embedding of %s because its size (%dKB) exceeds IE8-safe %dKB!", filePath, fileSizeInKB, DATA_URI_MAX_KB);
+                }
+
+                return new tree.URL(filePathNode || mimetypeNode, this.rootpath).eval(this.env);
+            } else if (!this.env.silent) {
+                // if explicitly disabled (via --no-ie-compat on CLI, or env.ieCompat === false), merely warn
+                console.warn("WARNING: Embedding %s (%dKB) exceeds IE8's data-uri size limit of %dKB!", filePath, fileSizeInKB, DATA_URI_MAX_KB);
+            }
+        }
+
+        buf = useBase64 ? buf.toString('base64')
+                        : encodeURIComponent(buf);
+
+        var uri = "'data:" + mimetype + ',' + buf + "'";
+        return new(tree.URL)(new(tree.Anonymous)(uri));
+    }
+};
+
+// these static methods are used as a fallback when the optional 'mime' dependency is missing
+tree._mime = {
+    // this map is intentionally incomplete
+    // if you want more, install 'mime' dep
+    _types: {
+        '.htm' : 'text/html',
+        '.html': 'text/html',
+        '.gif' : 'image/gif',
+        '.jpg' : 'image/jpeg',
+        '.jpeg': 'image/jpeg',
+        '.png' : 'image/png'
+    },
+    lookup: function (filepath) {
+        var ext = require('path').extname(filepath),
+            type = tree._mime._types[ext];
+        if (type === undefined) {
+            throw new Error('Optional dependency "mime" is required for ' + ext);
+        }
+        return type;
+    },
+    charsets: {
+        lookup: function (type) {
+            // assumes all text types are UTF-8
+            return type && (/^text\//).test(type) ? 'UTF-8' : '';
+        }
+    }
+};
+
+var mathFunctions = [{name:"ceil"}, {name:"floor"}, {name: "sqrt"}, {name:"abs"},
+        {name:"tan", unit: ""}, {name:"sin", unit: ""}, {name:"cos", unit: ""},
+        {name:"atan", unit: "rad"}, {name:"asin", unit: "rad"}, {name:"acos", unit: "rad"}],
+    createMathFunction = function(name, unit) {
+        return function(n) {
+            if (unit != null) {
+                n = n.unify();
+            }
+            return this._math(Math[name], unit, n);
+        };
+    };
+
+for(var i = 0; i < mathFunctions.length; i++) {
+    tree.functions[mathFunctions[i].name] = createMathFunction(mathFunctions[i].name, mathFunctions[i].unit);
+}
+
+function hsla(color) {
+    return tree.functions.hsla(color.h, color.s, color.l, color.a);
+}
+
+function scaled(n, size) {
+    if (n instanceof tree.Dimension && n.unit.is('%')) {
+        return parseFloat(n.value * size / 100);
+    } else {
+        return number(n);
+    }
+}
+
+function number(n) {
+    if (n instanceof tree.Dimension) {
+        return parseFloat(n.unit.is('%') ? n.value / 100 : n.value);
+    } else if (typeof(n) === 'number') {
+        return n;
+    } else {
+        throw {
+            error: "RuntimeError",
+            message: "color functions take numbers as parameters"
+        };
+    }
+}
+
+function clamp(val) {
+    return Math.min(1, Math.max(0, val));
+}
+
+tree.functionCall = function(env, rootpath, currentDirectory) {
+    this.env = env;
+    this.rootpath = rootpath;
+    this.currentDirectory = currentDirectory;
+};
+
+tree.functionCall.prototype = tree.functions;
+
+})(require('./tree'));
+(function (tree) {
+    tree.colors = {
+        'aliceblue':'#f0f8ff',
+        'antiquewhite':'#faebd7',
+        'aqua':'#00ffff',
+        'aquamarine':'#7fffd4',
+        'azure':'#f0ffff',
+        'beige':'#f5f5dc',
+        'bisque':'#ffe4c4',
+        'black':'#000000',
+        'blanchedalmond':'#ffebcd',
+        'blue':'#0000ff',
+        'blueviolet':'#8a2be2',
+        'brown':'#a52a2a',
+        'burlywood':'#deb887',
+        'cadetblue':'#5f9ea0',
+        'chartreuse':'#7fff00',
+        'chocolate':'#d2691e',
+        'coral':'#ff7f50',
+        'cornflowerblue':'#6495ed',
+        'cornsilk':'#fff8dc',
+        'crimson':'#dc143c',
+        'cyan':'#00ffff',
+        'darkblue':'#00008b',
+        'darkcyan':'#008b8b',
+        'darkgoldenrod':'#b8860b',
+        'darkgray':'#a9a9a9',
+        'darkgrey':'#a9a9a9',
+        'darkgreen':'#006400',
+        'darkkhaki':'#bdb76b',
+        'darkmagenta':'#8b008b',
+        'darkolivegreen':'#556b2f',
+        'darkorange':'#ff8c00',
+        'darkorchid':'#9932cc',
+        'darkred':'#8b0000',
+        'darksalmon':'#e9967a',
+        'darkseagreen':'#8fbc8f',
+        'darkslateblue':'#483d8b',
+        'darkslategray':'#2f4f4f',
+        'darkslategrey':'#2f4f4f',
+        'darkturquoise':'#00ced1',
+        'darkviolet':'#9400d3',
+        'deeppink':'#ff1493',
+        'deepskyblue':'#00bfff',
+        'dimgray':'#696969',
+        'dimgrey':'#696969',
+        'dodgerblue':'#1e90ff',
+        'firebrick':'#b22222',
+        'floralwhite':'#fffaf0',
+        'forestgreen':'#228b22',
+        'fuchsia':'#ff00ff',
+        'gainsboro':'#dcdcdc',
+        'ghostwhite':'#f8f8ff',
+        'gold':'#ffd700',
+        'goldenrod':'#daa520',
+        'gray':'#808080',
+        'grey':'#808080',
+        'green':'#008000',
+        'greenyellow':'#adff2f',
+        'honeydew':'#f0fff0',
+        'hotpink':'#ff69b4',
+        'indianred':'#cd5c5c',
+        'indigo':'#4b0082',
+        'ivory':'#fffff0',
+        'khaki':'#f0e68c',
+        'lavender':'#e6e6fa',
+        'lavenderblush':'#fff0f5',
+        'lawngreen':'#7cfc00',
+        'lemonchiffon':'#fffacd',
+        'lightblue':'#add8e6',
+        'lightcoral':'#f08080',
+        'lightcyan':'#e0ffff',
+        'lightgoldenrodyellow':'#fafad2',
+        'lightgray':'#d3d3d3',
+        'lightgrey':'#d3d3d3',
+        'lightgreen':'#90ee90',
+        'lightpink':'#ffb6c1',
+        'lightsalmon':'#ffa07a',
+        'lightseagreen':'#20b2aa',
+        'lightskyblue':'#87cefa',
+        'lightslategray':'#778899',
+        'lightslategrey':'#778899',
+        'lightsteelblue':'#b0c4de',
+        'lightyellow':'#ffffe0',
+        'lime':'#00ff00',
+        'limegreen':'#32cd32',
+        'linen':'#faf0e6',
+        'magenta':'#ff00ff',
+        'maroon':'#800000',
+        'mediumaquamarine':'#66cdaa',
+        'mediumblue':'#0000cd',
+        'mediumorchid':'#ba55d3',
+        'mediumpurple':'#9370d8',
+        'mediumseagreen':'#3cb371',
+        'mediumslateblue':'#7b68ee',
+        'mediumspringgreen':'#00fa9a',
+        'mediumturquoise':'#48d1cc',
+        'mediumvioletred':'#c71585',
+        'midnightblue':'#191970',
+        'mintcream':'#f5fffa',
+        'mistyrose':'#ffe4e1',
+        'moccasin':'#ffe4b5',
+        'navajowhite':'#ffdead',
+        'navy':'#000080',
+        'oldlace':'#fdf5e6',
+        'olive':'#808000',
+        'olivedrab':'#6b8e23',
+        'orange':'#ffa500',
+        'orangered':'#ff4500',
+        'orchid':'#da70d6',
+        'palegoldenrod':'#eee8aa',
+        'palegreen':'#98fb98',
+        'paleturquoise':'#afeeee',
+        'palevioletred':'#d87093',
+        'papayawhip':'#ffefd5',
+        'peachpuff':'#ffdab9',
+        'peru':'#cd853f',
+        'pink':'#ffc0cb',
+        'plum':'#dda0dd',
+        'powderblue':'#b0e0e6',
+        'purple':'#800080',
+        'red':'#ff0000',
+        'rosybrown':'#bc8f8f',
+        'royalblue':'#4169e1',
+        'saddlebrown':'#8b4513',
+        'salmon':'#fa8072',
+        'sandybrown':'#f4a460',
+        'seagreen':'#2e8b57',
+        'seashell':'#fff5ee',
+        'sienna':'#a0522d',
+        'silver':'#c0c0c0',
+        'skyblue':'#87ceeb',
+        'slateblue':'#6a5acd',
+        'slategray':'#708090',
+        'slategrey':'#708090',
+        'snow':'#fffafa',
+        'springgreen':'#00ff7f',
+        'steelblue':'#4682b4',
+        'tan':'#d2b48c',
+        'teal':'#008080',
+        'thistle':'#d8bfd8',
+        'tomato':'#ff6347',
+        // 'transparent':'rgba(0,0,0,0)',
+        'turquoise':'#40e0d0',
+        'violet':'#ee82ee',
+        'wheat':'#f5deb3',
+        'white':'#ffffff',
+        'whitesmoke':'#f5f5f5',
+        'yellow':'#ffff00',
+        'yellowgreen':'#9acd32'
+    };
+})(require('./tree'));
+(function (tree) {
+
+tree.Alpha = function (val) {
+    this.value = val;
+};
+tree.Alpha.prototype = {
+    toCSS: function () {
+        return "alpha(opacity=" +
+               (this.value.toCSS ? this.value.toCSS() : this.value) + ")";
+    },
+    eval: function (env) {
+        if (this.value.eval) { this.value = this.value.eval(env) }
+        return this;
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Anonymous = function (string) {
+    this.value = string.value || string;
+};
+tree.Anonymous.prototype = {
+    toCSS: function () {
+        return this.value;
+    },
+    eval: function () { return this },
+    compare: function (x) {
+        if (!x.toCSS) {
+            return -1;
+        }
+
+        var left = this.toCSS(),
+            right = x.toCSS();
+
+        if (left === right) {
+            return 0;
+        }
+
+        return left < right ? -1 : 1;
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Assignment = function (key, val) {
+    this.key = key;
+    this.value = val;
+};
+tree.Assignment.prototype = {
+    toCSS: function () {
+        return this.key + '=' + (this.value.toCSS ? this.value.toCSS() : this.value);
+    },
+    eval: function (env) {
+        if (this.value.eval) {
+            return new(tree.Assignment)(this.key, this.value.eval(env));
+        }
+        return this;
+    }
+};
+
+})(require('../tree'));(function (tree) {
+
+//
+// A function call node.
+//
+tree.Call = function (name, args, index, filename, rootpath, currentDirectory) {
+    this.name = name;
+    this.args = args;
+    this.index = index;
+    this.filename = filename;
+    this.rootpath = rootpath;
+    this.currentDirectory = currentDirectory;
+};
+tree.Call.prototype = {
+    //
+    // When evaluating a function call,
+    // we either find the function in `tree.functions` [1],
+    // in which case we call it, passing the  evaluated arguments,
+    // if this returns null or we cannot find the function, we
+    // simply print it out as it appeared originally [2].
+    //
+    // The *functions.js* file contains the built-in functions.
+    //
+    // The reason why we evaluate the arguments, is in the case where
+    // we try to pass a variable to a function, like: `saturate(@color)`.
+    // The function should receive the value, not the variable.
+    //
+    eval: function (env) {
+        var args = this.args.map(function (a) { return a.eval(env); }),
+            nameLC = this.name.toLowerCase(),
+            result, func;
+
+        if (nameLC in tree.functions) { // 1.
+            try {
+                func = new tree.functionCall(env, this.rootpath, this.currentDirectory);
+                result = func[nameLC].apply(func, args);
+                if (result != null) {
+                    return result;
+                }
+            } catch (e) {
+                throw { type: e.type || "Runtime",
+                        message: "error evaluating function `" + this.name + "`" +
+                                 (e.message ? ': ' + e.message : ''),
+                        index: this.index, filename: this.filename };
+            }
+        }
+
+        // 2.
+        return new(tree.Anonymous)(this.name +
+            "(" + args.map(function (a) { return a.toCSS(env); }).join(', ') + ")");
+    },
+
+    toCSS: function (env) {
+        return this.eval(env).toCSS();
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+//
+// RGB Colors - #ff0014, #eee
+//
+tree.Color = function (rgb, a) {
+    //
+    // The end goal here, is to parse the arguments
+    // into an integer triplet, such as `128, 255, 0`
+    //
+    // This facilitates operations and conversions.
+    //
+    if (Array.isArray(rgb)) {
+        this.rgb = rgb;
+    } else if (rgb.length == 6) {
+        this.rgb = rgb.match(/.{2}/g).map(function (c) {
+            return parseInt(c, 16);
+        });
+    } else {
+        this.rgb = rgb.split('').map(function (c) {
+            return parseInt(c + c, 16);
+        });
+    }
+    this.alpha = typeof(a) === 'number' ? a : 1;
+};
+tree.Color.prototype = {
+    eval: function () { return this },
+    luma: function () { return (0.2126 * this.rgb[0] / 255) + (0.7152 * this.rgb[1] / 255) + (0.0722 * this.rgb[2] / 255); },
+
+    //
+    // If we have some transparency, the only way to represent it
+    // is via `rgba`. Otherwise, we use the hex representation,
+    // which has better compatibility with older browsers.
+    // Values are capped between `0` and `255`, rounded and zero-padded.
+    //
+    toCSS: function (env, doNotCompress) {
+        var compress = env && env.compress && !doNotCompress;
+        if (this.alpha < 1.0) {
+            return "rgba(" + this.rgb.map(function (c) {
+                return Math.round(c);
+            }).concat(this.alpha).join(',' + (compress ? '' : ' ')) + ")";
+        } else {
+            var color = this.rgb.map(function (i) {
+                i = Math.round(i);
+                i = (i > 255 ? 255 : (i < 0 ? 0 : i)).toString(16);
+                return i.length === 1 ? '0' + i : i;
+            }).join('');
+
+            if (compress) {
+                color = color.split('');
+
+                // Convert color to short format
+                if (color[0] == color[1] && color[2] == color[3] && color[4] == color[5]) {
+                    color = color[0] + color[2] + color[4];
+                } else {
+                    color = color.join('');
+                }
+            }
+
+            return '#' + color;
+        }
+    },
+
+    //
+    // Operations have to be done per-channel, if not,
+    // channels will spill onto each other. Once we have
+    // our result, in the form of an integer triplet,
+    // we create a new Color node to hold the result.
+    //
+    operate: function (env, op, other) {
+        var result = [];
+
+        if (! (other instanceof tree.Color)) {
+            other = other.toColor();
+        }
+
+        for (var c = 0; c < 3; c++) {
+            result[c] = tree.operate(env, op, this.rgb[c], other.rgb[c]);
+        }
+        return new(tree.Color)(result, this.alpha + other.alpha);
+    },
+
+    toHSL: function () {
+        var r = this.rgb[0] / 255,
+            g = this.rgb[1] / 255,
+            b = this.rgb[2] / 255,
+            a = this.alpha;
+
+        var max = Math.max(r, g, b), min = Math.min(r, g, b);
+        var h, s, l = (max + min) / 2, d = max - min;
+
+        if (max === min) {
+            h = s = 0;
+        } else {
+            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+            switch (max) {
+                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+                case g: h = (b - r) / d + 2;               break;
+                case b: h = (r - g) / d + 4;               break;
+            }
+            h /= 6;
+        }
+        return { h: h * 360, s: s, l: l, a: a };
+    },
+    //Adapted from http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript
+    toHSV: function () {
+        var r = this.rgb[0] / 255,
+            g = this.rgb[1] / 255,
+            b = this.rgb[2] / 255,
+            a = this.alpha;
+
+        var max = Math.max(r, g, b), min = Math.min(r, g, b);
+        var h, s, v = max;
+
+        var d = max - min;
+        if (max === 0) {
+            s = 0;
+        } else {
+            s = d / max;
+        }
+
+        if (max === min) {
+            h = 0;
+        } else {
+            switch(max){
+                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+                case g: h = (b - r) / d + 2; break;
+                case b: h = (r - g) / d + 4; break;
+            }
+            h /= 6;
+        }
+        return { h: h * 360, s: s, v: v, a: a };
+    },
+    toARGB: function () {
+        var argb = [Math.round(this.alpha * 255)].concat(this.rgb);
+        return '#' + argb.map(function (i) {
+            i = Math.round(i);
+            i = (i > 255 ? 255 : (i < 0 ? 0 : i)).toString(16);
+            return i.length === 1 ? '0' + i : i;
+        }).join('');
+    },
+    compare: function (x) {
+        if (!x.rgb) {
+            return -1;
+        }
+
+        return (x.rgb[0] === this.rgb[0] &&
+            x.rgb[1] === this.rgb[1] &&
+            x.rgb[2] === this.rgb[2] &&
+            x.alpha === this.alpha) ? 0 : -1;
+    }
+};
+
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Comment = function (value, silent) {
+    this.value = value;
+    this.silent = !!silent;
+};
+tree.Comment.prototype = {
+    toCSS: function (env) {
+        return env.compress ? '' : this.value;
+    },
+    eval: function () { return this }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Condition = function (op, l, r, i, negate) {
+    this.op = op.trim();
+    this.lvalue = l;
+    this.rvalue = r;
+    this.index = i;
+    this.negate = negate;
+};
+tree.Condition.prototype.eval = function (env) {
+    var a = this.lvalue.eval(env),
+        b = this.rvalue.eval(env);
+
+    var i = this.index, result;
+
+    var result = (function (op) {
+        switch (op) {
+            case 'and':
+                return a && b;
+            case 'or':
+                return a || b;
+            default:
+                if (a.compare) {
+                    result = a.compare(b);
+                } else if (b.compare) {
+                    result = b.compare(a);
+                } else {
+                    throw { type: "Type",
+                            message: "Unable to perform comparison",
+                            index: i };
+                }
+                switch (result) {
+                    case -1: return op === '<' || op === '=<';
+                    case  0: return op === '=' || op === '>=' || op === '=<';
+                    case  1: return op === '>' || op === '>=';
+                }
+        }
+    })(this.op);
+    return this.negate ? !result : result;
+};
+
+})(require('../tree'));
+(function (tree) {
+
+//
+// A number with a unit
+//
+tree.Dimension = function (value, unit) {
+    this.value = parseFloat(value);
+    this.unit = (unit && unit instanceof tree.Unit) ? unit :
+      new(tree.Unit)(unit ? [unit] : undefined);
+};
+
+tree.Dimension.prototype = {
+    eval: function (env) {
+        return this;
+    },
+    toColor: function () {
+        return new(tree.Color)([this.value, this.value, this.value]);
+    },
+    toCSS: function (env) {
+        if ((!env || env.strictUnits !== false) && !this.unit.isSingular()) {
+            throw new Error("Multiple units in dimension. Correct the units or use the unit function. Bad unit: "+this.unit.toString());
+        }
+
+        var value = this.value,
+            strValue = String(value);
+
+        if (value !== 0 && value < 0.000001 && value > -0.000001) {
+            // would be output 1e-6 etc.
+            strValue = value.toFixed(20).replace(/0+$/, "");
+        }
+
+        if (env && env.compress) {
+            // Zero values doesn't need a unit
+            if (value === 0 && !this.unit.isAngle()) {
+                return strValue;
+            }
+
+            // Float values doesn't need a leading zero
+            if (value > 0 && value < 1) {
+                strValue = (strValue).substr(1);
+            }
+        }
+
+        return this.unit.isEmpty() ? strValue : (strValue + this.unit.toCSS());
+    },
+
+    // In an operation between two Dimensions,
+    // we default to the first Dimension's unit,
+    // so `1px + 2` will yield `3px`.
+    operate: function (env, op, other) {
+        var value = tree.operate(env, op, this.value, other.value),
+            unit = this.unit.clone();
+
+        if (op === '+' || op === '-') {
+            if (unit.numerator.length === 0 && unit.denominator.length === 0) {
+                unit.numerator = other.unit.numerator.slice(0);
+                unit.denominator = other.unit.denominator.slice(0);
+            } else if (other.unit.numerator.length == 0 && unit.denominator.length == 0) {
+                // do nothing
+            } else {
+                other = other.convertTo(this.unit.usedUnits());
+
+                if(env.strictUnits !== false && other.unit.toString() !== unit.toString()) {
+                  throw new Error("Incompatible units. Change the units or use the unit function. Bad units: '" + unit.toString() +
+                    "' and '" + other.unit.toString() + "'.");
+                }
+
+                value = tree.operate(env, op, this.value, other.value);
+            }
+        } else if (op === '*') {
+            unit.numerator = unit.numerator.concat(other.unit.numerator).sort();
+            unit.denominator = unit.denominator.concat(other.unit.denominator).sort();
+            unit.cancel();
+        } else if (op === '/') {
+            unit.numerator = unit.numerator.concat(other.unit.denominator).sort();
+            unit.denominator = unit.denominator.concat(other.unit.numerator).sort();
+            unit.cancel();
+        }
+        return new(tree.Dimension)(value, unit);
+    },
+
+    compare: function (other) {
+        if (other instanceof tree.Dimension) {
+            var a = this.unify(), b = other.unify(),
+                aValue = a.value, bValue = b.value;
+
+            if (bValue > aValue) {
+                return -1;
+            } else if (bValue < aValue) {
+                return 1;
+            } else {
+                if (!b.unit.isEmpty() && a.unit.compare(b) !== 0) {
+                    return -1;
+                }
+                return 0;
+            }
+        } else {
+            return -1;
+        }
+    },
+
+    unify: function () {
+      return this.convertTo({ length: 'm', duration: 's', angle: 'rad' });
+    },
+
+    convertTo: function (conversions) {
+      var value = this.value, unit = this.unit.clone(),
+          i, groupName, group, conversion, targetUnit, derivedConversions = {};
+
+      if (typeof conversions === 'string') {
+          for(i in tree.UnitConversions) {
+              if (tree.UnitConversions[i].hasOwnProperty(conversions)) {
+                  derivedConversions = {};
+                  derivedConversions[i] = conversions;
+              }
+          }
+          conversions = derivedConversions;
+      }
+
+      for (groupName in conversions) {
+        if (conversions.hasOwnProperty(groupName)) {
+          targetUnit = conversions[groupName];
+          group = tree.UnitConversions[groupName];
+
+          unit.map(function (atomicUnit, denominator) {
+            if (group.hasOwnProperty(atomicUnit)) {
+              if (denominator) {
+                value = value / (group[atomicUnit] / group[targetUnit]);
+              } else {
+                value = value * (group[atomicUnit] / group[targetUnit]);
+              }
+
+              return targetUnit;
+            }
+
+            return atomicUnit;
+          });
+        }
+      }
+
+      unit.cancel();
+
+      return new(tree.Dimension)(value, unit);
+    }
+};
+
+// http://www.w3.org/TR/css3-values/#absolute-lengths
+tree.UnitConversions = {
+  length: {
+     'm': 1,
+    'cm': 0.01,
+    'mm': 0.001,
+    'in': 0.0254,
+    'pt': 0.0254 / 72,
+    'pc': 0.0254 / 72 * 12
+  },
+  duration: {
+    's': 1,
+    'ms': 0.001
+  },
+  angle: {
+    'rad': 1/(2*Math.PI),
+    'deg': 1/360,
+    'grad': 1/400,
+    'turn': 1
+  }
+};
+
+tree.Unit = function (numerator, denominator) {
+  this.numerator = numerator ? numerator.slice(0).sort() : [];
+  this.denominator = denominator ? denominator.slice(0).sort() : [];
+};
+
+tree.Unit.prototype = {
+  clone: function () {
+    return new tree.Unit(this.numerator.slice(0), this.denominator.slice(0));
+  },
+
+  toCSS: function () {
+    if (this.numerator.length >= 1) {
+        return this.numerator[0];
+    }
+    if (this.denominator.length >= 1) {
+        return this.denominator[0];
+    }
+    return "";
+  },
+
+  toString: function () {
+      var i, returnStr = this.numerator.join("*");
+      for (i = 0; i < this.denominator.length; i++) {
+          returnStr += "/" + this.denominator[i];
+      }
+      return returnStr;
+  },
+
+  compare: function (other) {
+    return this.is(other.toCSS()) ? 0 : -1;
+  },
+
+  is: function (unitString) {
+    return this.toCSS() === unitString;
+  },
+
+  isAngle: function () {
+    return tree.UnitConversions.angle.hasOwnProperty(this.toCSS());
+  },
+
+  isEmpty: function () {
+    return this.numerator.length == 0 && this.denominator.length == 0;
+  },
+
+  isSingular: function() {
+      return this.numerator.length <= 1 && this.denominator.length == 0;
+  },
+
+  map: function(callback) {
+    var i;
+
+    for (i = 0; i < this.numerator.length; i++) {
+      this.numerator[i] = callback(this.numerator[i], false);
+    }
+
+    for (i = 0; i < this.denominator.length; i++) {
+      this.denominator[i] = callback(this.denominator[i], true);
+    }
+  },
+
+  usedUnits: function() {
+    var group, groupName, result = {};
+
+    for (groupName in tree.UnitConversions) {
+      if (tree.UnitConversions.hasOwnProperty(groupName)) {
+        group = tree.UnitConversions[groupName];
+
+        this.map(function (atomicUnit) {
+          if (group.hasOwnProperty(atomicUnit) && !result[groupName]) {
+            result[groupName] = atomicUnit;
+          }
+
+          return atomicUnit;
+        });
+      }
+    }
+
+    return result;
+  },
+
+  cancel: function () {
+    var counter = {}, atomicUnit, i;
+
+    for (i = 0; i < this.numerator.length; i++) {
+      atomicUnit = this.numerator[i];
+      counter[atomicUnit] = (counter[atomicUnit] || 0) + 1;
+    }
+
+    for (i = 0; i < this.denominator.length; i++) {
+      atomicUnit = this.denominator[i];
+      counter[atomicUnit] = (counter[atomicUnit] || 0) - 1;
+    }
+
+    this.numerator = [];
+    this.denominator = [];
+
+    for (atomicUnit in counter) {
+      if (counter.hasOwnProperty(atomicUnit)) {
+        var count = counter[atomicUnit];
+
+        if (count > 0) {
+          for (i = 0; i < count; i++) {
+            this.numerator.push(atomicUnit);
+          }
+        } else if (count < 0) {
+          for (i = 0; i < -count; i++) {
+            this.denominator.push(atomicUnit);
+          }
+        }
+      }
+    }
+
+    this.numerator.sort();
+    this.denominator.sort();
+  }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Directive = function (name, value) {
+    this.name = name;
+
+    if (Array.isArray(value)) {
+        this.ruleset = new(tree.Ruleset)([], value);
+        this.ruleset.allowImports = true;
+    } else {
+        this.value = value;
+    }
+};
+tree.Directive.prototype = {
+    toCSS: function (ctx, env) {
+        if (this.ruleset) {
+            this.ruleset.root = true;
+            return this.name + (env.compress ? '{' : ' {\n  ') +
+                   this.ruleset.toCSS(ctx, env).trim().replace(/\n/g, '\n  ') +
+                               (env.compress ? '}': '\n}\n');
+        } else {
+            return this.name + ' ' + this.value.toCSS() + ';\n';
+        }
+    },
+    eval: function (env) {
+        var evaldDirective = this;
+        if (this.ruleset) {
+            env.frames.unshift(this);
+            evaldDirective = new(tree.Directive)(this.name);
+            evaldDirective.ruleset = this.ruleset.eval(env);
+            env.frames.shift();
+        }
+        return evaldDirective;
+    },
+    variable: function (name) { return tree.Ruleset.prototype.variable.call(this.ruleset, name) },
+    find: function () { return tree.Ruleset.prototype.find.apply(this.ruleset, arguments) },
+    rulesets: function () { return tree.Ruleset.prototype.rulesets.apply(this.ruleset) }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Element = function (combinator, value, index) {
+    this.combinator = combinator instanceof tree.Combinator ?
+                      combinator : new(tree.Combinator)(combinator);
+
+    if (typeof(value) === 'string') {
+        this.value = value.trim();
+    } else if (value) {
+        this.value = value;
+    } else {
+        this.value = "";
+    }
+    this.index = index;
+};
+tree.Element.prototype.eval = function (env) {
+    return new(tree.Element)(this.combinator,
+                             this.value.eval ? this.value.eval(env) : this.value,
+                             this.index);
+};
+tree.Element.prototype.toCSS = function (env) {
+  var value = (this.value.toCSS ? this.value.toCSS(env) : this.value);
+  if (value == '' && this.combinator.value.charAt(0) == '&') {
+    return '';
+  } else {
+    return this.combinator.toCSS(env || {}) + value;
+  }
+};
+
+tree.Combinator = function (value) {
+    if (value === ' ') {
+        this.value = ' ';
+    } else {
+        this.value = value ? value.trim() : "";
+    }
+};
+tree.Combinator.prototype.toCSS = function (env) {
+    return {
+        ''  : '',
+        ' ' : ' ',
+        ':' : ' :',
+        '+' : env.compress ? '+' : ' + ',
+        '~' : env.compress ? '~' : ' ~ ',
+        '>' : env.compress ? '>' : ' > ',
+        '|' : env.compress ? '|' : ' | '
+    }[this.value];
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Expression = function (value) { this.value = value; };
+tree.Expression.prototype = {
+    eval: function (env) {
+        var returnValue,
+            inParenthesis = this.parens && !this.parensInOp,
+            doubleParen = false;
+        if (inParenthesis) {
+            env.inParenthesis();
+        }
+        if (this.value.length > 1) {
+            returnValue = new(tree.Expression)(this.value.map(function (e) {
+                return e.eval(env);
+            }));
+        } else if (this.value.length === 1) {
+            if (this.value[0].parens && !this.value[0].parensInOp) {
+                doubleParen = true;
+            }
+            returnValue = this.value[0].eval(env);
+        } else {
+            returnValue = this;
+        }
+        if (inParenthesis) {
+            env.outOfParenthesis();
+        }
+        if (this.parens && this.parensInOp && !(env.isMathsOn()) && !doubleParen) {
+            returnValue = new(tree.Paren)(returnValue);
+        }
+        return returnValue;
+    },
+    toCSS: function (env) {
+        return this.value.map(function (e) {
+            return e.toCSS ? e.toCSS(env) : '';
+        }).join(' ');
+    },
+    throwAwayComments: function () {
+        this.value = this.value.filter(function(v) {
+            return !(v instanceof tree.Comment);
+        });
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Extend = function Extend(elements, index) {
+    this.selector = new(tree.Selector)(elements);
+    this.index = index;
+};
+
+tree.Extend.prototype.eval = function Extend_eval(env, selectors) {
+    var selfSelectors = findSelfSelectors(selectors || env.selectors),
+        targetValue = this.selector.elements[0].value;
+
+    env.frames.forEach(function(frame) {
+        frame.rulesets().forEach(function(rule) {
+            rule.selectors.forEach(function(selector) {
+                selector.elements.forEach(function(element, idx) {
+                    if (element.value === targetValue) {
+                        selfSelectors.forEach(function(_selector) {
+                            _selector.elements[0] = new tree.Element(
+                                element.combinator,
+                                _selector.elements[0].value,
+                                _selector.elements[0].index
+                            );
+                            rule.selectors.push(new tree.Selector(
+                                selector.elements
+                                    .slice(0, idx)
+                                    .concat(_selector.elements)
+                                    .concat(selector.elements.slice(idx + 1))
+                            ));
+                        });
+                    }
+                });
+            });
+        });
+    });
+    return this;
+};
+
+function findSelfSelectors(selectors) {
+    var ret = [];
+
+    (function loop(elem, i) {
+        if (selectors[i] && selectors[i].length) {
+            selectors[i].forEach(function(s) {
+                loop(s.elements.concat(elem), i + 1);
+            });
+        }
+        else {
+            ret.push({ elements: elem });
+        }
+    })([], 0);
+
+    return ret;
+}
+
+
+})(require('../tree'));
+(function (tree) {
+//
+// CSS @import node
+//
+// The general strategy here is that we don't want to wait
+// for the parsing to be completed, before we start importing
+// the file. That's because in the context of a browser,
+// most of the time will be spent waiting for the server to respond.
+//
+// On creation, we push the import path to our import queue, though
+// `import,push`, we also pass it a callback, which it'll call once
+// the file has been fetched, and parsed.
+//
+tree.Import = function (path, imports, features, once, index, rootpath) {
+    var that = this;
+
+    this.once = once;
+    this.index = index;
+    this._path = path;
+    this.features = features;
+    this.rootpath = rootpath;
+
+    // The '.less' extension is optional
+    if (path instanceof tree.Quoted) {
+        this.path = /(\.[a-z]*$)|([\?;].*)$/.test(path.value) ? path.value : path.value + '.less';
+    } else {
+        this.path = path.value.value || path.value;
+    }
+
+    this.css = /css([\?;].*)?$/.test(this.path);
+
+    // Only pre-compile .less files
+    if (! this.css) {
+        imports.push(this.path, function (e, root, imported) {
+            if (e) { e.index = index; }
+            if (imported && that.once) { that.skip = imported; }
+            that.root = root || new(tree.Ruleset)([], []);
+        });
+    }
+};
+
+//
+// The actual import node doesn't return anything, when converted to CSS.
+// The reason is that it's used at the evaluation stage, so that the rules
+// it imports can be treated like any other rules.
+//
+// In `eval`, we make sure all Import nodes get evaluated, recursively, so
+// we end up with a flat structure, which can easily be imported in the parent
+// ruleset.
+//
+tree.Import.prototype = {
+    toCSS: function (env) {
+        var features = this.features ? ' ' + this.features.toCSS(env) : '';
+
+        if (this.css) {
+            // Add the base path if the import is relative
+            if (typeof this._path.value === "string" && !/^(?:[a-z-]+:|\/)/.test(this._path.value)) {
+                this._path.value = this.rootpath + this._path.value;
+            }
+            return "@import " + this._path.toCSS() + features + ';\n';
+        } else {
+            return "";
+        }
+    },
+    eval: function (env) {
+        var ruleset, features = this.features && this.features.eval(env);
+
+        if (this.skip) { return []; }
+
+        if (this.css) {
+            return new(tree.Import)(this._path, null, features, this.once, this.index, this.rootpath);
+        } else {
+            ruleset = new(tree.Ruleset)([], this.root.rules.slice(0));
+
+            ruleset.evalImports(env);
+
+            return this.features ? new(tree.Media)(ruleset.rules, this.features.value) : ruleset.rules;
+        }
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.JavaScript = function (string, index, escaped) {
+    this.escaped = escaped;
+    this.expression = string;
+    this.index = index;
+};
+tree.JavaScript.prototype = {
+    eval: function (env) {
+        var result,
+            that = this,
+            context = {};
+
+        var expression = this.expression.replace(/@\{([\w-]+)\}/g, function (_, name) {
+            return tree.jsify(new(tree.Variable)('@' + name, that.index).eval(env));
+        });
+
+        try {
+            expression = new(Function)('return (' + expression + ')');
+        } catch (e) {
+            throw { message: "JavaScript evaluation error: `" + expression + "`" ,
+                    index: this.index };
+        }
+
+        for (var k in env.frames[0].variables()) {
+            context[k.slice(1)] = {
+                value: env.frames[0].variables()[k].value,
+                toJS: function () {
+                    return this.value.eval(env).toCSS();
+                }
+            };
+        }
+
+        try {
+            result = expression.call(context);
+        } catch (e) {
+            throw { message: "JavaScript evaluation error: '" + e.name + ': ' + e.message + "'" ,
+                    index: this.index };
+        }
+        if (typeof(result) === 'string') {
+            return new(tree.Quoted)('"' + result + '"', result, this.escaped, this.index);
+        } else if (Array.isArray(result)) {
+            return new(tree.Anonymous)(result.join(', '));
+        } else {
+            return new(tree.Anonymous)(result);
+        }
+    }
+};
+
+})(require('../tree'));
+
+(function (tree) {
+
+tree.Keyword = function (value) { this.value = value };
+tree.Keyword.prototype = {
+    eval: function () { return this },
+    toCSS: function () { return this.value },
+    compare: function (other) {
+        if (other instanceof tree.Keyword) {
+            return other.value === this.value ? 0 : 1;
+        } else {
+            return -1;
+        }
+    }
+};
+
+tree.True = new(tree.Keyword)('true');
+tree.False = new(tree.Keyword)('false');
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Media = function (value, features) {
+    var selectors = this.emptySelectors();
+
+    this.features = new(tree.Value)(features);
+    this.ruleset = new(tree.Ruleset)(selectors, value);
+    this.ruleset.allowImports = true;
+};
+tree.Media.prototype = {
+    toCSS: function (ctx, env) {
+        var features = this.features.toCSS(env);
+
+        this.ruleset.root = (ctx.length === 0 || ctx[0].multiMedia);
+        return '@media ' + features + (env.compress ? '{' : ' {\n  ') +
+               this.ruleset.toCSS(ctx, env).trim().replace(/\n/g, '\n  ') +
+                           (env.compress ? '}': '\n}\n');
+    },
+    eval: function (env) {
+        if (!env.mediaBlocks) {
+            env.mediaBlocks = [];
+            env.mediaPath = [];
+        }
+
+        var media = new(tree.Media)([], []);
+        if(this.debugInfo) {
+            this.ruleset.debugInfo = this.debugInfo;
+            media.debugInfo = this.debugInfo;
+        }
+        var strictMathsBypass = false;
+        if (env.strictMaths === false) {
+            strictMathsBypass = true;
+            env.strictMaths = true;
+        }
+        try {
+            media.features = this.features.eval(env);
+        }
+        finally {
+            if (strictMathsBypass) {
+                env.strictMaths = false;
+            }
+        }
+
+        env.mediaPath.push(media);
+        env.mediaBlocks.push(media);
+
+        env.frames.unshift(this.ruleset);
+        media.ruleset = this.ruleset.eval(env);
+        env.frames.shift();
+
+        env.mediaPath.pop();
+
+        return env.mediaPath.length === 0 ? media.evalTop(env) :
+                    media.evalNested(env)
+    },
+    variable: function (name) { return tree.Ruleset.prototype.variable.call(this.ruleset, name) },
+    find: function () { return tree.Ruleset.prototype.find.apply(this.ruleset, arguments) },
+    rulesets: function () { return tree.Ruleset.prototype.rulesets.apply(this.ruleset) },
+    emptySelectors: function() {
+        var el = new(tree.Element)('', '&', 0);
+        return [new(tree.Selector)([el])];
+    },
+
+    evalTop: function (env) {
+        var result = this;
+
+        // Render all dependent Media blocks.
+        if (env.mediaBlocks.length > 1) {
+            var selectors = this.emptySelectors();
+            result = new(tree.Ruleset)(selectors, env.mediaBlocks);
+            result.multiMedia = true;
+        }
+
+        delete env.mediaBlocks;
+        delete env.mediaPath;
+
+        return result;
+    },
+    evalNested: function (env) {
+        var i, value,
+            path = env.mediaPath.concat([this]);
+
+        // Extract the media-query conditions separated with `,` (OR).
+        for (i = 0; i < path.length; i++) {
+            value = path[i].features instanceof tree.Value ?
+                        path[i].features.value : path[i].features;
+            path[i] = Array.isArray(value) ? value : [value];
+        }
+
+        // Trace all permutations to generate the resulting media-query.
+        //
+        // (a, b and c) with nested (d, e) ->
+        //    a and d
+        //    a and e
+        //    b and c and d
+        //    b and c and e
+        this.features = new(tree.Value)(this.permute(path).map(function (path) {
+            path = path.map(function (fragment) {
+                return fragment.toCSS ? fragment : new(tree.Anonymous)(fragment);
+            });
+
+            for(i = path.length - 1; i > 0; i--) {
+                path.splice(i, 0, new(tree.Anonymous)("and"));
+            }
+
+            return new(tree.Expression)(path);
+        }));
+
+        // Fake a tree-node that doesn't output anything.
+        return new(tree.Ruleset)([], []);
+    },
+    permute: function (arr) {
+      if (arr.length === 0) {
+          return [];
+      } else if (arr.length === 1) {
+          return arr[0];
+      } else {
+          var result = [];
+          var rest = this.permute(arr.slice(1));
+          for (var i = 0; i < rest.length; i++) {
+              for (var j = 0; j < arr[0].length; j++) {
+                  result.push([arr[0][j]].concat(rest[i]));
+              }
+          }
+          return result;
+      }
+    },
+    bubbleSelectors: function (selectors) {
+      this.ruleset = new(tree.Ruleset)(selectors.slice(0), [this.ruleset]);
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.mixin = {};
+tree.mixin.Call = function (elements, args, index, filename, important) {
+    this.selector = new(tree.Selector)(elements);
+    this.arguments = args;
+    this.index = index;
+    this.filename = filename;
+    this.important = important;
+};
+tree.mixin.Call.prototype = {
+    eval: function (env) {
+        var mixins, mixin, args, rules = [], match = false, i, m, f, isRecursive, isOneFound;
+
+        args = this.arguments && this.arguments.map(function (a) {
+            return { name: a.name, value: a.value.eval(env) };
+        });
+
+        for (i = 0; i < env.frames.length; i++) {
+            if ((mixins = env.frames[i].find(this.selector)).length > 0) {
+                isOneFound = true;
+                for (m = 0; m < mixins.length; m++) {
+                    mixin = mixins[m];
+                    isRecursive = false;
+                    for(f = 0; f < env.frames.length; f++) {
+                        if ((!(mixin instanceof tree.mixin.Definition)) && mixin === (env.frames[f].originalRuleset || env.frames[f])) {
+                            isRecursive = true;
+                            break;
+                        }
+                    }
+                    if (isRecursive) {
+                        continue;
+                    }
+                    if (mixin.matchArgs(args, env)) {
+                        if (!mixin.matchCondition || mixin.matchCondition(args, env)) {
+                            try {
+                                Array.prototype.push.apply(
+                                      rules, mixin.eval(env, args, this.important).rules);
+                            } catch (e) {
+                                throw { message: e.message, index: this.index, filename: this.filename, stack: e.stack };
+                            }
+                        }
+                        match = true;
+                    }
+                }
+                if (match) {
+                    return rules;
+                }
+            }
+        }
+        if (isOneFound) {
+            throw { type:    'Runtime',
+                    message: 'No matching definition was found for `' +
+                              this.selector.toCSS().trim() + '('      +
+                              (args ? args.map(function (a) {
+                                  var argValue = "";
+                                  if (a.name) {
+                                      argValue += a.name + ":";
+                                  }
+                                  if (a.value.toCSS) {
+                                      argValue += a.value.toCSS();
+                                  } else {
+                                      argValue += "???";
+                                  }
+                                  return argValue;
+                              }).join(', ') : "") + ")`",
+                    index:   this.index, filename: this.filename };
+        } else {
+            throw { type: 'Name',
+                message: this.selector.toCSS().trim() + " is undefined",
+                index: this.index, filename: this.filename };
+        }
+    }
+};
+
+tree.mixin.Definition = function (name, params, rules, condition, variadic) {
+    this.name = name;
+    this.selectors = [new(tree.Selector)([new(tree.Element)(null, name)])];
+    this.params = params;
+    this.condition = condition;
+    this.variadic = variadic;
+    this.arity = params.length;
+    this.rules = rules;
+    this._lookups = {};
+    this.required = params.reduce(function (count, p) {
+        if (!p.name || (p.name && !p.value)) { return count + 1 }
+        else                                 { return count }
+    }, 0);
+    this.parent = tree.Ruleset.prototype;
+    this.frames = [];
+};
+tree.mixin.Definition.prototype = {
+    toCSS:     function ()     { return "" },
+    variable:  function (name) { return this.parent.variable.call(this, name) },
+    variables: function ()     { return this.parent.variables.call(this) },
+    find:      function ()     { return this.parent.find.apply(this, arguments) },
+    rulesets:  function ()     { return this.parent.rulesets.apply(this) },
+
+    evalParams: function (env, mixinEnv, args, evaldArguments) {
+        var frame = new(tree.Ruleset)(null, []),
+            varargs, arg,
+            params = this.params.slice(0),
+            i, j, val, name, isNamedFound, argIndex;
+
+        mixinEnv = new tree.evalEnv(mixinEnv, [frame].concat(mixinEnv.frames));
+
+        if (args) {
+            args = args.slice(0);
+
+            for(i = 0; i < args.length; i++) {
+                arg = args[i];
+                if (name = (arg && arg.name)) {
+                    isNamedFound = false;
+                    for(j = 0; j < params.length; j++) {
+                        if (!evaldArguments[j] && name === params[j].name) {
+                            evaldArguments[j] = arg.value.eval(env);
+                            frame.rules.unshift(new(tree.Rule)(name, arg.value.eval(env)));
+                            isNamedFound = true;
+                            break;
+                        }
+                    }
+                    if (isNamedFound) {
+                        args.splice(i, 1);
+                        i--;
+                        continue;
+                    } else {
+                        throw { type: 'Runtime', message: "Named argument for " + this.name +
+                            ' ' + args[i].name + ' not found' };
+                    }
+                }
+            }
+        }
+        argIndex = 0;
+        for (i = 0; i < params.length; i++) {
+            if (evaldArguments[i]) continue;
+
+            arg = args && args[argIndex];
+
+            if (name = params[i].name) {
+                if (params[i].variadic && args) {
+                    varargs = [];
+                    for (j = argIndex; j < args.length; j++) {
+                        varargs.push(args[j].value.eval(env));
+                    }
+                    frame.rules.unshift(new(tree.Rule)(name, new(tree.Expression)(varargs).eval(env)));
+                } else {
+                    val = arg && arg.value;
+                    if (val) {
+                        val = val.eval(env);
+                    } else if (params[i].value) {
+                        val = params[i].value.eval(mixinEnv);
+                        frame.resetCache();
+                    } else {
+                        throw { type: 'Runtime', message: "wrong number of arguments for " + this.name +
+                            ' (' + args.length + ' for ' + this.arity + ')' };
+                    }
+
+                    frame.rules.unshift(new(tree.Rule)(name, val));
+                    evaldArguments[i] = val;
+                }
+            }
+
+            if (params[i].variadic && args) {
+                for (j = argIndex; j < args.length; j++) {
+                    evaldArguments[j] = args[j].value.eval(env);
+                }
+            }
+            argIndex++;
+        }
+
+        return frame;
+    },
+    eval: function (env, args, important) {
+        var _arguments = [],
+            mixinFrames = this.frames.concat(env.frames),
+            frame = this.evalParams(env, new(tree.evalEnv)(env, mixinFrames), args, _arguments),
+            context, rules, start, ruleset;
+
+        frame.rules.unshift(new(tree.Rule)('@arguments', new(tree.Expression)(_arguments).eval(env)));
+
+        rules = important ?
+            this.parent.makeImportant.apply(this).rules : this.rules.slice(0);
+
+        ruleset = new(tree.Ruleset)(null, rules).eval(new(tree.evalEnv)(env,
+                                                    [this, frame].concat(mixinFrames)));
+        ruleset.originalRuleset = this;
+        return ruleset;
+    },
+    matchCondition: function (args, env) {
+
+        if (this.condition && !this.condition.eval(
+            new(tree.evalEnv)(env,
+                [this.evalParams(env, new(tree.evalEnv)(env, this.frames.concat(env.frames)), args, [])]
+                    .concat(env.frames)))) {
+            return false;
+        }
+        return true;
+    },
+    matchArgs: function (args, env) {
+        var argsLength = (args && args.length) || 0, len, frame;
+
+        if (! this.variadic) {
+            if (argsLength < this.required)                               { return false }
+            if (argsLength > this.params.length)                          { return false }
+            if ((this.required > 0) && (argsLength > this.params.length)) { return false }
+        }
+
+        len = Math.min(argsLength, this.arity);
+
+        for (var i = 0; i < len; i++) {
+            if (!this.params[i].name && !this.params[i].variadic) {
+                if (args[i].value.eval(env).toCSS() != this.params[i].value.eval(env).toCSS()) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Negative = function (node) {
+    this.value = node;
+};
+tree.Negative.prototype = {
+    toCSS: function (env) {
+        return '-' + this.value.toCSS(env);
+    },
+    eval: function (env) {
+        if (env.isMathsOn()) {
+            return (new(tree.Operation)('*', [new(tree.Dimension)(-1), this.value])).eval(env);
+        }
+        return new(tree.Negative)(this.value.eval(env));
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Operation = function (op, operands, isSpaced) {
+    this.op = op.trim();
+    this.operands = operands;
+    this.isSpaced = isSpaced;
+};
+tree.Operation.prototype.eval = function (env) {
+    var a = this.operands[0].eval(env),
+        b = this.operands[1].eval(env),
+        temp;
+
+    if (env.isMathsOn()) {
+        if (a instanceof tree.Dimension && b instanceof tree.Color) {
+            if (this.op === '*' || this.op === '+') {
+                temp = b, b = a, a = temp;
+            } else {
+                throw { type: "Operation",
+                        message: "Can't substract or divide a color from a number" };
+            }
+        }
+        if (!a.operate) {
+            throw { type: "Operation",
+                    message: "Operation on an invalid type" };
+        }
+
+        return a.operate(env, this.op, b);
+    } else {
+        return new(tree.Operation)(this.op, [a, b], this.isSpaced);
+    }
+};
+tree.Operation.prototype.toCSS = function (env) {
+    var separator = this.isSpaced ? " " : "";
+    return this.operands[0].toCSS() + separator + this.op + separator + this.operands[1].toCSS();
+};
+
+tree.operate = function (env, op, a, b) {
+    switch (op) {
+        case '+': return a + b;
+        case '-': return a - b;
+        case '*': return a * b;
+        case '/': return a / b;
+    }
+};
+
+})(require('../tree'));
+
+(function (tree) {
+
+tree.Paren = function (node) {
+    this.value = node;
+};
+tree.Paren.prototype = {
+    toCSS: function (env) {
+        return '(' + this.value.toCSS(env).trim() + ')';
+    },
+    eval: function (env) {
+        return new(tree.Paren)(this.value.eval(env));
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Quoted = function (str, content, escaped, i) {
+    this.escaped = escaped;
+    this.value = content || '';
+    this.quote = str.charAt(0);
+    this.index = i;
+};
+tree.Quoted.prototype = {
+    toCSS: function () {
+        if (this.escaped) {
+            return this.value;
+        } else {
+            return this.quote + this.value + this.quote;
+        }
+    },
+    eval: function (env) {
+        var that = this;
+        var value = this.value.replace(/`([^`]+)`/g, function (_, exp) {
+            return new(tree.JavaScript)(exp, that.index, true).eval(env).value;
+        }).replace(/@\{([\w-]+)\}/g, function (_, name) {
+            var v = new(tree.Variable)('@' + name, that.index).eval(env, true);
+            return (v instanceof tree.Quoted) ? v.value : v.toCSS();
+        });
+        return new(tree.Quoted)(this.quote + value + this.quote, value, this.escaped, this.index);
+    },
+    compare: function (x) {
+        if (!x.toCSS) {
+            return -1;
+        }
+
+        var left = this.toCSS(),
+            right = x.toCSS();
+
+        if (left === right) {
+            return 0;
+        }
+
+        return left < right ? -1 : 1;
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Rule = function (name, value, important, index, inline) {
+    this.name = name;
+    this.value = (value instanceof tree.Value) ? value : new(tree.Value)([value]);
+    this.important = important ? ' ' + important.trim() : '';
+    this.index = index;
+    this.inline = inline || false;
+
+    if (name.charAt(0) === '@') {
+        this.variable = true;
+    } else { this.variable = false }
+};
+tree.Rule.prototype.toCSS = function (env) {
+    if (this.variable) { return "" }
+    else {
+        return this.name + (env.compress ? ':' : ': ') +
+               this.value.toCSS(env) +
+               this.important + (this.inline ? "" : ";");
+    }
+};
+
+tree.Rule.prototype.eval = function (env) {
+    var strictMathsBypass = false;
+    if (this.name === "font" && env.strictMaths === false) {
+        strictMathsBypass = true;
+        env.strictMaths = true;
+    }
+    try {
+        return new(tree.Rule)(this.name,
+                          this.value.eval(env),
+                          this.important,
+                          this.index, this.inline);
+    }
+    finally {
+        if (strictMathsBypass) {
+            env.strictMaths = false;
+        }
+    }
+};
+
+tree.Rule.prototype.makeImportant = function () {
+    return new(tree.Rule)(this.name,
+                          this.value,
+                          "!important",
+                          this.index, this.inline);
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Ruleset = function (selectors, rules, strictImports) {
+    this.selectors = selectors;
+    this.rules = rules;
+    this._lookups = {};
+    this.strictImports = strictImports;
+};
+tree.Ruleset.prototype = {
+    eval: function (env) {
+        var selectors = this.selectors && this.selectors.map(function (s) { return s.eval(env) });
+        var ruleset = new(tree.Ruleset)(selectors, this.rules.slice(0), this.strictImports);
+        var rules;
+
+        ruleset.originalRuleset = this;
+        ruleset.root = this.root;
+        ruleset.allowImports = this.allowImports;
+
+        if(this.debugInfo) {
+            ruleset.debugInfo = this.debugInfo;
+        }
+
+        // push the current ruleset to the frames stack
+        env.frames.unshift(ruleset);
+
+        // currrent selectors
+        if (!env.selectors) {
+            env.selectors = [];
+        }
+        env.selectors.unshift(this.selectors);
+
+        // Evaluate imports
+        if (ruleset.root || ruleset.allowImports || !ruleset.strictImports) {
+            ruleset.evalImports(env);
+        }
+
+        // Store the frames around mixin definitions,
+        // so they can be evaluated like closures when the time comes.
+        for (var i = 0; i < ruleset.rules.length; i++) {
+            if (ruleset.rules[i] instanceof tree.mixin.Definition) {
+                ruleset.rules[i].frames = env.frames.slice(0);
+            }
+        }
+
+        var mediaBlockCount = (env.mediaBlocks && env.mediaBlocks.length) || 0;
+
+        // Evaluate mixin calls.
+        for (var i = 0; i < ruleset.rules.length; i++) {
+            if (ruleset.rules[i] instanceof tree.mixin.Call) {
+                rules = ruleset.rules[i].eval(env).filter(function(r) {
+                    if ((r instanceof tree.Rule) && r.variable) {
+                        // do not pollute the scope if the variable is
+                        // already there. consider returning false here
+                        // but we need a way to "return" variable from mixins
+                        return !(ruleset.variable(r.name));
+                    }
+                    return true;
+                });
+                ruleset.rules.splice.apply(ruleset.rules, [i, 1].concat(rules));
+                i += rules.length-1;
+                ruleset.resetCache();
+            }
+        }
+
+        if (this.selectors) {
+            for (var i = 0; i < this.selectors.length; i++) {
+                if (this.selectors[i].extend) {
+                    this.selectors[i].extend.eval(env, [[this.selectors[i]]].concat(env.selectors.slice(1)));
+                }
+            }
+        }
+
+        // Evaluate everything else
+        for (var i = 0, rule; i < ruleset.rules.length; i++) {
+            rule = ruleset.rules[i];
+
+            if (! (rule instanceof tree.mixin.Definition)) {
+                ruleset.rules[i] = rule.eval ? rule.eval(env) : rule;
+            }
+        }
+
+        // Pop the stack
+        env.frames.shift();
+        env.selectors.shift();
+
+        if (env.mediaBlocks) {
+            for(var i = mediaBlockCount; i < env.mediaBlocks.length; i++) {
+                env.mediaBlocks[i].bubbleSelectors(selectors);
+            }
+        }
+
+        return ruleset;
+    },
+    evalImports: function(env) {
+        var i, rules;
+        for (i = 0; i < this.rules.length; i++) {
+            if (this.rules[i] instanceof tree.Import) {
+                rules = this.rules[i].eval(env);
+                if (typeof rules.length === "number") {
+                    this.rules.splice.apply(this.rules, [i, 1].concat(rules));
+                    i+= rules.length-1;
+                } else {
+                    this.rules.splice(i, 1, rules);
+                }
+                this.resetCache();
+            }
+        }
+    },
+    makeImportant: function() {
+        return new tree.Ruleset(this.selectors, this.rules.map(function (r) {
+                    if (r.makeImportant) {
+                        return r.makeImportant();
+                    } else {
+                        return r;
+                    }
+                }), this.strictImports);
+    },
+    matchArgs: function (args) {
+        return !args || args.length === 0;
+    },
+    resetCache: function () {
+        this._rulesets = null;
+        this._variables = null;
+        this._lookups = {};
+    },
+    variables: function () {
+        if (this._variables) { return this._variables }
+        else {
+            return this._variables = this.rules.reduce(function (hash, r) {
+                if (r instanceof tree.Rule && r.variable === true) {
+                    hash[r.name] = r;
+                }
+                return hash;
+            }, {});
+        }
+    },
+    variable: function (name) {
+        return this.variables()[name];
+    },
+    rulesets: function () {
+        return this.rules.filter(function (r) {
+            return (r instanceof tree.Ruleset) || (r instanceof tree.mixin.Definition);
+        });
+    },
+    find: function (selector, self) {
+        self = self || this;
+        var rules = [], rule, match,
+            key = selector.toCSS();
+
+        if (key in this._lookups) { return this._lookups[key] }
+
+        this.rulesets().forEach(function (rule) {
+            if (rule !== self) {
+                for (var j = 0; j < rule.selectors.length; j++) {
+                    if (match = selector.match(rule.selectors[j])) {
+                        if (selector.elements.length > rule.selectors[j].elements.length) {
+                            Array.prototype.push.apply(rules, rule.find(
+                                new(tree.Selector)(selector.elements.slice(1)), self));
+                        } else {
+                            rules.push(rule);
+                        }
+                        break;
+                    }
+                }
+            }
+        });
+        return this._lookups[key] = rules;
+    },
+    //
+    // Entry point for code generation
+    //
+    //     `context` holds an array of arrays.
+    //
+    toCSS: function (context, env) {
+        var css = [],      // The CSS output
+            rules = [],    // node.Rule instances
+           _rules = [],    //
+            rulesets = [], // node.Ruleset instances
+            paths = [],    // Current selectors
+            selector,      // The fully rendered selector
+            debugInfo,     // Line number debugging
+            rule;
+
+        if (! this.root) {
+            this.joinSelectors(paths, context, this.selectors);
+        }
+
+        // Compile rules and rulesets
+        for (var i = 0; i < this.rules.length; i++) {
+            rule = this.rules[i];
+
+            if (rule.rules || (rule instanceof tree.Media)) {
+                rulesets.push(rule.toCSS(paths, env));
+            } else if (rule instanceof tree.Directive) {
+                var cssValue = rule.toCSS(paths, env);
+                // Output only the first @charset definition as such - convert the others
+                // to comments in case debug is enabled
+                if (rule.name === "@charset") {
+                    // Only output the debug info together with subsequent @charset definitions
+                    // a comment (or @media statement) before the actual @charset directive would
+                    // be considered illegal css as it has to be on the first line
+                    if (env.charset) {
+                        if (rule.debugInfo) {
+                            rulesets.push(tree.debugInfo(env, rule));
+                            rulesets.push(new tree.Comment("/* "+cssValue.replace(/\n/g, "")+" */\n").toCSS(env));
+                        }
+                        continue;
+                    }
+                    env.charset = true;
+                }
+                rulesets.push(cssValue);
+            } else if (rule instanceof tree.Comment) {
+                if (!rule.silent) {
+                    if (this.root) {
+                        rulesets.push(rule.toCSS(env));
+                    } else {
+                        rules.push(rule.toCSS(env));
+                    }
+                }
+            } else {
+                if (rule.toCSS && !rule.variable) {
+                    rules.push(rule.toCSS(env));
+                } else if (rule.value && !rule.variable) {
+                    rules.push(rule.value.toString());
+                }
+            }
+        }
+
+        // Remove last semicolon
+        if (env.compress && rules.length) {
+            rule = rules[rules.length - 1];
+            if (rule.charAt(rule.length - 1) === ';') {
+                rules[rules.length - 1] = rule.substring(0, rule.length - 1);
+            }
+        }
+
+        rulesets = rulesets.join('');
+
+        // If this is the root node, we don't render
+        // a selector, or {}.
+        // Otherwise, only output if this ruleset has rules.
+        if (this.root) {
+            css.push(rules.join(env.compress ? '' : '\n'));
+        } else {
+            if (rules.length > 0) {
+                debugInfo = tree.debugInfo(env, this);
+                selector = paths.map(function (p) {
+                    return p.map(function (s) {
+                        return s.toCSS(env);
+                    }).join('').trim();
+                }).join(env.compress ? ',' : ',\n');
+
+                // Remove duplicates
+                for (var i = rules.length - 1; i >= 0; i--) {
+                    if (_rules.indexOf(rules[i]) === -1) {
+                        _rules.unshift(rules[i]);
+                    }
+                }
+                rules = _rules;
+
+                css.push(debugInfo + selector +
+                        (env.compress ? '{' : ' {\n  ') +
+                        rules.join(env.compress ? '' : '\n  ') +
+                        (env.compress ? '}' : '\n}\n'));
+            }
+        }
+        css.push(rulesets);
+
+        return css.join('')  + (env.compress ? '\n' : '');
+    },
+
+    joinSelectors: function (paths, context, selectors) {
+        for (var s = 0; s < selectors.length; s++) {
+            this.joinSelector(paths, context, selectors[s]);
+        }
+    },
+
+    joinSelector: function (paths, context, selector) {
+
+        var i, j, k,
+            hasParentSelector, newSelectors, el, sel, parentSel,
+            newSelectorPath, afterParentJoin, newJoinedSelector,
+            newJoinedSelectorEmpty, lastSelector, currentElements,
+            selectorsMultiplied;
+
+        for (i = 0; i < selector.elements.length; i++) {
+            el = selector.elements[i];
+            if (el.value === '&') {
+                hasParentSelector = true;
+            }
+        }
+
+        if (!hasParentSelector) {
+            if (context.length > 0) {
+                for(i = 0; i < context.length; i++) {
+                    paths.push(context[i].concat(selector));
+                }
+            }
+            else {
+                paths.push([selector]);
+            }
+            return;
+        }
+
+        // The paths are [[Selector]]
+        // The first list is a list of comma seperated selectors
+        // The inner list is a list of inheritance seperated selectors
+        // e.g.
+        // .a, .b {
+        //   .c {
+        //   }
+        // }
+        // == [[.a] [.c]] [[.b] [.c]]
+        //
+
+        // the elements from the current selector so far
+        currentElements = [];
+        // the current list of new selectors to add to the path.
+        // We will build it up. We initiate it with one empty selector as we "multiply" the new selectors
+        // by the parents
+        newSelectors = [[]];
+
+        for (i = 0; i < selector.elements.length; i++) {
+            el = selector.elements[i];
+            // non parent reference elements just get added
+            if (el.value !== "&") {
+                currentElements.push(el);
+            } else {
+                // the new list of selectors to add
+                selectorsMultiplied = [];
+
+                // merge the current list of non parent selector elements
+                // on to the current list of selectors to add
+                if (currentElements.length > 0) {
+                    this.mergeElementsOnToSelectors(currentElements, newSelectors);
+                }
+
+                // loop through our current selectors
+                for(j = 0; j < newSelectors.length; j++) {
+                    sel = newSelectors[j];
+                    // if we don't have any parent paths, the & might be in a mixin so that it can be used
+                    // whether there are parents or not
+                    if (context.length == 0) {
+                        // the combinator used on el should now be applied to the next element instead so that
+                        // it is not lost
+                        if (sel.length > 0) {
+                            sel[0].elements = sel[0].elements.slice(0);
+                            sel[0].elements.push(new(tree.Element)(el.combinator, '', 0)); //new Element(el.Combinator,  ""));
+                        }
+                        selectorsMultiplied.push(sel);
+                    }
+                    else {
+                        // and the parent selectors
+                        for(k = 0; k < context.length; k++) {
+                            parentSel = context[k];
+                            // We need to put the current selectors
+                            // then join the last selector's elements on to the parents selectors
+
+                            // our new selector path
+                            newSelectorPath = [];
+                            // selectors from the parent after the join
+                            afterParentJoin = [];
+                            newJoinedSelectorEmpty = true;
+
+                            //construct the joined selector - if & is the first thing this will be empty,
+                            // if not newJoinedSelector will be the last set of elements in the selector
+                            if (sel.length > 0) {
+                                newSelectorPath = sel.slice(0);
+                                lastSelector = newSelectorPath.pop();
+                                newJoinedSelector = new(tree.Selector)(lastSelector.elements.slice(0));
+                                newJoinedSelectorEmpty = false;
+                            }
+                            else {
+                                newJoinedSelector = new(tree.Selector)([]);
+                            }
+
+                            //put together the parent selectors after the join
+                            if (parentSel.length > 1) {
+                                afterParentJoin = afterParentJoin.concat(parentSel.slice(1));
+                            }
+
+                            if (parentSel.length > 0) {
+                                newJoinedSelectorEmpty = false;
+
+                                // join the elements so far with the first part of the parent
+                                newJoinedSelector.elements.push(new(tree.Element)(el.combinator, parentSel[0].elements[0].value, 0));
+                                newJoinedSelector.elements = newJoinedSelector.elements.concat(parentSel[0].elements.slice(1));
+                            }
+
+                            if (!newJoinedSelectorEmpty) {
+                                // now add the joined selector
+                                newSelectorPath.push(newJoinedSelector);
+                            }
+
+                            // and the rest of the parent
+                            newSelectorPath = newSelectorPath.concat(afterParentJoin);
+
+                            // add that to our new set of selectors
+                            selectorsMultiplied.push(newSelectorPath);
+                        }
+                    }
+                }
+
+                // our new selectors has been multiplied, so reset the state
+                newSelectors = selectorsMultiplied;
+                currentElements = [];
+            }
+        }
+
+        // if we have any elements left over (e.g. .a& .b == .b)
+        // add them on to all the current selectors
+        if (currentElements.length > 0) {
+            this.mergeElementsOnToSelectors(currentElements, newSelectors);
+        }
+
+        for(i = 0; i < newSelectors.length; i++) {
+            if (newSelectors[i].length > 0) {
+                paths.push(newSelectors[i]);
+            }
+        }
+    },
+
+    mergeElementsOnToSelectors: function(elements, selectors) {
+        var i, sel;
+
+        if (selectors.length == 0) {
+            selectors.push([ new(tree.Selector)(elements) ]);
+            return;
+        }
+
+        for(i = 0; i < selectors.length; i++) {
+            sel = selectors[i];
+
+            // if the previous thing in sel is a parent this needs to join on to it
+            if (sel.length > 0) {
+                sel[sel.length - 1] = new(tree.Selector)(sel[sel.length - 1].elements.concat(elements));
+            }
+            else {
+                sel.push(new(tree.Selector)(elements));
+            }
+        }
+    }
+};
+})(require('../tree'));
+(function (tree) {
+
+tree.Selector = function (elements, extend) {
+    this.elements = elements;
+    this.extend = extend;
+};
+tree.Selector.prototype.match = function (other) {
+    var elements = this.elements,
+        len = elements.length,
+        oelements, olen, max, i;
+
+    oelements = other.elements.slice(
+        (other.elements.length && other.elements[0].value === "&") ? 1 : 0);
+    olen = oelements.length;
+    max = Math.min(len, olen)
+
+    if (olen === 0 || len < olen) {
+        return false;
+    } else {
+        for (i = 0; i < max; i++) {
+            if (elements[i].value !== oelements[i].value) {
+                return false;
+            }
+        }
+    }
+    return true;
+};
+tree.Selector.prototype.eval = function (env) {
+    return new(tree.Selector)(this.elements.map(function (e) {
+        return e.eval(env);
+    }), this.extend);
+};
+tree.Selector.prototype.toCSS = function (env) {
+    if (this._css) { return this._css }
+
+    if (this.elements[0].combinator.value === "") {
+        this._css = ' ';
+    } else {
+        this._css = '';
+    }
+
+    this._css += this.elements.map(function (e) {
+        if (typeof(e) === 'string') {
+            return ' ' + e.trim();
+        } else {
+            return e.toCSS(env);
+        }
+    }).join('');
+
+    return this._css;
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.UnicodeDescriptor = function (value) {
+    this.value = value;
+};
+tree.UnicodeDescriptor.prototype = {
+    toCSS: function (env) {
+        return this.value;
+    },
+    eval: function () { return this }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.URL = function (val, rootpath) {
+    this.value = val;
+    this.rootpath = rootpath;
+};
+tree.URL.prototype = {
+    toCSS: function () {
+        return "url(" + this.value.toCSS() + ")";
+    },
+    eval: function (ctx) {
+        var val = this.value.eval(ctx), rootpath;
+
+        // Add the base path if the URL is relative
+        if (this.rootpath && typeof val.value === "string" && ctx.isPathRelative(val.value)) {
+            rootpath = this.rootpath;
+            if (!val.quote) {
+                rootpath = rootpath.replace(/[\(\)'"\s]/g, function(match) { return "\\"+match; });
+            }
+            val.value = rootpath + val.value;
+        }
+
+        return new(tree.URL)(val, null);
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Value = function (value) {
+    this.value = value;
+    this.is = 'value';
+};
+tree.Value.prototype = {
+    eval: function (env) {
+        if (this.value.length === 1) {
+            return this.value[0].eval(env);
+        } else {
+            return new(tree.Value)(this.value.map(function (v) {
+                return v.eval(env);
+            }));
+        }
+    },
+    toCSS: function (env) {
+        return this.value.map(function (e) {
+            return e.toCSS(env);
+        }).join(env.compress ? ',' : ', ');
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.Variable = function (name, index, file) { this.name = name, this.index = index, this.file = file };
+tree.Variable.prototype = {
+    eval: function (env) {
+        var variable, v, name = this.name;
+
+        if (name.indexOf('@@') == 0) {
+            name = '@' + new(tree.Variable)(name.slice(1)).eval(env).value;
+        }
+
+        if (this.evaluating) {
+            throw { type: 'Name',
+                    message: "Recursive variable definition for " + name,
+                    filename: this.file,
+                    index: this.index };
+        }
+
+        this.evaluating = true;
+
+        if (variable = tree.find(env.frames, function (frame) {
+            if (v = frame.variable(name)) {
+                return v.value.eval(env);
+            }
+        })) {
+            this.evaluating = false;
+            return variable;
+        }
+        else {
+            throw { type: 'Name',
+                    message: "variable " + name + " is undefined",
+                    filename: this.file,
+                    index: this.index };
+        }
+    }
+};
+
+})(require('../tree'));
+(function (tree) {
+
+tree.debugInfo = function(env, ctx) {
+    var result="";
+    if (env.dumpLineNumbers && !env.compress) {
+        switch(env.dumpLineNumbers) {
+            case 'comments':
+                result = tree.debugInfo.asComment(ctx);
+                break;
+            case 'mediaquery':
+                result = tree.debugInfo.asMediaQuery(ctx);
+                break;
+            case 'all':
+                result = tree.debugInfo.asComment(ctx)+tree.debugInfo.asMediaQuery(ctx);
+                break;
+        }
+    }
+    return result;
+};
+
+tree.debugInfo.asComment = function(ctx) {
+    return '/* line ' + ctx.debugInfo.lineNumber + ', ' + ctx.debugInfo.fileName + ' */\n';
+};
+
+tree.debugInfo.asMediaQuery = function(ctx) {
+    return '@media -sass-debug-info{filename{font-family:' +
+        ('file://' + ctx.debugInfo.fileName).replace(/[\/:.]/g, '\\$&') +
+        '}line{font-family:\\00003' + ctx.debugInfo.lineNumber + '}}\n';
+};
+
+tree.find = function (obj, fun) {
+    for (var i = 0, r; i < obj.length; i++) {
+        if (r = fun.call(obj, obj[i])) { return r }
+    }
+    return null;
+};
+tree.jsify = function (obj) {
+    if (Array.isArray(obj.value) && (obj.value.length > 1)) {
+        return '[' + obj.value.map(function (v) { return v.toCSS(false) }).join(', ') + ']';
+    } else {
+        return obj.toCSS(false);
+    }
+};
+
+})(require('./tree'));
+(function (tree) {
+
+    var parseCopyProperties = [
+        'paths',            // paths to search for imports on
+        'optimization',     // option - optimization level (for the chunker)
+        'filename',         // current filename, used for error reporting
+        'files',            // list of files that have been imported, used for import-once
+        'contents',         // browser-only, contents of all the files
+        'rootpath',         // current rootpath to append to all url's
+        'relativeUrls',     // option - whether to adjust URL's to be relative
+        'strictImports',    // option -
+        'dumpLineNumbers',  // option - whether to dump line numbers
+        'compress',         // option - whether to compress
+        'mime',             // browser only - mime type for sheet import
+        'entryPath',        // browser only - path of entry less file
+        'rootFilename',     // browser only - href of the entry less file
+        'currentDirectory'  // node only - the current directory
+    ];
+
+    tree.parseEnv = function(options) {
+        copyFromOriginal(options, this, parseCopyProperties);
+
+        if (!this.contents) { this.contents = {}; }
+        if (!this.rootpath) { this.rootpath = ''; }
+        if (!this.files) { this.files = {}; }
+    };
+
+    tree.parseEnv.prototype.toSheet = function (path) {
+        var env = new tree.parseEnv(this);
+        env.href = path;
+        //env.title = path;
+        env.type = this.mime;
+        return env;
+    };
+
+    var evalCopyProperties = [
+        'silent',      // whether to swallow errors and warnings
+        'verbose',     // whether to log more activity
+        'compress',    // whether to compress
+        'ieCompat',    // whether to enforce IE compatibility (IE8 data-uri)
+        'strictMaths', // whether maths has to be within parenthesis
+        'strictUnits'  // whether units need to evaluate correctly
+        ];
+
+    tree.evalEnv = function(options, frames) {
+        copyFromOriginal(options, this, evalCopyProperties);
+
+        this.frames = frames || [];
+    };
+
+    tree.evalEnv.prototype.inParenthesis = function () {
+        if (!this.parensStack) {
+            this.parensStack = [];
+        }
+        this.parensStack.push(true);
+    };
+
+    tree.evalEnv.prototype.outOfParenthesis = function () {
+        this.parensStack.pop();
+    };
+
+    tree.evalEnv.prototype.isMathsOn = function () {
+        return this.strictMaths === false ? true : (this.parensStack && this.parensStack.length);
+    };
+
+    tree.evalEnv.prototype.isPathRelative = function (path) {
+        return !/^(?:[a-z-]+:|\/)/.test(path);
+    };
+
+    //todo - do the same for the toCSS env
+    //tree.toCSSEnv = function (options) {
+    //};
+
+    var copyFromOriginal = function(original, destination, propertiesToCopy) {
+        if (!original) { return; }
+
+        for(var i = 0; i < propertiesToCopy.length; i++) {
+            if (original.hasOwnProperty(propertiesToCopy[i])) {
+                destination[propertiesToCopy[i]] = original[propertiesToCopy[i]];
+            }
+        }
+    }
+})(require('./tree'));//
+// browser.js - client-side engine
+//
+
+var isFileProtocol = /^(file|chrome(-extension)?|resource|qrc|app):/.test(location.protocol);
+
+less.env = less.env || (location.hostname == '127.0.0.1' ||
+                        location.hostname == '0.0.0.0'   ||
+                        location.hostname == 'localhost' ||
+                        location.port.length > 0         ||
+                        isFileProtocol                   ? 'development'
+                                                         : 'production');
+
+// Load styles asynchronously (default: false)
+//
+// This is set to `false` by default, so that the body
+// doesn't start loading before the stylesheets are parsed.
+// Setting this to `true` can result in flickering.
+//
+less.async = less.async || false;
+less.fileAsync = less.fileAsync || false;
+
+// Interval between watch polls
+less.poll = less.poll || (isFileProtocol ? 1000 : 1500);
+
+//Setup user functions
+if (less.functions) {
+    for(var func in less.functions) {
+        less.tree.functions[func] = less.functions[func];
+   }
+}
+
+var dumpLineNumbers = /!dumpLineNumbers:(comments|mediaquery|all)/.exec(location.hash);
+if (dumpLineNumbers) {
+    less.dumpLineNumbers = dumpLineNumbers[1];
+}
+
+//
+// Watch mode
+//
+less.watch   = function () {
+    if (!less.watchMode ){
+        less.env = 'development';
+         initRunningMode();
+    }
+    return this.watchMode = true
+};
+
+less.unwatch = function () {clearInterval(less.watchTimer); return this.watchMode = false; };
+
+function initRunningMode(){
+    if (less.env === 'development') {
+        less.optimization = 0;
+        less.watchTimer = setInterval(function () {
+            if (less.watchMode) {
+                loadStyleSheets(function (e, root, _, sheet, env) {
+                    if (e) {
+                        error(e, sheet.href);
+                    } else if (root) {
+                        createCSS(root.toCSS(less), sheet, env.lastModified);
+                    }
+                });
+            }
+        }, less.poll);
+    } else {
+        less.optimization = 3;
+    }
+}
+
+if (/!watch/.test(location.hash)) {
+    less.watch();
+}
+
+var cache = null;
+
+if (less.env != 'development') {
+    try {
+        cache = (typeof(window.localStorage) === 'undefined') ? null : window.localStorage;
+    } catch (_) {}
+}
+
+//
+// Get all  tags with the 'rel' attribute set to "stylesheet/less"
+//
+var links = document.getElementsByTagName('link');
+var typePattern = /^text\/(x-)?less$/;
+
+less.sheets = [];
+
+for (var i = 0; i < links.length; i++) {
+    if (links[i].rel === 'stylesheet/less' || (links[i].rel.match(/stylesheet/) &&
+       (links[i].type.match(typePattern)))) {
+        less.sheets.push(links[i]);
+    }
+}
+
+//
+// With this function, it's possible to alter variables and re-render
+// CSS without reloading less-files
+//
+var session_cache = '';
+less.modifyVars = function(record) {
+    var str = session_cache;
+    for (name in record) {
+        str += ((name.slice(0,1) === '@')? '' : '@') + name +': '+
+                ((record[name].slice(-1) === ';')? record[name] : record[name] +';');
+    }
+    new(less.Parser)(new less.tree.parseEnv(less)).parse(str, function (e, root) {
+        if (e) {
+            error(e, "session_cache");
+        } else {
+            createCSS(root.toCSS(less), less.sheets[less.sheets.length - 1]);
+        }
+    });
+};
+
+less.refresh = function (reload) {
+    var startTime, endTime;
+    startTime = endTime = new(Date);
+
+    loadStyleSheets(function (e, root, _, sheet, env) {
+        if (e) {
+            return error(e, sheet.href);
+        }
+        if (env.local) {
+            log("loading " + sheet.href + " from cache.");
+        } else {
+            log("parsed " + sheet.href + " successfully.");
+            createCSS(root.toCSS(less), sheet, env.lastModified);
+        }
+        log("css for " + sheet.href + " generated in " + (new(Date) - endTime) + 'ms');
+        (env.remaining === 0) && log("css generated in " + (new(Date) - startTime) + 'ms');
+        endTime = new(Date);
+    }, reload);
+
+    loadStyles();
+};
+less.refreshStyles = loadStyles;
+
+less.refresh(less.env === 'development');
+
+function loadStyles() {
+    var styles = document.getElementsByTagName('style');
+    for (var i = 0; i < styles.length; i++) {
+        if (styles[i].type.match(typePattern)) {
+            var env = new less.tree.parseEnv(less);
+            env.filename = document.location.href.replace(/#.*$/, '');
+
+            new(less.Parser)(env).parse(styles[i].innerHTML || '', function (e, cssAST) {
+                if (e) {
+                    return error(e, "inline");
+                }
+                var css = cssAST.toCSS(less);
+                var style = styles[i];
+                style.type = 'text/css';
+                if (style.styleSheet) {
+                    style.styleSheet.cssText = css;
+                } else {
+                    style.innerHTML = css;
+                }
+            });
+        }
+    }
+}
+
+function loadStyleSheets(callback, reload) {
+    for (var i = 0; i < less.sheets.length; i++) {
+        loadStyleSheet(less.sheets[i], callback, reload, less.sheets.length - (i + 1));
+    }
+}
+
+function pathDiff(url, baseUrl) {
+    // diff between two paths to create a relative path
+
+    var urlParts = extractUrlParts(url),
+        baseUrlParts = extractUrlParts(baseUrl),
+        i, max, urlDirectories, baseUrlDirectories, diff = "";
+    if (urlParts.hostPart !== baseUrlParts.hostPart) {
+        return "";
+    }
+    max = Math.max(baseUrlParts.directories.length, urlParts.directories.length);
+    for(i = 0; i < max; i++) {
+        if (baseUrlParts.directories[i] !== urlParts.directories[i]) { break; }
+    }
+    baseUrlDirectories = baseUrlParts.directories.slice(i);
+    urlDirectories = urlParts.directories.slice(i);
+    for(i = 0; i < baseUrlDirectories.length-1; i++) {
+        diff += "../";
+    }
+    for(i = 0; i < urlDirectories.length-1; i++) {
+        diff += urlDirectories[i] + "/";
+    }
+    return diff;
+}
+
+function extractUrlParts(url, baseUrl) {
+    // urlParts[1] = protocol&hostname || /
+    // urlParts[2] = / if path relative to host base
+    // urlParts[3] = directories
+    // urlParts[4] = filename
+    // urlParts[5] = parameters
+
+    var urlPartsRegex = /^((?:[a-z-]+:)?\/+?(?:[^\/\?#]*\/)|([\/\\]))?((?:[^\/\\\?#]*[\/\\])*)([^\/\\\?#]*)([#\?].*)?$/,
+        urlParts = url.match(urlPartsRegex),
+        returner = {}, directories = [], i, baseUrlParts;
+
+    if (!urlParts) {
+        throw new Error("Could not parse sheet href - '"+url+"'");
+    }
+
+    // Stylesheets in IE don't always return the full path
+    if (!urlParts[1] || urlParts[2]) {
+        baseUrlParts = baseUrl.match(urlPartsRegex);
+        if (!baseUrlParts) {
+            throw new Error("Could not parse page url - '"+baseUrl+"'");
+        }
+        urlParts[1] = baseUrlParts[1];
+        if (!urlParts[2]) {
+            urlParts[3] = baseUrlParts[3] + urlParts[3];
+        }
+    }
+
+    if (urlParts[3]) {
+        directories = urlParts[3].replace("\\", "/").split("/");
+
+        for(i = 0; i < directories.length; i++) {
+            if (directories[i] === ".." && i > 0) {
+                directories.splice(i-1, 2);
+                i -= 2;
+            }
+        }
+    }
+
+    returner.hostPart = urlParts[1];
+    returner.directories = directories;
+    returner.path = urlParts[1] + directories.join("/");
+    returner.fileUrl = returner.path + (urlParts[4] || "");
+    returner.url = returner.fileUrl + (urlParts[5] || "");
+    return returner;
+}
+
+function loadStyleSheet(sheet, callback, reload, remaining) {
+
+    // sheet may be set to the stylesheet for the initial load or a collection of properties including
+    // some env variables for imports
+    var hrefParts = extractUrlParts(sheet.href, window.location.href);
+    var href      = hrefParts.url;
+    var css       = cache && cache.getItem(href);
+    var timestamp = cache && cache.getItem(href + ':timestamp');
+    var styles    = { css: css, timestamp: timestamp };
+    var env;
+
+    if (sheet instanceof less.tree.parseEnv) {
+        env = new less.tree.parseEnv(sheet);
+    } else {
+        env = new less.tree.parseEnv(less);
+        env.entryPath = hrefParts.path;
+        env.mime = sheet.type;
+    }
+
+    if (env.relativeUrls) {
+        //todo - this relies on option being set on less object rather than being passed in as an option
+        //     - need an originalRootpath
+        if (less.rootpath) {
+            env.rootpath = extractUrlParts(less.rootpath + pathDiff(hrefParts.path, env.entryPath)).path;
+        } else {
+            env.rootpath = hrefParts.path;
+        }
+    } else  {
+        if (!less.rootpath) {
+            env.rootpath = env.entryPath;
+        }
+    }
+
+    xhr(href, sheet.type, function (data, lastModified) {
+        // Store data this session
+        session_cache += data.replace(/@import .+?;/ig, '');
+
+        if (!reload && styles && lastModified &&
+           (new(Date)(lastModified).valueOf() ===
+            new(Date)(styles.timestamp).valueOf())) {
+            // Use local copy
+            createCSS(styles.css, sheet);
+            callback(null, null, data, sheet, { local: true, remaining: remaining }, href);
+        } else {
+            // Use remote copy (re-parse)
+            try {
+                env.contents[href] = data;  // Updating content cache
+                env.paths = [hrefParts.path];
+                env.filename = href;
+                env.rootFilename = env.rootFilename || href;
+                new(less.Parser)(env).parse(data, function (e, root) {
+                    if (e) { return callback(e, null, null, sheet); }
+                    try {
+                        callback(e, root, data, sheet, { local: false, lastModified: lastModified, remaining: remaining }, href);
+                        //TODO - there must be a better way? A generic less-to-css function that can both call error
+                        //and removeNode where appropriate
+                        //should also add tests
+                        if (env.rootFilename === href) {
+                            removeNode(document.getElementById('less-error-message:' + extractId(href)));
+                        }
+                    } catch (e) {
+                        callback(e, null, null, sheet);
+                    }
+                });
+            } catch (e) {
+                callback(e, null, null, sheet);
+            }
+        }
+    }, function (status, url) {
+        callback({ type: 'File', message: "'" + url + "' wasn't found (" + status + ")" }, null, null, sheet);
+    });
+}
+
+function extractId(href) {
+    return href.replace(/^[a-z-]+:\/+?[^\/]+/, '' )  // Remove protocol & domain
+               .replace(/^\//,                 '' )  // Remove root /
+               .replace(/\.[a-zA-Z]+$/,        '' )  // Remove simple extension
+               .replace(/[^\.\w-]+/g,          '-')  // Replace illegal characters
+               .replace(/\./g,                 ':'); // Replace dots with colons(for valid id)
+}
+
+function createCSS(styles, sheet, lastModified) {
+    // Strip the query-string
+    var href = sheet.href || '';
+
+    // If there is no title set, use the filename, minus the extension
+    var id = 'less:' + (sheet.title || extractId(href));
+
+    // If this has already been inserted into the DOM, we may need to replace it
+    var oldCss = document.getElementById(id);
+    var keepOldCss = false;
+
+    // Create a new stylesheet node for insertion or (if necessary) replacement
+    var css = document.createElement('style');
+    css.setAttribute('type', 'text/css');
+    if (sheet.media) {
+        css.setAttribute('media', sheet.media);
+    }
+    css.id = id;
+
+    if (css.styleSheet) { // IE
+        try {
+            css.styleSheet.cssText = styles;
+        } catch (e) {
+            throw new(Error)("Couldn't reassign styleSheet.cssText.");
+        }
+    } else {
+        css.appendChild(document.createTextNode(styles));
+
+        // If new contents match contents of oldCss, don't replace oldCss
+        keepOldCss = (oldCss !== null && oldCss.childNodes.length > 0 && css.childNodes.length > 0 &&
+            oldCss.firstChild.nodeValue === css.firstChild.nodeValue);
+    }
+
+    var head = document.getElementsByTagName('head')[0];
+
+    // If there is no oldCss, just append; otherwise, only append if we need
+    // to replace oldCss with an updated stylesheet
+    if (oldCss == null || keepOldCss === false) {
+        var nextEl = sheet && sheet.nextSibling || null;
+        (nextEl || document.getElementsByTagName('head')[0]).parentNode.insertBefore(css, nextEl);
+    }
+    if (oldCss && keepOldCss === false) {
+        head.removeChild(oldCss);
+    }
+
+    // Don't update the local store if the file wasn't modified
+    if (lastModified && cache) {
+        log('saving ' + href + ' to cache.');
+        try {
+            cache.setItem(href, styles);
+            cache.setItem(href + ':timestamp', lastModified);
+        } catch(e) {
+            //TODO - could do with adding more robust error handling
+            log('failed to save');
+        }
+    }
+}
+
+function xhr(url, type, callback, errback) {
+    var xhr = getXMLHttpRequest();
+    var async = isFileProtocol ? less.fileAsync : less.async;
+
+    if (typeof(xhr.overrideMimeType) === 'function') {
+        xhr.overrideMimeType('text/css');
+    }
+    xhr.open('GET', url, async);
+    xhr.setRequestHeader('Accept', type || 'text/x-less, text/css; q=0.9, */*; q=0.5');
+    xhr.send(null);
+
+    if (isFileProtocol && !less.fileAsync) {
+        if (xhr.status === 0 || (xhr.status >= 200 && xhr.status < 300)) {
+            callback(xhr.responseText);
+        } else {
+            errback(xhr.status, url);
+        }
+    } else if (async) {
+        xhr.onreadystatechange = function () {
+            if (xhr.readyState == 4) {
+                handleResponse(xhr, callback, errback);
+            }
+        };
+    } else {
+        handleResponse(xhr, callback, errback);
+    }
+
+    function handleResponse(xhr, callback, errback) {
+        if (xhr.status >= 200 && xhr.status < 300) {
+            callback(xhr.responseText,
+                     xhr.getResponseHeader("Last-Modified"));
+        } else if (typeof(errback) === 'function') {
+            errback(xhr.status, url);
+        }
+    }
+}
+
+function getXMLHttpRequest() {
+    if (window.XMLHttpRequest) {
+        return new(XMLHttpRequest);
+    } else {
+        try {
+            return new(ActiveXObject)("MSXML2.XMLHTTP.3.0");
+        } catch (e) {
+            log("browser doesn't support AJAX.");
+            return null;
+        }
+    }
+}
+
+function removeNode(node) {
+    return node && node.parentNode.removeChild(node);
+}
+
+function log(str) {
+    if (less.env == 'development' && typeof(console) !== "undefined") { console.log('less: ' + str) }
+}
+
+function error(e, rootHref) {
+    var id = 'less-error-message:' + extractId(rootHref || "");
+    var template = '
  • {content}
  • '; + var elem = document.createElement('div'), timer, content, error = []; + var filename = e.filename || rootHref; + var filenameNoPath = filename.match(/([^\/]+(\?.*)?)$/)[1]; + + elem.id = id; + elem.className = "less-error-message"; + + content = '

    ' + (e.type || "Syntax") + "Error: " + (e.message || 'There is an error in your .less file') + + '

    ' + '

    in ' + filenameNoPath + " "; + + var errorline = function (e, i, classname) { + if (e.extract[i] != undefined) { + error.push(template.replace(/\{line\}/, (parseInt(e.line) || 0) + (i - 1)) + .replace(/\{class\}/, classname) + .replace(/\{content\}/, e.extract[i])); + } + }; + + if (e.extract) { + errorline(e, 0, ''); + errorline(e, 1, 'line'); + errorline(e, 2, ''); + content += 'on line ' + e.line + ', column ' + (e.column + 1) + ':

    ' + + '
      ' + error.join('') + '
    '; + } else if (e.stack) { + content += '
    ' + e.stack.split('\n').slice(1).join('
    '); + } + elem.innerHTML = content; + + // CSS for error messages + createCSS([ + '.less-error-message ul, .less-error-message li {', + 'list-style-type: none;', + 'margin-right: 15px;', + 'padding: 4px 0;', + 'margin: 0;', + '}', + '.less-error-message label {', + 'font-size: 12px;', + 'margin-right: 15px;', + 'padding: 4px 0;', + 'color: #cc7777;', + '}', + '.less-error-message pre {', + 'color: #dd6666;', + 'padding: 4px 0;', + 'margin: 0;', + 'display: inline-block;', + '}', + '.less-error-message pre.line {', + 'color: #ff0000;', + '}', + '.less-error-message h3 {', + 'font-size: 20px;', + 'font-weight: bold;', + 'padding: 15px 0 5px 0;', + 'margin: 0;', + '}', + '.less-error-message a {', + 'color: #10a', + '}', + '.less-error-message .error {', + 'color: red;', + 'font-weight: bold;', + 'padding-bottom: 2px;', + 'border-bottom: 1px dashed red;', + '}' + ].join('\n'), { title: 'error-message' }); + + elem.style.cssText = [ + "font-family: Arial, sans-serif", + "border: 1px solid #e00", + "background-color: #eee", + "border-radius: 5px", + "-webkit-border-radius: 5px", + "-moz-border-radius: 5px", + "color: #e00", + "padding: 15px", + "margin-bottom: 15px" + ].join(';'); + + if (less.env == 'development') { + timer = setInterval(function () { + if (document.body) { + if (document.getElementById(id)) { + document.body.replaceChild(elem, document.getElementById(id)); + } else { + document.body.insertBefore(elem, document.body.firstChild); + } + clearInterval(timer); + } + }, 10); + } +} +// amd.js +// +// Define Less as an AMD module. +if (typeof define === "function" && define.amd) { + define(function () { return less; } ); +} +})(window); \ No newline at end of file diff --git a/vendor/packages/python.tmbundle b/vendor/packages/python.tmbundle index df88cd66d..70dd4be1f 160000 --- a/vendor/packages/python.tmbundle +++ b/vendor/packages/python.tmbundle @@ -1 +1 @@ -Subproject commit df88cd66d00ed44b1d1a212a347334bb8308299c +Subproject commit 70dd4be1f12d6e5b2f9238f04e38567f7cebfe4c diff --git a/vendor/space-pen.coffee b/vendor/space-pen.coffee index 08b715960..f734350e0 100644 --- a/vendor/space-pen.coffee +++ b/vendor/space-pen.coffee @@ -162,7 +162,8 @@ class Builder options.attributes = arg options -jQuery.fn.view = -> this.data('view') +jQuery.fn.view = -> @data('view') +jQuery.fn.views = -> @toArray().map (elt) -> jQuery(elt).view() # Trigger attach event when views are added to the DOM callAttachHook = (element) ->