├── .github
├── no-response.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .pairs
├── CONTRIBUTING.md
├── ISSUE_TEMPLATE.md
├── LICENSE.md
├── PULL_REQUEST_TEMPLATE.md
├── README.md
├── keymaps
└── archive-view.cson
├── lib
├── archive-editor-view.js
├── archive-editor.js
├── default-file-icons.js
├── directory-view.js
├── file-view.js
└── get-icon-services.js
├── package.json
├── spec
├── archive-editor-spec.js
├── archive-editor-view-spec.js
├── async-spec-helpers.js
└── fixtures
│ ├── file-icons.zip
│ ├── invalid.zip
│ ├── multiple-entries.zip
│ └── nested.tar
└── styles
└── archive-view.less
/.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 | os: [ubuntu-latest, macos-latest, windows-latest]
13 | channel: [stable, beta]
14 | runs-on: ${{ matrix.os }}
15 | steps:
16 | - uses: actions/checkout@v1
17 | - uses: UziTech/action-setup-atom@v2
18 | with:
19 | version: ${{ matrix.channel }}
20 | - name: Install dependencies
21 | run: apm install
22 | - name: Run tests
23 | run: atom --test spec
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.pairs:
--------------------------------------------------------------------------------
1 | pairs:
2 | ns: Nathan Sobo; nathan
3 | cj: Corey Johnson; cj
4 | dg: David Graham; dgraham
5 | ks: Kevin Sawicki; kevin
6 | jc: Jerry Cheung; jerry
7 | bl: Brian Lopez; brian
8 | jp: Justin Palmer; justin
9 | gt: Garen Torikian; garen
10 | mc: Matt Colyer; mcolyer
11 | bo: Ben Ogle; benogle
12 | jr: Jason Rudolph; jasonrudolph
13 | jl: Jessica Lord; jlord
14 | email:
15 | domain: github.com
16 | #global: true
17 |
--------------------------------------------------------------------------------
/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 | # Archive view package
3 | [](https://travis-ci.org/atom/archive-view)
4 | [](https://ci.appveyor.com/project/Atom/archive-view/branch/master) [](https://david-dm.org/atom/archive-view)
5 |
6 | Adds support for browsing archive files in Atom with the following extensions:
7 |
8 | * `.egg`
9 | * `.epub`
10 | * `.jar`
11 | * `.love`
12 | * `.nupkg`
13 | * `.tar`
14 | * `.tar.gz`
15 | * `.tgz`
16 | * `.war`
17 | * `.whl`
18 | * `.xpi`
19 | * `.zip`
20 |
21 | Select a file to extract it to a temp file and open it in a new editor.
22 |
23 | 
24 |
--------------------------------------------------------------------------------
/keymaps/archive-view.cson:
--------------------------------------------------------------------------------
1 | '.archive-editor':
2 | 'k': 'core:move-up'
3 | 'j': 'core:move-down'
4 |
--------------------------------------------------------------------------------
/lib/archive-editor-view.js:
--------------------------------------------------------------------------------
1 | /** @babel */
2 | /** @jsx etch.dom */
3 |
4 | import fs from 'fs-plus'
5 | import humanize from 'humanize-plus'
6 | import archive from 'ls-archive'
7 | import {CompositeDisposable, Disposable, Emitter, File} from 'atom'
8 | import etch from 'etch'
9 |
10 | import FileView from './file-view'
11 | import DirectoryView from './directory-view'
12 |
13 | export default class ArchiveEditorView {
14 | constructor (archivePath) {
15 | this.disposables = new CompositeDisposable()
16 | this.emitter = new Emitter()
17 | this.path = archivePath
18 | this.file = new File(this.path)
19 | this.entries = []
20 | etch.initialize(this)
21 |
22 | this.refresh()
23 |
24 | this.disposables.add(this.file.onDidChange(() => this.refresh()))
25 | this.disposables.add(this.file.onDidRename(() => this.refresh()))
26 | this.disposables.add(this.file.onDidDelete(() => this.destroy()))
27 |
28 | const focusHandler = () => this.focusSelectedFile()
29 |
30 | this.element.addEventListener('focus', focusHandler)
31 | this.disposables.add(new Disposable(() => this.element.removeEventListener('focus', focusHandler)))
32 | }
33 |
34 | update () {}
35 |
36 | render () {
37 | return (
38 |
39 |
40 |
{`Loading archive\u2026`}
41 |
42 |
46 |
47 |
48 | )
49 | }
50 |
51 | copy () {
52 | return new ArchiveEditorView(this.path)
53 | }
54 |
55 | destroy () {
56 | while (this.entries.length > 0) {
57 | this.entries.pop().destroy()
58 | }
59 | this.disposables.dispose()
60 | this.emitter.emit('did-destroy')
61 | etch.destroy(this)
62 | }
63 |
64 | onDidDestroy (callback) {
65 | return this.emitter.on('did-destroy', callback)
66 | }
67 |
68 | onDidChangeTitle (callback) {
69 | return this.emitter.on('did-change-title', callback)
70 | }
71 |
72 | serialize () {
73 | return {
74 | deserializer: this.constructor.name,
75 | path: this.path
76 | }
77 | }
78 |
79 | getPath () {
80 | return this.file.getPath()
81 | }
82 |
83 | getTitle () {
84 | return this.path ? this.file.getBaseName() : 'untitled'
85 | }
86 |
87 | getURI () {
88 | return this.path
89 | }
90 |
91 | refresh () {
92 | this.refs.summary.style.display = 'none'
93 | this.refs.tree.style.display = 'none'
94 | this.refs.loadingMessage.style.display = ''
95 | this.refs.errorMessage.style.display = 'none'
96 |
97 | if (this.path !== this.getPath()) {
98 | this.path = this.getPath()
99 | this.emitter.emit('did-change-title')
100 | }
101 |
102 | const originalPath = this.path
103 | archive.list(this.path, {tree: true}, (error, entries) => {
104 | if (originalPath !== this.path) {
105 | return
106 | }
107 |
108 | if (error != null) {
109 | let message = 'Reading the archive file failed'
110 | if (error.message) {
111 | message += `: ${error.message}`
112 | }
113 | this.refs.errorMessage.style.display = ''
114 | this.refs.errorMessage.textContent = message
115 | } else {
116 | this.createTreeEntries(entries)
117 | this.updateSummary()
118 | }
119 |
120 | // We hide the loading message _after_ creating the archive tree
121 | // to avoid forced reflows.
122 | this.refs.loadingMessage.style.display = 'none'
123 | })
124 | }
125 |
126 | createTreeEntries (entries) {
127 | while (this.entries.length > 0) {
128 | this.entries.pop().destroy()
129 | }
130 |
131 | let index = 0
132 | for (const entry of entries) {
133 | if (entry.isDirectory()) {
134 | const entryView = new DirectoryView(this, index, this.path, entry)
135 | this.entries.push(entryView)
136 | } else {
137 | const entryView = new FileView(this, index, this.path, entry)
138 | this.entries.push(entryView)
139 | }
140 | index++
141 | }
142 |
143 | this.selectFileAfterIndex(-1)
144 |
145 | // Wait until selecting (focusing) the first file before appending the entries
146 | // to avoid a double-forced reflow when focusing.
147 | for (const entry of this.entries) {
148 | this.refs.tree.appendChild(entry.element)
149 | }
150 |
151 | this.refs.tree.style.display = ''
152 | }
153 |
154 | updateSummary () {
155 | const fileCount = this.entries.filter((entry) => entry instanceof FileView).length
156 | const fileLabel = fileCount === 1 ? '1 file' : `${humanize.intComma(fileCount)} files`
157 |
158 | const directoryCount = this.entries.filter((entry) => entry instanceof DirectoryView).length
159 | const directoryLabel = directoryCount === 1 ? '1 folder' : `${humanize.intComma(directoryCount)} folders`
160 |
161 | this.refs.summary.style.display = ''
162 | this.refs.summary.textContent = `${humanize.fileSize(fs.getSizeSync(this.path))} with ${fileLabel} and ${directoryLabel}`
163 | }
164 |
165 | focusSelectedFile () {
166 | const selectedFile = this.refs.tree.querySelector('.selected')
167 | if (selectedFile) {
168 | selectedFile.focus()
169 | }
170 | }
171 |
172 | selectFileBeforeIndex (index) {
173 | for (let i = index - 1; i >= 0; i--) {
174 | const previousEntry = this.entries[i]
175 | if (previousEntry instanceof FileView) {
176 | previousEntry.select()
177 | break
178 | } else {
179 | if (previousEntry.selectLastFile()) {
180 | break
181 | }
182 | }
183 | }
184 | }
185 |
186 | selectFileAfterIndex (index) {
187 | for (let i = index + 1; i < this.entries.length; i++) {
188 | const nextEntry = this.entries[i]
189 | if (nextEntry instanceof FileView) {
190 | nextEntry.select()
191 | break
192 | } else {
193 | if (nextEntry.selectFirstFile()) {
194 | break
195 | }
196 | }
197 | }
198 | }
199 |
200 | focus () {
201 | this.focusSelectedFile()
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/lib/archive-editor.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs-plus')
2 | const path = require('path')
3 | const {Disposable} = require('atom')
4 |
5 | const getIconServices = require('./get-icon-services')
6 | const ArchiveEditorView = require('./archive-editor-view')
7 |
8 | module.exports = {
9 | activate () {
10 | this.disposable = atom.workspace.addOpener((filePath = '') => {
11 | // Check that filePath exists before opening, in case a remote URI was given
12 | if (isPathSupported(filePath) && fs.isFileSync(filePath)) {
13 | return new ArchiveEditorView(filePath)
14 | }
15 | })
16 | },
17 |
18 | deactivate () {
19 | this.disposable.dispose()
20 | for (const item of atom.workspace.getPaneItems()) {
21 | if (item instanceof ArchiveEditorView) {
22 | item.destroy()
23 | }
24 | }
25 | },
26 |
27 | consumeElementIcons (service) {
28 | getIconServices().setElementIcons(service)
29 | return new Disposable(() => getIconServices().resetElementIcons())
30 | },
31 |
32 | consumeFileIcons (service) {
33 | getIconServices().setFileIcons(service)
34 | return new Disposable(() => getIconServices().resetFileIcons())
35 | },
36 |
37 | deserialize (params = {}) {
38 | if (fs.isFileSync(params.path)) {
39 | return new ArchiveEditorView(params.path)
40 | } else {
41 | console.warn(`Can't build ArchiveEditorView for path "${params.path}"; file no longer exists`)
42 | }
43 | }
44 | }
45 |
46 | function isPathSupported (filePath) {
47 | switch (path.extname(filePath)) {
48 | case '.egg':
49 | case '.epub':
50 | case '.jar':
51 | case '.love':
52 | case '.nupkg':
53 | case '.tar':
54 | case '.tgz':
55 | case '.war':
56 | case '.whl':
57 | case '.xpi':
58 | case '.zip':
59 | return true
60 | case '.gz':
61 | return path.extname(path.basename(filePath, '.gz')) === '.tar'
62 | default:
63 | return false
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/lib/default-file-icons.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs-plus')
2 | const path = require('path')
3 |
4 | class DefaultFileIcons {
5 | iconClassForPath (filePath) {
6 | const extension = path.extname(filePath)
7 |
8 | if (fs.isSymbolicLinkSync(filePath)) {
9 | return 'icon-file-symlink-file'
10 | } else if (fs.isReadmePath(filePath)) {
11 | return 'icon-book'
12 | } else if (fs.isCompressedExtension(extension)) {
13 | return 'icon-file-zip'
14 | } else if (fs.isImageExtension(extension)) {
15 | return 'icon-file-media'
16 | } else if (fs.isPdfExtension(extension)) {
17 | return 'icon-file-pdf'
18 | } else if (fs.isBinaryExtension(extension)) {
19 | return 'icon-file-binary'
20 | } else {
21 | return 'icon-file-text'
22 | }
23 | }
24 | }
25 |
26 | module.exports = new DefaultFileIcons()
27 |
--------------------------------------------------------------------------------
/lib/directory-view.js:
--------------------------------------------------------------------------------
1 | /** @babel */
2 |
3 | import {CompositeDisposable, Disposable} from 'atom'
4 |
5 | import FileView from './file-view'
6 | import getIconServices from './get-icon-services'
7 |
8 | export default class DirectoryView {
9 | constructor (parentView, indexInParentView, archivePath, entry) {
10 | this.disposables = new CompositeDisposable()
11 | this.entries = []
12 | this.parentView = parentView
13 | this.indexInParentView = indexInParentView
14 | this.element = document.createElement('li')
15 | this.element.classList.add('list-nested-item', 'entry')
16 |
17 | const listItem = document.createElement('span')
18 | listItem.classList.add('list-item')
19 |
20 | const clickHandler = (event) => {
21 | event.stopPropagation()
22 | event.preventDefault()
23 | this.element.classList.toggle('collapsed')
24 | }
25 | listItem.addEventListener('click', clickHandler)
26 | this.disposables.add(new Disposable(() => { listItem.removeEventListener('click', clickHandler) }))
27 |
28 | const entrySpan = document.createElement('span')
29 | entrySpan.textContent = entry.getName()
30 | listItem.appendChild(entrySpan)
31 | this.element.appendChild(listItem)
32 |
33 | this.entry = entry
34 | this.entrySpan = entrySpan
35 | getIconServices().updateDirectoryIcon(this)
36 |
37 | this.entriesTree = document.createElement('ol')
38 | this.entriesTree.classList.add('list-tree')
39 | let index = 0
40 | for (const child of entry.children) {
41 | if (child.isDirectory()) {
42 | const entryView = new DirectoryView(this, index, archivePath, child)
43 | this.entries.push(entryView)
44 | this.entriesTree.appendChild(entryView.element)
45 | } else {
46 | const entryView = new FileView(this, index, archivePath, child)
47 | this.entries.push(entryView)
48 | this.entriesTree.appendChild(entryView.element)
49 | }
50 |
51 | index++
52 | }
53 | this.element.appendChild(this.entriesTree)
54 | }
55 |
56 | destroy () {
57 | if (this.iconDisposable) {
58 | this.iconDisposable.dispose()
59 | this.iconDisposable = null
60 | }
61 |
62 | while (this.entries.length > 0) {
63 | this.entries.pop().destroy()
64 | }
65 |
66 | this.disposables.dispose()
67 | this.element.remove()
68 | }
69 |
70 | selectFileBeforeIndex (index) {
71 | for (let i = index - 1; i >= 0; i--) {
72 | const previousEntry = this.entries[i]
73 | if (previousEntry instanceof FileView) {
74 | previousEntry.select()
75 | return
76 | } else {
77 | if (previousEntry.selectLastFile()) {
78 | return
79 | }
80 | }
81 | }
82 |
83 | this.parentView.selectFileBeforeIndex(this.indexInParentView)
84 | }
85 |
86 | selectFileAfterIndex (index) {
87 | for (let i = index + 1; i < this.entries.length; i++) {
88 | const nextEntry = this.entries[i]
89 | if (nextEntry instanceof FileView) {
90 | nextEntry.select()
91 | return
92 | } else {
93 | if (nextEntry.selectFirstFile()) {
94 | return
95 | }
96 | }
97 | }
98 |
99 | this.parentView.selectFileAfterIndex(this.indexInParentView)
100 | }
101 |
102 | selectFirstFile () {
103 | for (const entry of this.entries) {
104 | if (entry instanceof FileView) {
105 | entry.select()
106 | return true
107 | } else {
108 | if (entry.selectFirstFile()) {
109 | return true
110 | }
111 | }
112 | }
113 |
114 | return false
115 | }
116 |
117 | selectLastFile () {
118 | for (var i = this.entries.length - 1; i >= 0; i--) {
119 | const entry = this.entries[i]
120 | if (entry instanceof FileView) {
121 | entry.select()
122 | return true
123 | } else {
124 | if (entry.selectLastFile()) {
125 | return true
126 | }
127 | }
128 | }
129 |
130 | return false
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/lib/file-view.js:
--------------------------------------------------------------------------------
1 | /** @babel */
2 |
3 | import {CompositeDisposable, Disposable} from 'atom'
4 | import path from 'path'
5 | import fs from 'fs-plus'
6 | import temp from 'temp'
7 | import archive from 'ls-archive'
8 |
9 | import getIconServices from './get-icon-services'
10 |
11 | export default class FileView {
12 | constructor (parentView, indexInParentView, archivePath, entry) {
13 | this.disposables = new CompositeDisposable()
14 | this.parentView = parentView
15 | this.indexInParentView = indexInParentView
16 | this.archivePath = archivePath
17 | this.entry = entry
18 |
19 | this.element = document.createElement('li')
20 | this.element.classList.add('list-item', 'entry')
21 | this.element.tabIndex = -1
22 |
23 | this.name = document.createElement('span')
24 | getIconServices().updateFileIcon(this)
25 | this.name.textContent = this.entry.getName()
26 | this.element.appendChild(this.name)
27 |
28 | const clickHandler = () => {
29 | this.select()
30 | this.openFile()
31 | }
32 | this.element.addEventListener('click', clickHandler)
33 | this.disposables.add(new Disposable(() => { this.element.removeEventListener('click', clickHandler) }))
34 |
35 | this.disposables.add(atom.commands.add(this.element, {
36 | 'core:confirm': () => {
37 | if (this.isSelected()) {
38 | this.openFile()
39 | }
40 | },
41 |
42 | 'core:move-down': () => {
43 | if (this.isSelected()) {
44 | this.parentView.selectFileAfterIndex(this.indexInParentView)
45 | }
46 | },
47 |
48 | 'core:move-up': () => {
49 | if (this.isSelected()) {
50 | this.parentView.selectFileBeforeIndex(this.indexInParentView)
51 | }
52 | }
53 | }))
54 | }
55 |
56 | destroy () {
57 | this.disposables.dispose()
58 | this.element.remove()
59 | }
60 |
61 | isSelected () {
62 | return this.element.classList.contains('selected')
63 | }
64 |
65 | logError (message, error) {
66 | console.error(message, error.stack != null ? error.stack : error)
67 | }
68 |
69 | openFile () {
70 | archive.readFile(this.archivePath, this.entry.getPath(), (error, contents) => {
71 | if (error != null) {
72 | this.logError(`Error reading: ${this.entry.getPath()} from ${this.archivePath}`, error)
73 | } else {
74 | temp.mkdir('atom-', (error, tempDirPath) => {
75 | if (error != null) {
76 | this.logError(`Error creating temp directory: ${tempDirPath}`, error)
77 | } else {
78 | const tempFilePath = path.join(tempDirPath, path.basename(this.archivePath), this.entry.getName())
79 | fs.writeFile(tempFilePath, contents, error => {
80 | if (error != null) {
81 | return this.logError(`Error writing to ${tempFilePath}`, error)
82 | } else {
83 | return atom.workspace.open(tempFilePath)
84 | }
85 | })
86 | }
87 | })
88 | }
89 | })
90 | }
91 |
92 | select () {
93 | this.element.focus()
94 |
95 | const archiveEditorElement = this.element.closest('.archive-editor')
96 | // On initial tree creation, it is not possible for any entries to be selected
97 | // (The entries also haven't been added to the DOM yet)
98 | if (archiveEditorElement) {
99 | for (const selected of archiveEditorElement.querySelectorAll('.selected')) {
100 | selected.classList.remove('selected')
101 | }
102 | }
103 | this.element.classList.add('selected')
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/lib/get-icon-services.js:
--------------------------------------------------------------------------------
1 | const DefaultFileIcons = require('./default-file-icons')
2 | const {Emitter, CompositeDisposable} = require('atom')
3 | const path = require('path')
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) { this.elementIconDisposables = new CompositeDisposable() }
37 | this.elementIcons = service
38 | return this.emitter.emit('did-change')
39 | }
40 | }
41 |
42 | setFileIcons (service) {
43 | if (service !== this.fileIcons) {
44 | this.fileIcons = service
45 | return this.emitter.emit('did-change')
46 | }
47 | }
48 |
49 | updateDirectoryIcon (view) {
50 | view.entrySpan.classList.add('directory', 'icon', 'icon-file-directory')
51 | if (this.elementIcons) {
52 | view.iconDisposable = this.elementIcons(view.entrySpan, view.entry.path, {isDirectory: true})
53 | }
54 | }
55 |
56 | updateFileIcon (view) {
57 | const nameClasses = ['file', 'icon']
58 | if (this.elementIcons) {
59 | const fullPath = path.join(view.archivePath, view.entry.path)
60 | const disposable = this.elementIcons(view.name, fullPath)
61 | view.disposables.add(disposable)
62 | this.elementIconDisposables.add(disposable)
63 | } else {
64 | let typeClass = this.fileIcons.iconClassForPath(view.entry.path, 'archive-view') || []
65 | if (!Array.isArray(typeClass) && typeClass) {
66 | typeClass = typeClass.toString().split(/\s+/g)
67 | }
68 | nameClasses.push(...typeClass)
69 | }
70 | view.name.classList.add(...nameClasses)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "archive-view",
3 | "version": "0.66.0",
4 | "description": "View the files and folders inside archive files",
5 | "main": "./lib/archive-editor",
6 | "dependencies": {
7 | "etch": "0.9.0",
8 | "fs-plus": "^3.0.0",
9 | "humanize-plus": "~1.8.2",
10 | "ls-archive": "1.3.4",
11 | "temp": "~0.8.1"
12 | },
13 | "devDependencies": {
14 | "standard": "^11.0.1"
15 | },
16 | "standard": {
17 | "env": {
18 | "atomtest": true,
19 | "browser": true,
20 | "jasmine": true,
21 | "node": true
22 | },
23 | "globals": [
24 | "atom"
25 | ]
26 | },
27 | "repository": "https://github.com/atom/archive-view",
28 | "license": "MIT",
29 | "engines": {
30 | "atom": "*"
31 | },
32 | "deserializers": {
33 | "ArchiveEditor": "deserialize",
34 | "ArchiveEditorView": "deserialize"
35 | },
36 | "consumedServices": {
37 | "atom.file-icons": {
38 | "versions": {
39 | "1.0.0": "consumeFileIcons"
40 | }
41 | },
42 | "file-icons.element-icons": {
43 | "versions": {
44 | "1.0.0": "consumeElementIcons"
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/spec/archive-editor-spec.js:
--------------------------------------------------------------------------------
1 | const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') // eslint-disable-line no-unused-vars
2 |
3 | const path = require('path')
4 | const ArchiveEditor = require('../lib/archive-editor')
5 | const ArchiveEditorView = require('../lib/archive-editor-view')
6 |
7 | describe('ArchiveEditor', () => {
8 | const tarPath = path.join(__dirname, 'fixtures', 'nested.tar')
9 |
10 | // Don't log during specs
11 | beforeEach(() => spyOn(console, 'warn'))
12 |
13 | describe('.deserialize', () => {
14 | it('returns undefined if no file exists at the given path', () => {
15 | const editor1 = new ArchiveEditorView(tarPath)
16 | const state = editor1.serialize()
17 | editor1.destroy()
18 |
19 | const editor2 = ArchiveEditor.deserialize(state)
20 | expect(editor2).toBeDefined()
21 | editor2.destroy()
22 |
23 | state.path = 'bogus'
24 | expect(ArchiveEditor.deserialize(state)).toBeUndefined()
25 | })
26 | })
27 |
28 | describe('.deactivate()', () => {
29 | it('removes all ArchiveEditorViews from the workspace and does not open any new ones', async () => {
30 | const getArchiveEditorViews = () => {
31 | return atom.workspace.getPaneItems().filter(item => item instanceof ArchiveEditorView)
32 | }
33 | await atom.packages.activatePackage('archive-view')
34 | await atom.workspace.open(path.join(__dirname, 'fixtures', 'nested.tar'))
35 | await atom.workspace.open(path.join(__dirname, 'fixtures', 'invalid.zip'))
36 | await atom.workspace.open()
37 | expect(getArchiveEditorViews().length).toBe(2)
38 |
39 | await atom.packages.deactivatePackage('archive-view')
40 | expect(getArchiveEditorViews().length).toBe(0)
41 |
42 | await atom.workspace.open(path.join(__dirname, 'fixtures', 'nested.tar'))
43 | expect(getArchiveEditorViews().length).toBe(0)
44 | })
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/spec/archive-editor-view-spec.js:
--------------------------------------------------------------------------------
1 | const {Disposable, File} = require('atom')
2 | const getIconServices = require('../lib/get-icon-services')
3 | const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') // eslint-disable-line no-unused-vars
4 |
5 | async function condition (handler) {
6 | if (jasmine.isSpy(window.setTimeout)) {
7 | jasmine.useRealClock()
8 | }
9 | return conditionPromise(handler)
10 | }
11 |
12 | describe('ArchiveEditorView', () => {
13 | let archiveEditorView, onDidChangeCallback, onDidRenameCallback, onDidDeleteCallback
14 |
15 | beforeEach(async () => {
16 | spyOn(File.prototype, 'onDidChange').andCallFake(function (callback) {
17 | if (/\.tar$/.test(this.getPath())) {
18 | onDidChangeCallback = callback
19 | }
20 | return new Disposable()
21 | })
22 |
23 | spyOn(File.prototype, 'onDidRename').andCallFake(function (callback) {
24 | if (/\.tar$/.test(this.getPath())) {
25 | onDidRenameCallback = callback
26 | }
27 | return new Disposable()
28 | })
29 |
30 | spyOn(File.prototype, 'onDidDelete').andCallFake(function (callback) {
31 | if (/\.tar$/.test(this.getPath())) {
32 | onDidDeleteCallback = callback
33 | }
34 | return new Disposable()
35 | })
36 |
37 | await atom.packages.activatePackage('archive-view')
38 | archiveEditorView = await atom.workspace.open('nested.tar')
39 | })
40 |
41 | describe('.constructor()', () => {
42 | it('displays the files and folders in the archive file', async () => {
43 | expect(archiveEditorView.element).toExist()
44 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
45 |
46 | const directoryElements = archiveEditorView.element.querySelectorAll('.directory')
47 | expect(directoryElements.length).toBe(6)
48 | expect(directoryElements[0].textContent).toBe('d1')
49 | expect(directoryElements[1].textContent).toBe('d2')
50 | expect(directoryElements[2].textContent).toBe('d3')
51 | expect(directoryElements[3].textContent).toBe('d4')
52 | expect(directoryElements[4].textContent).toBe('da')
53 | expect(directoryElements[5].textContent).toBe('db')
54 |
55 | const fileElements = archiveEditorView.element.querySelectorAll('.file')
56 | expect(fileElements.length).toBe(3)
57 | expect(fileElements[0].textContent).toBe('f1.txt')
58 | expect(fileElements[1].textContent).toBe('f2.txt')
59 | expect(fileElements[2].textContent).toBe('fa.txt')
60 | })
61 |
62 | it('selects the first file', async () => {
63 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
64 | expect(archiveEditorView.element.querySelector('.selected').textContent).toBe('f1.txt')
65 | })
66 | })
67 |
68 | describe('.copy()', () => {
69 | it('returns a new ArchiveEditorView for the same file', () => {
70 | const newArchiveView = archiveEditorView.copy()
71 | expect(newArchiveView.getPath()).toBe(archiveEditorView.getPath())
72 | })
73 | })
74 |
75 | describe('archive summary', () => {
76 | beforeEach(async () => {
77 | await atom.workspace.open('multiple-entries.zip')
78 | archiveEditorView = atom.workspace.getActivePaneItem()
79 | jasmine.attachToDOM(atom.views.getView(atom.workspace))
80 | })
81 |
82 | it('shows correct statistics', async () => {
83 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
84 | const heading = archiveEditorView.element.querySelector('.inset-panel .panel-heading')
85 | expect(heading).not.toBe(null)
86 | expect(heading.textContent).toBe('704 bytes with 4 files and 1 folder')
87 | })
88 | })
89 |
90 | describe('when core:move-up/core:move-down is triggered', () => {
91 | let selectedEntry
92 | const dispatch = (command) => {
93 | atom.commands.dispatch(archiveEditorView.element.querySelector('.selected'), command)
94 | selectedEntry = archiveEditorView.element.querySelector('.selected').textContent
95 | return true
96 | }
97 |
98 | it('selects the next/previous file', async () => {
99 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
100 | expect(archiveEditorView.element).toBeDefined()
101 | dispatch('core:move-up') && expect(selectedEntry).toBe('f1.txt')
102 | dispatch('core:move-down') && expect(selectedEntry).toBe('f2.txt')
103 | dispatch('core:move-down') && expect(selectedEntry).toBe('fa.txt')
104 | dispatch('core:move-down') && expect(selectedEntry).toBe('fa.txt')
105 | dispatch('core:move-up') && expect(selectedEntry).toBe('f2.txt')
106 | dispatch('core:move-up') && expect(selectedEntry).toBe('f1.txt')
107 | })
108 | })
109 |
110 | describe('when a file is clicked', () => {
111 | it('copies the contents to a temp file and opens it in a new editor', async () => {
112 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
113 | archiveEditorView.element.querySelectorAll('.file')[2].click()
114 | await condition(() => atom.workspace.getActivePane().getItems().length > 1)
115 | expect(atom.workspace.getActivePaneItem().getText()).toBe('hey there\n')
116 | expect(atom.workspace.getActivePaneItem().getTitle()).toBe('fa.txt')
117 | })
118 | })
119 |
120 | describe('when a directory is clicked', () => {
121 | it('collapses/expands itself', async () => {
122 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
123 | let directory = archiveEditorView.element.querySelectorAll('.list-nested-item.entry')[0]
124 | expect(directory.classList.contains('collapsed')).toBeFalsy()
125 | directory.querySelector('.list-item').click()
126 | expect(directory.classList.contains('collapsed')).toBeTruthy()
127 | directory.querySelector('.list-item').click()
128 | expect(directory.classList.contains('collapsed')).toBeFalsy()
129 | })
130 | })
131 |
132 | describe('when core:confirm is triggered', () => {
133 | it('copies the contents to a temp file and opens it in a new editor', async () => {
134 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
135 | atom.commands.dispatch(archiveEditorView.element.querySelector('.file'), 'core:confirm')
136 | await condition(() => atom.workspace.getActivePane().getItems().length > 1)
137 | expect(atom.workspace.getActivePaneItem().getText()).toBe('')
138 | expect(atom.workspace.getActivePaneItem().getTitle()).toBe('f1.txt')
139 | })
140 | })
141 |
142 | describe('when the file is modified', () => {
143 | it('refreshes the view', async () => {
144 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
145 | spyOn(archiveEditorView, 'refresh')
146 | onDidChangeCallback()
147 | expect(archiveEditorView.refresh).toHaveBeenCalled()
148 | })
149 | })
150 |
151 | describe('when the file is renamed', () => {
152 | it('refreshes the view and updates the title', async () => {
153 | spyOn(File.prototype, 'getPath').andReturn('nested-renamed.tar')
154 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
155 | spyOn(archiveEditorView, 'refresh').andCallThrough()
156 | spyOn(archiveEditorView, 'getTitle')
157 | onDidRenameCallback()
158 | expect(archiveEditorView.refresh).toHaveBeenCalled()
159 | expect(archiveEditorView.getTitle).toHaveBeenCalled()
160 | })
161 | })
162 |
163 | describe('when the file is removed', () => {
164 | it('destroys the view', async () => {
165 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
166 | expect(atom.workspace.getActivePane().getItems().length).toBe(1)
167 | onDidDeleteCallback()
168 | expect(atom.workspace.getActivePaneItem()).toBeUndefined()
169 | })
170 | })
171 |
172 | describe('when the file is invalid', () => {
173 | beforeEach(async () => {
174 | await atom.workspace.open('invalid.zip')
175 | archiveEditorView = atom.workspace.getActivePaneItem()
176 | jasmine.attachToDOM(atom.views.getView(atom.workspace))
177 | })
178 |
179 | it('shows the error', async () => {
180 | await condition(() => archiveEditorView.refs.errorMessage.offsetHeight > 0)
181 | expect(archiveEditorView.refs.errorMessage.textContent.length).toBeGreaterThan(0)
182 | })
183 | })
184 |
185 | describe('FileIcons', () => {
186 | async function openFile () {
187 | await atom.workspace.open('file-icons.zip')
188 | archiveEditorView = atom.workspace.getActivePaneItem()
189 | jasmine.attachToDOM(atom.views.getView(atom.workspace))
190 | }
191 |
192 | describe('Icon service', () => {
193 | const service = { iconClassForPath () {} }
194 | beforeEach(() => openFile())
195 |
196 | it('provides a default service', () => {
197 | expect(getIconServices().fileIcons).toBeDefined()
198 | expect(getIconServices().fileIcons).not.toBeNull()
199 | })
200 |
201 | it('allows the default to be overridden', () => {
202 | getIconServices().setFileIcons(service)
203 | expect(getIconServices().fileIcons).toBe(service)
204 | })
205 |
206 | it('allows service to be reset without hassle', () => {
207 | getIconServices().setFileIcons(service)
208 | getIconServices().resetFileIcons()
209 | expect(getIconServices().fileIcons).not.toBe(service)
210 | })
211 | })
212 |
213 | describe('Class handling', () => {
214 | function findEntryContainingText (text) {
215 | for (const entry of archiveEditorView.element.querySelectorAll('.list-item.entry')) {
216 | if (entry.textContent.includes(text)) { return entry }
217 | }
218 | return null
219 | }
220 |
221 | function checkMultiClass () {
222 | expect(findEntryContainingText('adobe.pdf').querySelector('.file.icon').className).toBe('file icon text pdf-icon document')
223 | expect(findEntryContainingText('spacer.gif').querySelector('.file.icon').className).toBe('file icon binary gif-icon image')
224 | expect(findEntryContainingText('font.ttf').querySelector('.file.icon').className).toBe('file icon binary ttf-icon font')
225 | }
226 |
227 | it('displays default file-icons', async () => {
228 | await openFile()
229 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
230 | expect(findEntryContainingText('adobe.pdf').querySelector('.file.icon.icon-file-pdf').length).not.toBe(0)
231 | expect(findEntryContainingText('spacer.gif').querySelector('.file.icon.icon-file-media').length).not.toBe(0)
232 | expect(findEntryContainingText('sunn.o').querySelector('.file.icon.icon-file-binary').length).not.toBe(0)
233 | })
234 |
235 | it('allows multiple classes to be passed', async () => {
236 | getIconServices().setFileIcons({
237 | iconClassForPath: (path) => {
238 | switch (path.match(/\w*$/)[0]) {
239 | case 'pdf': return 'text pdf-icon document'
240 | case 'ttf': return 'binary ttf-icon font'
241 | case 'gif': return 'binary gif-icon image'
242 | }
243 | }
244 | })
245 | await openFile()
246 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
247 | checkMultiClass()
248 | })
249 |
250 | it('allows an array of classes to be passed', async () => {
251 | getIconServices().setFileIcons({
252 | iconClassForPath: (path) => {
253 | switch (path.match(/\w*$/)[0]) {
254 | case 'pdf': return ['text', 'pdf-icon', 'document']
255 | case 'ttf': return ['binary', 'ttf-icon', 'font']
256 | case 'gif': return ['binary', 'gif-icon', 'image']
257 | }
258 | }
259 | })
260 | await openFile()
261 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
262 | checkMultiClass()
263 | })
264 |
265 | it('identifies context to icon-service providers', async () => {
266 | getIconServices().setFileIcons({
267 | iconClassForPath: (path, context) => `icon-${context}`
268 | })
269 | await openFile()
270 | await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
271 | const icons = findEntryContainingText('adobe.pdf').querySelectorAll('.file.icon-archive-view')
272 | expect(icons.length).not.toBe(0)
273 | })
274 | })
275 | })
276 | })
277 |
--------------------------------------------------------------------------------
/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/fixtures/file-icons.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atom/archive-view/35876ff335a5ee63759c775571b5db7ac9d5cb70/spec/fixtures/file-icons.zip
--------------------------------------------------------------------------------
/spec/fixtures/invalid.zip:
--------------------------------------------------------------------------------
1 | invalid
2 |
--------------------------------------------------------------------------------
/spec/fixtures/multiple-entries.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atom/archive-view/35876ff335a5ee63759c775571b5db7ac9d5cb70/spec/fixtures/multiple-entries.zip
--------------------------------------------------------------------------------
/spec/fixtures/nested.tar:
--------------------------------------------------------------------------------
1 | d1/ 000755 000765 000024 00000000000 12154443524 011645 5 ustar 00kevin staff 000000 000000 d1/d2/ 000755 000765 000024 00000000000 12154443502 012146 5 ustar 00kevin staff 000000 000000 d1/d4/ 000755 000765 000024 00000000000 12154443524 012154 5 ustar 00kevin staff 000000 000000 d1/f2.txt 000644 000765 000024 00000000000 12154443512 012700 0 ustar 00kevin staff 000000 000000 d1/d2/d3/ 000755 000765 000024 00000000000 12154443422 012455 5 ustar 00kevin staff 000000 000000 d1/d2/f1.txt 000644 000765 000024 00000000000 12154443502 013203 0 ustar 00kevin staff 000000 000000 da/ 000755 000765 000024 00000000000 12154445060 011722 5 ustar 00kevin staff 000000 000000 da/db/ 000755 000765 000024 00000000000 12154445041 012306 5 ustar 00kevin staff 000000 000000 da/fa.txt 000644 000765 000024 00000000012 12155434722 013046 0 ustar 00kevin staff 000000 000000 hey there
2 |
--------------------------------------------------------------------------------
/styles/archive-view.less:
--------------------------------------------------------------------------------
1 | @import "ui-variables";
2 |
3 | .archive-editor {
4 | background-color: @inset-panel-background-color;
5 | overflow: auto;
6 | contain: strict;
7 |
8 | .archive-container {
9 | height: 100%;
10 | width: 100%;
11 |
12 | .inset-panel {
13 | border-width: 0;
14 |
15 | .panel-heading {
16 | border-radius: 0;
17 | }
18 |
19 | .archive-tree {
20 | padding: 5px;
21 | }
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------