refactor(gui): Refactor file picker fs I/O

This refactors the experimental file picker to avoid fs i/o
in as many places as possible to improve performance.
Further, rendering performance is improved by avoiding unnecessary
element state changes invalidating components.
Also, recent files & favorites have been temporarily disabled
due to lack of need for Etcher Pro.

Change-Type: patch
This commit is contained in:
Jonas Hermsmeier 2018-05-31 20:01:56 +02:00
parent d1c44ab7b1
commit fc22e9e28a
No known key found for this signature in database
GPG Key ID: 1B870F801A0CEE9F
11 changed files with 3970 additions and 800 deletions

View File

@ -0,0 +1,45 @@
/*
* Copyright 2018 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
/**
* @summary Color scheme
* @constant
* @private
*/
const colors = {
primary: {
color: '#3a3c41',
background: '#ffffff',
subColor: '#ababab',
faded: '#c3c4c6'
},
secondary: {
color: '#1c1d1e',
background: '#ebeff4',
title: '#b3b6b9'
},
highlight: {
color: 'white',
background: '#2297de'
},
soft: {
color: '#4d5056'
}
}
module.exports = colors

View File

@ -0,0 +1,303 @@
/*
* Copyright 2018 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const React = require('react')
const propTypes = require('prop-types')
const styled = require('styled-components').default
const rendition = require('rendition')
const colors = require('./colors')
const prettyBytes = require('pretty-bytes')
const files = require('../../../models/files')
const middleEllipsis = require('../../../utils/middle-ellipsis')
const supportedFormats = require('../../../../../shared/supported-formats')
const debug = require('debug')('etcher:gui:file-selector')
/**
* @summary Character limit of a filename before a middle-ellipsis is added
* @constant
* @private
*/
const FILENAME_CHAR_LIMIT = 20
/**
* @summary Pattern to match all supported formats for highlighting
* @constant
* @private
*/
const SUPPORTED_FORMATS_PATTERN = new RegExp(`^\\.(${supportedFormats.getAllExtensions().join('|')})$`, 'i')
/**
* @summary Flex styled component
* @function
* @type {ReactElement}
*/
const Flex = styled.div`
display: flex;
flex: ${ props => props.flex };
flex-direction: ${ props => props.direction };
justify-content: ${ props => props.justifyContent };
align-items: ${ props => props.alignItems };
flex-wrap: ${ props => props.wrap };
flex-grow: ${ props => props.grow };
`
/**
* @summary Anchor flex styled component
* @function
* @type {ReactElement}
*/
const ClickableFlex = styled.a`
display: flex;
flex: ${ props => props.flex };
flex-direction: ${ props => props.direction };
justify-content: ${ props => props.justifyContent };
align-items: ${ props => props.alignItems };
flex-wrap: ${ props => props.wrap };
flex-grow: ${ props => props.grow };
`
/**
* @summary FileList scroll wrapper element
* @class
* @type {ReactElement}
*/
class UnstyledFileListWrap extends React.PureComponent {
constructor (props) {
super(props)
this.scrollElem = null
}
render () {
return (
<Flex className={ this.props.className }
innerRef={ ::this.setScrollElem }
wrap="wrap">
{ this.props.children }
</Flex>
)
}
setScrollElem (element) {
this.scrollElem = element
}
componentDidUpdate (prevProps) {
if (this.scrollElem) {
this.scrollElem.scrollTop = 0
}
}
}
/**
* @summary FileList scroll wrapper element
* @class
* @type {StyledComponent}
*/
const FileListWrap = styled(UnstyledFileListWrap)`
overflow-x: hidden;
overflow-y: auto;
padding: 0 20px;
`
/**
* @summary File element
* @class
* @type {ReactElement}
*/
class UnstyledFile extends React.PureComponent {
static getFileIconClass (file) {
return file.isDirectory
? 'fas fa-folder'
: 'fas fa-file-alt'
}
onHighlight (event) {
event.preventDefault()
this.props.onHighlight(this.props.file)
}
onSelect (event) {
event.preventDefault()
this.props.onSelect(this.props.file)
}
render () {
const file = this.props.file
return (
<ClickableFlex
data-path={ file.path }
href={ `file://${file.path}` }
direction="column"
alignItems="stretch"
className={ this.props.className }
onClick={ ::this.onHighlight }
onDoubleClick={ ::this.onSelect }>
<span className={ UnstyledFile.getFileIconClass(file) } />
<span>{ middleEllipsis(file.basename, FILENAME_CHAR_LIMIT) }</span>
<div>{ file.isDirectory ? '' : prettyBytes(file.size || 0) }</div>
</ClickableFlex>
)
}
}
/**
* @summary File element
* @class
* @type {StyledComponent}
*/
const File = styled(UnstyledFile)`
width: 100px;
min-height: 100px;
max-height: 128px;
margin: 5px 10px;
padding: 5px;
background-color: none;
transition: 0.05s background-color ease-out;
color: ${ colors.primary.color };
cursor: pointer;
border-radius: 5px;
word-break: break-word;
> span:first-of-type {
align-self: center;
line-height: 1;
margin-bottom: 6px;
font-size: 48px;
color: ${ props => props.disabled ? colors.primary.faded : colors.soft.color };
}
> span:last-of-type {
display: flex;
justify-content: center;
text-align: center;
font-size: 14px;
}
> div:last-child {
background-color: none;
color: ${ colors.primary.subColor };
text-align: center;
font-size: 12px;
}
:hover, :visited {
color: ${ colors.primary.color };
}
:focus,
:active {
color: ${ colors.highlight.color };
background-color: ${ colors.highlight.background };
}
:focus > span:first-of-type,
:active > span:first-of-type {
color: ${ colors.highlight.color };
}
:focus > div:last-child,
:active > div:last-child {
color: ${ colors.highlight.color };
}
`
/**
* @summary FileList element
* @class
* @type {ReactElement}
*/
class FileList extends React.Component {
constructor (props) {
super(props)
this.state = {
path: props.path,
highlighted: null,
files: [],
}
}
readdir (dirname) {
debug('FileList:readdir', dirname)
files.readdirAsync(dirname).then((files) => {
window.requestAnimationFrame(() => {
this.setState({ files: files })
})
})
}
componentDidMount () {
process.nextTick(() => {
this.readdir(this.state.path)
})
}
onHighlight (file) {
debug('FileList:onHighlight', file)
this.props.onHighlight(file)
}
onSelect (file) {
debug('FileList:onSelect', file.path, file.isDirectory)
this.props.onSelect(file)
}
shouldComponentUpdate (nextProps, nextState) {
const shouldUpdate = (this.state.files !== nextState.files)
debug('FileList:shouldComponentUpdate', shouldUpdate)
if (this.props.path !== nextProps.path) {
this.readdir(nextProps.path)
}
return shouldUpdate
}
static isSelectable (file) {
return file.isDirectory || !file.ext ||
SUPPORTED_FORMATS_PATTERN.test(file.ext)
}
render () {
return (
<FileListWrap wrap="wrap">
{
this.state.files.map((file) => {
return (
<File key={ file.path }
file={ file }
disabled={ !FileList.isSelectable(file) }
onSelect={ ::this.onSelect }
onHighlight={ ::this.onHighlight }/>
)
})
}
</FileListWrap>
)
}
}
FileList.propTypes = {
path: propTypes.string,
onNavigate: propTypes.func,
onSelect: propTypes.func,
constraints: propTypes.arrayOf(propTypes.string)
}
module.exports = FileList

