├── .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 | [![macOS build status](https://travis-ci.org/atom/archive-view.svg?branch=master)](https://travis-ci.org/atom/archive-view) 4 | [![Windows build status](https://ci.appveyor.com/api/projects/status/u3qfgaod4lhriqlj/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/archive-view/branch/master) [![Dependency status](https://david-dm.org/atom/archive-view.svg)](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 | ![](https://f.cloud.github.com/assets/671378/2241218/e18a8846-9cc5-11e3-9456-3cbca9dfcff0.png) 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 |
43 |
44 |
    45 |
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 5ustar00kevinstaff000000 000000 d1/d2/000755 000765 000024 00000000000 12154443502 012146 5ustar00kevinstaff000000 000000 d1/d4/000755 000765 000024 00000000000 12154443524 012154 5ustar00kevinstaff000000 000000 d1/f2.txt000644 000765 000024 00000000000 12154443512 012700 0ustar00kevinstaff000000 000000 d1/d2/d3/000755 000765 000024 00000000000 12154443422 012455 5ustar00kevinstaff000000 000000 d1/d2/f1.txt000644 000765 000024 00000000000 12154443502 013203 0ustar00kevinstaff000000 000000 da/000755 000765 000024 00000000000 12154445060 011722 5ustar00kevinstaff000000 000000 da/db/000755 000765 000024 00000000000 12154445041 012306 5ustar00kevinstaff000000 000000 da/fa.txt000644 000765 000024 00000000012 12155434722 013046 0ustar00kevinstaff000000 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 | --------------------------------------------------------------------------------