├── .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 | [](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 | 
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 |
--------------------------------------------------------------------------------