View File

@ -16,123 +16,29 @@
'use strict'
const _ = require('lodash')
const fs = require('fs')
const path = require('path')
const Bluebird = require('bluebird')
const React = require('react')
const propTypes = require('prop-types')
const { default: styled } = require('styled-components')
const styled = require('styled-components').default
const rendition = require('rendition')
const prettyBytes = require('pretty-bytes')
const Bluebird = require('bluebird')
const fontAwesome = require('@fortawesome/fontawesome')
const {
faFileAlt,
faFolder,
faAngleLeft,
faHdd
} = require('@fortawesome/fontawesome-free-solid')
const Storage = require('../../../models/storage')
const analytics = require('../../../modules/analytics')
const middleEllipsis = require('../../../utils/middle-ellipsis')
const files = require('../../../models/files')
const colors = require('./colors')
const Breadcrumbs = require('./path-breadcrumbs')
const FileList = require('./file-list')
const RecentFiles = require('./recent-files')
const selectionState = require('../../../models/selection-state')
const imageStream = require('../../../../../sdk/image-stream')
const errors = require('../../../../../shared/errors')
const osDialog = require('../../../os/dialog')
const exceptionReporter = require('../../../modules/exception-reporter')
const messages = require('../../../../../shared/messages')
const errors = require('../../../../../shared/errors')
const imageStream = require('../../../../../sdk/image-stream')
const supportedFormats = require('../../../../../shared/supported-formats')
const analytics = require('../../../modules/analytics')
/**
* @summary Recent files localStorage object key
* @constant
* @private
*/
const RECENT_FILES_KEY = 'file-selector-recent-files'
const recentStorage = new Storage(RECENT_FILES_KEY)
/**
* @summary How many directories to show with the breadcrumbs
* @type {Number}
* @constant
* @private
*/
const MAX_DIR_CRUMBS = 3
/**
* @summary Character limit of a filename before a middle-ellipsis is added
* @constant
* @private
*/
const FILENAME_CHAR_LIMIT = 20
/**
* @summary Character limit of a filename before a middle-ellipsis is added
* @constant
* @private
*/
const FILENAME_CHAR_LIMIT_SHORT = 15
/**
* @summary Color scheme
* @constant
* @private
*/
const colors = {
primary: {
color: '#3a3c41',
background: '#ffffff',
subColor: '#ababab'
},
secondary: {
color: '#1c1d1e',
background: '#ebeff4',
title: '#b3b6b9'
},
highlight: {
color: 'white',
background: '#2297de'
},
soft: {
color: '#4d5056'
}
}
/**
* @summary Awesome icons HTML object
* @constant
* @type {Object<HTMLElement>}
*/
const icons = {
faFileAlt: fontAwesome.icon(faFileAlt).html[0],
faFolder: fontAwesome.icon(faFolder).html[0],
faAngleLeft: fontAwesome.icon(faAngleLeft).html[0],
faHdd: fontAwesome.icon(faHdd).html[0]
}
/**
* @summary Icon React component
* @class
* @type {ReactElement}
*/
class UnstyledIcon extends React.PureComponent {
render () {
const { type, ...restProps } = this.props
return (
<span className={ this.props.className } dangerouslySetInnerHTML={ { __html: icons[type] } } { ...restProps } />
)
}
}
/**
* @summary Icon Styled component
* @function
* @type {StyledComponent}
*/
const Icon = styled(UnstyledIcon)`
color: ${props => props.color};
font-size: ${props => props.size};
`
const debug = require('debug')('etcher:gui:file-selector')
/**
* @summary Flex styled component
@ -149,76 +55,6 @@ const Flex = styled.div`
flex-grow: ${ props => props.grow };
`
class UnstyledFileLink extends React.PureComponent {
constructor (props) {
super(props)
this.highlightFile = this.highlightFile.bind(this)
this.selectFile = this.selectFile.bind(this)
}
render () {
const icon = this.props.file.isDirectory ? 'faFolder' : 'faFileAlt'
return (
<Flex
direction="column"
alignItems="stretch"
className={ this.props.className }
onClick={ this.highlightFile }
onDoubleClick={ this.selectFile }>
<Icon type={ icon } size="48px" />
<span>
{ middleEllipsis(this.props.file.basename || '', FILENAME_CHAR_LIMIT_SHORT) }
</span>
<div>{ prettyBytes(this.props.file.size || 0) }</div>
</Flex>
)
}
highlightFile () {
this.props.highlightFile(this.props.file)
}
selectFile () {
this.props.selectFile(this.props.file)
}
}
const FileLink = styled(UnstyledFileLink)`
width: 100px;
max-height: 128px;
margin: 5px 10px;
padding: 5px;
background-color: ${ props => props.highlight ? colors.highlight.background : 'none' };
transition: 0.15s background-color ease-out;
color: ${ props => props.highlight ? colors.highlight.color : colors.primary.color };
cursor: pointer;
border-radius: 5px;
> span:first-of-type {
align-self: center;
line-height: 1;
margin-bottom: 6px;
color: ${ props => props.highlight ? colors.highlight.color : colors.soft.color }
}
> span:last-of-type {
display: flex;
justify-content: center;
text-align: center;
word-break: break-all;
font-size: 16px;
}
> div:last-child {
background-color: ${ props => props.highlight ? colors.highlight.background : 'none' }
color: ${ props => props.highlight ? colors.highlight.color : colors.primary.subColor }
text-align: center;
font-size: 12px;
}
`
const Header = Flex.extend`
margin: 10px 15px 0;
@ -238,527 +74,177 @@ const Footer = Flex.extend`
}
`
class UnstyledFileListWrap extends React.PureComponent {
constructor (props) {
super(props)
this.scrollElem = null
this.setScrollElem = this.setScrollElem.bind(this)
}
render () {
return (
<Flex
className={ this.props.className }
innerRef={ this.setScrollElem }
wrap="wrap"
>
{ this.props.children }
</Flex>
)
}
setScrollElem (scrollElem) {
this.scrollElem = scrollElem
}
componentDidUpdate (prevProps) {
if (prevProps.path !== this.props.path && this.scrollElem) {
this.scrollElem.scrollTop = 0
}
}
}
const FileListWrap = styled(UnstyledFileListWrap)`
overflow-x: hidden;
overflow-y: auto;
padding: 0 20px;
`
class RecentFileLink extends React.PureComponent {
constructor (props) {
super(props)
this.select = this.select.bind(this)
}
render () {
return (
<rendition.Button
onClick={ this.select }
plaintext={ true }>
{ middleEllipsis(path.basename(this.props.title), FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Button>
)
}
select () {
files.getFileMetadataAsync(this.props.fullpath).then(this.props.selectFile)
}
}
class RecentFilesUnstyled extends React.PureComponent {
constructor (props) {
super(props)
this.state = {
recents: recentStorage.get('recents', []),
favorites: recentStorage.get('favorites', [])
}
}
render () {
return (
<Flex className={ this.props.className }>
<h5>Recent</h5>
{
_.map(this.state.recents, (file) => {
return (
<RecentFileLink
key={ file.dirname }
fullpath={ file.dirname }
title={ path.basename(file.dirname) }
selectFile={ this.props.selectFile }
/>
)
})
}
<h5>Favorite</h5>
{
_.map(this.state.favorites.slice(0, 4), (file) => {
return (
<RecentFileLink
key={ file.fullpath }
fullpath={ file.fullpath }
title={ file.basename }
selectFile={ this.props.selectFile }
/>
)
})
}
</Flex>
)
}
componentWillMount () {
window.addEventListener('storage', this.onStorage)
}
componentWillUnmount () {
window.removeEventListener('storage', this.onStorage)
}
componentDidMount () {
Bluebird.reduce(this.state.recents, (newRecents, recent) => {
return files.existsAsync(recent.fullpath).then((exists) => {
if (exists) {
return newRecents.concat(recent)
}
return newRecents
})
}, []).then((recents) => {
this.setState({ recents })
})
Bluebird.reduce(this.state.favorites, (newFavorites, favorite) => {
return files.existsAsync(favorite.fullpath).then((exists) => {
if (exists) {
return newFavorites.concat(favorite)
}
return newFavorites
})
}, []).then((favorites) => {
this.setState({ favorites })
})
}
onStorage (event) {
if (event.key === RECENT_FILES_KEY) {
this.setState(event.newValue)
}
}
}
const RecentFiles = styled(RecentFilesUnstyled)`
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-start;
width: 130px;
background-color: ${ colors.secondary.background };
padding: 20px;
color: ${ colors.secondary.color };
> h5 {
color: ${ colors.secondary.title };
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
margin-bottom: 15px;
}
> h5:last-of-type {
margin-top: 20px;
}
> button {
margin-bottom: 10px;
text-align: start;
font-size: 16px;
}
`
const labels = {
'/': 'Root',
'mountpoints': 'Mountpoints'
}
class Crumb extends React.PureComponent {
constructor (props) {
super(props)
this.selectFile = this.selectFile.bind(this)
}
render () {
return (
<rendition.Button
onClick={ this.selectFile }
plaintext={ true }>
<rendition.Txt bold={ this.props.bold }>
{ middleEllipsis(labels[this.props.dir.fullpath] || this.props.dir.basename, FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Txt>
</rendition.Button>
)
}
selectFile () {
this.props.selectFile(this.props.dir)
}
}
class UnstyledBreadcrumbs extends React.PureComponent {
render () {
const folderConstraints = this.props.constraints.length
? this.props.constraints
: [ path.parse(this.props.path).root ]
const dirs = files.subpaths(this.props.path).filter((subpath) => {
// Guard against displaying folders outside the constrained folders
return folderConstraints.some((folderConstraint) => {
return !path.relative(folderConstraint, subpath.fullpath).startsWith('..')
})
})
return (
<div className={ this.props.className }>
{ dirs.length > MAX_DIR_CRUMBS ? '... / ' : null }
{
_.map(dirs.slice(-MAX_DIR_CRUMBS), (dir, index) => {
return (
<Crumb
key={ dir.fullpath }
bold={ index === dirs.length - 1 }
dir={ dir }
selectFile={ this.props.selectFile }
/>
)
})
}
</div>
)
}
}
const Breadcrumbs = styled(UnstyledBreadcrumbs)`
font-size: 18px;
& > button:not(:last-child)::after {
content: '/';
margin: 9px;
}
`
class FileSelector extends React.PureComponent {
constructor (props) {
super(props)
this.highlighted = null
this.state = {
path: props.path,
files: [],
history: [],
highlighted: null,
error: null
}
}
confirmSelection () {
if (this.highlighted) {
this.selectFile(this.highlighted)
}
}
close () {
this.props.close()
}
componentDidUpdate () {
debug('FileSelector:componentDidUpdate')
}
navigate (newPath) {
debug('FileSelector:navigate', newPath)
this.setState({ path: newPath })
}
navigateUp () {
const newPath = path.join( this.state.path, '..' )
debug('FileSelector:navigateUp', this.state.path, '->', newPath)
this.setState({ path: newPath })
}
selectImage (image) {
debug('FileSelector:selectImage', image)
if (!supportedFormats.isSupportedImage(image.path)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(image)
})
osDialog.showError(invalidImageError)
analytics.logEvent('Invalid image', image)
return
}
// Filters schema
this.schema = {
type: 'object',
properties: {
basename: {
type: 'string'
},
isHidden: {
type: 'boolean'
},
isDirectory: {
type: 'boolean'
}
return Bluebird.try(() => {
let message = null
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', image)
message = messages.warning.looksLikeWindowsImage()
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', image)
message = messages.warning.missingPartitionTable()
}
if (message) {
// TODO: `Continue` should be on a red background (dangerous action) instead of `Change`.
// We want `X` to act as `Continue`, that's why `Continue` is the `rejectionLabel`
return osDialog.showWarning({
confirmationLabel: 'Change',
rejectionLabel: 'Continue',
title: 'Warning',
description: message
})
}
return false
}).then((shouldChange) => {
if (shouldChange) {
return
}
selectionState.selectImage(image)
this.close()
// An easy way so we can quickly identify if we're making use of
// certain features without printing pages of text to DevTools.
image.logo = Boolean(image.logo)
image.bmap = Boolean(image.bmap)
analytics.logEvent('Select image', image)
}).catch(exceptionReporter.report)
}
selectFile (file) {
debug('FileSelector:selectFile', file)
if (file.isDirectory) {
this.navigate(file.path)
return
}
this.closeModal = this.closeModal.bind(this)
this.resolveModal = this.resolveModal.bind(this)
this.browsePath = this.browsePath.bind(this)
this.selectFile = this.selectFile.bind(this)
this.highlightFile = this.highlightFile.bind(this)
this.previousDirectory = this.previousDirectory.bind(this)
debug('FileSelector:getImageMetadata', file)
imageStream.getImageMetadata(file.path)
.then((imageMetadata) => {
debug('FileSelector:getImageMetadata', imageMetadata)
return this.selectImage(imageMetadata)
})
.catch((error) => {
debug('FileSelector:getImageMetadata', error)
const imageError = errors.createUserError({
title: 'Error opening image',
description: messages.error.openImage(path.basename(file.path), error.message)
})
osDialog.showError(imageError)
analytics.logException(error)
})
}
onHighlight (file) {
this.highlighted = file
}
render () {
const items = this.state.files
const styles = {
display: 'flex',
height: 'calc(100vh - 20px)',
}
const errorModal = (
<rendition.Modal
title={ _.get(this.state.error, [ 'title' ]) || "Error" }
done={ () => this.setState({ error: null }) }
action="Dismiss"
primaryButtonProps={ { danger: true, primary: false } }
>
{ _.get(this.state.error, [ 'message' ]) || this.state.error }
</rendition.Modal>
)
return (
<rendition.Provider
style={ styles }>
<RecentFiles selectFile={ this.selectFile } flex="0 0 auto" />
<rendition.Provider style={ styles }>
{/*<RecentFiles flex="0 0 auto"
selectFile={ ::this.selectFile }
navigate={ ::this.navigate } />*/}
<Flex direction="column" grow="1">
<Header flex="0 0 auto" alignItems="baseline">
<rendition.Button
bg={ colors.secondary.background }
color={ colors.primary.color }
disabled={ !this.state.history.length }
onClick={ this.previousDirectory }>
<Icon type={ 'faAngleLeft' } />
onClick={ ::this.navigateUp }>
<span className="fas fa-angle-left" />
&nbsp;Back
</rendition.Button>
<Icon type={ 'faHdd' } />
<span className="fas fa-hdd" />
<Breadcrumbs
path={ this.state.path }
selectFile={ this.selectFile }
navigate={ ::this.navigate }
constraints={ this.props.constraints }
/>
</Header>
<Main flex="1">
<Flex direction="column" grow="1">
<FileListWrap path={ this.state.path }>
{
items.map((item) => {
return (
<FileLink
key={ item.fullpath }
file={ item }
highlight={ _.get(this.state.highlighted, 'fullpath') === _.get(item, 'fullpath') }
highlightFile={ this.highlightFile }
selectFile={ this.selectFile }
/>
)
})
}
</FileListWrap>
<FileList path={ this.state.path }
onHighlight={ ::this.onHighlight }
onSelect={ ::this.selectFile }></FileList>
</Flex>
</Main>
<Footer justifyContent="flex-end">
<rendition.Button onClick={ this.closeModal }>Cancel</rendition.Button>
<rendition.Button onClick={ ::this.close }>Cancel</rendition.Button>
<rendition.Button
primary
onClick={ this.resolveModal }
disabled={ !this.state.highlighted }>
onClick={ ::this.confirmSelection }>
Select file
</rendition.Button>
</Footer>
</Flex>
{ this.state.error ? errorModal : null }
</rendition.Provider>
)
}
componentDidMount () {
this.setFilesProgressively(this.state.path)
}
closeModal () {
this.props.close()
}
resolveModal () {
this.selectFile(this.state.highlighted)
}
setFilesProgressively (dirname) {
return fs.readdirAsync(dirname).then((basenames) => {
return files.getAllFilesMetadataAsync(dirname, basenames)
}).then((fileObjs) => {
// Sort folders first and ignore case
fileObjs.sort((fileA, fileB) => {
// NOTE(Shou): the multiplication is an arbitrarily large enough number
// to ensure folders have precedence over filenames
const directoryPrecedence = (-Number(fileA.isDirectory) + Number(fileB.isDirectory)) * 3
return directoryPrecedence + fileA.basename.localeCompare(fileB.basename, undefined, { sensitivity: 'base' })
})
this.setState({ files: fileObjs })
})
}
browsePath (file) {
analytics.logEvent('File browse', _.omit(file, [
'fullpath',
'basename',
'dirname'
]))
const folderConstraints = this.props.constraints.length
? this.props.constraints
: [ path.parse(this.props.path).root ]
// Guard against browsing outside the constrained folder
const isWithinAnyConstraint = folderConstraints.some((folderConstraint) => {
return !path.relative(folderConstraint, file.fullpath).startsWith('..')
})
if (!isWithinAnyConstraint) {
const error = `Cannot browse outside constrained folders ${folderConstraints}`
analytics.logException(new Error(error))
this.setState({ error })
return
}
this.setFilesProgressively(file.fullpath).then(() => {
this.setState({ path: file.fullpath })
}).catch((error) => {
this.setState({ error: error.message })
})
}
selectFile (file, event) {
if (file === null) {
analytics.logEvent('File dismiss', null)
} else if (file.isDirectory) {
const prevFile = files.getFileMetadataSync(this.state.path)
this.setState({
history: this.state.history.concat(prevFile)
})
this.browsePath(file)
} else {
analytics.logEvent('File select', file.basename)
imageStream.getImageMetadata(file.fullpath)
.then((image) => {
if (!supportedFormats.isSupportedImage(image.path)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(image)
})
this.setState({ error: invalidImageError })
analytics.logEvent('Invalid image', image)
return
}
return Bluebird.try(() => {
let message = null
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', image)
message = messages.warning.looksLikeWindowsImage()
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', image)
message = messages.warning.missingPartitionTable()
}
if (message) {
// TODO(Shou): `Continue` should be on a red background (dangerous action) instead of `Change`.
// We want `X` to act as `Continue`, that's why `Continue` is the `rejectionLabel`
this.setState({ error: message })
return
}
return image
}).then((image) => {
if (image) {
selectionState.selectImage(image)
}
})
})
.catch((error) => {
const imageError = errors.createUserError({
title: 'Error opening image',
description: messages.error.openImage(file.basename, error.message)
})
this.setState({ error: imageError })
analytics.logException(error)
})
// Add folder to recently used
recentStorage.modify('recents', (recents) => {
const newRecents = _.uniqBy([ file ].concat(recents), (recentFile) => {
return recentFile.dirname
})
// NOTE(Shou): we want to limit how many recent directories are stored - since we only
// display four and don't rely on past state, we can only store four
return newRecents.slice(0, 4)
}, [])
// Add file to potential favorites list
recentStorage.modify('favorites', (favorites) => {
const favorite = _.find(favorites, (favoriteFile) => {
return favoriteFile.fullpath === file.fullpath
}) || _.assign({}, file, { frequency: 1 })
const newFavorites = _.uniqBy([ favorite ].concat(favorites), (favoriteFile) => {
return favoriteFile.fullpath
})
// NOTE(Shou): we want to limit how many favorite files are stored - since we
// *do* rely on past state, we need to store a reasonable amount of favorites so
// they can be sorted by frequency. We only display four.
return newFavorites.slice(0, 256)
}, [])
this.closeModal()
}
}
highlightFile (file) {
this.setState({ highlighted: file })
}
previousDirectory () {
analytics.logEvent('Prev directory', null)
const dir = this.state.history.shift()
this.setState({ history: this.state.history })
if (dir) {
this.browsePath(dir)
}
}
}
FileSelector.propTypes = {
path: propTypes.string,
close: propTypes.func,
constraints: propTypes.arrayOf(propTypes.string)
}

