mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-22 18:56:31 +00:00
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:
parent
d1c44ab7b1
commit
fc22e9e28a
45
lib/gui/app/components/file-selector/file-selector/colors.js
Normal file
45
lib/gui/app/components/file-selector/file-selector/colors.js
Normal 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
|
303
lib/gui/app/components/file-selector/file-selector/file-list.jsx
Normal file
303
lib/gui/app/components/file-selector/file-selector/file-list.jsx
Normal 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
|
@ -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" />
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -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
|
@ -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
|
@ -48,5 +48,6 @@ module.exports = function (ModalService, $q) {
|
||||
if (modal) {
|
||||
modal.close()
|
||||
}
|
||||
modal = null
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
3169
lib/gui/css/main.css
3169
lib/gui/css/main.css
File diff suppressed because it is too large
Load Diff
14
npm-shrinkwrap.json
generated
14
npm-shrinkwrap.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user