Bundle archive-view

This commit is contained in:
confused-Techie 2023-01-03 02:42:52 -08:00
parent a764577f55
commit 7427639be0
20 changed files with 1161 additions and 10 deletions

View File

@ -25,7 +25,7 @@
"@atom/source-map-support": "^0.3.4",
"@babel/core": "7.18.6",
"about": "file:packages/about",
"archive-view": "https://codeload.github.com/atom/archive-view/legacy.tar.gz/refs/tags/v0.66.0",
"archive-view": "file:packages/archive-view",
"async": "3.2.4",
"atom-dark-syntax": "file:packages/atom-dark-syntax",
"atom-dark-ui": "file:packages/atom-dark-ui",
@ -187,7 +187,7 @@
"solarized-dark-syntax": "file:./packages/solarized-dark-syntax",
"solarized-light-syntax": "file:./packages/solarized-light-syntax",
"about": "file:./packages/about",
"archive-view": "0.66.0",
"archive-view": "file:./packages/archive-view",
"autocomplete-atom-api": "0.10.7",
"autocomplete-css": "file:./packages/autocomplete-css",
"autocomplete-html": "file:./packages/autocomplete-html",

View File

@ -0,0 +1,20 @@
# Archive view package
Adds support for browsing archive files in Pulsar with the following extensions:
* `.egg`
* `.epub`
* `.jar`
* `.love`
* `.nupkg`
* `.tar`
* `.tar.gz`
* `.tgz`
* `.war`
* `.whl`
* `.xpi`
* `.zip`
Select a file to extract it to a temp file and open it in a new editor.
![](./resources/preview.png)

View File

@ -0,0 +1,3 @@
'.archive-editor':
'k': 'core:move-up'
'j': 'core:move-down'

View File

