├── .coffeelintignore ├── .github ├── no-response.yml └── workflows │ └── ci.yml ├── .gitignore ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── coffeelint.json ├── keymaps └── tree-view.cson ├── lib ├── add-dialog.coffee ├── add-projects-view.js ├── copy-dialog.coffee ├── default-file-icons.coffee ├── dialog.coffee ├── directory-view.js ├── directory.js ├── file-view.js ├── file.js ├── get-icon-services.js ├── helpers.coffee ├── ignored-names.coffee ├── main.js ├── move-dialog.coffee ├── root-drag-and-drop.coffee ├── tree-view-package.js └── tree-view.coffee ├── menus └── tree-view.cson ├── package-lock.json ├── package.json ├── spec ├── async-spec-helpers.js ├── default-file-icons-spec.coffee ├── event-helpers.coffee ├── file-icons-spec.coffee ├── file-stats-spec.coffee ├── fixtures │ ├── git │ │ └── working-dir │ │ │ ├── dir │ │ │ └── b.txt │ │ │ ├── file.txt │ │ │ ├── git.git │ │ │ ├── HEAD │ │ │ ├── config │ │ │ ├── index │ │ │ ├── objects │ │ │ │ ├── 28 │ │ │ │ │ └── 569d0a51e27dd112c0d4994c1e2914dd0db754 │ │ │ │ ├── 65 │ │ │ │ │ └── a457425a679cbe9adf0d2741785d3ceabb44a7 │ │ │ │ ├── 71 │ │ │ │ │ └── 1877003346f1e619bdfda3cc6495600ba08763 │ │ │ │ ├── e6 │ │ │ │ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 │ │ │ │ ├── ec │ │ │ │ │ └── 5e386905ff2d36e291086a1207f2585aaa8920 │ │ │ │ └── ef │ │ │ │ │ └── 046e9eecaa5255ea5e9817132d4001724d6ae1 │ │ │ └── refs │ │ │ │ └── heads │ │ │ │ └── master │ │ │ └── other.txt │ ├── root-dir1 │ │ ├── dir1 │ │ │ ├── file1 │ │ │ └── sub-dir1 │ │ │ │ └── sub-file1 │ │ ├── dir2 │ │ │ └── file2 │ │ ├── nested │ │ │ └── nested2 │ │ │ │ └── .gitkeep │ │ ├── tree-view.js │ │ └── tree-view.txt │ └── root-dir2 │ │ ├── another-file.txt │ │ └── dir3 │ │ └── file3 ├── helpers-spec.js ├── tree-view-package-spec.coffee └── tree-view-spec.js └── styles └── tree-view.less /.coffeelintignore: -------------------------------------------------------------------------------- 1 | spec/fixtures 2 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an issue is closed for lack of response 4 | daysUntilClose: 28 5 | 6 | # Label requiring a response 7 | responseRequiredLabel: more-information-needed 8 | 9 | # Comment to post when closing an issue for lack of response. Set to `false` to disable. 10 | closeComment: > 11 | This issue has been automatically closed because there has been no response 12 | to our request for more information from the original author. With only the 13 | information that is currently in the issue, we don't have enough information 14 | to take action. Please reach out if you have or find the answers we need so 15 | that we can investigate further. 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | Test: 10 | strategy: 11 | matrix: 12 | channel: [stable, beta] 13 | runs-on: macos-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: UziTech/action-setup-atom@v2 17 | with: 18 | version: ${{ matrix.channel }} 19 | - name: Install dependencies 20 | run: apm install 21 | - name: Run tests 22 | run: atom --test spec 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See the [Atom contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md) 2 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Prerequisites 10 | 11 | * [ ] Put an X between the brackets on this line if you have done all of the following: 12 | * Reproduced the problem in Safe Mode: http://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode 13 | * Followed all applicable steps in the debugging guide: http://flight-manual.atom.io/hacking-atom/sections/debugging/ 14 | * Checked the FAQs on the message board for common solutions: https://discuss.atom.io/c/faq 15 | * Checked that your issue isn't already filed: https://github.com/issues?utf8=✓&q=is%3Aissue+user%3Aatom 16 | * Checked that there is not already an Atom package that provides the described functionality: https://atom.io/packages 17 | 18 | ### Description 19 | 20 | [Description of the issue] 21 | 22 | ### Steps to Reproduce 23 | 24 | 1. [First Step] 25 | 2. [Second Step] 26 | 3. [and so on...] 27 | 28 | **Expected behavior:** [What you expect to happen] 29 | 30 | **Actual behavior:** [What actually happens] 31 | 32 | **Reproduces how often:** [What percentage of the time does it reproduce?] 33 | 34 | ### Versions 35 | 36 | You can get this information from copy and pasting the output of `atom --version` and `apm --version` from the command line. Also, please include the OS and what version of the OS you're running. 37 | 38 | ### Additional Information 39 | 40 | Any additional information, configuration or data that might be necessary to reproduce the issue. 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Requirements 2 | 3 | * Filling out the template is required. Any pull request that does not include enough information to be reviewed in a timely manner may be closed at the maintainers' discretion. 4 | * All new code requires tests to ensure against regressions 5 | 6 | ### Description of the Change 7 | 8 | 13 | 14 | ### Alternate Designs 15 | 16 | 17 | 18 | ### Benefits 19 | 20 | 21 | 22 | ### Possible Drawbacks 23 | 24 | 25 | 26 | ### Applicable Issues 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # Tree View package 3 | [![CI](https://github.com/atom/tree-view/actions/workflows/ci.yml/badge.svg)](https://github.com/atom/tree-view/actions/workflows/ci.yml) 4 | 5 | Explore and open files in the current project. 6 | 7 | Press ctrl-\\ or cmd-\\ to open/close the tree view and 8 | alt-\\ or ctrl-0 to focus it. 9 | 10 | When the tree view has focus you can press a, shift-a, 11 | m, or delete to add, move or delete files and folders. 12 | 13 | To move the Tree view to the opposite side, select and drag the Tree view dock to the other side. 14 | 15 | ![](https://f.cloud.github.com/assets/671378/2241932/6d9cface-9ceb-11e3-9026-31d5011d889d.png) 16 | 17 | ## API 18 | This package provides a service that you can use in other Atom packages. 19 | To use it, include `tree-view` in the `consumedServices` section of your 20 | `package.json`: 21 | 22 | ``` json 23 | { 24 | "name": "my-package", 25 | "consumedServices": { 26 | "tree-view": { 27 | "versions": { 28 | "^1.0.0": "consumeTreeView" 29 | } 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | Then, in your package's main module, call methods on the service: 36 | 37 | ``` coffee 38 | module.exports = 39 | activate: -> # ... 40 | 41 | consumeTreeView: (treeView) -> 42 | selectedPaths = treeView.selectedPaths() 43 | # Do something with the paths... 44 | ``` 45 | 46 | The `tree-view` API has two methods: 47 | * `selectedPaths()` - Returns the paths to the selected tree view entries. 48 | * `entryForPath(entryPath)` - Returns a tree view entry for the given path. 49 | 50 | ## Customization 51 | The tree view displays icons next to files. These icons are customizable by 52 | installing a package that provides an `atom.file-icons` service. 53 | 54 | The `atom.file-icons` service must provide the following methods: 55 | * `iconClassForPath(path)` - Returns a CSS class name to add to the file view. 56 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_line_length": { 3 | "level": "ignore" 4 | }, 5 | "no_empty_param_list": { 6 | "level": "error" 7 | }, 8 | "arrow_spacing": { 9 | "level": "error" 10 | }, 11 | "no_interpolation_in_single_quotes": { 12 | "level": "error" 13 | }, 14 | "no_debugger": { 15 | "level": "error" 16 | }, 17 | "prefer_english_operator": { 18 | "level": "error" 19 | }, 20 | "colon_assignment_spacing": { 21 | "spacing": { 22 | "left": 0, 23 | "right": 1 24 | }, 25 | "level": "error" 26 | }, 27 | "braces_spacing": { 28 | "spaces": 0, 29 | "level": "error" 30 | }, 31 | "spacing_after_comma": { 32 | "level": "error" 33 | }, 34 | "no_stand_alone_at": { 35 | "level": "error" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /keymaps/tree-view.cson: -------------------------------------------------------------------------------- 1 | '.platform-darwin': 2 | 'cmd-\\': 'tree-view:toggle' 3 | 'cmd-k cmd-b': 'tree-view:toggle' 4 | 'cmd-|': 'tree-view:reveal-active-file' 5 | 'ctrl-0': 'tree-view:toggle-focus' 6 | 7 | '.platform-win32, .platform-linux': 8 | 'ctrl-\\': 'tree-view:toggle' 9 | 'ctrl-k ctrl-b': 'tree-view:toggle' 10 | 'ctrl-|': 'tree-view:reveal-active-file' 11 | 'alt-\\': 'tree-view:toggle-focus' 12 | 13 | '.platform-darwin .tree-view': 14 | 'cmd-c': 'tree-view:copy' 15 | 'cmd-x': 'tree-view:cut' 16 | 'cmd-v': 'tree-view:paste' 17 | 'ctrl-f': 'tree-view:expand-item' 18 | 'ctrl-b': 'tree-view:collapse-directory' 19 | 'cmd-k right': 'tree-view:open-selected-entry-right' 20 | 'cmd-k l': 'tree-view:open-selected-entry-right' 21 | 'cmd-k left': 'tree-view:open-selected-entry-left' 22 | 'cmd-k h': 'tree-view:open-selected-entry-left' 23 | 'cmd-k up': 'tree-view:open-selected-entry-up' 24 | 'cmd-k k': 'tree-view:open-selected-entry-up' 25 | 'cmd-k down': 'tree-view:open-selected-entry-down' 26 | 'cmd-k j': 'tree-view:open-selected-entry-down' 27 | 'cmd-1': 'tree-view:open-selected-entry-in-pane-1' 28 | 'cmd-2': 'tree-view:open-selected-entry-in-pane-2' 29 | 'cmd-3': 'tree-view:open-selected-entry-in-pane-3' 30 | 'cmd-4': 'tree-view:open-selected-entry-in-pane-4' 31 | 'cmd-5': 'tree-view:open-selected-entry-in-pane-5' 32 | 'cmd-6': 'tree-view:open-selected-entry-in-pane-6' 33 | 'cmd-7': 'tree-view:open-selected-entry-in-pane-7' 34 | 'cmd-8': 'tree-view:open-selected-entry-in-pane-8' 35 | 'cmd-9': 'tree-view:open-selected-entry-in-pane-9' 36 | 37 | '.platform-win32 .tree-view, .platform-linux .tree-view': 38 | 'ctrl-c': 'tree-view:copy' 39 | 'ctrl-x': 'tree-view:cut' 40 | 'ctrl-v': 'tree-view:paste' 41 | 'ctrl-k right': 'tree-view:open-selected-entry-right' 42 | 'ctrl-k l': 'tree-view:open-selected-entry-right' 43 | 'ctrl-k left': 'tree-view:open-selected-entry-left' 44 | 'ctrl-k h': 'tree-view:open-selected-entry-left' 45 | 'ctrl-k up': 'tree-view:open-selected-entry-up' 46 | 'ctrl-k k': 'tree-view:open-selected-entry-up' 47 | 'ctrl-k down': 'tree-view:open-selected-entry-down' 48 | 'ctrl-k j': 'tree-view:open-selected-entry-down' 49 | 'ctrl-1': 'tree-view:open-selected-entry-in-pane-1' 50 | 'ctrl-2': 'tree-view:open-selected-entry-in-pane-2' 51 | 'ctrl-3': 'tree-view:open-selected-entry-in-pane-3' 52 | 'ctrl-4': 'tree-view:open-selected-entry-in-pane-4' 53 | 'ctrl-5': 'tree-view:open-selected-entry-in-pane-5' 54 | 'ctrl-6': 'tree-view:open-selected-entry-in-pane-6' 55 | 'ctrl-7': 'tree-view:open-selected-entry-in-pane-7' 56 | 'ctrl-8': 'tree-view:open-selected-entry-in-pane-8' 57 | 'ctrl-9': 'tree-view:open-selected-entry-in-pane-9' 58 | 59 | '.tree-view': 60 | 'right': 'tree-view:expand-item' 61 | 'ctrl-]': 'tree-view:expand-item' 62 | 'l': 'tree-view:expand-item' 63 | 'left': 'tree-view:collapse-directory' 64 | 'ctrl-[': 'tree-view:collapse-directory' 65 | 'alt-ctrl-]': 'tree-view:recursive-expand-directory' 66 | 'alt-right': 'tree-view:recursive-expand-directory' 67 | 'alt-ctrl-[': 'tree-view:recursive-collapse-directory' 68 | 'alt-left': 'tree-view:recursive-collapse-directory' 69 | 'h': 'tree-view:collapse-directory' 70 | 'enter': 'tree-view:open-selected-entry' 71 | 'escape': 'tree-view:unfocus' 72 | 'ctrl-C': 'tree-view:copy-full-path' 73 | 'm': 'tree-view:move' 74 | 'f2': 'tree-view:move' 75 | 'a': 'tree-view:add-file' 76 | 'shift-a': 'tree-view:add-folder' 77 | 'd': 'tree-view:duplicate' 78 | 'delete': 'tree-view:remove' 79 | 'backspace': 'tree-view:remove' 80 | 'k': 'core:move-up' 81 | 'j': 'core:move-down' 82 | 'i': 'tree-view:toggle-vcs-ignored-files' 83 | 'home': 'core:move-to-top' 84 | 'end': 'core:move-to-bottom' 85 | 86 | '.tree-view-dialog atom-text-editor[mini]': 87 | 'enter': 'core:confirm' 88 | 'escape': 'core:cancel' 89 | -------------------------------------------------------------------------------- /lib/add-dialog.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | fs = require 'fs-plus' 3 | Dialog = require './dialog' 4 | {repoForPath} = require './helpers' 5 | 6 | module.exports = 7 | class AddDialog extends Dialog 8 | constructor: (initialPath, isCreatingFile) -> 9 | @isCreatingFile = isCreatingFile 10 | 11 | if fs.isFileSync(initialPath) 12 | directoryPath = path.dirname(initialPath) 13 | else 14 | directoryPath = initialPath 15 | 16 | relativeDirectoryPath = directoryPath 17 | [@rootProjectPath, relativeDirectoryPath] = atom.project.relativizePath(directoryPath) 18 | relativeDirectoryPath += path.sep if relativeDirectoryPath.length > 0 19 | 20 | super 21 | prompt: "Enter the path for the new " + if isCreatingFile then "file." else "folder." 22 | initialPath: relativeDirectoryPath 23 | select: false 24 | iconClass: if isCreatingFile then 'icon-file-add' else 'icon-file-directory-create' 25 | 26 | onDidCreateFile: (callback) -> 27 | @emitter.on('did-create-file', callback) 28 | 29 | onDidCreateDirectory: (callback) -> 30 | @emitter.on('did-create-directory', callback) 31 | 32 | onConfirm: (newPath) -> 33 | newPath = newPath.replace(/\s+$/, '') # Remove trailing whitespace 34 | endsWithDirectorySeparator = newPath[newPath.length - 1] is path.sep 35 | unless path.isAbsolute(newPath) 36 | unless @rootProjectPath? 37 | @showError("You must open a directory to create a file with a relative path") 38 | return 39 | 40 | newPath = path.join(@rootProjectPath, newPath) 41 | 42 | return unless newPath 43 | 44 | try 45 | if fs.existsSync(newPath) 46 | @showError("'#{newPath}' already exists.") 47 | else if @isCreatingFile 48 | if endsWithDirectorySeparator 49 | @showError("File names must not end with a '#{path.sep}' character.") 50 | else 51 | fs.writeFileSync(newPath, '') 52 | repoForPath(newPath)?.getPathStatus(newPath) 53 | @emitter.emit('did-create-file', newPath) 54 | @close() 55 | else 56 | fs.makeTreeSync(newPath) 57 | @emitter.emit('did-create-directory', newPath) 58 | @cancel() 59 | catch error 60 | @showError("#{error.message}.") 61 | -------------------------------------------------------------------------------- /lib/add-projects-view.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class AddProjectView { 3 | constructor () { 4 | this.element = document.createElement('div') 5 | this.element.id = 'add-projects-view' 6 | 7 | this.icon = document.createElement('div') 8 | this.icon.classList.add('icon', 'icon-large', 'icon-telescope') 9 | this.element.appendChild(this.icon) 10 | 11 | this.description = document.createElement('div') 12 | this.description.classList.add('description') 13 | this.description.innerText = 'Your project is currently empty' 14 | this.element.appendChild(this.description) 15 | 16 | this.addProjectsButton = document.createElement('button') 17 | this.addProjectsButton.classList.add('btn', 'btn-primary') 18 | this.addProjectsButton.innerText = 'Add folders' 19 | this.addProjectsButton.addEventListener('click', () => { 20 | atom.pickFolder(paths => { 21 | if (paths) { 22 | atom.project.setPaths(paths) 23 | } 24 | }) 25 | }) 26 | this.element.appendChild(this.addProjectsButton) 27 | 28 | this.reopenProjectButton = document.createElement('button') 29 | this.reopenProjectButton.classList.add('btn') 30 | this.reopenProjectButton.innerText = 'Reopen a project' 31 | this.reopenProjectButton.addEventListener('click', () => { 32 | atom.commands.dispatch(this.element, 'application:reopen-project') 33 | }) 34 | this.element.appendChild(this.reopenProjectButton) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/copy-dialog.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | fs = require 'fs-plus' 3 | Dialog = require './dialog' 4 | {repoForPath} = require "./helpers" 5 | 6 | module.exports = 7 | class CopyDialog extends Dialog 8 | constructor: (@initialPath, {@onCopy}) -> 9 | super 10 | prompt: 'Enter the new path for the duplicate.' 11 | initialPath: atom.project.relativize(@initialPath) 12 | select: true 13 | iconClass: 'icon-arrow-right' 14 | 15 | onConfirm: (newPath) -> 16 | newPath = newPath.replace(/\s+$/, '') # Remove trailing whitespace 17 | unless path.isAbsolute(newPath) 18 | [rootPath] = atom.project.relativizePath(@initialPath) 19 | newPath = path.join(rootPath, newPath) 20 | return unless newPath 21 | 22 | if @initialPath is newPath 23 | @close() 24 | return 25 | 26 | if fs.existsSync(newPath) 27 | @showError("'#{newPath}' already exists.") 28 | return 29 | 30 | activeEditor = atom.workspace.getActiveTextEditor() 31 | activeEditor = null unless activeEditor?.getPath() is @initialPath 32 | try 33 | if fs.isDirectorySync(@initialPath) 34 | fs.copySync(@initialPath, newPath) 35 | @onCopy?({initialPath: @initialPath, newPath: newPath}) 36 | else 37 | fs.copy @initialPath, newPath, => 38 | @onCopy?({initialPath: @initialPath, newPath: newPath}) 39 | atom.workspace.open newPath, 40 | activatePane: true 41 | initialLine: activeEditor?.getLastCursor().getBufferRow() 42 | initialColumn: activeEditor?.getLastCursor().getBufferColumn() 43 | if repo = repoForPath(newPath) 44 | repo.getPathStatus(@initialPath) 45 | repo.getPathStatus(newPath) 46 | @close() 47 | catch error 48 | @showError("#{error.message}.") 49 | -------------------------------------------------------------------------------- /lib/default-file-icons.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs-plus' 2 | path = require 'path' 3 | 4 | class DefaultFileIcons 5 | iconClassForPath: (filePath) -> 6 | extension = path.extname(filePath) 7 | 8 | if fs.isSymbolicLinkSync(filePath) 9 | 'icon-file-symlink-file' 10 | else if fs.isReadmePath(filePath) 11 | 'icon-book' 12 | else if fs.isCompressedExtension(extension) 13 | 'icon-file-zip' 14 | else if fs.isImageExtension(extension) 15 | 'icon-file-media' 16 | else if fs.isPdfExtension(extension) 17 | 'icon-file-pdf' 18 | else if fs.isBinaryExtension(extension) 19 | 'icon-file-binary' 20 | else 21 | 'icon-file-text' 22 | 23 | module.exports = new DefaultFileIcons 24 | -------------------------------------------------------------------------------- /lib/dialog.coffee: -------------------------------------------------------------------------------- 1 | {TextEditor, CompositeDisposable, Disposable, Emitter, Range, Point} = require 'atom' 2 | path = require 'path' 3 | {getFullExtension} = require "./helpers" 4 | 5 | module.exports = 6 | class Dialog 7 | constructor: ({initialPath, select, iconClass, prompt} = {}) -> 8 | @emitter = new Emitter() 9 | @disposables = new CompositeDisposable() 10 | 11 | @element = document.createElement('div') 12 | @element.classList.add('tree-view-dialog') 13 | 14 | @promptText = document.createElement('label') 15 | @promptText.classList.add('icon') 16 | @promptText.classList.add(iconClass) if iconClass 17 | @promptText.textContent = prompt 18 | @element.appendChild(@promptText) 19 | 20 | @miniEditor = new TextEditor({mini: true}) 21 | blurHandler = => 22 | @close() if document.hasFocus() 23 | @miniEditor.element.addEventListener('blur', blurHandler) 24 | @disposables.add(new Disposable(=> @miniEditor.element.removeEventListener('blur', blurHandler))) 25 | @disposables.add(@miniEditor.onDidChange => @showError()) 26 | @element.appendChild(@miniEditor.element) 27 | 28 | @errorMessage = document.createElement('div') 29 | @errorMessage.classList.add('error-message') 30 | @element.appendChild(@errorMessage) 31 | 32 | atom.commands.add @element, 33 | 'core:confirm': => @onConfirm(@miniEditor.getText()) 34 | 'core:cancel': => @cancel() 35 | 36 | @miniEditor.setText(initialPath) 37 | 38 | if select 39 | extension = getFullExtension(initialPath) 40 | baseName = path.basename(initialPath) 41 | selectionStart = initialPath.length - baseName.length 42 | if baseName is extension 43 | selectionEnd = initialPath.length 44 | else 45 | selectionEnd = initialPath.length - extension.length 46 | @miniEditor.setSelectedBufferRange(Range(Point(0, selectionStart), Point(0, selectionEnd))) 47 | 48 | attach: -> 49 | @panel = atom.workspace.addModalPanel(item: this) 50 | @miniEditor.element.focus() 51 | @miniEditor.scrollToCursorPosition() 52 | 53 | close: -> 54 | panel = @panel 55 | @panel = null 56 | panel?.destroy() 57 | @emitter.dispose() 58 | @disposables.dispose() 59 | @miniEditor.destroy() 60 | activePane = atom.workspace.getCenter().getActivePane() 61 | activePane.activate() unless activePane.isDestroyed() 62 | 63 | cancel: -> 64 | @close() 65 | document.querySelector('.tree-view')?.focus() 66 | 67 | showError: (message='') -> 68 | @errorMessage.textContent = message 69 | if message 70 | @element.classList.add('error') 71 | window.setTimeout((=> @element.classList.remove('error')), 300) 72 | -------------------------------------------------------------------------------- /lib/directory-view.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable} = require('atom') 2 | const getIconServices = require('./get-icon-services') 3 | const Directory = require('./directory') 4 | const FileView = require('./file-view') 5 | 6 | module.exports = 7 | class DirectoryView { 8 | constructor (directory) { 9 | this.directory = directory 10 | this.subscriptions = new CompositeDisposable() 11 | this.subscriptions.add(this.directory.onDidDestroy(() => this.subscriptions.dispose())) 12 | this.subscribeToDirectory() 13 | 14 | this.element = document.createElement('li') 15 | this.element.setAttribute('is', 'tree-view-directory') 16 | this.element.classList.add('directory', 'entry', 'list-nested-item', 'collapsed') 17 | 18 | this.header = document.createElement('div') 19 | this.header.classList.add('header', 'list-item') 20 | 21 | this.directoryName = document.createElement('span') 22 | this.directoryName.classList.add('name', 'icon') 23 | 24 | this.entries = document.createElement('ol') 25 | this.entries.classList.add('entries', 'list-tree') 26 | 27 | this.updateIcon() 28 | this.subscriptions.add(getIconServices().onDidChange(() => this.updateIcon())) 29 | this.directoryName.dataset.path = this.directory.path 30 | 31 | if (this.directory.squashedNames != null) { 32 | this.directoryName.dataset.name = this.directory.squashedNames.join('') 33 | this.directoryName.title = this.directory.squashedNames.join('') 34 | 35 | const squashedDirectoryNameNode = document.createElement('span') 36 | squashedDirectoryNameNode.classList.add('squashed-dir') 37 | squashedDirectoryNameNode.textContent = this.directory.squashedNames[0] 38 | this.directoryName.appendChild(squashedDirectoryNameNode) 39 | this.directoryName.appendChild(document.createTextNode(this.directory.squashedNames[1])) 40 | } else { 41 | this.directoryName.dataset.name = this.directory.name 42 | this.directoryName.title = this.directory.name 43 | this.directoryName.textContent = this.directory.name 44 | } 45 | 46 | this.element.appendChild(this.header) 47 | this.header.appendChild(this.directoryName) 48 | this.element.appendChild(this.entries) 49 | 50 | if (this.directory.isRoot) { 51 | this.element.classList.add('project-root') 52 | this.header.classList.add('project-root-header') 53 | } else { 54 | this.element.draggable = true 55 | } 56 | 57 | this.subscriptions.add(this.directory.onDidStatusChange(() => this.updateStatus())) 58 | this.updateStatus() 59 | 60 | if (this.directory.expansionState.isExpanded) { 61 | this.expand() 62 | } 63 | 64 | this.element.collapse = this.collapse.bind(this) 65 | this.element.expand = this.expand.bind(this) 66 | this.element.toggleExpansion = this.toggleExpansion.bind(this) 67 | this.element.reload = this.reload.bind(this) 68 | this.element.isExpanded = this.isExpanded 69 | this.element.updateStatus = this.updateStatus.bind(this) 70 | this.element.isPathEqual = this.isPathEqual.bind(this) 71 | this.element.getPath = this.getPath.bind(this) 72 | this.element.directory = this.directory 73 | this.element.header = this.header 74 | this.element.entries = this.entries 75 | this.element.directoryName = this.directoryName 76 | } 77 | 78 | updateIcon () { 79 | getIconServices().updateDirectoryIcon(this) 80 | } 81 | 82 | updateStatus () { 83 | this.element.classList.remove('status-ignored', 'status-ignored-name', 'status-modified', 'status-added') 84 | if (this.directory.status != null) { 85 | this.element.classList.add(`status-${this.directory.status}`) 86 | } 87 | } 88 | 89 | subscribeToDirectory () { 90 | this.subscriptions.add(this.directory.onDidAddEntries(addedEntries => { 91 | if (!this.isExpanded) return 92 | 93 | const numberOfEntries = this.entries.children.length 94 | 95 | for (let entry of addedEntries) { 96 | const view = this.createViewForEntry(entry) 97 | 98 | const insertionIndex = entry.indexInParentDirectory 99 | if (insertionIndex < numberOfEntries) { 100 | this.entries.insertBefore(view.element, this.entries.children[insertionIndex]) 101 | } else { 102 | this.entries.appendChild(view.element) 103 | } 104 | } 105 | })) 106 | } 107 | 108 | getPath () { 109 | return this.directory.path 110 | } 111 | 112 | isPathEqual (pathToCompare) { 113 | return this.directory.isPathEqual(pathToCompare) 114 | } 115 | 116 | createViewForEntry (entry) { 117 | const view = entry instanceof Directory ? new DirectoryView(entry) : new FileView(entry) 118 | 119 | const subscription = this.directory.onDidRemoveEntries(removedEntries => { 120 | if (removedEntries.has(entry)) { 121 | view.element.remove() 122 | subscription.dispose() 123 | } 124 | }) 125 | 126 | this.subscriptions.add(subscription) 127 | 128 | return view 129 | } 130 | 131 | reload () { 132 | if (this.isExpanded) { 133 | this.directory.reload() 134 | } 135 | } 136 | 137 | toggleExpansion (isRecursive) { 138 | if (isRecursive == null) { 139 | isRecursive = false 140 | } 141 | if (this.isExpanded) { 142 | this.collapse(isRecursive) 143 | } else { 144 | this.expand(isRecursive) 145 | } 146 | } 147 | 148 | expand (isRecursive) { 149 | if (isRecursive == null) { 150 | isRecursive = false 151 | } 152 | 153 | if (!this.isExpanded) { 154 | this.isExpanded = true 155 | this.element.isExpanded = this.isExpanded 156 | this.element.classList.add('expanded') 157 | this.element.classList.remove('collapsed') 158 | this.directory.expand() 159 | } 160 | 161 | if (isRecursive) { 162 | for (let entry of this.entries.children) { 163 | if (entry.classList.contains('directory')) { 164 | entry.expand(true) 165 | } 166 | } 167 | } 168 | } 169 | 170 | collapse (isRecursive) { 171 | if (isRecursive == null) isRecursive = false 172 | this.isExpanded = false 173 | this.element.isExpanded = false 174 | 175 | if (isRecursive) { 176 | for (let entry of this.entries.children) { 177 | if (entry.isExpanded) { 178 | entry.collapse(true) 179 | } 180 | } 181 | } 182 | 183 | this.element.classList.remove('expanded') 184 | this.element.classList.add('collapsed') 185 | this.directory.collapse() 186 | this.entries.innerHTML = '' 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /lib/directory.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const _ = require('underscore-plus') 3 | const {CompositeDisposable, Emitter} = require('atom') 4 | const fs = require('fs-plus') 5 | const PathWatcher = require('pathwatcher') 6 | const File = require('./file') 7 | const {repoForPath} = require('./helpers') 8 | 9 | module.exports = 10 | class Directory { 11 | constructor ({name, fullPath, symlink, expansionState, isRoot, ignoredNames, useSyncFS, stats}) { 12 | this.name = name 13 | this.symlink = symlink 14 | this.expansionState = expansionState 15 | this.isRoot = isRoot 16 | this.ignoredNames = ignoredNames 17 | this.useSyncFS = useSyncFS 18 | this.stats = stats 19 | this.destroyed = false 20 | this.emitter = new Emitter() 21 | this.subscriptions = new CompositeDisposable() 22 | 23 | if (atom.config.get('tree-view.squashDirectoryNames') && !this.isRoot) { 24 | fullPath = this.squashDirectoryNames(fullPath) 25 | } 26 | 27 | this.path = fullPath 28 | this.realPath = this.path 29 | if (fs.isCaseInsensitive()) { 30 | this.lowerCasePath = this.path.toLowerCase() 31 | this.lowerCaseRealPath = this.lowerCasePath 32 | } 33 | 34 | if (this.isRoot == null) { 35 | this.isRoot = false 36 | } 37 | 38 | if (this.expansionState == null) { 39 | this.expansionState = {} 40 | } 41 | 42 | if (this.expansionState.isExpanded == null) { 43 | this.expansionState.isExpanded = false 44 | } 45 | 46 | // TODO: This can be removed after a sufficient amount 47 | // of time has passed since @expansionState.entries 48 | // has been converted to a Map 49 | if (!(this.expansionState.entries instanceof Map)) { 50 | const convertEntriesToMap = entries => { 51 | const temp = new Map() 52 | for (let name in entries) { 53 | const entry = entries[name] 54 | if (entry.entries != null) { 55 | entry.entries = convertEntriesToMap(entry.entries) 56 | } 57 | temp.set(name, entry) 58 | } 59 | return temp 60 | } 61 | 62 | this.expansionState.entries = convertEntriesToMap(this.expansionState.entries) 63 | } 64 | 65 | if (this.expansionState.entries == null) { 66 | this.expansionState.entries = new Map() 67 | } 68 | 69 | this.status = null 70 | this.entries = new Map() 71 | 72 | const repo = repoForPath(this.path) 73 | this.submodule = repo && repo.isSubmodule(this.path) 74 | 75 | this.subscribeToRepo() 76 | this.updateStatus() 77 | this.loadRealPath() 78 | } 79 | 80 | destroy () { 81 | this.destroyed = true 82 | this.unwatch() 83 | this.subscriptions.dispose() 84 | this.emitter.emit('did-destroy') 85 | } 86 | 87 | onDidDestroy (callback) { 88 | return this.emitter.on('did-destroy', callback) 89 | } 90 | 91 | onDidStatusChange (callback) { 92 | return this.emitter.on('did-status-change', callback) 93 | } 94 | 95 | onDidAddEntries (callback) { 96 | return this.emitter.on('did-add-entries', callback) 97 | } 98 | 99 | onDidRemoveEntries (callback) { 100 | return this.emitter.on('did-remove-entries', callback) 101 | } 102 | 103 | onDidCollapse (callback) { 104 | return this.emitter.on('did-collapse', callback) 105 | } 106 | 107 | onDidExpand (callback) { 108 | return this.emitter.on('did-expand', callback) 109 | } 110 | 111 | loadRealPath () { 112 | if (this.useSyncFS) { 113 | this.realPath = fs.realpathSync(this.path) 114 | if (fs.isCaseInsensitive()) { 115 | this.lowerCaseRealPath = this.realPath.toLowerCase() 116 | } 117 | } else { 118 | fs.realpath(this.path, (error, realPath) => { 119 | // FIXME: Add actual error handling 120 | if (error || this.destroyed) return 121 | if (realPath && (realPath !== this.path)) { 122 | this.realPath = realPath 123 | if (fs.isCaseInsensitive()) { 124 | this.lowerCaseRealPath = this.realPath.toLowerCase() 125 | } 126 | this.updateStatus() 127 | } 128 | }) 129 | } 130 | } 131 | 132 | // Subscribe to project's repo for changes to the Git status of this directory. 133 | subscribeToRepo () { 134 | const repo = repoForPath(this.path) 135 | if (repo == null) return 136 | 137 | this.subscriptions.add(repo.onDidChangeStatus(event => { 138 | if (this.contains(event.path)) { 139 | this.updateStatus(repo) 140 | } 141 | })) 142 | this.subscriptions.add(repo.onDidChangeStatuses(() => { 143 | this.updateStatus(repo) 144 | })) 145 | } 146 | 147 | // Update the status property of this directory using the repo. 148 | updateStatus () { 149 | const repo = repoForPath(this.path) 150 | if (repo == null) return 151 | 152 | let newStatus = null 153 | if (repo.isPathIgnored(this.path)) { 154 | newStatus = 'ignored' 155 | } else if (this.ignoredNames.matches(this.path)) { 156 | newStatus = 'ignored-name' 157 | } else { 158 | let status 159 | if (this.isRoot) { 160 | // repo.getDirectoryStatus will always fail for the 161 | // root because the path is relativized + concatenated with '/' 162 | // making the matching string be '/'. Then path.indexOf('/') 163 | // is run and will never match beginning of string with a leading '/' 164 | for (let statusPath in repo.statuses) { 165 | status |= parseInt(repo.statuses[statusPath], 10) 166 | } 167 | } else { 168 | status = repo.getDirectoryStatus(this.path) 169 | } 170 | 171 | if (repo.isStatusModified(status)) { 172 | newStatus = 'modified' 173 | } else if (repo.isStatusNew(status)) { 174 | newStatus = 'added' 175 | } 176 | } 177 | 178 | if (newStatus !== this.status) { 179 | this.status = newStatus 180 | this.emitter.emit('did-status-change', newStatus) 181 | } 182 | } 183 | 184 | // Is the given path ignored? 185 | isPathIgnored (filePath) { 186 | if (atom.config.get('tree-view.hideVcsIgnoredFiles')) { 187 | const repo = repoForPath(this.path) 188 | if (repo && repo.isProjectAtRoot() && repo.isPathIgnored(filePath)) return true 189 | } 190 | 191 | if (atom.config.get('tree-view.hideIgnoredNames')) { 192 | if (this.ignoredNames.matches(filePath)) return true 193 | } 194 | 195 | return false 196 | } 197 | 198 | // Does given full path start with the given prefix? 199 | isPathPrefixOf (prefix, fullPath) { 200 | return fullPath.indexOf(prefix) === 0 && fullPath[prefix.length] === path.sep 201 | } 202 | 203 | isPathEqual (pathToCompare) { 204 | return this.path === pathToCompare || this.realPath === pathToCompare 205 | } 206 | 207 | // Public: Does this directory contain the given path? 208 | // 209 | // See atom.Directory::contains for more details. 210 | contains (pathToCheck) { 211 | if (!pathToCheck) return false 212 | 213 | // Normalize forward slashes to back slashes on Windows 214 | if (process.platform === 'win32') { 215 | pathToCheck = pathToCheck.replace(/\//g, '\\') 216 | } 217 | 218 | let directoryPath 219 | if (fs.isCaseInsensitive()) { 220 | directoryPath = this.lowerCasePath 221 | pathToCheck = pathToCheck.toLowerCase() 222 | } else { 223 | directoryPath = this.path 224 | } 225 | 226 | if (this.isPathPrefixOf(directoryPath, pathToCheck)) return true 227 | 228 | // Check real path 229 | if (this.realPath !== this.path) { 230 | if (fs.isCaseInsensitive()) { 231 | directoryPath = this.lowerCaseRealPath 232 | } else { 233 | directoryPath = this.realPath 234 | } 235 | 236 | return this.isPathPrefixOf(directoryPath, pathToCheck) 237 | } 238 | 239 | return false 240 | } 241 | 242 | // Public: Stop watching this directory for changes. 243 | unwatch () { 244 | if (this.watchSubscription != null) { 245 | this.watchSubscription.close() 246 | this.watchSubscription = null 247 | } 248 | 249 | for (let [key, entry] of this.entries) { 250 | entry.destroy() 251 | this.entries.delete(key) 252 | } 253 | } 254 | 255 | // Public: Watch this directory for changes. 256 | watch () { 257 | if (this.watchSubscription != null) return 258 | try { 259 | this.watchSubscription = PathWatcher.watch(this.path, eventType => { 260 | switch (eventType) { 261 | case 'change': 262 | this.reload() 263 | break 264 | case 'delete': 265 | this.destroy() 266 | break 267 | } 268 | }) 269 | } catch (error) {} 270 | } 271 | 272 | getEntries () { 273 | let names 274 | try { 275 | names = fs.readdirSync(this.path) 276 | } catch (error) { 277 | names = [] 278 | } 279 | names.sort(new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}).compare) 280 | 281 | const files = [] 282 | const directories = [] 283 | 284 | for (let name of names) { 285 | const fullPath = path.join(this.path, name) 286 | if (this.isPathIgnored(fullPath)) continue 287 | 288 | let stat = fs.lstatSyncNoException(fullPath) 289 | const symlink = typeof stat.isSymbolicLink === 'function' && stat.isSymbolicLink() 290 | if (symlink) { 291 | stat = fs.statSyncNoException(fullPath) 292 | } 293 | 294 | const statFlat = _.pick(stat, _.keys(stat)) 295 | for (let key of ['atime', 'birthtime', 'ctime', 'mtime']) { 296 | statFlat[key] = statFlat[key] && statFlat[key].getTime() 297 | } 298 | 299 | if (typeof stat.isDirectory === 'function' && stat.isDirectory()) { 300 | if (this.entries.has(name)) { 301 | // push a placeholder since this entry already exists but this helps 302 | // track the insertion index for the created views 303 | directories.push(name) 304 | } else { 305 | const expansionState = this.expansionState.entries.get(name) 306 | directories.push(new Directory({ 307 | name, 308 | fullPath, 309 | symlink, 310 | expansionState, 311 | ignoredNames: this.ignoredNames, 312 | useSyncFS: this.useSyncFS, 313 | stats: statFlat 314 | })) 315 | } 316 | } else if (typeof stat.isFile === 'function' && stat.isFile()) { 317 | if (this.entries.has(name)) { 318 | // push a placeholder since this entry already exists but this helps 319 | // track the insertion index for the created views 320 | files.push(name) 321 | } else { 322 | files.push(new File({name, fullPath, symlink, ignoredNames: this.ignoredNames, useSyncFS: this.useSyncFS, stats: statFlat})) 323 | } 324 | } 325 | } 326 | 327 | return this.sortEntries(directories.concat(files)) 328 | } 329 | 330 | normalizeEntryName (value) { 331 | let normalizedValue = value.name 332 | if (normalizedValue == null) { 333 | normalizedValue = value 334 | } 335 | 336 | if (normalizedValue != null) { 337 | normalizedValue = normalizedValue.toLowerCase() 338 | } 339 | return normalizedValue 340 | } 341 | 342 | sortEntries (combinedEntries) { 343 | if (atom.config.get('tree-view.sortFoldersBeforeFiles')) { 344 | return combinedEntries 345 | } else { 346 | return combinedEntries.sort((first, second) => { 347 | const firstName = this.normalizeEntryName(first) 348 | const secondName = this.normalizeEntryName(second) 349 | return firstName.localeCompare(secondName) 350 | }) 351 | } 352 | } 353 | 354 | // Public: Perform a synchronous reload of the directory. 355 | reload () { 356 | const newEntries = [] 357 | const removedEntries = new Map(this.entries) 358 | 359 | let index = 0 360 | for (let entry of this.getEntries()) { 361 | if (this.entries.has(entry)) { 362 | removedEntries.delete(entry) 363 | index++ 364 | continue 365 | } 366 | 367 | entry.indexInParentDirectory = index 368 | index++ 369 | newEntries.push(entry) 370 | } 371 | 372 | let entriesRemoved = false 373 | for (let [name, entry] of removedEntries) { 374 | entriesRemoved = true 375 | entry.destroy() 376 | 377 | if (this.entries.has(name)) { 378 | this.entries.delete(name) 379 | } 380 | 381 | if (this.expansionState.entries.has(name)) { 382 | this.expansionState.entries.delete(name) 383 | } 384 | } 385 | 386 | // Convert removedEntries to a Set containing only the entries for O(1) lookup 387 | if (entriesRemoved) { 388 | this.emitter.emit('did-remove-entries', new Set(removedEntries.values())) 389 | } 390 | 391 | if (newEntries.length > 0) { 392 | for (let entry of newEntries) { 393 | this.entries.set(entry.name, entry) 394 | } 395 | this.emitter.emit('did-add-entries', newEntries) 396 | } 397 | } 398 | 399 | // Public: Collapse this directory and stop watching it. 400 | collapse () { 401 | this.expansionState.isExpanded = false 402 | this.expansionState = this.serializeExpansionState() 403 | this.unwatch() 404 | this.emitter.emit('did-collapse') 405 | } 406 | 407 | // Public: Expand this directory, load its children, and start watching it for 408 | // changes. 409 | expand () { 410 | this.expansionState.isExpanded = true 411 | this.reload() 412 | this.watch() 413 | this.emitter.emit('did-expand') 414 | } 415 | 416 | serializeExpansionState () { 417 | const expansionState = {} 418 | expansionState.isExpanded = this.expansionState.isExpanded 419 | expansionState.entries = new Map() 420 | for (let [name, entry] of this.entries) { 421 | if (entry.expansionState == null) break 422 | expansionState.entries.set(name, entry.serializeExpansionState()) 423 | } 424 | return expansionState 425 | } 426 | 427 | squashDirectoryNames (fullPath) { 428 | const squashedDirs = [this.name] 429 | let contents 430 | while (true) { 431 | try { 432 | contents = fs.listSync(fullPath) 433 | } catch (error) { 434 | break 435 | } 436 | 437 | if (contents.length !== 1) break 438 | if (!fs.isDirectorySync(contents[0])) break 439 | const relativeDir = path.relative(fullPath, contents[0]) 440 | squashedDirs.push(relativeDir) 441 | fullPath = path.join(fullPath, relativeDir) 442 | } 443 | 444 | if (squashedDirs.length > 1) { 445 | this.squashedNames = [squashedDirs.slice(0, squashedDirs.length - 1).join(path.sep) + path.sep, _.last(squashedDirs)] 446 | } 447 | 448 | return fullPath 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /lib/file-view.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable} = require('atom') 2 | const getIconServices = require('./get-icon-services') 3 | 4 | module.exports = 5 | class FileView { 6 | constructor (file) { 7 | this.file = file 8 | this.subscriptions = new CompositeDisposable() 9 | this.subscriptions.add(this.file.onDidDestroy(() => this.subscriptions.dispose())) 10 | 11 | this.element = document.createElement('li') 12 | this.element.setAttribute('is', 'tree-view-file') 13 | this.element.draggable = true 14 | this.element.classList.add('file', 'entry', 'list-item') 15 | 16 | this.fileName = document.createElement('span') 17 | this.fileName.classList.add('name', 'icon') 18 | this.element.appendChild(this.fileName) 19 | this.fileName.textContent = this.file.name 20 | this.fileName.title = this.file.name 21 | this.fileName.dataset.name = this.file.name 22 | this.fileName.dataset.path = this.file.path 23 | 24 | this.updateIcon() 25 | this.subscriptions.add(this.file.onDidStatusChange(() => this.updateStatus())) 26 | this.subscriptions.add(getIconServices().onDidChange(() => this.updateIcon())) 27 | this.updateStatus() 28 | } 29 | 30 | updateIcon () { 31 | getIconServices().updateFileIcon(this) 32 | this.element.getPath = this.getPath.bind(this) 33 | this.element.isPathEqual = this.isPathEqual.bind(this) 34 | this.element.file = this.file 35 | this.element.fileName = this.fileName 36 | this.element.updateStatus = this.updateStatus.bind(this) 37 | } 38 | 39 | updateStatus () { 40 | this.element.classList.remove('status-ignored', 'status-ignored-name', 'status-modified', 'status-added') 41 | if (this.file.status != null) { 42 | this.element.classList.add(`status-${this.file.status}`) 43 | } 44 | } 45 | 46 | getPath () { 47 | return this.fileName.dataset.path 48 | } 49 | 50 | isPathEqual (pathToCompare) { 51 | return this.file.isPathEqual(pathToCompare) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/file.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-plus') 2 | const {CompositeDisposable, Emitter} = require('atom') 3 | const {repoForPath} = require('./helpers') 4 | 5 | module.exports = 6 | class File { 7 | constructor ({name, fullPath, symlink, ignoredNames, useSyncFS, stats}) { 8 | this.name = name 9 | this.symlink = symlink 10 | this.ignoredNames = ignoredNames 11 | this.stats = stats 12 | this.destroyed = false 13 | this.emitter = new Emitter() 14 | this.subscriptions = new CompositeDisposable() 15 | 16 | this.path = fullPath 17 | this.realPath = this.path 18 | 19 | this.subscribeToRepo() 20 | this.updateStatus() 21 | 22 | if (useSyncFS) { 23 | this.realPath = fs.realpathSync(this.path) 24 | } else { 25 | fs.realpath(this.path, (error, realPath) => { 26 | // FIXME: Add actual error handling 27 | if (error || this.destroyed) return 28 | if (realPath && realPath !== this.path) { 29 | this.realPath = realPath 30 | this.updateStatus() 31 | } 32 | }) 33 | } 34 | } 35 | 36 | destroy () { 37 | this.destroyed = true 38 | this.subscriptions.dispose() 39 | this.emitter.emit('did-destroy') 40 | } 41 | 42 | onDidDestroy (callback) { 43 | return this.emitter.on('did-destroy', callback) 44 | } 45 | 46 | onDidStatusChange (callback) { 47 | return this.emitter.on('did-status-change', callback) 48 | } 49 | 50 | // Subscribe to the project's repo for changes to the Git status of this file. 51 | subscribeToRepo () { 52 | const repo = repoForPath(this.path) 53 | if (repo == null) return 54 | 55 | this.subscriptions.add(repo.onDidChangeStatus(event => { 56 | if (this.isPathEqual(event.path)) { 57 | this.updateStatus(repo) 58 | } 59 | })) 60 | this.subscriptions.add(repo.onDidChangeStatuses(() => { 61 | this.updateStatus(repo) 62 | })) 63 | } 64 | 65 | // Update the status property of this directory using the repo. 66 | updateStatus () { 67 | const repo = repoForPath(this.path) 68 | if (repo == null) return 69 | 70 | let newStatus = null 71 | if (repo.isPathIgnored(this.path)) { 72 | newStatus = 'ignored' 73 | } else if (this.ignoredNames.matches(this.path)) { 74 | newStatus = 'ignored-name' 75 | } else { 76 | const status = repo.getCachedPathStatus(this.path) 77 | if (repo.isStatusModified(status)) { 78 | newStatus = 'modified' 79 | } else if (repo.isStatusNew(status)) { 80 | newStatus = 'added' 81 | } 82 | } 83 | 84 | if (newStatus !== this.status) { 85 | this.status = newStatus 86 | this.emitter.emit('did-status-change', newStatus) 87 | } 88 | } 89 | 90 | isPathEqual (pathToCompare) { 91 | return this.path === pathToCompare || this.realPath === pathToCompare 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /lib/get-icon-services.js: -------------------------------------------------------------------------------- 1 | const DefaultFileIcons = require('./default-file-icons') 2 | const {Emitter, CompositeDisposable} = require('atom') 3 | const {repoForPath} = require('./helpers') 4 | 5 | let iconServices 6 | module.exports = function getIconServices () { 7 | if (!iconServices) iconServices = new IconServices() 8 | return iconServices 9 | } 10 | 11 | class IconServices { 12 | constructor () { 13 | this.emitter = new Emitter() 14 | this.elementIcons = null 15 | this.elementIconDisposables = new CompositeDisposable() 16 | this.fileIcons = DefaultFileIcons 17 | } 18 | 19 | onDidChange (callback) { 20 | return this.emitter.on('did-change', callback) 21 | } 22 | 23 | resetElementIcons () { 24 | this.setElementIcons(null) 25 | } 26 | 27 | resetFileIcons () { 28 | this.setFileIcons(DefaultFileIcons) 29 | } 30 | 31 | setElementIcons (service) { 32 | if (service !== this.elementIcons) { 33 | if (this.elementIconDisposables != null) { 34 | this.elementIconDisposables.dispose() 35 | } 36 | if (service) { 37 | this.elementIconDisposables = new CompositeDisposable() 38 | } 39 | this.elementIcons = service 40 | this.emitter.emit('did-change') 41 | } 42 | } 43 | 44 | setFileIcons (service) { 45 | if (service !== this.fileIcons) { 46 | this.fileIcons = service 47 | this.emitter.emit('did-change') 48 | } 49 | } 50 | 51 | updateDirectoryIcon (view) { 52 | view.directoryName.className = '' 53 | 54 | const classes = ['name', 'icon'] 55 | if (this.elementIcons) { 56 | const disposable = this.elementIcons(view.directoryName, view.directory.path) 57 | this.elementIconDisposables.add(disposable) 58 | } else { 59 | let iconClass 60 | if (view.directory.symlink) { 61 | iconClass = 'icon-file-symlink-directory' 62 | } else { 63 | iconClass = 'icon-file-directory' 64 | if (view.directory.isRoot) { 65 | const repo = repoForPath(view.directory.path) 66 | if (repo && repo.isProjectAtRoot()) iconClass = 'icon-repo' 67 | } else { 68 | if (view.directory.submodule) iconClass = 'icon-file-submodule' 69 | } 70 | } 71 | classes.push(iconClass) 72 | } 73 | view.directoryName.classList.add(...classes) 74 | } 75 | 76 | updateFileIcon (view) { 77 | view.fileName.className = '' 78 | 79 | const classes = ['name', 'icon'] 80 | let iconClass 81 | if (this.elementIcons) { 82 | const disposable = this.elementIcons(view.fileName, view.file.path) 83 | this.elementIconDisposables.add(disposable) 84 | } else { 85 | iconClass = this.fileIcons.iconClassForPath(view.file.path, 'tree-view') 86 | } 87 | if (iconClass) { 88 | if (!Array.isArray(iconClass)) { 89 | iconClass = iconClass.toString().split(/\s+/g) 90 | } 91 | classes.push(...iconClass) 92 | } 93 | view.fileName.classList.add(...classes) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/helpers.coffee: -------------------------------------------------------------------------------- 1 | path = require "path" 2 | 3 | module.exports = 4 | repoForPath: (goalPath) -> 5 | for projectPath, i in atom.project.getPaths() 6 | if goalPath is projectPath or goalPath.indexOf(projectPath + path.sep) is 0 7 | return atom.project.getRepositories()[i] 8 | null 9 | 10 | getStyleObject: (el) -> 11 | styleProperties = window.getComputedStyle(el) 12 | styleObject = {} 13 | for property of styleProperties 14 | value = styleProperties.getPropertyValue property 15 | camelizedAttr = property.replace /\-([a-z])/g, (a, b) -> b.toUpperCase() 16 | styleObject[camelizedAttr] = value 17 | styleObject 18 | 19 | getFullExtension: (filePath) -> 20 | basename = path.basename(filePath) 21 | position = basename.indexOf('.') 22 | if position > 0 then basename[position..] else '' 23 | -------------------------------------------------------------------------------- /lib/ignored-names.coffee: -------------------------------------------------------------------------------- 1 | Minimatch = null # Defer requiring until actually needed 2 | 3 | module.exports = 4 | class IgnoredNames 5 | constructor: -> 6 | @ignoredPatterns = [] 7 | 8 | Minimatch ?= require('minimatch').Minimatch 9 | 10 | ignoredNames = atom.config.get('core.ignoredNames') ? [] 11 | ignoredNames = [ignoredNames] if typeof ignoredNames is 'string' 12 | for ignoredName in ignoredNames when ignoredName 13 | try 14 | @ignoredPatterns.push(new Minimatch(ignoredName, matchBase: true, dot: true)) 15 | catch error 16 | atom.notifications.addWarning("Error parsing ignore pattern (#{ignoredName})", detail: error.message) 17 | 18 | matches: (filePath) -> 19 | for ignoredPattern in @ignoredPatterns 20 | return true if ignoredPattern.match(filePath) 21 | 22 | return false 23 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | const TreeViewPackage = require('./tree-view-package') 2 | 3 | module.exports = new TreeViewPackage() 4 | -------------------------------------------------------------------------------- /lib/move-dialog.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | fs = require 'fs-plus' 3 | Dialog = require './dialog' 4 | {repoForPath} = require "./helpers" 5 | 6 | module.exports = 7 | class MoveDialog extends Dialog 8 | constructor: (@initialPath, {@willMove, @onMove, @onMoveFailed}) -> 9 | if fs.isDirectorySync(@initialPath) 10 | prompt = 'Enter the new path for the directory.' 11 | else 12 | prompt = 'Enter the new path for the file.' 13 | 14 | super 15 | prompt: prompt 16 | initialPath: atom.project.relativize(@initialPath) 17 | select: true 18 | iconClass: 'icon-arrow-right' 19 | 20 | onConfirm: (newPath) -> 21 | newPath = newPath.replace(/\s+$/, '') # Remove trailing whitespace 22 | unless path.isAbsolute(newPath) 23 | [rootPath] = atom.project.relativizePath(@initialPath) 24 | newPath = path.join(rootPath, newPath) 25 | return unless newPath 26 | 27 | if @initialPath is newPath 28 | @close() 29 | return 30 | 31 | unless @isNewPathValid(newPath) 32 | @showError("'#{newPath}' already exists.") 33 | return 34 | 35 | directoryPath = path.dirname(newPath) 36 | try 37 | @willMove?(initialPath: @initialPath, newPath: newPath) 38 | fs.makeTreeSync(directoryPath) unless fs.existsSync(directoryPath) 39 | fs.moveSync(@initialPath, newPath) 40 | @onMove?(initialPath: @initialPath, newPath: newPath) 41 | if repo = repoForPath(newPath) 42 | repo.getPathStatus(@initialPath) 43 | repo.getPathStatus(newPath) 44 | @close() 45 | catch error 46 | @showError("#{error.message}.") 47 | @onMoveFailed?(initialPath: @initialPath, newPath: newPath) 48 | 49 | isNewPathValid: (newPath) -> 50 | try 51 | oldStat = fs.statSync(@initialPath) 52 | newStat = fs.statSync(newPath) 53 | 54 | # New path exists so check if it points to the same file as the initial 55 | # path to see if the case of the file name is being changed on a on a 56 | # case insensitive filesystem. 57 | @initialPath.toLowerCase() is newPath.toLowerCase() and 58 | oldStat.dev is newStat.dev and 59 | oldStat.ino is newStat.ino 60 | catch 61 | true # new path does not exist so it is valid 62 | -------------------------------------------------------------------------------- /lib/root-drag-and-drop.coffee: -------------------------------------------------------------------------------- 1 | url = require 'url' 2 | 3 | {ipcRenderer, remote} = require 'electron' 4 | 5 | # TODO: Support dragging external folders and using the drag-and-drop indicators for them 6 | # Currently they're handled in TreeView's drag listeners 7 | 8 | module.exports = 9 | class RootDragAndDropHandler 10 | constructor: (@treeView) -> 11 | ipcRenderer.on('tree-view:project-folder-dropped', @onDropOnOtherWindow) 12 | @handleEvents() 13 | 14 | dispose: -> 15 | ipcRenderer.removeListener('tree-view:project-folder-dropped', @onDropOnOtherWindow) 16 | 17 | handleEvents: -> 18 | # onDragStart is called directly by TreeView's onDragStart 19 | # will be cleaned up by tree view, since they are tree-view's handlers 20 | @treeView.element.addEventListener 'dragenter', @onDragEnter.bind(this) 21 | @treeView.element.addEventListener 'dragend', @onDragEnd.bind(this) 22 | @treeView.element.addEventListener 'dragleave', @onDragLeave.bind(this) 23 | @treeView.element.addEventListener 'dragover', @onDragOver.bind(this) 24 | @treeView.element.addEventListener 'drop', @onDrop.bind(this) 25 | 26 | onDragStart: (e) => 27 | return unless @treeView.list.contains(e.target) 28 | 29 | @prevDropTargetIndex = null 30 | e.dataTransfer.setData 'atom-tree-view-root-event', 'true' 31 | projectRoot = e.target.closest('.project-root') 32 | directory = projectRoot.directory 33 | 34 | e.dataTransfer.setData 'project-root-index', Array.from(projectRoot.parentElement.children).indexOf(projectRoot) 35 | 36 | rootIndex = -1 37 | (rootIndex = index; break) for root, index in @treeView.roots when root.directory is directory 38 | 39 | e.dataTransfer.setData 'from-root-index', rootIndex 40 | e.dataTransfer.setData 'from-root-path', directory.path 41 | e.dataTransfer.setData 'from-window-id', @getWindowId() 42 | 43 | e.dataTransfer.setData 'text/plain', directory.path 44 | 45 | if process.platform in ['darwin', 'linux'] 46 | pathUri = "file://#{directory.path}" unless @uriHasProtocol(directory.path) 47 | e.dataTransfer.setData 'text/uri-list', pathUri 48 | 49 | uriHasProtocol: (uri) -> 50 | try 51 | url.parse(uri).protocol? 52 | catch error 53 | false 54 | 55 | onDragEnter: (e) -> 56 | return unless @treeView.list.contains(e.target) 57 | return unless @isAtomTreeViewEvent(e) 58 | 59 | e.stopPropagation() 60 | 61 | onDragLeave: (e) => 62 | return unless @treeView.list.contains(e.target) 63 | return unless @isAtomTreeViewEvent(e) 64 | 65 | e.stopPropagation() 66 | @removePlaceholder() if e.target is e.currentTarget 67 | 68 | onDragEnd: (e) => 69 | return unless e.target.matches('.project-root-header') 70 | return unless @isAtomTreeViewEvent(e) 71 | 72 | e.stopPropagation() 73 | @clearDropTarget() 74 | 75 | onDragOver: (e) => 76 | return unless @treeView.list.contains(e.target) 77 | return unless @isAtomTreeViewEvent(e) 78 | 79 | e.preventDefault() 80 | e.stopPropagation() 81 | 82 | entry = e.currentTarget 83 | 84 | if @treeView.roots.length is 0 85 | @treeView.list.appendChild(@getPlaceholder()) 86 | return 87 | 88 | newDropTargetIndex = @getDropTargetIndex(e) 89 | return unless newDropTargetIndex? 90 | return if @prevDropTargetIndex is newDropTargetIndex 91 | @prevDropTargetIndex = newDropTargetIndex 92 | 93 | projectRoots = @treeView.roots 94 | 95 | if newDropTargetIndex < projectRoots.length 96 | element = projectRoots[newDropTargetIndex] 97 | element.classList.add('is-drop-target') 98 | element.parentElement.insertBefore(@getPlaceholder(), element) 99 | else 100 | element = projectRoots[newDropTargetIndex - 1] 101 | element.classList.add('drop-target-is-after') 102 | element.parentElement.insertBefore(@getPlaceholder(), element.nextSibling) 103 | 104 | onDropOnOtherWindow: (e, fromItemIndex) => 105 | paths = atom.project.getPaths() 106 | paths.splice(fromItemIndex, 1) 107 | atom.project.setPaths(paths) 108 | 109 | @clearDropTarget() 110 | 111 | clearDropTarget: -> 112 | element = @treeView.element.querySelector(".is-dragging") 113 | element?.classList.remove('is-dragging') 114 | element?.updateTooltip() 115 | @removePlaceholder() 116 | 117 | onDrop: (e) => 118 | return unless @treeView.list.contains(e.target) 119 | return unless @isAtomTreeViewEvent(e) 120 | 121 | e.preventDefault() 122 | e.stopPropagation() 123 | 124 | {dataTransfer} = e 125 | 126 | fromWindowId = parseInt(dataTransfer.getData('from-window-id')) 127 | fromRootPath = dataTransfer.getData('from-root-path') 128 | fromIndex = parseInt(dataTransfer.getData('project-root-index')) 129 | fromRootIndex = parseInt(dataTransfer.getData('from-root-index')) 130 | 131 | toIndex = @getDropTargetIndex(e) 132 | 133 | @clearDropTarget() 134 | 135 | if fromWindowId is @getWindowId() 136 | unless fromIndex is toIndex 137 | projectPaths = atom.project.getPaths() 138 | projectPaths.splice(fromIndex, 1) 139 | if toIndex > fromIndex then toIndex -= 1 140 | projectPaths.splice(toIndex, 0, fromRootPath) 141 | atom.project.setPaths(projectPaths) 142 | else 143 | projectPaths = atom.project.getPaths() 144 | projectPaths.splice(toIndex, 0, fromRootPath) 145 | atom.project.setPaths(projectPaths) 146 | 147 | if not isNaN(fromWindowId) 148 | # Let the window where the drag started know that the tab was dropped 149 | browserWindow = remote.BrowserWindow.fromId(fromWindowId) 150 | browserWindow?.webContents.send('tree-view:project-folder-dropped', fromIndex) 151 | 152 | getDropTargetIndex: (e) -> 153 | return if @isPlaceholder(e.target) 154 | 155 | projectRoots = @treeView.roots 156 | projectRoot = e.target.closest('.project-root') 157 | projectRoot = projectRoots[projectRoots.length - 1] unless projectRoot 158 | 159 | return 0 unless projectRoot 160 | 161 | projectRootIndex = @treeView.roots.indexOf(projectRoot) 162 | 163 | center = projectRoot.getBoundingClientRect().top + projectRoot.offsetHeight / 2 164 | 165 | if e.pageY < center 166 | projectRootIndex 167 | else 168 | projectRootIndex + 1 169 | 170 | canDragStart: (e) -> 171 | e.target.closest('.project-root-header') 172 | 173 | isDragging: (e) -> 174 | for item in e.dataTransfer.items 175 | if item.type is 'from-root-path' 176 | return true 177 | 178 | return false 179 | 180 | isAtomTreeViewEvent: (e) -> 181 | for item in e.dataTransfer.items 182 | if item.type is 'atom-tree-view-root-event' 183 | return true 184 | 185 | return false 186 | 187 | getPlaceholder: -> 188 | unless @placeholderEl 189 | @placeholderEl = document.createElement('li') 190 | @placeholderEl.classList.add('placeholder') 191 | @placeholderEl 192 | 193 | removePlaceholder: -> 194 | @placeholderEl?.remove() 195 | @placeholderEl = null 196 | 197 | isPlaceholder: (element) -> 198 | element.classList.contains('.placeholder') 199 | 200 | getWindowId: -> 201 | @processId ?= atom.getCurrentWindow().id 202 | -------------------------------------------------------------------------------- /lib/tree-view-package.js: -------------------------------------------------------------------------------- 1 | const {Disposable, CompositeDisposable} = require('atom') 2 | 3 | const getIconServices = require('./get-icon-services') 4 | const TreeView = require('./tree-view') 5 | 6 | module.exports = 7 | class TreeViewPackage { 8 | activate () { 9 | this.disposables = new CompositeDisposable() 10 | this.disposables.add(atom.commands.add('atom-workspace', { 11 | 'tree-view:show': () => this.getTreeViewInstance().show(), 12 | 'tree-view:toggle': () => this.getTreeViewInstance().toggle(), 13 | 'tree-view:toggle-focus': () => this.getTreeViewInstance().toggleFocus(), 14 | 'tree-view:reveal-active-file': () => this.getTreeViewInstance().revealActiveFile({show: true}), 15 | 'tree-view:add-file': () => this.getTreeViewInstance().add(true), 16 | 'tree-view:add-folder': () => this.getTreeViewInstance().add(false), 17 | 'tree-view:duplicate': () => this.getTreeViewInstance().copySelectedEntry(), 18 | 'tree-view:remove': () => this.getTreeViewInstance().removeSelectedEntries(), 19 | 'tree-view:rename': () => this.getTreeViewInstance().moveSelectedEntry(), 20 | 'tree-view:show-current-file-in-file-manager': () => this.getTreeViewInstance().showCurrentFileInFileManager() 21 | })) 22 | 23 | const treeView = this.getTreeViewInstance() 24 | const showOnAttach = !atom.workspace.getActivePaneItem() 25 | this.treeViewOpenPromise = atom.workspace.open(treeView, { 26 | activatePane: showOnAttach, 27 | activateItem: showOnAttach 28 | }) 29 | 30 | this.treeViewOpenPromise.then(() => { 31 | if (atom.config.get("tree-view.hiddenOnStartup")) { 32 | this.treeView.hide(); 33 | } else { 34 | this.treeView.show(); 35 | } 36 | }) 37 | } 38 | 39 | async deactivate () { 40 | this.disposables.dispose() 41 | await this.treeViewOpenPromise // Wait for Tree View to finish opening before destroying it 42 | if (this.treeView) this.treeView.destroy() 43 | this.treeView = null 44 | } 45 | 46 | consumeElementIcons (service) { 47 | getIconServices().setElementIcons(service) 48 | return new Disposable(() => { 49 | getIconServices().resetElementIcons() 50 | }) 51 | } 52 | 53 | consumeFileIcons (service) { 54 | getIconServices().setFileIcons(service) 55 | return new Disposable(() => { 56 | getIconServices().resetFileIcons() 57 | }) 58 | } 59 | 60 | provideTreeView () { 61 | return { 62 | selectedPaths: () => this.getTreeViewInstance().selectedPaths(), 63 | entryForPath: (entryPath) => this.getTreeViewInstance().entryForPath(entryPath) 64 | } 65 | } 66 | 67 | getTreeViewInstance (state = {}) { 68 | if (this.treeView == null) { 69 | this.treeView = new TreeView(state) 70 | this.treeView.onDidDestroy(() => { this.treeView = null }) 71 | } 72 | return this.treeView 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/tree-view.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | {shell} = require 'electron' 3 | 4 | _ = require 'underscore-plus' 5 | {BufferedProcess, CompositeDisposable, Emitter} = require 'atom' 6 | {repoForPath, getStyleObject, getFullExtension} = require "./helpers" 7 | fs = require 'fs-plus' 8 | 9 | AddDialog = require './add-dialog' 10 | MoveDialog = require './move-dialog' 11 | CopyDialog = require './copy-dialog' 12 | IgnoredNames = null # Defer requiring until actually needed 13 | 14 | AddProjectsView = require './add-projects-view' 15 | 16 | Directory = require './directory' 17 | DirectoryView = require './directory-view' 18 | RootDragAndDrop = require './root-drag-and-drop' 19 | 20 | TREE_VIEW_URI = 'atom://tree-view' 21 | 22 | toggleConfig = (keyPath) -> 23 | atom.config.set(keyPath, not atom.config.get(keyPath)) 24 | 25 | nextId = 1 26 | 27 | module.exports = 28 | class TreeView 29 | constructor: (state) -> 30 | @id = nextId++ 31 | @element = document.createElement('div') 32 | @element.classList.add('tool-panel', 'tree-view') 33 | @element.tabIndex = -1 34 | 35 | @list = document.createElement('ol') 36 | @list.classList.add('tree-view-root', 'full-menu', 'list-tree', 'has-collapsable-children', 'focusable-panel') 37 | 38 | @disposables = new CompositeDisposable 39 | @emitter = new Emitter 40 | @roots = [] 41 | @selectedPath = null 42 | @selectOnMouseUp = null 43 | @lastFocusedEntry = null 44 | @ignoredPatterns = [] 45 | @useSyncFS = false 46 | @currentlyOpening = new Map 47 | @editorsToMove = [] 48 | @editorsToDestroy = [] 49 | 50 | @dragEventCounts = new WeakMap 51 | @rootDragAndDrop = new RootDragAndDrop(this) 52 | 53 | @handleEvents() 54 | 55 | process.nextTick => 56 | @onStylesheetsChanged() 57 | onStylesheetsChanged = _.debounce(@onStylesheetsChanged, 100) 58 | @disposables.add atom.styles.onDidAddStyleElement(onStylesheetsChanged) 59 | @disposables.add atom.styles.onDidRemoveStyleElement(onStylesheetsChanged) 60 | @disposables.add atom.styles.onDidUpdateStyleElement(onStylesheetsChanged) 61 | 62 | @updateRoots(state.directoryExpansionStates) 63 | 64 | if state.selectedPaths?.length > 0 65 | @selectMultipleEntries(@entryForPath(selectedPath)) for selectedPath in state.selectedPaths 66 | else 67 | @selectEntry(@roots[0]) 68 | 69 | if state.scrollTop? or state.scrollLeft? 70 | observer = new IntersectionObserver(=> 71 | if @isVisible() 72 | @element.scrollTop = state.scrollTop 73 | @element.scrollLeft = state.scrollLeft 74 | observer.disconnect() 75 | ) 76 | observer.observe(@element) 77 | 78 | @element.style.width = "#{state.width}px" if state.width > 0 79 | 80 | @disposables.add @onWillMoveEntry ({initialPath, newPath}) => 81 | editors = atom.workspace.getTextEditors() 82 | if fs.isDirectorySync(initialPath) 83 | initialPath += path.sep # Avoid moving lib2's editors when lib was moved 84 | for editor in editors 85 | filePath = editor.getPath() 86 | if filePath?.startsWith(initialPath) 87 | @editorsToMove.push(filePath) 88 | else 89 | for editor in editors 90 | filePath = editor.getPath() 91 | if filePath is initialPath 92 | @editorsToMove.push(filePath) 93 | 94 | @disposables.add @onEntryMoved ({initialPath, newPath}) => 95 | for editor in atom.workspace.getTextEditors() 96 | filePath = editor.getPath() 97 | index = @editorsToMove.indexOf(filePath) 98 | if index isnt -1 99 | editor.getBuffer().setPath(filePath.replace(initialPath, newPath)) 100 | @editorsToMove.splice(index, 1) 101 | 102 | @disposables.add @onMoveEntryFailed ({initialPath, newPath}) => 103 | index = @editorsToMove.indexOf(initialPath) 104 | @editorsToMove.splice(index, 1) if index isnt -1 105 | 106 | @disposables.add @onWillDeleteEntry ({pathToDelete}) => 107 | editors = atom.workspace.getTextEditors() 108 | if fs.isDirectorySync(pathToDelete) 109 | pathToDelete += path.sep # Avoid destroying lib2's editors when lib was deleted 110 | for editor in editors 111 | filePath = editor.getPath() 112 | if filePath?.startsWith(pathToDelete) and not editor.isModified() 113 | @editorsToDestroy.push(filePath) 114 | else 115 | for editor in editors 116 | filePath = editor.getPath() 117 | if filePath is pathToDelete and not editor.isModified() 118 | @editorsToDestroy.push(filePath) 119 | 120 | @disposables.add @onEntryDeleted ({pathToDelete}) => 121 | for editor in atom.workspace.getTextEditors() 122 | index = @editorsToDestroy.indexOf(editor.getPath()) 123 | if index isnt -1 124 | editor.destroy() 125 | @editorsToDestroy.splice(index, 1) 126 | 127 | @disposables.add @onDeleteEntryFailed ({pathToDelete}) => 128 | index = @editorsToDestroy.indexOf(pathToDelete) 129 | @editorsToDestroy.splice(index, 1) if index isnt -1 130 | 131 | serialize: -> 132 | directoryExpansionStates: new ((roots) -> 133 | @[root.directory.path] = root.directory.serializeExpansionState() for root in roots 134 | this)(@roots) 135 | deserializer: 'TreeView' 136 | selectedPaths: Array.from(@getSelectedEntries(), (entry) -> entry.getPath()) 137 | scrollLeft: @element.scrollLeft 138 | scrollTop: @element.scrollTop 139 | width: parseInt(@element.style.width or 0) 140 | 141 | destroy: -> 142 | root.directory.destroy() for root in @roots 143 | @disposables.dispose() 144 | @rootDragAndDrop.dispose() 145 | @emitter.emit('did-destroy') 146 | 147 | onDidDestroy: (callback) -> 148 | @emitter.on('did-destroy', callback) 149 | 150 | getTitle: -> "Project" 151 | 152 | getURI: -> TREE_VIEW_URI 153 | 154 | getPreferredLocation: -> 155 | if atom.config.get('tree-view.showOnRightSide') 156 | 'right' 157 | else 158 | 'left' 159 | 160 | getAllowedLocations: -> ["left", "right"] 161 | 162 | isPermanentDockItem: -> true 163 | 164 | getPreferredWidth: -> 165 | @list.style.width = 'min-content' 166 | result = @list.offsetWidth 167 | @list.style.width = '' 168 | result 169 | 170 | onDirectoryCreated: (callback) -> 171 | @emitter.on('directory-created', callback) 172 | 173 | onEntryCopied: (callback) -> 174 | @emitter.on('entry-copied', callback) 175 | 176 | onWillDeleteEntry: (callback) -> 177 | @emitter.on('will-delete-entry', callback) 178 | 179 | onEntryDeleted: (callback) -> 180 | @emitter.on('entry-deleted', callback) 181 | 182 | onDeleteEntryFailed: (callback) -> 183 | @emitter.on('delete-entry-failed', callback) 184 | 185 | onWillMoveEntry: (callback) -> 186 | @emitter.on('will-move-entry', callback) 187 | 188 | onEntryMoved: (callback) -> 189 | @emitter.on('entry-moved', callback) 190 | 191 | onMoveEntryFailed: (callback) -> 192 | @emitter.on('move-entry-failed', callback) 193 | 194 | onFileCreated: (callback) -> 195 | @emitter.on('file-created', callback) 196 | 197 | handleEvents: -> 198 | @element.addEventListener 'click', (e) => 199 | # This prevents accidental collapsing when a .entries element is the event target 200 | return if e.target.classList.contains('entries') 201 | 202 | @entryClicked(e) unless e.shiftKey or e.metaKey or e.ctrlKey 203 | @element.addEventListener 'mousedown', (e) => @onMouseDown(e) 204 | @element.addEventListener 'mouseup', (e) => @onMouseUp(e) 205 | @element.addEventListener 'dragstart', (e) => @onDragStart(e) 206 | @element.addEventListener 'dragenter', (e) => @onDragEnter(e) 207 | @element.addEventListener 'dragleave', (e) => @onDragLeave(e) 208 | @element.addEventListener 'dragover', (e) => @onDragOver(e) 209 | @element.addEventListener 'drop', (e) => @onDrop(e) 210 | 211 | atom.commands.add @element, 212 | 'core:move-up': (e) => @moveUp(e) 213 | 'core:move-down': (e) => @moveDown(e) 214 | 'core:page-up': => @pageUp() 215 | 'core:page-down': => @pageDown() 216 | 'core:move-to-top': => @scrollToTop() 217 | 'core:move-to-bottom': => @scrollToBottom() 218 | 'tree-view:expand-item': => @openSelectedEntry(pending: true, true) 219 | 'tree-view:recursive-expand-directory': => @expandDirectory(true) 220 | 'tree-view:collapse-directory': => @collapseDirectory() 221 | 'tree-view:recursive-collapse-directory': => @collapseDirectory(true) 222 | 'tree-view:collapse-all': => @collapseDirectory(true, true) 223 | 'tree-view:open-selected-entry': => @openSelectedEntry() 224 | 'tree-view:open-selected-entry-right': => @openSelectedEntryRight() 225 | 'tree-view:open-selected-entry-left': => @openSelectedEntryLeft() 226 | 'tree-view:open-selected-entry-up': => @openSelectedEntryUp() 227 | 'tree-view:open-selected-entry-down': => @openSelectedEntryDown() 228 | 'tree-view:move': => @moveSelectedEntry() 229 | 'tree-view:copy': => @copySelectedEntries() 230 | 'tree-view:cut': => @cutSelectedEntries() 231 | 'tree-view:paste': => @pasteEntries() 232 | 'tree-view:copy-full-path': => @copySelectedEntryPath(false) 233 | 'tree-view:show-in-file-manager': => @showSelectedEntryInFileManager() 234 | 'tree-view:open-in-new-window': => @openSelectedEntryInNewWindow() 235 | 'tree-view:copy-project-path': => @copySelectedEntryPath(true) 236 | 'tree-view:unfocus': => @unfocus() 237 | 'tree-view:toggle-vcs-ignored-files': -> toggleConfig 'tree-view.hideVcsIgnoredFiles' 238 | 'tree-view:toggle-ignored-names': -> toggleConfig 'tree-view.hideIgnoredNames' 239 | 'tree-view:remove-project-folder': (e) => @removeProjectFolder(e) 240 | 241 | [0..8].forEach (index) => 242 | atom.commands.add @element, "tree-view:open-selected-entry-in-pane-#{index + 1}", => 243 | @openSelectedEntryInPane index 244 | 245 | @disposables.add atom.workspace.getCenter().onDidChangeActivePaneItem => 246 | @selectActiveFile() 247 | @revealActiveFile({show: false, focus: false}) if atom.config.get('tree-view.autoReveal') 248 | @disposables.add atom.project.onDidChangePaths => 249 | @updateRoots() 250 | @disposables.add atom.config.onDidChange 'tree-view.hideVcsIgnoredFiles', => 251 | @updateRoots() 252 | @disposables.add atom.config.onDidChange 'tree-view.hideIgnoredNames', => 253 | @updateRoots() 254 | @disposables.add atom.config.onDidChange 'core.ignoredNames', => 255 | @updateRoots() if atom.config.get('tree-view.hideIgnoredNames') 256 | @disposables.add atom.config.onDidChange 'tree-view.sortFoldersBeforeFiles', => 257 | @updateRoots() 258 | @disposables.add atom.config.onDidChange 'tree-view.squashDirectoryNames', => 259 | @updateRoots() 260 | 261 | toggle: -> 262 | atom.workspace.toggle(this) 263 | 264 | show: (focus) -> 265 | atom.workspace.open(this, { 266 | searchAllPanes: true, 267 | activatePane: false, 268 | activateItem: false, 269 | }).then => 270 | atom.workspace.paneContainerForURI(@getURI()).show() 271 | @focus() if focus 272 | 273 | hide: -> 274 | atom.workspace.hide(this) 275 | 276 | focus: -> 277 | @element.focus() 278 | 279 | unfocus: -> 280 | atom.workspace.getCenter().activate() 281 | 282 | hasFocus: -> 283 | document.activeElement is @element 284 | 285 | toggleFocus: -> 286 | if @hasFocus() 287 | @unfocus() 288 | else 289 | @show(true) 290 | 291 | entryClicked: (e) -> 292 | if entry = e.target.closest('.entry') 293 | isRecursive = e.altKey or false 294 | @selectEntry(entry) 295 | if entry.classList.contains('directory') 296 | entry.toggleExpansion(isRecursive) 297 | else if entry.classList.contains('file') 298 | @fileViewEntryClicked(e) 299 | 300 | fileViewEntryClicked: (e) -> 301 | filePath = e.target.closest('.entry').getPath() 302 | detail = e.detail ? 1 303 | alwaysOpenExisting = atom.config.get('tree-view.alwaysOpenExisting') 304 | if detail is 1 305 | if atom.config.get('core.allowPendingPaneItems') 306 | openPromise = atom.workspace.open(filePath, pending: true, activatePane: false, searchAllPanes: alwaysOpenExisting) 307 | @currentlyOpening.set(filePath, openPromise) 308 | openPromise.then => @currentlyOpening.delete(filePath) 309 | else if detail is 2 310 | @openAfterPromise(filePath, searchAllPanes: alwaysOpenExisting) 311 | 312 | openAfterPromise: (uri, options) -> 313 | if promise = @currentlyOpening.get(uri) 314 | promise.then -> atom.workspace.open(uri, options) 315 | else 316 | atom.workspace.open(uri, options) 317 | 318 | updateRoots: (expansionStates={}) -> 319 | selectedPaths = @selectedPaths() 320 | 321 | oldExpansionStates = {} 322 | for root in @roots 323 | oldExpansionStates[root.directory.path] = root.directory.serializeExpansionState() 324 | root.directory.destroy() 325 | root.remove() 326 | 327 | @roots = [] 328 | 329 | projectPaths = atom.project.getPaths() 330 | if projectPaths.length > 0 331 | @element.appendChild(@list) unless @element.querySelector('tree-view-root') 332 | 333 | addProjectsViewElement = @element.querySelector('#add-projects-view') 334 | @element.removeChild(addProjectsViewElement) if addProjectsViewElement 335 | 336 | IgnoredNames ?= require('./ignored-names') 337 | 338 | @roots = for projectPath in projectPaths 339 | stats = fs.lstatSyncNoException(projectPath) 340 | continue unless stats 341 | stats = _.pick stats, _.keys(stats)... 342 | for key in ["atime", "birthtime", "ctime", "mtime"] 343 | stats[key] = stats[key].getTime() 344 | 345 | directory = new Directory({ 346 | name: path.basename(projectPath) 347 | fullPath: projectPath 348 | symlink: false 349 | isRoot: true 350 | expansionState: expansionStates[projectPath] ? 351 | oldExpansionStates[projectPath] ? 352 | {isExpanded: true} 353 | ignoredNames: new IgnoredNames() 354 | @useSyncFS 355 | stats 356 | }) 357 | root = new DirectoryView(directory).element 358 | @list.appendChild(root) 359 | root 360 | 361 | # The DOM has been recreated; reselect everything 362 | @selectMultipleEntries(@entryForPath(selectedPath)) for selectedPath in selectedPaths 363 | else 364 | @element.removeChild(@list) if @element.querySelector('.tree-view-root') 365 | @element.appendChild(new AddProjectsView().element) unless @element.querySelector('#add-projects-view') 366 | 367 | getActivePath: -> atom.workspace.getCenter().getActivePaneItem()?.getPath?() 368 | 369 | selectActiveFile: -> 370 | activeFilePath = @getActivePath() 371 | if @entryForPath(activeFilePath) 372 | @selectEntryForPath(activeFilePath) 373 | else 374 | # If the active file is not part of the project, deselect all entries 375 | @deselect() 376 | 377 | revealActiveFile: (options = {}) -> 378 | return Promise.resolve() unless atom.project.getPaths().length 379 | 380 | {show, focus} = options 381 | 382 | focus ?= atom.config.get('tree-view.focusOnReveal') 383 | promise = if show or focus then @show(focus) else Promise.resolve() 384 | promise.then => 385 | return unless activeFilePath = @getActivePath() 386 | 387 | [rootPath, relativePath] = atom.project.relativizePath(activeFilePath) 388 | return unless rootPath? 389 | 390 | activePathComponents = relativePath.split(path.sep) 391 | # Add the root folder to the path components 392 | activePathComponents.unshift(rootPath.substr(rootPath.lastIndexOf(path.sep) + 1)) 393 | # And remove it from the current path 394 | currentPath = rootPath.substr(0, rootPath.lastIndexOf(path.sep)) 395 | for pathComponent in activePathComponents 396 | currentPath += path.sep + pathComponent 397 | entry = @entryForPath(currentPath) 398 | if entry.classList.contains('directory') 399 | entry.expand() 400 | else 401 | @selectEntry(entry) 402 | @scrollToEntry(entry) 403 | 404 | copySelectedEntryPath: (relativePath = false) -> 405 | if pathToCopy = @selectedPath 406 | pathToCopy = atom.project.relativize(pathToCopy) if relativePath 407 | atom.clipboard.write(pathToCopy) 408 | 409 | entryForPath: (entryPath) -> 410 | bestMatchEntry = null 411 | bestMatchLength = 0 412 | 413 | for entry in @list.querySelectorAll('.entry') 414 | if entry.isPathEqual(entryPath) 415 | return entry 416 | 417 | entryLength = entry.getPath().length 418 | if entry.directory?.contains(entryPath) and entryLength > bestMatchLength 419 | bestMatchEntry = entry 420 | bestMatchLength = entryLength 421 | 422 | bestMatchEntry 423 | 424 | selectEntryForPath: (entryPath) -> 425 | @selectEntry(@entryForPath(entryPath)) 426 | 427 | moveDown: (event) -> 428 | event?.stopImmediatePropagation() 429 | selectedEntry = @selectedEntry() 430 | if selectedEntry? 431 | if selectedEntry.classList.contains('directory') 432 | if @selectEntry(selectedEntry.entries.children[0]) 433 | @scrollToEntry(@selectedEntry(), false) 434 | return 435 | 436 | if nextEntry = @nextEntry(selectedEntry) 437 | @selectEntry(nextEntry) 438 | else 439 | @selectEntry(@roots[0]) 440 | 441 | @scrollToEntry(@selectedEntry(), false) 442 | 443 | moveUp: (event) -> 444 | event.stopImmediatePropagation() 445 | selectedEntry = @selectedEntry() 446 | if selectedEntry? 447 | if previousEntry = @previousEntry(selectedEntry) 448 | @selectEntry(previousEntry) 449 | else 450 | @selectEntry(selectedEntry.parentElement.closest('.directory')) 451 | else 452 | entries = @list.querySelectorAll('.entry') 453 | @selectEntry(entries[entries.length - 1]) 454 | 455 | @scrollToEntry(@selectedEntry(), false) 456 | 457 | nextEntry: (entry) -> 458 | currentEntry = entry 459 | while currentEntry? 460 | if currentEntry.nextSibling? 461 | currentEntry = currentEntry.nextSibling 462 | if currentEntry.matches('.entry') 463 | return currentEntry 464 | else 465 | currentEntry = currentEntry.parentElement.closest('.directory') 466 | 467 | return null 468 | 469 | previousEntry: (entry) -> 470 | previousEntry = entry.previousSibling 471 | while previousEntry? and not previousEntry.matches('.entry') 472 | previousEntry = previousEntry.previousSibling 473 | 474 | return null unless previousEntry? 475 | 476 | # If the previous entry is an expanded directory, 477 | # we need to select the last entry in that directory, 478 | # not the directory itself 479 | if previousEntry.matches('.directory.expanded') 480 | entries = previousEntry.querySelectorAll('.entry') 481 | return entries[entries.length - 1] if entries.length > 0 482 | 483 | return previousEntry 484 | 485 | expandDirectory: (isRecursive=false) -> 486 | selectedEntry = @selectedEntry() 487 | return unless selectedEntry? 488 | 489 | directory = selectedEntry.closest('.directory') 490 | if isRecursive is false and directory.isExpanded 491 | # Select the first entry in the expanded folder if it exists 492 | @moveDown() if directory.directory.getEntries().length > 0 493 | else 494 | directory.expand(isRecursive) 495 | 496 | collapseDirectory: (isRecursive=false, allDirectories=false) -> 497 | if allDirectories 498 | root.collapse(true) for root in @roots 499 | return 500 | 501 | selectedEntry = @selectedEntry() 502 | return unless selectedEntry? 503 | 504 | if directory = selectedEntry.closest('.expanded.directory') 505 | directory.collapse(isRecursive) 506 | @selectEntry(directory) 507 | 508 | openSelectedEntry: (options={}, expandDirectory=false) -> 509 | selectedEntry = @selectedEntry() 510 | return unless selectedEntry? 511 | 512 | if selectedEntry.classList.contains('directory') 513 | if expandDirectory 514 | @expandDirectory(false) 515 | else 516 | selectedEntry.toggleExpansion() 517 | else if selectedEntry.classList.contains('file') 518 | if atom.config.get('tree-view.alwaysOpenExisting') 519 | options = Object.assign searchAllPanes: true, options 520 | @openAfterPromise(selectedEntry.getPath(), options) 521 | 522 | openSelectedEntrySplit: (orientation, side) -> 523 | selectedEntry = @selectedEntry() 524 | return unless selectedEntry? 525 | 526 | pane = atom.workspace.getCenter().getActivePane() 527 | if pane and selectedEntry.classList.contains('file') 528 | if atom.workspace.getCenter().getActivePaneItem() 529 | split = pane.split orientation, side 530 | atom.workspace.openURIInPane selectedEntry.getPath(), split 531 | else 532 | @openSelectedEntry yes 533 | 534 | openSelectedEntryRight: -> 535 | @openSelectedEntrySplit 'horizontal', 'after' 536 | 537 | openSelectedEntryLeft: -> 538 | @openSelectedEntrySplit 'horizontal', 'before' 539 | 540 | openSelectedEntryUp: -> 541 | @openSelectedEntrySplit 'vertical', 'before' 542 | 543 | openSelectedEntryDown: -> 544 | @openSelectedEntrySplit 'vertical', 'after' 545 | 546 | openSelectedEntryInPane: (index) -> 547 | selectedEntry = @selectedEntry() 548 | return unless selectedEntry? 549 | 550 | pane = atom.workspace.getCenter().getPanes()[index] 551 | if pane and selectedEntry.classList.contains('file') 552 | atom.workspace.openURIInPane selectedEntry.getPath(), pane 553 | 554 | moveSelectedEntry: -> 555 | if @hasFocus() 556 | entry = @selectedEntry() 557 | return if not entry? or entry in @roots 558 | oldPath = entry.getPath() 559 | else 560 | oldPath = @getActivePath() 561 | 562 | if oldPath 563 | dialog = new MoveDialog oldPath, 564 | willMove: ({initialPath, newPath}) => 565 | @emitter.emit 'will-move-entry', {initialPath, newPath} 566 | onMove: ({initialPath, newPath}) => 567 | @emitter.emit 'entry-moved', {initialPath, newPath} 568 | onMoveFailed: ({initialPath, newPath}) => 569 | @emitter.emit 'move-entry-failed', {initialPath, newPath} 570 | dialog.attach() 571 | 572 | showSelectedEntryInFileManager: -> 573 | return unless filePath = @selectedEntry()?.getPath() 574 | 575 | return unless fs.existsSync(filePath) 576 | atom.notifications.addWarning("Unable to show #{filePath} in #{@getFileManagerName()}") 577 | 578 | shell.showItemInFolder(filePath) 579 | 580 | showCurrentFileInFileManager: -> 581 | return unless filePath = atom.workspace.getCenter().getActiveTextEditor()?.getPath() 582 | 583 | return unless fs.existsSync(filePath) 584 | atom.notifications.addWarning("Unable to show #{filePath} in #{@getFileManagerName()}") 585 | 586 | shell.showItemInFolder(filePath) 587 | 588 | getFileManagerName: -> 589 | switch process.platform 590 | when 'darwin' 591 | return 'Finder' 592 | when 'win32' 593 | return 'Explorer' 594 | else 595 | return 'File Manager' 596 | 597 | openSelectedEntryInNewWindow: -> 598 | if pathToOpen = @selectedEntry()?.getPath() 599 | atom.open({pathsToOpen: [pathToOpen], newWindow: true}) 600 | 601 | copySelectedEntry: -> 602 | if @hasFocus() 603 | entry = @selectedEntry() 604 | return if entry in @roots 605 | oldPath = entry?.getPath() 606 | else 607 | oldPath = @getActivePath() 608 | return unless oldPath 609 | 610 | dialog = new CopyDialog oldPath, 611 | onCopy: ({initialPath, newPath}) => 612 | @emitter.emit 'entry-copied', {initialPath, newPath} 613 | dialog.attach() 614 | 615 | removeSelectedEntries: -> 616 | if @hasFocus() 617 | selectedPaths = @selectedPaths() 618 | selectedEntries = @getSelectedEntries() 619 | else if activePath = @getActivePath() 620 | selectedPaths = [activePath] 621 | selectedEntries = [@entryForPath(activePath)] 622 | 623 | return unless selectedPaths?.length > 0 624 | 625 | for root in @roots 626 | if root.getPath() in selectedPaths 627 | atom.confirm({ 628 | message: "The root directory '#{root.directory.name}' can't be removed.", 629 | buttons: ['OK'] 630 | }, -> # noop 631 | ) 632 | return 633 | 634 | atom.confirm({ 635 | message: "Are you sure you want to delete the selected #{if selectedPaths.length > 1 then 'items' else 'item'}?", 636 | detailedMessage: "You are deleting:\n#{selectedPaths.join('\n')}", 637 | buttons: ['Move to Trash', 'Cancel'] 638 | }, (response) => 639 | if response is 0 # Move to Trash 640 | failedDeletions = [] 641 | for selectedPath in selectedPaths 642 | # Don't delete entries which no longer exist. This can happen, for example, when: 643 | # * The entry is deleted outside of Atom before "Move to Trash" is selected 644 | # * A folder and one of its children are both selected for deletion, 645 | # but the parent folder is deleted first 646 | continue unless fs.existsSync(selectedPath) 647 | 648 | @emitter.emit 'will-delete-entry', {pathToDelete: selectedPath} 649 | if shell.moveItemToTrash(selectedPath) 650 | @emitter.emit 'entry-deleted', {pathToDelete: selectedPath} 651 | else 652 | @emitter.emit 'delete-entry-failed', {pathToDelete: selectedPath} 653 | failedDeletions.push selectedPath 654 | 655 | if repo = repoForPath(selectedPath) 656 | repo.getPathStatus(selectedPath) 657 | 658 | if failedDeletions.length > 0 659 | atom.notifications.addError @formatTrashFailureMessage(failedDeletions), 660 | description: @formatTrashEnabledMessage() 661 | detail: "#{failedDeletions.join('\n')}" 662 | dismissable: true 663 | 664 | # Focus the first parent folder 665 | if firstSelectedEntry = selectedEntries[0] 666 | @selectEntry(firstSelectedEntry.closest('.directory:not(.selected)')) 667 | @updateRoots() if atom.config.get('tree-view.squashDirectoryNames') 668 | ) 669 | 670 | formatTrashFailureMessage: (failedDeletions) -> 671 | fileText = if failedDeletions.length > 1 then 'files' else 'file' 672 | 673 | "The following #{fileText} couldn't be moved to the trash." 674 | 675 | formatTrashEnabledMessage: -> 676 | switch process.platform 677 | when 'linux' then 'Is `gvfs-trash` installed?' 678 | when 'darwin' then 'Is Trash enabled on the volume where the files are stored?' 679 | when 'win32' then 'Is there a Recycle Bin on the drive where the files are stored?' 680 | 681 | # Public: Copy the path of the selected entry element. 682 | # Save the path in localStorage, so that copying from 2 different 683 | # instances of atom works as intended 684 | # 685 | # 686 | # Returns `copyPath`. 687 | copySelectedEntries: -> 688 | selectedPaths = @selectedPaths() 689 | return unless selectedPaths and selectedPaths.length > 0 690 | # save to localStorage so we can paste across multiple open apps 691 | window.localStorage.removeItem('tree-view:cutPath') 692 | window.localStorage['tree-view:copyPath'] = JSON.stringify(selectedPaths) 693 | 694 | # Public: Cut the path of the selected entry element. 695 | # Save the path in localStorage, so that cutting from 2 different 696 | # instances of atom works as intended 697 | # 698 | # 699 | # Returns `cutPath` 700 | cutSelectedEntries: -> 701 | selectedPaths = @selectedPaths() 702 | return unless selectedPaths and selectedPaths.length > 0 703 | # save to localStorage so we can paste across multiple open apps 704 | window.localStorage.removeItem('tree-view:copyPath') 705 | window.localStorage['tree-view:cutPath'] = JSON.stringify(selectedPaths) 706 | 707 | # Public: Paste a copied or cut item. 708 | # If a file is selected, the file's parent directory is used as the 709 | # paste destination. 710 | pasteEntries: -> 711 | selectedEntry = @selectedEntry() 712 | return unless selectedEntry 713 | 714 | cutPaths = if window.localStorage['tree-view:cutPath'] then JSON.parse(window.localStorage['tree-view:cutPath']) else null 715 | copiedPaths = if window.localStorage['tree-view:copyPath'] then JSON.parse(window.localStorage['tree-view:copyPath']) else null 716 | initialPaths = copiedPaths or cutPaths 717 | return unless initialPaths?.length 718 | 719 | newDirectoryPath = selectedEntry.getPath() 720 | newDirectoryPath = path.dirname(newDirectoryPath) if selectedEntry.classList.contains('file') 721 | 722 | for initialPath in initialPaths 723 | if fs.existsSync(initialPath) 724 | if copiedPaths 725 | @copyEntry(initialPath, newDirectoryPath) 726 | else if cutPaths 727 | break unless @moveEntry(initialPath, newDirectoryPath) 728 | 729 | add: (isCreatingFile) -> 730 | selectedEntry = @selectedEntry() ? @roots[0] 731 | selectedPath = selectedEntry?.getPath() ? '' 732 | 733 | dialog = new AddDialog(selectedPath, isCreatingFile) 734 | dialog.onDidCreateDirectory (createdPath) => 735 | @entryForPath(createdPath)?.reload() 736 | @selectEntryForPath(createdPath) 737 | @updateRoots() if atom.config.get('tree-view.squashDirectoryNames') 738 | @emitter.emit 'directory-created', {path: createdPath} 739 | dialog.onDidCreateFile (createdPath) => 740 | @entryForPath(createdPath)?.reload() 741 | atom.workspace.open(createdPath) 742 | @updateRoots() if atom.config.get('tree-view.squashDirectoryNames') 743 | @emitter.emit 'file-created', {path: createdPath} 744 | dialog.attach() 745 | 746 | removeProjectFolder: (e) -> 747 | # Remove the targeted project folder (generally this only happens through the context menu) 748 | pathToRemove = e.target.closest(".project-root > .header")?.querySelector(".name")?.dataset.path 749 | # If an entry is selected, remove that entry's project folder 750 | pathToRemove ?= @selectedEntry()?.closest(".project-root")?.querySelector(".header")?.querySelector(".name")?.dataset.path 751 | # Finally, if only one project folder exists and nothing is selected, remove that folder 752 | pathToRemove ?= @roots[0].querySelector(".header")?.querySelector(".name")?.dataset.path if @roots.length is 1 753 | atom.project.removePath(pathToRemove) if pathToRemove? 754 | 755 | selectedEntry: -> 756 | @list.querySelector('.selected') 757 | 758 | selectEntry: (entry) -> 759 | return unless entry? 760 | 761 | @selectedPath = entry.getPath() 762 | @lastFocusedEntry = entry 763 | 764 | selectedEntries = @getSelectedEntries() 765 | if selectedEntries.length > 1 or selectedEntries[0] isnt entry 766 | @deselect(selectedEntries) 767 | entry.classList.add('selected') 768 | entry 769 | 770 | getSelectedEntries: -> 771 | @list.querySelectorAll('.selected') 772 | 773 | deselect: (elementsToDeselect=@getSelectedEntries()) -> 774 | selected.classList.remove('selected') for selected in elementsToDeselect 775 | undefined 776 | 777 | scrollTop: (top) -> 778 | if top? 779 | @element.scrollTop = top 780 | else 781 | @element.scrollTop 782 | 783 | scrollBottom: (bottom) -> 784 | if bottom? 785 | @element.scrollTop = bottom - @element.offsetHeight 786 | else 787 | @element.scrollTop + @element.offsetHeight 788 | 789 | scrollToEntry: (entry, center=true) -> 790 | element = if entry?.classList.contains('directory') then entry.header else entry 791 | element?.scrollIntoViewIfNeeded(center) 792 | 793 | scrollToBottom: -> 794 | if lastEntry = _.last(@list.querySelectorAll('.entry')) 795 | @selectEntry(lastEntry) 796 | @scrollToEntry(lastEntry) 797 | 798 | scrollToTop: -> 799 | @selectEntry(@roots[0]) if @roots[0]? 800 | @element.scrollTop = 0 801 | 802 | pageUp: -> 803 | @element.scrollTop -= @element.offsetHeight 804 | 805 | pageDown: -> 806 | @element.scrollTop += @element.offsetHeight 807 | 808 | # Copies an entry from `initialPath` to `newDirectoryPath` 809 | # If the entry already exists in `newDirectoryPath`, a number is appended to the basename 810 | copyEntry: (initialPath, newDirectoryPath) -> 811 | initialPathIsDirectory = fs.isDirectorySync(initialPath) 812 | 813 | # Do not allow copying test/a/ into test/a/b/ 814 | # Note: A trailing path.sep is added to prevent false positives, such as test/a -> test/ab 815 | realNewDirectoryPath = fs.realpathSync(newDirectoryPath) + path.sep 816 | realInitialPath = fs.realpathSync(initialPath) + path.sep 817 | if initialPathIsDirectory and realNewDirectoryPath.startsWith(realInitialPath) 818 | unless fs.isSymbolicLinkSync(initialPath) 819 | atom.notifications.addWarning('Cannot copy a folder into itself') 820 | return 821 | 822 | newPath = path.join(newDirectoryPath, path.basename(initialPath)) 823 | 824 | # append a number to the file if an item with the same name exists 825 | fileCounter = 0 826 | originalNewPath = newPath 827 | while fs.existsSync(newPath) 828 | if initialPathIsDirectory 829 | newPath = "#{originalNewPath}#{fileCounter}" 830 | else 831 | extension = getFullExtension(originalNewPath) 832 | filePath = path.join(path.dirname(originalNewPath), path.basename(originalNewPath, extension)) 833 | newPath = "#{filePath}#{fileCounter}#{extension}" 834 | fileCounter += 1 835 | 836 | try 837 | @emitter.emit 'will-copy-entry', {initialPath, newPath} 838 | if initialPathIsDirectory 839 | # use fs.copy to copy directories since read/write will fail for directories 840 | fs.copySync(initialPath, newPath) 841 | else 842 | # read the old file and write a new one at target location 843 | # TODO: Replace with fs.copyFileSync 844 | fs.writeFileSync(newPath, fs.readFileSync(initialPath)) 845 | @emitter.emit 'entry-copied', {initialPath, newPath} 846 | 847 | if repo = repoForPath(newPath) 848 | repo.getPathStatus(initialPath) 849 | repo.getPathStatus(newPath) 850 | 851 | catch error 852 | @emitter.emit 'copy-entry-failed', {initialPath, newPath} 853 | atom.notifications.addWarning("Failed to copy entry #{initialPath} to #{newDirectoryPath}", detail: error.message) 854 | 855 | # Moves an entry from `initialPath` to `newDirectoryPath` 856 | moveEntry: (initialPath, newDirectoryPath) -> 857 | # Do not allow moving test/a/ into test/a/b/ 858 | # Note: A trailing path.sep is added to prevent false positives, such as test/a -> test/ab 859 | try 860 | realNewDirectoryPath = fs.realpathSync(newDirectoryPath) + path.sep 861 | realInitialPath = fs.realpathSync(initialPath) + path.sep 862 | if fs.isDirectorySync(initialPath) and realNewDirectoryPath.startsWith(realInitialPath) 863 | unless fs.isSymbolicLinkSync(initialPath) 864 | atom.notifications.addWarning('Cannot move a folder into itself') 865 | return 866 | catch error 867 | atom.notifications.addWarning("Failed to move entry #{initialPath} to #{newDirectoryPath}", detail: error.message) 868 | 869 | newPath = path.join(newDirectoryPath, path.basename(initialPath)) 870 | 871 | try 872 | @emitter.emit 'will-move-entry', {initialPath, newPath} 873 | fs.moveSync(initialPath, newPath) 874 | @emitter.emit 'entry-moved', {initialPath, newPath} 875 | 876 | if repo = repoForPath(newPath) 877 | repo.getPathStatus(initialPath) 878 | repo.getPathStatus(newPath) 879 | 880 | catch error 881 | if error.code is 'EEXIST' 882 | return @moveConflictingEntry(initialPath, newPath, newDirectoryPath) 883 | else 884 | @emitter.emit 'move-entry-failed', {initialPath, newPath} 885 | atom.notifications.addWarning("Failed to move entry #{initialPath} to #{newDirectoryPath}", detail: error.message) 886 | 887 | return true 888 | 889 | moveConflictingEntry: (initialPath, newPath, newDirectoryPath) => 890 | try 891 | unless fs.isDirectorySync(initialPath) 892 | # Files, symlinks, anything but a directory 893 | chosen = atom.confirm 894 | message: "'#{path.relative(newDirectoryPath, newPath)}' already exists" 895 | detailedMessage: 'Do you want to replace it?' 896 | buttons: ['Replace file', 'Skip', 'Cancel'] 897 | 898 | switch chosen 899 | when 0 # Replace 900 | fs.renameSync(initialPath, newPath) 901 | @emitter.emit 'entry-moved', {initialPath, newPath} 902 | 903 | if repo = repoForPath(newPath) 904 | repo.getPathStatus(initialPath) 905 | repo.getPathStatus(newPath) 906 | break 907 | when 2 # Cancel 908 | return false 909 | else 910 | entries = fs.readdirSync(initialPath) 911 | for entry in entries 912 | if fs.existsSync(path.join(newPath, entry)) 913 | return false unless @moveConflictingEntry(path.join(initialPath, entry), path.join(newPath, entry), newDirectoryPath) 914 | else 915 | @moveEntry(path.join(initialPath, entry), newPath) 916 | 917 | # "Move" the containing folder by deleting it, since we've already moved everything in it 918 | fs.rmdirSync(initialPath) unless fs.readdirSync(initialPath).length 919 | catch error 920 | @emitter.emit 'move-entry-failed', {initialPath, newPath} 921 | atom.notifications.addWarning("Failed to move entry #{initialPath} to #{newPath}", detail: error.message) 922 | 923 | return true 924 | 925 | onStylesheetsChanged: => 926 | # If visible, force a redraw so the scrollbars are styled correctly based on 927 | # the theme 928 | return unless @isVisible() 929 | @element.style.display = 'none' 930 | @element.offsetWidth 931 | @element.style.display = '' 932 | 933 | onMouseDown: (e) -> 934 | return unless entryToSelect = e.target.closest('.entry') 935 | 936 | e.stopPropagation() 937 | 938 | # TODO: meta+click and ctrl+click should not do the same thing on Windows. 939 | # Right now removing metaKey if platform is not darwin breaks tests 940 | # that set the metaKey to true when simulating a cmd+click on macos 941 | # and ctrl+click on windows and linux. 942 | cmdKey = e.metaKey or (e.ctrlKey and process.platform isnt 'darwin') 943 | 944 | # return early if clicking on a selected entry 945 | if entryToSelect.classList.contains('selected') 946 | # mouse right click or ctrl click as right click on darwin platforms 947 | if e.button is 2 or (e.ctrlKey and process.platform is 'darwin') 948 | return 949 | else 950 | # allow click on mouseup if not dragging 951 | {shiftKey} = e 952 | @selectOnMouseUp = {shiftKey, cmdKey} 953 | return 954 | 955 | if e.shiftKey and cmdKey 956 | # select continuous from @lastFocusedEntry but leave others 957 | @selectContinuousEntries(entryToSelect, false) 958 | @showMultiSelectMenuIfNecessary() 959 | else if e.shiftKey 960 | # select continuous from @lastFocusedEntry and deselect rest 961 | @selectContinuousEntries(entryToSelect) 962 | @showMultiSelectMenuIfNecessary() 963 | # only allow ctrl click for multi selection on non darwin systems 964 | else if cmdKey 965 | @selectMultipleEntries(entryToSelect) 966 | @lastFocusedEntry = entryToSelect 967 | @showMultiSelectMenuIfNecessary() 968 | else 969 | @selectEntry(entryToSelect) 970 | @showFullMenu() 971 | 972 | onMouseUp: (e) -> 973 | return unless @selectOnMouseUp? 974 | 975 | {shiftKey, cmdKey} = @selectOnMouseUp 976 | @selectOnMouseUp = null 977 | 978 | return unless entryToSelect = e.target.closest('.entry') 979 | 980 | e.stopPropagation() 981 | 982 | if shiftKey and cmdKey 983 | # select continuous from @lastFocusedEntry but leave others 984 | @selectContinuousEntries(entryToSelect, false) 985 | @showMultiSelectMenuIfNecessary() 986 | else if shiftKey 987 | # select continuous from @lastFocusedEntry and deselect rest 988 | @selectContinuousEntries(entryToSelect) 989 | @showMultiSelectMenuIfNecessary() 990 | # only allow ctrl click for multi selection on non darwin systems 991 | else if cmdKey 992 | @deselect([entryToSelect]) 993 | @lastFocusedEntry = entryToSelect 994 | @showMultiSelectMenuIfNecessary() 995 | else 996 | @selectEntry(entryToSelect) 997 | @showFullMenu() 998 | 999 | # Public: Return an array of paths from all selected items 1000 | # 1001 | # Example: @selectedPaths() 1002 | # => ['selected/path/one', 'selected/path/two', 'selected/path/three'] 1003 | # Returns Array of selected item paths 1004 | selectedPaths: -> 1005 | entry.getPath() for entry in @getSelectedEntries() 1006 | 1007 | # Public: Selects items within a range defined by a currently selected entry and 1008 | # a new given entry. This is shift+click functionality 1009 | # 1010 | # Returns array of selected elements 1011 | selectContinuousEntries: (entry, deselectOthers = true) -> 1012 | currentSelectedEntry = @lastFocusedEntry ? @selectedEntry() 1013 | parentContainer = entry.parentElement 1014 | elements = [] 1015 | if parentContainer is currentSelectedEntry.parentElement 1016 | entries = Array.from(parentContainer.querySelectorAll('.entry')) 1017 | entryIndex = entries.indexOf(entry) 1018 | selectedIndex = entries.indexOf(currentSelectedEntry) 1019 | elements = (entries[i] for i in [entryIndex..selectedIndex]) 1020 | 1021 | @deselect() if deselectOthers 1022 | element.classList.add('selected') for element in elements 1023 | 1024 | elements 1025 | 1026 | # Public: Selects consecutive given entries without clearing previously selected 1027 | # items. This is cmd+click functionality 1028 | # 1029 | # Returns given entry 1030 | selectMultipleEntries: (entry) -> 1031 | entry?.classList.toggle('selected') 1032 | entry 1033 | 1034 | # Public: Toggle full-menu class on the main list element to display the full context 1035 | # menu. 1036 | showFullMenu: -> 1037 | @list.classList.remove('multi-select') 1038 | @list.classList.add('full-menu') 1039 | 1040 | # Public: Toggle multi-select class on the main list element to display the 1041 | # menu with only items that make sense for multi select functionality 1042 | showMultiSelectMenu: -> 1043 | @list.classList.remove('full-menu') 1044 | @list.classList.add('multi-select') 1045 | 1046 | showMultiSelectMenuIfNecessary: -> 1047 | if @getSelectedEntries().length > 1 1048 | @showMultiSelectMenu() 1049 | else 1050 | @showFullMenu() 1051 | 1052 | # Public: Check for multi-select class on the main list 1053 | # 1054 | # Returns boolean 1055 | multiSelectEnabled: -> 1056 | @list.classList.contains('multi-select') 1057 | 1058 | onDragEnter: (e) => 1059 | if entry = e.target.closest('.entry.directory') 1060 | return if @rootDragAndDrop.isDragging(e) 1061 | return unless @isAtomTreeViewEvent(e) 1062 | 1063 | e.stopPropagation() 1064 | 1065 | @dragEventCounts.set(entry, 0) unless @dragEventCounts.get(entry) 1066 | unless @dragEventCounts.get(entry) isnt 0 or entry.classList.contains('selected') 1067 | entry.classList.add('drag-over', 'selected') 1068 | 1069 | @dragEventCounts.set(entry, @dragEventCounts.get(entry) + 1) 1070 | 1071 | onDragLeave: (e) => 1072 | if entry = e.target.closest('.entry.directory') 1073 | return if @rootDragAndDrop.isDragging(e) 1074 | return unless @isAtomTreeViewEvent(e) 1075 | 1076 | e.stopPropagation() 1077 | 1078 | @dragEventCounts.set(entry, @dragEventCounts.get(entry) - 1) 1079 | if @dragEventCounts.get(entry) is 0 and entry.classList.contains('drag-over') 1080 | entry.classList.remove('drag-over', 'selected') 1081 | 1082 | # Handle entry name object dragstart event 1083 | onDragStart: (e) -> 1084 | @dragEventCounts = new WeakMap 1085 | @selectOnMouseUp = null 1086 | if entry = e.target.closest('.entry') 1087 | e.stopPropagation() 1088 | 1089 | if @rootDragAndDrop.canDragStart(e) 1090 | return @rootDragAndDrop.onDragStart(e) 1091 | 1092 | dragImage = document.createElement("ol") 1093 | dragImage.classList.add("entries", "list-tree") 1094 | dragImage.style.position = "absolute" 1095 | dragImage.style.top = 0 1096 | dragImage.style.left = 0 1097 | # Ensure the cloned file name element is rendered on a separate GPU layer 1098 | # to prevent overlapping elements located at (0px, 0px) from being used as 1099 | # the drag image. 1100 | dragImage.style.willChange = "transform" 1101 | 1102 | initialPaths = [] 1103 | for target in @getSelectedEntries() 1104 | entryPath = target.querySelector(".name").dataset.path 1105 | parentSelected = target.parentNode.closest(".entry.selected") 1106 | unless parentSelected 1107 | initialPaths.push(entryPath) 1108 | newElement = target.cloneNode(true) 1109 | if newElement.classList.contains("directory") 1110 | newElement.querySelector(".entries").remove() 1111 | for key, value of getStyleObject(target) 1112 | newElement.style[key] = value 1113 | newElement.style.paddingLeft = "1em" 1114 | newElement.style.paddingRight = "1em" 1115 | dragImage.append(newElement) 1116 | 1117 | document.body.appendChild(dragImage) 1118 | 1119 | e.dataTransfer.effectAllowed = "move" 1120 | e.dataTransfer.setDragImage(dragImage, 0, 0) 1121 | e.dataTransfer.setData("initialPaths", JSON.stringify(initialPaths)) 1122 | e.dataTransfer.setData("atom-tree-view-event", "true") 1123 | 1124 | window.requestAnimationFrame -> 1125 | dragImage.remove() 1126 | 1127 | # Handle entry dragover event; reset default dragover actions 1128 | onDragOver: (e) -> 1129 | if entry = e.target.closest('.entry.directory') 1130 | return if @rootDragAndDrop.isDragging(e) 1131 | return unless @isAtomTreeViewEvent(e) 1132 | 1133 | e.preventDefault() 1134 | e.stopPropagation() 1135 | 1136 | if @dragEventCounts.get(entry) > 0 and not entry.classList.contains('selected') 1137 | entry.classList.add('drag-over', 'selected') 1138 | 1139 | # Handle entry drop event 1140 | onDrop: (e) -> 1141 | @dragEventCounts = new WeakMap 1142 | if entry = e.target.closest('.entry.directory') 1143 | return if @rootDragAndDrop.isDragging(e) 1144 | return unless @isAtomTreeViewEvent(e) 1145 | 1146 | e.preventDefault() 1147 | e.stopPropagation() 1148 | 1149 | newDirectoryPath = entry.querySelector('.name')?.dataset.path 1150 | return false unless newDirectoryPath 1151 | 1152 | initialPaths = e.dataTransfer.getData('initialPaths') 1153 | 1154 | if initialPaths 1155 | # Drop event from Atom 1156 | initialPaths = JSON.parse(initialPaths) 1157 | return if initialPaths.includes(newDirectoryPath) 1158 | 1159 | entry.classList.remove('drag-over', 'selected') 1160 | 1161 | # iterate backwards so files in a dir are moved before the dir itself 1162 | for initialPath in initialPaths by -1 1163 | # Note: this is necessary on Windows to circumvent node-pathwatcher 1164 | # holding a lock on expanded folders and preventing them from 1165 | # being moved or deleted 1166 | # TODO: This can be removed when tree-view is switched to @atom/watcher 1167 | @entryForPath(initialPath)?.collapse?() 1168 | if (process.platform is 'darwin' and e.metaKey) or e.ctrlKey 1169 | @copyEntry(initialPath, newDirectoryPath) 1170 | else 1171 | break unless @moveEntry(initialPath, newDirectoryPath) 1172 | else 1173 | # Drop event from OS 1174 | entry.classList.remove('selected') 1175 | for file in e.dataTransfer.files 1176 | if (process.platform is 'darwin' and e.metaKey) or e.ctrlKey 1177 | @copyEntry(file.path, newDirectoryPath) 1178 | else 1179 | break unless @moveEntry(file.path, newDirectoryPath) 1180 | else if e.dataTransfer.files.length 1181 | # Drop event from OS that isn't targeting a folder: add a new project folder 1182 | atom.project.addPath(entry.path) for entry in e.dataTransfer.files 1183 | 1184 | isAtomTreeViewEvent: (e) -> 1185 | for item in e.dataTransfer.items 1186 | if item.type is 'atom-tree-view-event' or item.kind is 'file' 1187 | return true 1188 | 1189 | return false 1190 | 1191 | isVisible: -> 1192 | @element.offsetWidth isnt 0 or @element.offsetHeight isnt 0 1193 | -------------------------------------------------------------------------------- /menus/tree-view.cson: -------------------------------------------------------------------------------- 1 | 'menu': [ 2 | { 3 | 'label': 'View' 4 | 'submenu': [ 5 | {'label': 'Toggle Tree View', 'command': 'tree-view:toggle'} 6 | ] 7 | } 8 | { 9 | 'label': 'Packages' 10 | 'submenu': [ 11 | 'label': 'Tree View' 12 | 'submenu': [ 13 | {'label': 'Focus', 'command': 'tree-view:toggle-focus'} 14 | {'label': 'Toggle', 'command': 'tree-view:toggle'} 15 | {'label': 'Reveal Active File', 'command': 'tree-view:reveal-active-file'} 16 | {'label': 'Toggle Tree Side', 'command': 'tree-view:toggle-side'} 17 | ] 18 | ] 19 | } 20 | ] 21 | 22 | 'context-menu': 23 | '.tree-view .full-menu': [ 24 | {'label': 'New File', 'command': 'tree-view:add-file'} 25 | {'label': 'New Folder', 'command': 'tree-view:add-folder'} 26 | {'type': 'separator'} 27 | 28 | {'label': 'Rename', 'command': 'tree-view:move'} 29 | {'label': 'Duplicate', 'command': 'tree-view:duplicate'} 30 | {'label': 'Delete', 'command': 'tree-view:remove'} 31 | {'label': 'Copy', 'command': 'tree-view:copy'} 32 | {'label': 'Cut', 'command': 'tree-view:cut'} 33 | {'label': 'Paste', 'command': 'tree-view:paste'} 34 | {'type': 'separator'} 35 | 36 | {'label': 'Add Project Folder', 'command': 'application:add-project-folder'} 37 | {'type': 'separator'} 38 | 39 | {'label': 'Copy Full Path', 'command': 'tree-view:copy-full-path'} 40 | {'label': 'Copy Project Path', 'command': 'tree-view:copy-project-path'} 41 | {'label': 'Open in New Window', 'command': 'tree-view:open-in-new-window'} 42 | ] 43 | 44 | '.tree-view .full-menu [is="tree-view-file"]': [ 45 | {'label': 'Split Up', 'command': 'tree-view:open-selected-entry-up'} 46 | {'label': 'Split Down', 'command': 'tree-view:open-selected-entry-down'} 47 | {'label': 'Split Left', 'command': 'tree-view:open-selected-entry-left'} 48 | {'label': 'Split Right', 'command': 'tree-view:open-selected-entry-right'} 49 | {'type': 'separator'} 50 | ] 51 | 52 | '.tree-view .full-menu .project-root > .header': [ 53 | {'label': 'New File', 'command': 'tree-view:add-file'} 54 | {'label': 'New Folder', 'command': 'tree-view:add-folder'} 55 | {'type': 'separator'} 56 | 57 | {'label': 'Rename', 'command': 'tree-view:move'} 58 | {'label': 'Duplicate', 'command': 'tree-view:duplicate'} 59 | {'label': 'Delete', 'command': 'tree-view:remove'} 60 | {'label': 'Copy', 'command': 'tree-view:copy'} 61 | {'label': 'Cut', 'command': 'tree-view:cut'} 62 | {'label': 'Paste', 'command': 'tree-view:paste'} 63 | {'type': 'separator'} 64 | 65 | {'label': 'Add Project Folder', 'command': 'application:add-project-folder'} 66 | {'label': 'Remove Project Folder', 'command': 'tree-view:remove-project-folder'} 67 | {'label': 'Collapse All Project Folders', 'command': 'tree-view:collapse-all'} 68 | {'type': 'separator'} 69 | 70 | {'label': 'Copy Full Path', 'command': 'tree-view:copy-full-path'} 71 | {'label': 'Copy Project Path', 'command': 'tree-view:copy-project-path'} 72 | {'label': 'Open in New Window', 'command': 'tree-view:open-in-new-window'} 73 | ] 74 | 75 | '.platform-darwin .tree-view .full-menu': [ 76 | {'label': 'Reveal in Finder', 'command': 'tree-view:show-in-file-manager'} 77 | ] 78 | 79 | '.platform-win32 .tree-view .full-menu': [ 80 | {'label': 'Show in Explorer', 'command': 'tree-view:show-in-file-manager'} 81 | ] 82 | 83 | '.platform-linux .tree-view .full-menu': [ 84 | {'label': 'Show in File Manager', 'command': 'tree-view:show-in-file-manager'} 85 | ] 86 | 87 | '.tree-view .multi-select': [ 88 | {'label': 'Delete', 'command': 'tree-view:remove'} 89 | {'label': 'Copy', 'command': 'tree-view:copy'} 90 | {'label': 'Cut', 'command': 'tree-view:cut'} 91 | {'label': 'Paste', 'command': 'tree-view:paste'} 92 | ] 93 | 94 | 'atom-pane[data-active-item-path] .item-views': [ 95 | {'label': 'Reveal in Tree View', 'command': 'tree-view:reveal-active-file'} 96 | ] 97 | 98 | 'atom-pane[data-active-item-path] .tab.active': [ 99 | {'label': 'Rename', 'command': 'tree-view:rename'} 100 | {'label': 'Reveal in Tree View', 'command': 'tree-view:reveal-active-file'} 101 | ] 102 | 103 | '.platform-darwin atom-pane[data-active-item-path] .tab.active': [ 104 | {'label': 'Reveal In Finder', 'command': 'tree-view:show-current-file-in-file-manager'} 105 | ] 106 | 107 | '.platform-win32 atom-pane[data-active-item-path] .tab.active': [ 108 | {'label': 'Show In Explorer', 'command': 'tree-view:show-current-file-in-file-manager'} 109 | ] 110 | 111 | '.platform-linux atom-pane[data-active-item-path] .tab.active': [ 112 | {'label': 'Show in File Manager', 'command': 'tree-view:show-current-file-in-file-manager'} 113 | ] 114 | 115 | '.platform-darwin atom-text-editor:not([mini])': [ 116 | {'label': 'Reveal In Finder', 'command': 'tree-view:show-current-file-in-file-manager'} 117 | ] 118 | 119 | '.platform-win32 atom-text-editor:not([mini])': [ 120 | {'label': 'Show In Explorer', 'command': 'tree-view:show-current-file-in-file-manager'} 121 | ] 122 | 123 | '.platform-linux atom-text-editor:not([mini])': [ 124 | {'label': 'Show in File Manager', 'command': 'tree-view:show-current-file-in-file-manager'} 125 | ] 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tree-view", 3 | "version": "0.229.1", 4 | "main": "./lib/main", 5 | "description": "Explore and open files in the current project.", 6 | "repository": "https://github.com/atom/tree-view", 7 | "license": "MIT", 8 | "engines": { 9 | "atom": "*" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "fs-plus": "^3.0.0", 14 | "minimatch": "~0.3.0", 15 | "pathwatcher": "^8.1.0", 16 | "temp": "~0.9.0", 17 | "underscore-plus": "^1.0.0" 18 | }, 19 | "devDependencies": { 20 | "coffeelint": "^1.9.7", 21 | "standard": "^11.0.1" 22 | }, 23 | "deserializers": { 24 | "TreeView": "getTreeViewInstance" 25 | }, 26 | "consumedServices": { 27 | "atom.file-icons": { 28 | "versions": { 29 | "1.0.0": "consumeFileIcons" 30 | } 31 | }, 32 | "file-icons.element-icons": { 33 | "versions": { 34 | "1.0.0": "consumeElementIcons" 35 | } 36 | } 37 | }, 38 | "providedServices": { 39 | "tree-view": { 40 | "description": "A tree-like view of directories and files", 41 | "versions": { 42 | "1.0.0": "provideTreeView" 43 | } 44 | } 45 | }, 46 | "standard": { 47 | "env": [ 48 | "browser", 49 | "node", 50 | "atomtest", 51 | "jasmine" 52 | ], 53 | "globals": [ 54 | "atom" 55 | ], 56 | "ignore": [ 57 | "/spec/fixtures/" 58 | ] 59 | }, 60 | "configSchema": { 61 | "squashDirectoryNames": { 62 | "type": "boolean", 63 | "default": false, 64 | "title": "Collapse directories", 65 | "description": "Collapse directories that only contain a single directory." 66 | }, 67 | "hideVcsIgnoredFiles": { 68 | "type": "boolean", 69 | "default": false, 70 | "title": "Hide VCS Ignored Files", 71 | "description": "Don't show files and directories ignored by the current project's VCS system. For example, projects using Git have these paths defined in their `.gitignore` file." 72 | }, 73 | "hideIgnoredNames": { 74 | "type": "boolean", 75 | "default": false, 76 | "description": "Don't show items matched by the `Ignored Names` core config setting." 77 | }, 78 | "sortFoldersBeforeFiles": { 79 | "type": "boolean", 80 | "default": true, 81 | "description": "When listing directory items, list subdirectories before listing files." 82 | }, 83 | "autoReveal": { 84 | "type": "boolean", 85 | "default": false, 86 | "description": "Reveal tree view entries when they become the active pane item." 87 | }, 88 | "focusOnReveal": { 89 | "type": "boolean", 90 | "default": true, 91 | "description": "Focus the tree view when revealing entries." 92 | }, 93 | "alwaysOpenExisting": { 94 | "type": "boolean", 95 | "default": false, 96 | "description": "When opening a file, always focus an already-existing view of the file even if it's in another pane." 97 | }, 98 | "hiddenOnStartup": { 99 | "type": "boolean", 100 | "default": false, 101 | "description": "When Atom is opened, the view is hidden." 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /spec/async-spec-helpers.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | export function beforeEach (fn) { 4 | global.beforeEach(function () { 5 | const result = fn() 6 | if (result instanceof Promise) { 7 | waitsForPromise(() => result) 8 | } 9 | }) 10 | } 11 | 12 | export function afterEach (fn) { 13 | global.afterEach(function () { 14 | const result = fn() 15 | if (result instanceof Promise) { 16 | waitsForPromise(() => result) 17 | } 18 | }) 19 | } 20 | 21 | ['it', 'fit', 'ffit', 'fffit'].forEach(function (name) { 22 | module.exports[name] = function (description, fn) { 23 | if (fn === undefined) { 24 | global[name](description) 25 | return 26 | } 27 | 28 | global[name](description, function () { 29 | const result = fn() 30 | if (result instanceof Promise) { 31 | waitsForPromise(() => result) 32 | } 33 | }) 34 | } 35 | }) 36 | 37 | export async function conditionPromise (condition, description = 'anonymous condition') { 38 | const startTime = Date.now() 39 | 40 | while (true) { 41 | await timeoutPromise(100) 42 | 43 | if (await condition()) { 44 | return 45 | } 46 | 47 | if (Date.now() - startTime > 5000) { 48 | throw new Error('Timed out waiting on ' + description) 49 | } 50 | } 51 | } 52 | 53 | export function timeoutPromise (timeout) { 54 | return new Promise(function (resolve) { 55 | global.setTimeout(resolve, timeout) 56 | }) 57 | } 58 | 59 | function waitsForPromise (fn) { 60 | const promise = fn() 61 | global.waitsFor('spec promise to resolve', function (done) { 62 | promise.then(done, function (error) { 63 | jasmine.getEnv().currentSpec.fail(error) 64 | done() 65 | }) 66 | }) 67 | } 68 | 69 | export function emitterEventPromise (emitter, event, timeout = 15000) { 70 | return new Promise((resolve, reject) => { 71 | const timeoutHandle = setTimeout(() => { 72 | reject(new Error(`Timed out waiting for '${event}' event`)) 73 | }, timeout) 74 | emitter.once(event, () => { 75 | clearTimeout(timeoutHandle) 76 | resolve() 77 | }) 78 | }) 79 | } 80 | 81 | export function promisify (original) { 82 | return function (...args) { 83 | return new Promise((resolve, reject) => { 84 | args.push((err, ...results) => { 85 | if (err) { 86 | reject(err) 87 | } else { 88 | resolve(...results) 89 | } 90 | }) 91 | 92 | return original(...args) 93 | }) 94 | } 95 | } 96 | 97 | export function promisifySome (obj, fnNames) { 98 | const result = {} 99 | for (const fnName of fnNames) { 100 | result[fnName] = promisify(obj[fnName]) 101 | } 102 | return result 103 | } 104 | -------------------------------------------------------------------------------- /spec/default-file-icons-spec.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs-plus' 2 | path = require 'path' 3 | temp = require('temp').track() 4 | 5 | fileIcons = require '../lib/default-file-icons' 6 | 7 | describe 'DefaultFileIcons', -> 8 | it 'defaults to text', -> 9 | expect(fileIcons.iconClassForPath('foo.bar')).toEqual('icon-file-text') 10 | 11 | it 'recognizes READMEs', -> 12 | expect(fileIcons.iconClassForPath('README.md')).toEqual('icon-book') 13 | 14 | it 'recognizes compressed files', -> 15 | expect(fileIcons.iconClassForPath('foo.zip')).toEqual('icon-file-zip') 16 | 17 | it 'recognizes image files', -> 18 | expect(fileIcons.iconClassForPath('foo.png')).toEqual('icon-file-media') 19 | 20 | it 'recognizes PDF files', -> 21 | expect(fileIcons.iconClassForPath('foo.pdf')).toEqual('icon-file-pdf') 22 | 23 | it 'recognizes binary files', -> 24 | expect(fileIcons.iconClassForPath('foo.exe')).toEqual('icon-file-binary') 25 | 26 | describe 'symlinks', -> 27 | [tempDir] = [] 28 | 29 | beforeEach -> 30 | tempDir = temp.mkdirSync('atom-tree-view') 31 | 32 | it 'recognizes symlinks', -> 33 | filePath = path.join(tempDir, 'foo.bar') 34 | linkPath = path.join(tempDir, 'link.bar') 35 | fs.writeFileSync(filePath, '') 36 | fs.symlinkSync(filePath, linkPath, 'junction') 37 | 38 | expect(fileIcons.iconClassForPath(linkPath)).toEqual('icon-file-symlink-file') 39 | 40 | it 'recognizes as symlink instead of other types', -> 41 | filePath = path.join(tempDir, 'foo.zip') 42 | linkPath = path.join(tempDir, 'link.zip') 43 | fs.writeFileSync(filePath, '') 44 | fs.symlinkSync(filePath, linkPath, 'junction') 45 | 46 | expect(fileIcons.iconClassForPath(linkPath)).toEqual('icon-file-symlink-file') 47 | -------------------------------------------------------------------------------- /spec/event-helpers.coffee: -------------------------------------------------------------------------------- 1 | module.exports.buildInternalDragEvents = (dragged, enterTarget, dropTarget, treeView, copy = false) -> 2 | dataTransfer = 3 | data: {} 4 | setData: (key, value) -> @data[key] = "#{value}" # Drag events stringify data values 5 | getData: (key) -> @data[key] 6 | clearData: (key) -> 7 | if key 8 | delete @data[key] 9 | else 10 | @data = {} 11 | setDragImage: (@image) -> return 12 | 13 | Object.defineProperty( 14 | dataTransfer, 15 | 'items', 16 | get: -> 17 | Object.keys(dataTransfer.data).map((key) -> {type: key}) 18 | ) 19 | 20 | treeView.deselect() 21 | for entry in dragged 22 | treeView.selectMultipleEntries(entry) 23 | 24 | dragStartEvent = new DragEvent('dragstart') 25 | Object.defineProperty(dragStartEvent, 'target', value: dragged[0]) 26 | Object.defineProperty(dragStartEvent, 'currentTarget', value: dragged[0]) 27 | Object.defineProperty(dragStartEvent, 'dataTransfer', value: dataTransfer) 28 | 29 | dropEvent = new DragEvent('drop') 30 | Object.defineProperty(dropEvent, 'target', value: dropTarget) 31 | Object.defineProperty(dropEvent, 'currentTarget', value: dropTarget) 32 | Object.defineProperty(dropEvent, 'dataTransfer', value: dataTransfer) 33 | if copy 34 | key = if process.platform is 'darwin' then 'metaKey' else 'ctrlKey' 35 | Object.defineProperty(dropEvent, key, value: true) 36 | 37 | dragEnterEvent = new DragEvent('dragenter') 38 | Object.defineProperty(dragEnterEvent, 'target', value: enterTarget) 39 | Object.defineProperty(dragEnterEvent, 'currentTarget', value: enterTarget) 40 | Object.defineProperty(dragEnterEvent, 'dataTransfer', value: dataTransfer) 41 | 42 | [dragStartEvent, dragEnterEvent, dropEvent] 43 | 44 | module.exports.buildExternalDropEvent = (filePaths, dropTarget, copy = false) -> 45 | dataTransfer = 46 | data: {} 47 | setData: (key, value) -> @data[key] = "#{value}" # Drag events stringify data values 48 | getData: (key) -> @data[key] 49 | clearData: (key) -> 50 | if key 51 | delete @data[key] 52 | else 53 | @data = {} 54 | files: [] 55 | 56 | Object.defineProperty( 57 | dataTransfer, 58 | 'items', 59 | get: -> 60 | Object.keys(dataTransfer.data).map((key) -> {type: key, kind: 'file'}) 61 | ) 62 | 63 | dropEvent = new DragEvent('drop') 64 | Object.defineProperty(dropEvent, 'target', value: dropTarget) 65 | Object.defineProperty(dropEvent, 'currentTarget', value: dropTarget) 66 | Object.defineProperty(dropEvent, 'dataTransfer', value: dataTransfer) 67 | if copy 68 | key = if process.platform is 'darwin' then 'metaKey' else 'ctrlKey' 69 | Object.defineProperty(dropEvent, key, value: true) 70 | 71 | for filePath in filePaths 72 | dropEvent.dataTransfer.files.push({path: filePath}) 73 | dropEvent.dataTransfer.setData(filePath, 'bla') # Not technically correct, but gets the job done 74 | 75 | dropEvent 76 | 77 | buildElementPositionalDragEvents = (el, dataTransfer, currentTargetSelector) -> 78 | if not el? 79 | return {} 80 | 81 | currentTarget = if currentTargetSelector then el.closest(currentTargetSelector) else el 82 | 83 | topEvent = new DragEvent('dragover') 84 | Object.defineProperty(topEvent, 'target', value: el) 85 | Object.defineProperty(topEvent, 'currentTarget', value: currentTarget) 86 | Object.defineProperty(topEvent, 'dataTransfer', value: dataTransfer) 87 | Object.defineProperty(topEvent, 'pageY', value: el.getBoundingClientRect().top) 88 | 89 | middleEvent = new DragEvent('dragover') 90 | Object.defineProperty(middleEvent, 'target', value: el) 91 | Object.defineProperty(middleEvent, 'currentTarget', value: currentTarget) 92 | Object.defineProperty(middleEvent, 'dataTransfer', value: dataTransfer) 93 | Object.defineProperty(middleEvent, 'pageY', value: el.getBoundingClientRect().top + el.offsetHeight * 0.5) 94 | 95 | bottomEvent = new DragEvent('dragover') 96 | Object.defineProperty(bottomEvent, 'target', value: el) 97 | Object.defineProperty(bottomEvent, 'currentTarget', value: currentTarget) 98 | Object.defineProperty(bottomEvent, 'dataTransfer', value: dataTransfer) 99 | Object.defineProperty(bottomEvent, 'pageY', value: el.getBoundingClientRect().bottom) 100 | 101 | {top: topEvent, middle: middleEvent, bottom: bottomEvent} 102 | 103 | 104 | module.exports.buildPositionalDragEvents = (dragged, target, currentTargetSelector) -> 105 | dataTransfer = 106 | data: {} 107 | setData: (key, value) -> @data[key] = "#{value}" # Drag events stringify data values 108 | getData: (key) -> @data[key] 109 | clearData: (key) -> 110 | if key 111 | delete @data[key] 112 | else 113 | @data = {} 114 | setDragImage: (@image) -> return 115 | 116 | Object.defineProperty( 117 | dataTransfer, 118 | 'items', 119 | get: -> 120 | Object.keys(dataTransfer.data).map((key) -> {type: key}) 121 | ) 122 | 123 | dragStartEvent = new DragEvent('dragstart') 124 | Object.defineProperty(dragStartEvent, 'target', value: dragged) 125 | Object.defineProperty(dragStartEvent, 'currentTarget', value: dragged) 126 | Object.defineProperty(dragStartEvent, 'dataTransfer', value: dataTransfer) 127 | 128 | dragEndEvent = new DragEvent('dragend') 129 | Object.defineProperty(dragEndEvent, 'target', value: dragged) 130 | Object.defineProperty(dragEndEvent, 'currentTarget', value: dragged) 131 | Object.defineProperty(dragEndEvent, 'dataTransfer', value: dataTransfer) 132 | 133 | [dragStartEvent, buildElementPositionalDragEvents(target, dataTransfer, currentTargetSelector), dragEndEvent] 134 | -------------------------------------------------------------------------------- /spec/file-icons-spec.coffee: -------------------------------------------------------------------------------- 1 | DefaultFileIcons = require '../lib/default-file-icons' 2 | getIconServices = require '../lib/get-icon-services' 3 | {Disposable} = require 'atom' 4 | 5 | describe 'IconServices', -> 6 | afterEach -> 7 | getIconServices().resetFileIcons() 8 | getIconServices().resetElementIcons() 9 | 10 | describe 'FileIcons', -> 11 | it 'provides a default', -> 12 | expect(getIconServices().fileIcons).toBeDefined() 13 | expect(getIconServices().fileIcons).toBe(DefaultFileIcons) 14 | 15 | it 'allows the default to be overridden', -> 16 | service = new Object 17 | getIconServices().setFileIcons service 18 | expect(getIconServices().fileIcons).toBe(service) 19 | 20 | it 'allows the service to be reset to the default easily', -> 21 | service = new Object 22 | getIconServices().setFileIcons service 23 | getIconServices().resetFileIcons() 24 | expect(getIconServices().fileIcons).toBe(DefaultFileIcons) 25 | 26 | describe 'ElementIcons', -> 27 | it 'does not provide a default', -> 28 | expect(getIconServices().elementIcons).toBe(null) 29 | 30 | it 'consumes the ElementIcons service', -> 31 | service = -> 32 | getIconServices().setElementIcons service 33 | expect(getIconServices().elementIcons).toBe(service) 34 | 35 | it 'does not call the FileIcons service when the ElementIcons service is provided', -> 36 | elementIcons = -> 37 | new Disposable -> 38 | fileIcons = 39 | iconClassForPath: -> 40 | spyOn(fileIcons, 'iconClassForPath').andCallThrough() 41 | getIconServices().setElementIcons elementIcons 42 | getIconServices().setFileIcons fileIcons 43 | getIconServices().updateFileIcon(file: {}, fileName: classList: add: ->) 44 | expect(fileIcons.iconClassForPath).not.toHaveBeenCalled() 45 | -------------------------------------------------------------------------------- /spec/file-stats-spec.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'underscore-plus' 2 | fs = require 'fs-plus' 3 | path = require 'path' 4 | temp = require('temp').track() 5 | 6 | describe "FileStats", -> 7 | describe "provision of filesystem stats", -> 8 | [file1Data, file2Data, timeStarted, treeView] = ["ABCDEFGHIJKLMNOPQRSTUVWXYZ", "0123456789"] 9 | 10 | beforeEach -> 11 | jasmine.useRealClock() 12 | timeStarted = Date.now() 13 | rootDirPath = fs.absolute(temp.mkdirSync("tree-view")) 14 | subdirPath = path.join(rootDirPath, "subdir") 15 | filePath1 = path.join(rootDirPath, "file1.txt") 16 | filePath2 = path.join(subdirPath, "file2.txt") 17 | 18 | fs.makeTreeSync(subdirPath) 19 | fs.writeFileSync(filePath1, file1Data) 20 | fs.writeFileSync(filePath2, file2Data) 21 | atom.project.setPaths([rootDirPath]) 22 | 23 | waitsFor (done) -> 24 | atom.workspace.onDidOpen(done) 25 | atom.packages.activatePackage("tree-view") 26 | 27 | runs -> 28 | treeView = atom.workspace.getLeftDock().getActivePaneItem() 29 | 30 | it "passes stats to File instances", -> 31 | stats = treeView.roots[0].directory.entries.get("file1.txt").stats 32 | expect(stats).toBeDefined() 33 | expect(stats.mtime).toBeDefined() 34 | expect(stats.size).toEqual(file1Data.length) 35 | 36 | it "passes stats to Directory instances", -> 37 | stats = treeView.roots[0].directory.entries.get("subdir").stats 38 | expect(stats).toBeDefined() 39 | expect(stats.mtime).toBeDefined() 40 | 41 | it "passes stats to a root directory when initialised", -> 42 | expect(treeView.roots[0].directory.stats).toBeDefined() 43 | 44 | it "passes stats to File instances in subdirectories", -> 45 | treeView.element.querySelector(".entries > li").expand() 46 | subdir = treeView.roots[0].directory.entries.get("subdir") 47 | stats = subdir.entries.get("file2.txt").stats 48 | expect(stats).toBeDefined() 49 | expect(stats.size).toEqual(file2Data.length) 50 | 51 | it "converts date-stats to timestamps", -> 52 | stats = treeView.roots[0].directory.entries.get("file1.txt").stats 53 | stamp = stats.mtime 54 | expect(_.isDate stamp).toBe(false) 55 | expect(typeof stamp).toBe("number") 56 | expect(Number.isNaN stamp).toBe(false) 57 | 58 | it "accurately converts timestamps", -> 59 | stats = treeView.roots[0].directory.entries.get("file1.txt").stats 60 | # Two minutes should be enough 61 | expect(Math.abs stats.mtime - timeStarted).toBeLessThan(120000) 62 | 63 | describe "virtual filepaths", -> 64 | beforeEach -> 65 | atom.project.setPaths([]) 66 | waitsForPromise -> Promise.all [ 67 | atom.packages.activatePackage("tree-view") 68 | atom.packages.activatePackage("about") 69 | ] 70 | 71 | it "doesn't throw an exception when accessing virtual filepaths", -> 72 | atom.project.setPaths(["atom://about"]) 73 | -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/dir/b.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/dir/b.txt -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/file.txt: -------------------------------------------------------------------------------- 1 | undefined -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/master 2 | -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = false 5 | logallrefupdates = true 6 | ignorecase = true 7 | -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/index: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/index -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/objects/28/569d0a51e27dd112c0d4994c1e2914dd0db754: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/objects/28/569d0a51e27dd112c0d4994c1e2914dd0db754 -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/objects/71/1877003346f1e619bdfda3cc6495600ba08763: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/objects/71/1877003346f1e619bdfda3cc6495600ba08763 -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/objects/ec/5e386905ff2d36e291086a1207f2585aaa8920: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/objects/ec/5e386905ff2d36e291086a1207f2585aaa8920 -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/objects/ef/046e9eecaa5255ea5e9817132d4001724d6ae1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/git/working-dir/git.git/objects/ef/046e9eecaa5255ea5e9817132d4001724d6ae1 -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/git.git/refs/heads/master: -------------------------------------------------------------------------------- 1 | 711877003346f1e619bdfda3cc6495600ba08763 2 | -------------------------------------------------------------------------------- /spec/fixtures/git/working-dir/other.txt: -------------------------------------------------------------------------------- 1 | Full of text -------------------------------------------------------------------------------- /spec/fixtures/root-dir1/dir1/file1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/root-dir1/dir1/file1 -------------------------------------------------------------------------------- /spec/fixtures/root-dir1/dir1/sub-dir1/sub-file1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/root-dir1/dir1/sub-dir1/sub-file1 -------------------------------------------------------------------------------- /spec/fixtures/root-dir1/dir2/file2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/root-dir1/dir2/file2 -------------------------------------------------------------------------------- /spec/fixtures/root-dir1/nested/nested2/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/root-dir1/nested/nested2/.gitkeep -------------------------------------------------------------------------------- /spec/fixtures/root-dir1/tree-view.js: -------------------------------------------------------------------------------- 1 | var quicksort = function () { 2 | var sort = function(items) { 3 | if (items.length <= 1) return items; 4 | var pivot = items.shift(), current, left = [], right = []; 5 | while(items.length > 0) { 6 | current = items.shift(); 7 | current < pivot ? left.push(current) : right.push(current); 8 | } 9 | return sort(left).concat(pivot).concat(sort(right)); 10 | }; 11 | 12 | return sort(Array.apply(this, arguments)); 13 | }; -------------------------------------------------------------------------------- /spec/fixtures/root-dir1/tree-view.txt: -------------------------------------------------------------------------------- 1 | Some text. 2 | -------------------------------------------------------------------------------- /spec/fixtures/root-dir2/another-file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/root-dir2/another-file.txt -------------------------------------------------------------------------------- /spec/fixtures/root-dir2/dir3/file3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atom/tree-view/13053feb6fef5224068b6459340fd6e542b4daec/spec/fixtures/root-dir2/dir3/file3 -------------------------------------------------------------------------------- /spec/helpers-spec.js: -------------------------------------------------------------------------------- 1 | const {it, fit, ffit, beforeEach, afterEach} = require('./async-spec-helpers') // eslint-disable-line no-unused-vars 2 | 3 | const path = require('path') 4 | 5 | const helpers = require('../lib/helpers') 6 | 7 | describe('Helpers', () => { 8 | describe('repoForPath', () => { 9 | let fixturesPath, fixturesRepo 10 | 11 | beforeEach(async () => { 12 | fixturesPath = atom.project.getPaths()[0] 13 | fixturesRepo = await atom.project.repositoryForDirectory(atom.project.getDirectories()[0]) 14 | }) 15 | 16 | it('returns the repository for a given project path', () => { 17 | expect(helpers.repoForPath(fixturesPath)).toEqual(fixturesRepo) 18 | }) 19 | 20 | it('returns the project repository for a subpath', () => { 21 | expect(helpers.repoForPath(path.join(fixturesPath, 'root-dir1', 'tree-view.txt'))).toEqual(fixturesRepo) 22 | }) 23 | 24 | it('returns null for a path outside the project', () => { 25 | expect(helpers.repoForPath(path.join(fixturesPath, '..'))).toEqual(null) 26 | }) 27 | }) 28 | 29 | describe('getFullExtension', () => { 30 | it('returns the extension for a simple file', () => { 31 | expect(helpers.getFullExtension('filename.txt')).toBe('.txt') 32 | }) 33 | 34 | it('returns the extension for a path', () => { 35 | expect(helpers.getFullExtension(path.join('path', 'to', 'filename.txt'))).toBe('.txt') 36 | }) 37 | 38 | it('returns the full extension for a filename with more than one extension', () => { 39 | expect(helpers.getFullExtension('index.html.php')).toBe('.html.php') 40 | expect(helpers.getFullExtension('archive.tar.gz.bak')).toBe('.tar.gz.bak') 41 | }) 42 | 43 | it('returns no extension when the filename begins with a period', () => { 44 | expect(helpers.getFullExtension('.gitconfig')).toBe('') 45 | expect(helpers.getFullExtension(path.join('path', 'to', '.gitconfig'))).toBe('') 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /spec/tree-view-spec.js: -------------------------------------------------------------------------------- 1 | const TreeView = require('../lib/tree-view') 2 | 3 | describe('TreeView', () => { 4 | describe('serialization', () => { 5 | it('restores the expanded directories and selected files', () => { 6 | const treeView = new TreeView({}) 7 | treeView.roots[0].expand() 8 | treeView.roots[0].entries.firstChild.expand() 9 | treeView.selectEntry(treeView.roots[0].entries.firstChild.entries.firstChild) 10 | treeView.selectMultipleEntries(treeView.roots[0].entries.lastChild) 11 | 12 | const treeView2 = new TreeView(treeView.serialize()) 13 | 14 | expect(treeView2.roots[0].isExpanded).toBe(true) 15 | expect(treeView2.roots[0].entries.children[0].isExpanded).toBe(true) 16 | expect(treeView2.roots[0].entries.children[1].isExpanded).toBeUndefined() 17 | expect(Array.from(treeView2.getSelectedEntries())).toEqual([ 18 | treeView2.roots[0].entries.firstChild.entries.firstChild, 19 | treeView2.roots[0].entries.lastChild 20 | ]) 21 | }) 22 | 23 | it('restores the scroll position', () => { 24 | const treeView = new TreeView({}) 25 | treeView.roots[0].expand() 26 | treeView.roots[0].entries.firstChild.expand() 27 | treeView.element.style.overflow = 'auto' 28 | treeView.element.style.height = '80px' 29 | treeView.element.style.width = '80px' 30 | jasmine.attachToDOM(treeView.element) 31 | 32 | treeView.element.scrollTop = 42 33 | treeView.element.scrollLeft = 43 34 | 35 | expect(treeView.element.scrollTop).toBe(42) 36 | expect(treeView.element.scrollLeft).toBe(43) 37 | 38 | const treeView2 = new TreeView(treeView.serialize()) 39 | treeView2.element.style.overflow = 'auto' 40 | treeView2.element.style.height = '80px' 41 | treeView2.element.style.width = '80px' 42 | jasmine.attachToDOM(treeView2.element) 43 | 44 | waitsFor(() => 45 | treeView2.element.scrollTop === 42 && 46 | treeView2.element.scrollLeft === 43 47 | ) 48 | }) 49 | }) 50 | 51 | describe('clicking', () => { 52 | it('should leave multiple entries selected on right click', () => { 53 | const treeView = new TreeView({}) 54 | const entries = treeView.roots[0].entries 55 | 56 | treeView.onMouseDown({ 57 | stopPropagation () {}, 58 | target: entries.children[0], 59 | button: 0 60 | }) 61 | 62 | treeView.onMouseDown({ 63 | stopPropagation () {}, 64 | target: entries.children[1], 65 | button: 0, 66 | metaKey: true 67 | }) 68 | 69 | let child = entries.children[0] 70 | while (child.children.length > 0) { 71 | child = child.firstChild 72 | } 73 | 74 | treeView.onMouseDown({ 75 | stopPropagation () {}, 76 | target: child, 77 | button: 2 78 | }) 79 | 80 | expect(treeView.getSelectedEntries().length).toBe(2) 81 | expect(treeView.multiSelectEnabled()).toBe(true) 82 | }) 83 | }) 84 | 85 | describe('file selection', () => { 86 | it('keeps files selected after roots have been updated', () => { 87 | const treeView = new TreeView({}) 88 | treeView.roots[0].expand() 89 | treeView.roots[0].entries.firstChild.expand() 90 | treeView.selectEntry(treeView.roots[0].entries.firstChild.entries.firstChild) 91 | treeView.selectMultipleEntries(treeView.roots[0].entries.lastChild) 92 | 93 | expect(Array.from(treeView.getSelectedEntries())).toEqual([ 94 | treeView.roots[0].entries.firstChild.entries.firstChild, 95 | treeView.roots[0].entries.lastChild 96 | ]) 97 | 98 | treeView.updateRoots() 99 | 100 | expect(Array.from(treeView.getSelectedEntries())).toEqual([ 101 | treeView.roots[0].entries.firstChild.entries.firstChild, 102 | treeView.roots[0].entries.lastChild 103 | ]) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /styles/tree-view.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | 3 | .project-root-header { 4 | -webkit-user-drag: element; 5 | } 6 | 7 | .tree-view { 8 | contain: size; 9 | overflow: auto; 10 | z-index: 2; 11 | -webkit-user-select: none; 12 | 13 | display: flex; 14 | flex-direction: column; 15 | 16 | #add-projects-view { 17 | display: flex; 18 | flex: 1; 19 | flex-direction: column; 20 | justify-content: center; 21 | text-align: center; 22 | font-size: 1.25em; 23 | padding: 30px; 24 | cursor: default; 25 | 26 | > * { 27 | margin: 10px 0; 28 | } 29 | 30 | .description { 31 | margin: 10px 0; 32 | } 33 | 34 | .icon::before { 35 | color: #c1c1c1; 36 | } 37 | 38 | .btn { 39 | overflow: hidden; 40 | text-overflow: ellipsis; 41 | white-space: nowrap; 42 | } 43 | } 44 | 45 | .icon-large::before { 46 | margin-right: 0; 47 | margin-bottom: 50px; 48 | width: auto; 49 | height: auto; 50 | font-size: 8em; 51 | } 52 | 53 | .tree-view-root { 54 | padding-left: @component-icon-padding; 55 | padding-right: @component-padding; 56 | background-color: inherit; 57 | 58 | /* 59 | * Force a new stacking context to prevent a large, duplicate paint layer from 60 | * being created for tree-view's scrolling contents that can make the cost of 61 | * layer tree updates scale at 3x the size of the layer rather than the 62 | * optimal 1x. 63 | * 64 | * On high resolution displays, Chromium handles layers for scrolling content 65 | * differently and inadvertently creates a duplicate paint layer the size of 66 | * .tree-view-scroller because descendants of the scroller overlap the 67 | * auto-created layer. 68 | */ 69 | isolation: isolate; 70 | 71 | // Expands tree-view root to take up full height. 72 | // This makes sure that the context menu can still be openend in the empty 73 | // area underneath the files. 74 | flex-grow: 1; 75 | 76 | // Expands tree-view root to take up as much width as needed by the content. 77 | // This makes sure that the selected item's "bar/background" expands to full width. 78 | position: relative; 79 | min-width: min-content; 80 | } 81 | 82 | .header { 83 | position: relative; 84 | } 85 | 86 | .tree-view-root .list-tree { 87 | // Keeps selections expanded while dragging 88 | position: static; 89 | } 90 | 91 | .entry { 92 | // This fixes #110, see that issue for more details 93 | &::before { 94 | content: ''; 95 | position: absolute; 96 | } 97 | } 98 | 99 | /* Drag and Drop */ 100 | .placeholder { 101 | position: absolute; 102 | left: @component-icon-padding; 103 | padding: 0; 104 | z-index: 999; 105 | display: inline-block; 106 | 107 | width: calc(~"100% -" @component-icon-padding); 108 | background: @background-color-info; 109 | 110 | list-style: none; 111 | pointer-events: none; 112 | 113 | // bar 114 | &:before { 115 | content: ""; 116 | position: absolute; 117 | height: 2px; 118 | margin: -1px; padding: 0; 119 | width: inherit; 120 | background: inherit; 121 | } 122 | 123 | &:after { 124 | content: ""; 125 | position: absolute; 126 | left: 0; 127 | margin-top: -2px; 128 | margin-left: -1px; 129 | width: 4px; 130 | height: 4px; 131 | background: @background-color-info; 132 | border-radius: 4px; 133 | border: 1px solid transparent; 134 | } 135 | 136 | // ensure that placeholder doesn't disappear above the top of the view 137 | &:first-child { 138 | margin-top: 1px; 139 | } 140 | } 141 | } 142 | --------------------------------------------------------------------------------