View File

@ -0,0 +1,119 @@
/*
* Copyright 2018 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const path = require('path')
const React = require('react')
const propTypes = require('prop-types')
const styled = require('styled-components').default
const rendition = require('rendition')
const middleEllipsis = require('../../../utils/middle-ellipsis')
/**
* @summary How many directories to show with the breadcrumbs
* @type {Number}
* @constant
* @private
*/
const MAX_DIR_CRUMBS = 3
/**
* @summary Character limit of a filename before a middle-ellipsis is added
* @constant
* @private
*/
const FILENAME_CHAR_LIMIT_SHORT = 15
function splitComponents(dirname, root) {
const components = []
let basename = null
root = root || path.parse(dirname).root
while( dirname !== root ) {
basename = path.basename(dirname)
components.unshift({
path: dirname,
basename: basename,
name: basename
})
dirname = path.join( dirname, '..' )
}
if (components.length < MAX_DIR_CRUMBS) {
components.unshift({
path: root,
basename: root,
name: 'Root'
})
}
return components
}
class Crumb extends React.PureComponent {
constructor (props) {
super(props)
}
render () {
return (
<rendition.Button
onClick={ ::this.navigate }
plaintext={ true }>
<rendition.Txt bold={ this.props.bold }>
{ middleEllipsis(this.props.dir.name, FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Txt>
</rendition.Button>
)
}
navigate () {
this.props.navigate(this.props.dir.path)
}
}
class UnstyledBreadcrumbs extends React.PureComponent {
render () {
const components = splitComponents(this.props.path).slice(-MAX_DIR_CRUMBS)
return (
<div className={ this.props.className }>
{
components.map((dir, index) => {
return (
<Crumb
key={ dir.path }
bold={ index === components.length - 1 }
dir={ dir }
navigate={ ::this.props.navigate }
/>
)
})
}
</div>
)
}
}
const Breadcrumbs = styled(UnstyledBreadcrumbs)`
font-size: 18px;
& > button:not(:last-child)::after {
content: '/';
margin: 9px;
}
`
module.exports = Breadcrumbs

View File

@ -0,0 +1,125 @@
/*
* Copyright 2018 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const React = require('react')
const propTypes = require('prop-types')
const styled = require('styled-components').default
const rendition = require('rendition')
const colors = require('./colors')
const middleEllipsis = require('../../../utils/middle-ellipsis')
/**
* @summary Flex styled component
* @function
* @type {ReactElement}
*/
const Flex = styled.div`
display: flex;
flex: ${ props => props.flex };
flex-direction: ${ props => props.direction };
justify-content: ${ props => props.justifyContent };
align-items: ${ props => props.alignItems };
flex-wrap: ${ props => props.wrap };
flex-grow: ${ props => props.grow };
`
class RecentFileLink extends React.PureComponent {
constructor (props) {
super(props)
}
render () {
const file = this.props.file
return (
<rendition.Button
onClick={ ::this.select }
plaintext={ true }>
{ middleEllipsis(file.name, FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Button>
)
}
select () {
this.props.onSelect(this.props.file)
}
}
class UnstyledRecentFiles extends React.PureComponent {
constructor(props) {
super(props)
this.state = {
recent: [],
favorites: []
}
}
render () {
return (
<Flex className={ this.props.className }>
<h5>Recent</h5>
{
this.state.recent.map((file) => {
<RecentFileLink key={ file.path }
file={ file }
onSelect={ this.props.selectFile }/>
})
}
<h5>Favorite</h5>
{
this.state.favorites.map((file) => {
<RecentFileLink key={ file.path }
file={ file }
onSelect={ this.props.navigate }/>
})
}
</Flex>
)
}
}
const RecentFiles = styled(UnstyledRecentFiles)`
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-start;
width: 130px;
background-color: ${ colors.secondary.background };
padding: 20px;
color: ${ colors.secondary.color };
> h5 {
color: ${ colors.secondary.title };
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
margin-bottom: 15px;
}
> h5:last-of-type {
margin-top: 20px;
}
> button {
margin-bottom: 10px;
text-align: start;
font-size: 16px;
}
`
module.exports = RecentFiles

View File

@ -48,5 +48,6 @@ module.exports = function (ModalService, $q) {
if (modal) {
modal.close()
}
modal = null
}
}

View File

@ -16,133 +16,91 @@
'use strict'
const _ = require('lodash')
const fs = require('fs')
const path = require('path')
const Bluebird = require('bluebird')
const fs = Bluebird.promisifyAll(require('fs'))
/* eslint-disable lodash/prefer-lodash-method */
/* eslint-disable no-undefined */
const CONCURRENCY = 10
const collator = new Intl.Collator(undefined, {
sensitivity: 'case'
})
/**
* @summary Async exists function
* @function
* @public
*
* @description
* This is a promise for convenience, as it never fails with an exception/catch.
*
* @param {String} fullpath - full path
* @returns {Boolean}
* @summary Sort files by their names / stats
* @param {FileEntry} fileA - first file
* @param {FileEntry} fileB - second file
* @returns {Number}
*
* @example
* files.existsAsync('/home/user/file').then(console.log)
* > true
* files.readdirAsync(dirname).then((files) => {
* return files.sort(sortFiles)
* })
*/
exports.existsAsync = (fullpath) => {
return new Bluebird((resolve, reject) => {
return fs.accessAsync(fullpath, fs.constants.F_OK).then(() => {
resolve(true)
}).catch(() => {
resolve(false)
})
})
const sortFiles = (fileA, fileB) => {
return (fileB.isDirectory - fileA.isDirectory) ||
collator.compare(fileA.basename, fileB.basename)
}
/**
* @summary Get file metadata
* @function
* @private
*
* @param {String} dirname - directory name
* @param {String} [basename] - custom basename to append
* @returns {Object} file metadata
*
* @example
* try {
* const file = files.getFileMetadataSync('/home/user')
* console.log(`Is ${file.basename} a directory? ${file.isDirectory}`)
* } catch (error) {
* console.error(error)
* }
* @summary FileEntry struct
* @class
* @type {FileEntry}
*/
exports.getFileMetadataSync = (dirname, basename = '') => {
// TODO(Shou): use path.parse object information here
const fullpath = path.join(dirname, basename)
const pathObj = path.parse(fullpath)
class FileEntry {
/**
* @summary FileEntry
* @param {String} filename - filename
* @param {fs.Stats} stats - stats
*
* @example
* new FileEntry(filename, stats)
*/
constructor (filename, stats) {
const components = path.parse(filename)
// TODO(Shou): this is not true for Windows, figure out Windows hidden files
const isHidden = pathObj.base.startsWith('.')
const stats = fs.statSync(fullpath)
return {
basename: pathObj.base,
dirname: pathObj.dir,
fullpath,
extension: pathObj.ext,
name: pathObj.name,
isDirectory: stats.isDirectory(),
isHidden,
size: stats.size
this.path = filename
this.dirname = components.dir
this.basename = components.base
this.name = components.name
this.ext = components.ext
this.isHidden = components.name.startsWith('.')
this.isFile = stats.isFile()
this.isDirectory = stats.isDirectory()
this.size = stats.size
this.stats = stats
}
}
/**
* @summary Get file metadata asynchronously
* @function
* @private
*
* @param {String} fullpath - full path
* @returns {Promise<Object>} promise of file metadata
* @summary Read a directory & stat all contents
* @param {String} dirpath - Directory path
* @returns {Array<FileEntry>}
*
* @example
* files.getFileMetadataAsync('/home/user').then((file) => {
* console.log(`Is ${file.basename} a directory? ${file.isDirectory}`)
* files.readdirAsync('/').then((files) => {
* // ...
* })
*/
exports.getFileMetadataAsync = (fullpath) => {
const pathObj = path.parse(fullpath)
// NOTE(Shou): this is not true for Windows
const isHidden = pathObj.base.startsWith('.')
return fs.statAsync(fullpath).then((stats) => {
return {
basename: pathObj.base,
dirname: pathObj.dir,
fullpath,
extension: pathObj.ext,
name: pathObj.name,
isDirectory: stats.isDirectory(),
isHidden,
size: stats.size
}
})
}
/**
* @summary Get file metadata for a list of filenames
* @function
* @public
*
* @description Note that this omits any file that errors
*
* @param {String} dirname - directory path
* @param {Array<String>} basenames - file names
* @returns {Promise<Array<Object>>} promise of file objects
*
* @example
* files.getAllFilesMetadataAsync(os.homedir(), [ 'file1.txt', 'file2.txt' ])
*/
exports.getAllFilesMetadataAsync = (dirname, basenames) => {
return Bluebird.reduce(basenames, (fileMetas, basename) => {
return new Bluebird((resolve, reject) => {
exports.getFileMetadataAsync(path.join(dirname, basename)).then((metadata) => {
resolve(fileMetas.concat(metadata))
}).catch(() => {
resolve(fileMetas)
})
exports.readdirAsync = (dirpath) => {
console.time('readdirAsync')
const dirname = path.resolve(dirpath)
return fs.readdirAsync(dirname).then((ls) => {
return ls.filter((filename) => {
return !filename.startsWith('.')
}).map((filename) => {
return path.join(dirname, filename)
})
}, [])
}).map((filename, index, length) => {
return fs.statAsync(filename).then((stats) => {
return new FileEntry(filename, stats)
})
}, { concurrency: CONCURRENCY }).then((files) => {
console.timeEnd('readdirAsync')
return files.sort(sortFiles)
})
}
/**
@ -180,33 +138,3 @@ exports.splitPath = (fullpath, subpaths = []) => {
return exports.splitPath(dir, [ base ].concat(subpaths))
}
/**
* @summary Get all subpaths contained in a path
* @function
* @private
*
* @param {String} fullpath - path string
* @returns {Array<Object>} - all subpaths as file objects
*
* @example
* const subpaths = files.subpaths('/home/user/Downloads')
* console.log(subpaths.map(file => file.fullpath))
* // Linux/macOS
* > [ '/', '/home', '/home/user', '/home/user/Downloads' ]
* // Windows
* > [ 'C:', 'Users', 'user', 'Downloads' ]
*/
exports.subpaths = (fullpath) => {
if (!_.isString(fullpath)) {
return null
}
const dirs = exports.splitPath(fullpath)
return _.map(dirs, (dir, index) => {
// eslint-disable-next-line no-magic-numbers
const subdir = dirs.slice(0, index + 1)
return exports.getFileMetadataSync(path.join(...subdir))
})
}

View File

@ -42,6 +42,11 @@ $disabled-opacity: 0.2;
@import "../pages/settings/styles/settings";
@import "../pages/finish/styles/finish";
$fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free-webfonts/webfonts";
@import "../../../../node_modules/@fortawesome/fontawesome-free-webfonts/scss/fontawesome";
@import "../../../../node_modules/@fortawesome/fontawesome-free-webfonts/scss/fa-solid";
@font-face {
font-family: Roboto;
src: url('../../../node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff');
@ -71,7 +76,7 @@ $disabled-opacity: 0.2;
}
body {
letter-spacing: 1px;
letter-spacing: 0.5px;
display: flex;
flex-direction: column;

File diff suppressed because it is too large Load Diff

14
npm-shrinkwrap.json generated
View File

@ -2,17 +2,9 @@
"name": "etcher",
"version": "1.4.4",
"dependencies": {
"@fortawesome/fontawesome": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome/-/fontawesome-1.1.5.tgz"
},
"@fortawesome/fontawesome-common-types": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.1.3.tgz"
},
"@fortawesome/fontawesome-free-solid": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free-solid/-/fontawesome-free-solid-5.0.9.tgz"
"@fortawesome/fontawesome-free-webfonts": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free-webfonts/-/fontawesome-free-webfonts-1.0.9.tgz"
},
"@types/angular": {
"version": "1.6.45",

View File

@ -40,8 +40,7 @@
"fsevents"
],
"dependencies": {
"@fortawesome/fontawesome": "1.1.5",
"@fortawesome/fontawesome-free-solid": "5.0.9",
"@fortawesome/fontawesome-free-webfonts": "^1.0.9",
"@types/react": "16.3.14",
"@types/react-dom": "16.0.5",
"angular": "1.6.3",