@ -0,0 +1,208 @@
/** @babel */
/** @jsx etch.dom */
import fs from 'fs'
import humanize from 'humanize-plus'
import archive from 'ls-archive'
import {CompositeDisposable, Disposable, Emitter, File} from 'atom'
import etch from 'etch'
import FileView from './file-view'
import DirectoryView from './directory-view'
export default class ArchiveEditorView {
constructor (archivePath) {
this.disposables = new CompositeDisposable()
this.emitter = new Emitter()
this.path = archivePath
this.file = new File(this.path)
this.entries = []
etch.initialize(this)
this.refresh()
this.disposables.add(this.file.onDidChange(() => this.refresh()))
this.disposables.add(this.file.onDidRename(() => this.refresh()))
this.disposables.add(this.file.onDidDelete(() => this.destroy()))
const focusHandler = () => this.focusSelectedFile()
this.element.addEventListener('focus', focusHandler)
this.disposables.add(new Disposable(() => this.element.removeEventListener('focus', focusHandler)))
}
update () {}
render () {
return (
<div className='archive-editor' tabIndex='-1'>
<div className='archive-container'>
<div ref='loadingMessage' className='padded icon icon-hourglass text-info'>{`Loading archive\u2026`}</div>
<div ref='errorMessage' className='padded icon icon-alert text-error' />
<div className='inset-panel'>
<div ref='summary' className='panel-heading' />
<ol ref='tree' className='archive-tree padded list-tree has-collapsable-children' />
</div>
</div>
</div>
)
}
copy () {
return new ArchiveEditorView(this.path)
}
destroy () {
while (this.entries.length > 0) {
this.entries.pop().destroy()
}
this.disposables.dispose()
this.emitter.emit('did-destroy')
etch.destroy(this)
}
onDidDestroy (callback) {
return this.emitter.on('did-destroy', callback)
}
onDidChangeTitle (callback) {
return this.emitter.on('did-change-title', callback)
}
serialize () {
return {
deserializer: this.constructor.name,
path: this.path
}
}
getPath () {
return this.file.getPath()
}
getTitle () {
return this.path ? this.file.getBaseName() : 'untitled'
}
getURI () {
return this.path
}
refresh () {
this.refs.summary.style.display = 'none'
this.refs.tree.style.display = 'none'
this.refs.loadingMessage.style.display = ''
this.refs.errorMessage.style.display = 'none'
if (this.path !== this.getPath()) {
this.path = this.getPath()
this.emitter.emit('did-change-title')
}
const originalPath = this.path
archive.list(this.path, {tree: true}, (error, entries) => {
if (originalPath !== this.path) {
return
}
if (error != null) {
let message = 'Reading the archive file failed'
if (error.message) {
message += `: ${error.message}`
}
this.refs.errorMessage.style.display = ''
this.refs.errorMessage.textContent = message
} else {
this.createTreeEntries(entries)
this.updateSummary()
}
// We hide the loading message _after_ creating the archive tree
// to avoid forced reflows.
this.refs.loadingMessage.style.display = 'none'
})
}
createTreeEntries (entries) {
while (this.entries.length > 0) {
this.entries.pop().destroy()
}
let index = 0
for (const entry of entries) {
if (entry.isDirectory()) {
const entryView = new DirectoryView(this, index, this.path, entry)
this.entries.push(entryView)
} else {
const entryView = new FileView(this, index, this.path, entry)
this.entries.push(entryView)
}
index++
}
this.selectFileAfterIndex(-1)
// Wait until selecting (focusing) the first file before appending the entries
// to avoid a double-forced reflow when focusing.
for (const entry of this.entries) {
this.refs.tree.appendChild(entry.element)
}
this.refs.tree.style.display = ''
}
updateSummary () {
const fileCount = this.entries.filter((entry) => entry instanceof FileView).length
const fileLabel = fileCount === 1 ? '1 file' : `${humanize.intComma(fileCount)} files`
const directoryCount = this.entries.filter((entry) => entry instanceof DirectoryView).length
const directoryLabel = directoryCount === 1 ? '1 folder' : `${humanize.intComma(directoryCount)} folders`
this.refs.summary.style.display = ''
let fileSize
try {
fileSize = fs.statSync(this.path)?.size;
} catch (e) {}
if (fileSize == null) fileSize = -1
this.refs.summary.textContent = `${humanize.fileSize(fileSize)} with ${fileLabel} and ${directoryLabel}`
}
focusSelectedFile () {
const selectedFile = this.refs.tree.querySelector('.selected')
if (selectedFile) {
selectedFile.focus()
}
}
selectFileBeforeIndex (index) {
for (let i = index - 1; i >= 0; i--) {
const previousEntry = this.entries[i]
if (previousEntry instanceof FileView) {
previousEntry.select()
break
} else {
if (previousEntry.selectLastFile()) {
break
}
}
}
}
selectFileAfterIndex (index) {
for (let i = index + 1; i < this.entries.length; i++) {
const nextEntry = this.entries[i]
if (nextEntry instanceof FileView) {
nextEntry.select()
break
} else {
if (nextEntry.selectFirstFile()) {
break
}
}
}
}
focus () {
this.focusSelectedFile()
}
}

View File

@ -0,0 +1,74 @@
const fs = require('fs')
const path = require('path')
const {Disposable} = require('atom')
const getIconServices = require('./get-icon-services')
const ArchiveEditorView = require('./archive-editor-view')
module.exports = {
activate () {
this.disposable = atom.workspace.addOpener((filePath = '') => {
// Check that filePath exists before opening, in case a remote URI was given
if (!isPathSupported(filePath)) return;
let isFile = false
try {
isFile = fs.statSync(filePath)?.isFile()
} catch (e) {}
if (isFile) {
return new ArchiveEditorView(filePath)
}
})
},
deactivate () {
this.disposable.dispose()
for (const item of atom.workspace.getPaneItems()) {
if (item instanceof ArchiveEditorView) {
item.destroy()
}
}
},
consumeElementIcons (service) {
getIconServices().setElementIcons(service)
return new Disposable(() => getIconServices().resetElementIcons())
},
consumeFileIcons (service) {
getIconServices().setFileIcons(service)
return new Disposable(() => getIconServices().resetFileIcons())
},
deserialize (params = {}) {
let isFile = false
try {
isFile = fs.statSync(params.path)?.isFile()
} catch (e) {}
if (isFile) {
return new ArchiveEditorView(params.path)
} else {
console.warn(`Can't build ArchiveEditorView for path "${params.path}"; file no longer exists`)
}
}
}
function isPathSupported (filePath) {
switch (path.extname(filePath)) {
case '.egg':
case '.epub':
case '.jar':
case '.love':
case '.nupkg':
case '.tar':
case '.tgz':
case '.war':
case '.whl':
case '.xpi':
case '.zip':
return true
case '.gz':
return path.extname(path.basename(filePath, '.gz')) === '.tar'
default:
return false
}
}

View File

@ -0,0 +1,36 @@
const fs = require('fs')
const path = require('path')
class DefaultFileIcons {
iconClassForPath (filePath) {
const extension = path.extname(filePath).toLowerCase()
const base = path.basename(filePath, extension).toLowerCase();
let isSymbolicLinkSync = false
try {
fs.lstatSync(filePath)?.isSymbolicLink();
} catch (e) {}
if (isSymbolicLinkSync) return 'icon-file-symlink-file'
if (base === 'readme' && ['','.markdown','.md','.mdown','.mkd','.mkdown','.rmd','.ron'].includes(extension)) {
return 'icon-book'
}
if (['.bz2','.egg','.epub','.gem','.gz','.jar','.lz','.lzma','.lzo','.rar','.tar','.tgz','.war','.whl','.xpi','.xz','.z','.zip'].includes(extension)) {
return 'icon-file-zip'
}
if (['.gif','.ico','.jpeg','.jpg','.png','.tif','.tiff','.webp'].includes(extension)) {
return 'icon-file-media'
}
if (extension === ".pdf") return 'icon-file-pdf'
if (['.ds_store','.a','.exe','.o','.pyc','.pyo','.so','.woff'].includes(extension)) {
return 'icon-file-binary'
}
return 'icon-file-text'
}
}
module.exports = new DefaultFileIcons()

View File

@ -0,0 +1,132 @@
/** @babel */
import {CompositeDisposable, Disposable} from 'atom'
import FileView from './file-view'
import getIconServices from './get-icon-services'
export default class DirectoryView {
constructor (parentView, indexInParentView, archivePath, entry) {
this.disposables = new CompositeDisposable()
this.entries = []
this.parentView = parentView
this.indexInParentView = indexInParentView
this.element = document.createElement('li')
this.element.classList.add('list-nested-item', 'entry')
const listItem = document.createElement('span')
listItem.classList.add('list-item')
const clickHandler = (event) => {
event.stopPropagation()
event.preventDefault()
this.element.classList.toggle('collapsed')
}
listItem.addEventListener('click', clickHandler)
this.disposables.add(new Disposable(() => { listItem.removeEventListener('click', clickHandler) }))
const entrySpan = document.createElement('span')
entrySpan.textContent = entry.getName()
listItem.appendChild(entrySpan)
this.element.appendChild(listItem)
this.entry = entry
this.entrySpan = entrySpan
getIconServices().updateDirectoryIcon(this)
this.entriesTree = document.createElement('ol')
this.entriesTree.classList.add('list-tree')
let index = 0
for (const child of entry.children) {
if (child.isDirectory()) {
const entryView = new DirectoryView(this, index, archivePath, child)
this.entries.push(entryView)
this.entriesTree.appendChild(entryView.element)
} else {
const entryView = new FileView(this, index, archivePath, child)
this.entries.push(entryView)
this.entriesTree.appendChild(entryView.element)
}
index++
}
this.element.appendChild(this.entriesTree)
}
destroy () {
if (this.iconDisposable) {
this.iconDisposable.dispose()
this.iconDisposable = null
}
while (this.entries.length > 0) {
this.entries.pop().destroy()
}
this.disposables.dispose()
this.element.remove()
}
selectFileBeforeIndex (index) {
for (let i = index - 1; i >= 0; i--) {
const previousEntry = this.entries[i]
if (previousEntry instanceof FileView) {
previousEntry.select()
return
} else {
if (previousEntry.selectLastFile()) {
return
}
}
}
this.parentView.selectFileBeforeIndex(this.indexInParentView)
}
selectFileAfterIndex (index) {
for (let i = index + 1; i < this.entries.length; i++) {
const nextEntry = this.entries[i]
if (nextEntry instanceof FileView) {
nextEntry.select()
return
} else {
if (nextEntry.selectFirstFile()) {
return
}
}
}
this.parentView.selectFileAfterIndex(this.indexInParentView)
}
selectFirstFile () {
for (const entry of this.entries) {
if (entry instanceof FileView) {
entry.select()
return true
} else {
if (entry.selectFirstFile()) {
return true
}
}
}
return false
}
selectLastFile () {
for (var i = this.entries.length - 1; i >= 0; i--) {
const entry = this.entries[i]
if (entry instanceof FileView) {
entry.select()
return true
} else {
if (entry.selectLastFile()) {
return true
}
}
}
return false
}
}

View File

@ -0,0 +1,112 @@
/** @babel */
import {CompositeDisposable, Disposable} from 'atom'
import path from 'path'
import fs from 'fs'
import temp from 'temp'
import archive from 'ls-archive'
import getIconServices from './get-icon-services'
export default class FileView {
constructor (parentView, indexInParentView, archivePath, entry) {
this.disposables = new CompositeDisposable()
this.parentView = parentView
this.indexInParentView = indexInParentView
this.archivePath = archivePath
this.entry = entry
this.element = document.createElement('li')
this.element.classList.add('list-item', 'entry')
this.element.tabIndex = -1
this.name = document.createElement('span')
getIconServices().updateFileIcon(this)
this.name.textContent = this.entry.getName()
this.element.appendChild(this.name)
const clickHandler = () => {
this.select()
this.openFile()
}
this.element.addEventListener('click', clickHandler)
this.disposables.add(new Disposable(() => { this.element.removeEventListener('click', clickHandler) }))
this.disposables.add(atom.commands.add(this.element, {
'core:confirm': () => {
if (this.isSelected()) {
this.openFile()
}
},
'core:move-down': () => {
if (this.isSelected()) {
this.parentView.selectFileAfterIndex(this.indexInParentView)
}
},
'core:move-up': () => {
if (this.isSelected()) {
this.parentView.selectFileBeforeIndex(this.indexInParentView)
}
}
}))
}
destroy () {
this.disposables.dispose()
this.element.remove()
}
isSelected () {
return this.element.classList.contains('selected')
}
logError (message, error) {
console.error(message, error.stack != null ? error.stack : error)
}
openFile () {
archive.readFile(this.archivePath, this.entry.getPath(), (error, contents) => {
if (error != null) {
this.logError(`Error reading: ${this.entry.getPath()} from ${this.archivePath}`, error)
} else {
temp.mkdir('atom-', (error, tempDirPath) => {
if (error != null) {
this.logError(`Error creating temp directory: ${tempDirPath}`, error)
} else {
const tempArchiveDirPath = path.join(tempDirPath, path.basename(this.archivePath))
fs.mkdir(tempArchiveDirPath, {recursive:true}, error => {
if (error != null) {
this.logError(`Error creating archive directory ${tempArchiveDirPath}`, error)
} else {
const tempFilePath = path.join(tempArchiveDirPath, this.entry.getName())
fs.writeFile(tempFilePath, contents, error => {
if (error != null) {
this.logError(`Error writing to ${tempFilePath}`, error)
} else {
atom.workspace.open(tempFilePath)
}
})
}
})
}
})
}
})
}
select () {
this.element.focus()
const archiveEditorElement = this.element.closest('.archive-editor')
// On initial tree creation, it is not possible for any entries to be selected
// (The entries also haven't been added to the DOM yet)
if (archiveEditorElement) {
for (const selected of archiveEditorElement.querySelectorAll('.selected')) {
selected.classList.remove('selected')
}
}
this.element.classList.add('selected')
}
}

View File

@ -0,0 +1,72 @@
const DefaultFileIcons = require('./default-file-icons')
const {Emitter, CompositeDisposable} = require('atom')
const path = require('path')
let iconServices
module.exports = function getIconServices () {
if (!iconServices) iconServices = new IconServices()
return iconServices
}
class IconServices {
constructor () {
this.emitter = new Emitter()
this.elementIcons = null
this.elementIconDisposables = new CompositeDisposable()
this.fileIcons = DefaultFileIcons
}
onDidChange (callback) {
return this.emitter.on('did-change', callback)
}
resetElementIcons () {
this.setElementIcons(null)
}
resetFileIcons () {
this.setFileIcons(DefaultFileIcons)
}
setElementIcons (service) {
if (service !== this.elementIcons) {
if (this.elementIconDisposables != null) {
this.elementIconDisposables.dispose()
}
if (service) { this.elementIconDisposables = new CompositeDisposable() }
this.elementIcons = service
return this.emitter.emit('did-change')
}
}
setFileIcons (service) {
if (service !== this.fileIcons) {
this.fileIcons = service
return this.emitter.emit('did-change')
}
}
updateDirectoryIcon (view) {
view.entrySpan.classList.add('directory', 'icon', 'icon-file-directory')
if (this.elementIcons) {
view.iconDisposable = this.elementIcons(view.entrySpan, view.entry.path, {isDirectory: true})
}
}
updateFileIcon (view) {
const nameClasses = ['file', 'icon']
if (this.elementIcons) {
const fullPath = path.join(view.archivePath, view.entry.path)
const disposable = this.elementIcons(view.name, fullPath)
view.disposables.add(disposable)
this.elementIconDisposables.add(disposable)
} else {
let typeClass = this.fileIcons.iconClassForPath(view.entry.path, 'archive-view') || []
if (!Array.isArray(typeClass) && typeClass) {
typeClass = typeClass.toString().split(/\s+/g)
}
nameClasses.push(...typeClass)
}
view.name.classList.add(...nameClasses)
}
}

View File

@ -0,0 +1,47 @@
{
"name": "archive-view",
"version": "0.66.0",
"description": "View the files and folders inside archive files",
"main": "./lib/archive-editor",
"dependencies": {
"etch": "0.9.0",
"humanize-plus": "~1.8.2",
"ls-archive": "https://github.com/pulsar-edit/node-ls-archive/archive/refs/tags/v1.3.1.tar.gz",
"temp": "~0.8.1"
},
"devDependencies": {
"standard": "^11.0.1"
},
"standard": {
"env": {
"atomtest": true,
"browser": true,
"jasmine": true,
"node": true
},
"globals": [
"atom"
]
},
"repository": "https://github.com/pulsar-edit/pulsar",
"license": "MIT",
"engines": {
"atom": "*"
},
"deserializers": {
"ArchiveEditor": "deserialize",
"ArchiveEditorView": "deserialize"
},
"consumedServices": {
"atom.file-icons": {
"versions": {
"1.0.0": "consumeFileIcons"
}
},
"file-icons.element-icons": {
"versions": {
"1.0.0": "consumeElementIcons"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -0,0 +1,46 @@
const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') // eslint-disable-line no-unused-vars
const path = require('path')
const ArchiveEditor = require('../lib/archive-editor')
const ArchiveEditorView = require('../lib/archive-editor-view')
describe('ArchiveEditor', () => {
const tarPath = path.join(__dirname, 'fixtures', 'nested.tar')
// Don't log during specs
beforeEach(() => spyOn(console, 'warn'))
describe('.deserialize', () => {
it('returns undefined if no file exists at the given path', () => {
const editor1 = new ArchiveEditorView(tarPath)
const state = editor1.serialize()
editor1.destroy()
const editor2 = ArchiveEditor.deserialize(state)
expect(editor2).toBeDefined()
editor2.destroy()
state.path = 'bogus'
expect(ArchiveEditor.deserialize(state)).toBeUndefined()
})
})
describe('.deactivate()', () => {
it('removes all ArchiveEditorViews from the workspace and does not open any new ones', async () => {
const getArchiveEditorViews = () => {
return atom.workspace.getPaneItems().filter(item => item instanceof ArchiveEditorView)
}
await atom.packages.activatePackage('archive-view')
await atom.workspace.open(path.join(__dirname, 'fixtures', 'nested.tar'))
await atom.workspace.open(path.join(__dirname, 'fixtures', 'invalid.zip'))
await atom.workspace.open()
expect(getArchiveEditorViews().length).toBe(2)
await atom.packages.deactivatePackage('archive-view')
expect(getArchiveEditorViews().length).toBe(0)
await atom.workspace.open(path.join(__dirname, 'fixtures', 'nested.tar'))
expect(getArchiveEditorViews().length).toBe(0)
})
})
})

View File

@ -0,0 +1,276 @@
const {Disposable, File} = require('atom')
const getIconServices = require('../lib/get-icon-services')
const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') // eslint-disable-line no-unused-vars
async function condition (handler) {
if (jasmine.isSpy(window.setTimeout)) {
jasmine.useRealClock()
}
return conditionPromise(handler)
}
describe('ArchiveEditorView', () => {
let archiveEditorView, onDidChangeCallback, onDidRenameCallback, onDidDeleteCallback
beforeEach(async () => {
spyOn(File.prototype, 'onDidChange').andCallFake(function (callback) {
if (/\.tar$/.test(this.getPath())) {
onDidChangeCallback = callback
}
return new Disposable()
})
spyOn(File.prototype, 'onDidRename').andCallFake(function (callback) {
if (/\.tar$/.test(this.getPath())) {
onDidRenameCallback = callback
}
return new Disposable()
})
spyOn(File.prototype, 'onDidDelete').andCallFake(function (callback) {
if (/\.tar$/.test(this.getPath())) {
onDidDeleteCallback = callback
}
return new Disposable()
})
await atom.packages.activatePackage('archive-view')
archiveEditorView = await atom.workspace.open('nested.tar')
})
describe('.constructor()', () => {
it('displays the files and folders in the archive file', async () => {
expect(archiveEditorView.element).toExist()
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
const directoryElements = archiveEditorView.element.querySelectorAll('.directory')
expect(directoryElements.length).toBe(6)
expect(directoryElements[0].textContent).toBe('d1')
expect(directoryElements[1].textContent).toBe('d2')
expect(directoryElements[2].textContent).toBe('d3')
expect(directoryElements[3].textContent).toBe('d4')
expect(directoryElements[4].textContent).toBe('da')
expect(directoryElements[5].textContent).toBe('db')
const fileElements = archiveEditorView.element.querySelectorAll('.file')
expect(fileElements.length).toBe(3)
expect(fileElements[0].textContent).toBe('f1.txt')
expect(fileElements[1].textContent).toBe('f2.txt')
expect(fileElements[2].textContent).toBe('fa.txt')
})
it('selects the first file', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
expect(archiveEditorView.element.querySelector('.selected').textContent).toBe('f1.txt')
})
})
describe('.copy()', () => {
it('returns a new ArchiveEditorView for the same file', () => {
const newArchiveView = archiveEditorView.copy()
expect(newArchiveView.getPath()).toBe(archiveEditorView.getPath())
})
})
describe('archive summary', () => {
beforeEach(async () => {
await atom.workspace.open('multiple-entries.zip')
archiveEditorView = atom.workspace.getActivePaneItem()
jasmine.attachToDOM(atom.views.getView(atom.workspace))
})
it('shows correct statistics', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
const heading = archiveEditorView.element.querySelector('.inset-panel .panel-heading')
expect(heading).not.toBe(null)
expect(heading.textContent).toBe('704 bytes with 4 files and 1 folder')
})
})
describe('when core:move-up/core:move-down is triggered', () => {
let selectedEntry
const dispatch = (command) => {
atom.commands.dispatch(archiveEditorView.element.querySelector('.selected'), command)
selectedEntry = archiveEditorView.element.querySelector('.selected').textContent
return true
}
it('selects the next/previous file', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
expect(archiveEditorView.element).toBeDefined()
dispatch('core:move-up') && expect(selectedEntry).toBe('f1.txt')
dispatch('core:move-down') && expect(selectedEntry).toBe('f2.txt')
dispatch('core:move-down') && expect(selectedEntry).toBe('fa.txt')
dispatch('core:move-down') && expect(selectedEntry).toBe('fa.txt')
dispatch('core:move-up') && expect(selectedEntry).toBe('f2.txt')
dispatch('core:move-up') && expect(selectedEntry).toBe('f1.txt')
})
})
describe('when a file is clicked', () => {
it('copies the contents to a temp file and opens it in a new editor', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
archiveEditorView.element.querySelectorAll('.file')[2].click()
await condition(() => atom.workspace.getActivePane().getItems().length > 1)
expect(atom.workspace.getActivePaneItem().getText()).toBe('hey there\n')
expect(atom.workspace.getActivePaneItem().getTitle()).toBe('fa.txt')
})
})
describe('when a directory is clicked', () => {
it('collapses/expands itself', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
let directory = archiveEditorView.element.querySelectorAll('.list-nested-item.entry')[0]
expect(directory.classList.contains('collapsed')).toBeFalsy()
directory.querySelector('.list-item').click()
expect(directory.classList.contains('collapsed')).toBeTruthy()
directory.querySelector('.list-item').click()
expect(directory.classList.contains('collapsed')).toBeFalsy()
})
})
describe('when core:confirm is triggered', () => {
it('copies the contents to a temp file and opens it in a new editor', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
atom.commands.dispatch(archiveEditorView.element.querySelector('.file'), 'core:confirm')
await condition(() => atom.workspace.getActivePane().getItems().length > 1)
expect(atom.workspace.getActivePaneItem().getText()).toBe('')
expect(atom.workspace.getActivePaneItem().getTitle()).toBe('f1.txt')
})
})
describe('when the file is modified', () => {
it('refreshes the view', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
spyOn(archiveEditorView, 'refresh')
onDidChangeCallback()
expect(archiveEditorView.refresh).toHaveBeenCalled()
})
})
describe('when the file is renamed', () => {
it('refreshes the view and updates the title', async () => {
spyOn(File.prototype, 'getPath').andReturn('nested-renamed.tar')
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
spyOn(archiveEditorView, 'refresh').andCallThrough()
spyOn(archiveEditorView, 'getTitle')
onDidRenameCallback()
expect(archiveEditorView.refresh).toHaveBeenCalled()
expect(archiveEditorView.getTitle).toHaveBeenCalled()
})
})
describe('when the file is removed', () => {
it('destroys the view', async () => {
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
expect(atom.workspace.getActivePane().getItems().length).toBe(1)
onDidDeleteCallback()
expect(atom.workspace.getActivePaneItem()).toBeUndefined()
})
})
describe('when the file is invalid', () => {
beforeEach(async () => {
await atom.workspace.open('invalid.zip')
archiveEditorView = atom.workspace.getActivePaneItem()
jasmine.attachToDOM(atom.views.getView(atom.workspace))
})
it('shows the error', async () => {
await condition(() => archiveEditorView.refs.errorMessage.offsetHeight > 0)
expect(archiveEditorView.refs.errorMessage.textContent.length).toBeGreaterThan(0)
})
})
describe('FileIcons', () => {
async function openFile () {
await atom.workspace.open('file-icons.zip')
archiveEditorView = atom.workspace.getActivePaneItem()
jasmine.attachToDOM(atom.views.getView(atom.workspace))
}
describe('Icon service', () => {
const service = { iconClassForPath () {} }
beforeEach(() => openFile())
it('provides a default service', () => {
expect(getIconServices().fileIcons).toBeDefined()
expect(getIconServices().fileIcons).not.toBeNull()
})
it('allows the default to be overridden', () => {
getIconServices().setFileIcons(service)
expect(getIconServices().fileIcons).toBe(service)
})
it('allows service to be reset without hassle', () => {
getIconServices().setFileIcons(service)
getIconServices().resetFileIcons()
expect(getIconServices().fileIcons).not.toBe(service)
})
})
describe('Class handling', () => {
function findEntryContainingText (text) {
for (const entry of archiveEditorView.element.querySelectorAll('.list-item.entry')) {
if (entry.textContent.includes(text)) { return entry }
}
return null
}
function checkMultiClass () {
expect(findEntryContainingText('adobe.pdf').querySelector('.file.icon').className).toBe('file icon text pdf-icon document')
expect(findEntryContainingText('spacer.gif').querySelector('.file.icon').className).toBe('file icon binary gif-icon image')
expect(findEntryContainingText('font.ttf').querySelector('.file.icon').className).toBe('file icon binary ttf-icon font')
}
it('displays default file-icons', async () => {
await openFile()
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
expect(findEntryContainingText('adobe.pdf').querySelector('.file.icon.icon-file-pdf').length).not.toBe(0)
expect(findEntryContainingText('spacer.gif').querySelector('.file.icon.icon-file-media').length).not.toBe(0)
expect(findEntryContainingText('sunn.o').querySelector('.file.icon.icon-file-binary').length).not.toBe(0)
})
it('allows multiple classes to be passed', async () => {
getIconServices().setFileIcons({
iconClassForPath: (path) => {
switch (path.match(/\w*$/)[0]) {
case 'pdf': return 'text pdf-icon document'
case 'ttf': return 'binary ttf-icon font'
case 'gif': return 'binary gif-icon image'
}
}
})
await openFile()
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
checkMultiClass()
})
it('allows an array of classes to be passed', async () => {
getIconServices().setFileIcons({
iconClassForPath: (path) => {
switch (path.match(/\w*$/)[0]) {
case 'pdf': return ['text', 'pdf-icon', 'document']
case 'ttf': return ['binary', 'ttf-icon', 'font']
case 'gif': return ['binary', 'gif-icon', 'image']
}
}
})
await openFile()
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
checkMultiClass()
})
it('identifies context to icon-service providers', async () => {
getIconServices().setFileIcons({
iconClassForPath: (path, context) => `icon-${context}`
})
await openFile()
await condition(() => archiveEditorView.element.querySelectorAll('.entry').length > 0)
const icons = findEntryContainingText('adobe.pdf').querySelectorAll('.file.icon-archive-view')
expect(icons.length).not.toBe(0)
})
})
})
})

View File

@ -0,0 +1,103 @@
/** @babel */
export function beforeEach (fn) {
global.beforeEach(function () {
const result = fn()
if (result instanceof Promise) {
waitsForPromise(() => result)
}
})
}
export function afterEach (fn) {
global.afterEach(function () {
const result = fn()
if (result instanceof Promise) {
waitsForPromise(() => result)
}
})
}
['it', 'fit', 'ffit', 'fffit'].forEach(function (name) {
module.exports[name] = function (description, fn) {
if (fn === undefined) {
global[name](description)
return
}
global[name](description, function () {
const result = fn()
if (result instanceof Promise) {
waitsForPromise(() => result)
}
})
}
})
export async function conditionPromise (condition, description = 'anonymous condition') {
const startTime = Date.now()
while (true) {
await timeoutPromise(100)
if (await condition()) {
return
}
if (Date.now() - startTime > 5000) {
throw new Error('Timed out waiting on ' + description)
}
}
}
export function timeoutPromise (timeout) {
return new Promise(function (resolve) {
global.setTimeout(resolve, timeout)
})
}
function waitsForPromise (fn) {
const promise = fn()
global.waitsFor('spec promise to resolve', function (done) {
promise.then(done, function (error) {
jasmine.getEnv().currentSpec.fail(error)
done()
})
})
}
export function emitterEventPromise (emitter, event, timeout = 15000) {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out waiting for '${event}' event`))
}, timeout)
emitter.once(event, () => {
clearTimeout(timeoutHandle)
resolve()
})
})
}
export function promisify (original) {
return function (...args) {
return new Promise((resolve, reject) => {
args.push((err, ...results) => {
if (err) {
reject(err)
} else {
resolve(...results)
}
})
return original(...args)
})
}
}
export function promisifySome (obj, fnNames) {
const result = {}
for (const fnName of fnNames) {
result[fnName] = promisify(obj[fnName])
}
return result
}

Binary file not shown.

View File

@ -0,0 +1 @@
invalid

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,24 @@
@import "ui-variables";
.archive-editor {
background-color: @inset-panel-background-color;
overflow: auto;
contain: strict;
.archive-container {
height: 100%;
width: 100%;
.inset-panel {
border-width: 0;
.panel-heading {
border-radius: 0;
}
.archive-tree {
padding: 5px;
}
}
}
}

View File

@ -2036,14 +2036,12 @@ aproba@^1.0.3:
resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
"archive-view@https://codeload.github.com/atom/archive-view/legacy.tar.gz/refs/tags/v0.66.0":
"archive-view@file:packages/archive-view":
version "0.66.0"
resolved "https://codeload.github.com/atom/archive-view/legacy.tar.gz/refs/tags/v0.66.0#e4563bf7cf4d6916eee4a949ca260315e8b2fe06"
dependencies:
etch "0.9.0"
fs-plus "^3.0.0"
humanize-plus "~1.8.2"
ls-archive "1.3.4"
ls-archive "https://github.com/pulsar-edit/node-ls-archive/archive/refs/tags/v1.3.1.tar.gz"
temp "~0.8.1"
archiver-utils@^2.1.0:
@ -6269,10 +6267,9 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
ls-archive@1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/ls-archive/-/ls-archive-1.3.4.tgz#52150919dab1acb094cdcef9dde9c66934a4650f"
integrity sha512-7GmjZOckV+gzm4PM1/LcWIsZIRsSkAVmIchoEf5xjquNKU0Ti5KUvGQ3dl/7VsbZIduMOPwRDXrvpo3LVJ0Pmg==
"ls-archive@https://github.com/pulsar-edit/node-ls-archive/archive/refs/tags/v1.3.1.tar.gz":
version "1.3.1"
resolved "https://github.com/pulsar-edit/node-ls-archive/archive/refs/tags/v1.3.1.tar.gz#43baa626a66f9c904548741b5bdf2f8095fbc7d9"
dependencies:
async "~0.2.9"
colors "~0.6.2"