mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-23 03:06:38 +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'
|
'use strict'
|
||||||
|
|
||||||
const _ = require('lodash')
|
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
const Bluebird = require('bluebird')
|
||||||
const React = require('react')
|
const React = require('react')
|
||||||
const propTypes = require('prop-types')
|
const propTypes = require('prop-types')
|
||||||
const { default: styled } = require('styled-components')
|
const styled = require('styled-components').default
|
||||||
const rendition = require('rendition')
|
const rendition = require('rendition')
|
||||||
const prettyBytes = require('pretty-bytes')
|
const colors = require('./colors')
|
||||||
const Bluebird = require('bluebird')
|
|
||||||
const fontAwesome = require('@fortawesome/fontawesome')
|
const Breadcrumbs = require('./path-breadcrumbs')
|
||||||
const {
|
const FileList = require('./file-list')
|
||||||
faFileAlt,
|
const RecentFiles = require('./recent-files')
|
||||||
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 selectionState = require('../../../models/selection-state')
|
const selectionState = require('../../../models/selection-state')
|
||||||
const imageStream = require('../../../../../sdk/image-stream')
|
const osDialog = require('../../../os/dialog')
|
||||||
const errors = require('../../../../../shared/errors')
|
const exceptionReporter = require('../../../modules/exception-reporter')
|
||||||
const messages = require('../../../../../shared/messages')
|
const messages = require('../../../../../shared/messages')
|
||||||
|
const errors = require('../../../../../shared/errors')
|
||||||
|
const imageStream = require('../../../../../sdk/image-stream')
|
||||||
const supportedFormats = require('../../../../../shared/supported-formats')
|
const supportedFormats = require('../../../../../shared/supported-formats')
|
||||||
|
const analytics = require('../../../modules/analytics')
|
||||||
|
|
||||||
/**
|
const debug = require('debug')('etcher:gui:file-selector')
|
||||||
* @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};
|
|
||||||
`
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Flex styled component
|
* @summary Flex styled component
|
||||||
@ -149,76 +55,6 @@ const Flex = styled.div`
|
|||||||
flex-grow: ${ props => props.grow };
|
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`
|
const Header = Flex.extend`
|
||||||
margin: 10px 15px 0;
|
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 {
|
class FileSelector extends React.PureComponent {
|
||||||
constructor (props) {
|
constructor (props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
this.highlighted = null
|
||||||
this.state = {
|
this.state = {
|
||||||
path: props.path,
|
path: props.path,
|
||||||
files: [],
|
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
|
return Bluebird.try(() => {
|
||||||
this.schema = {
|
let message = null
|
||||||
type: 'object',
|
|
||||||
properties: {
|
if (supportedFormats.looksLikeWindowsImage(image.path)) {
|
||||||
basename: {
|
analytics.logEvent('Possibly Windows image', image)
|
||||||
type: 'string'
|
message = messages.warning.looksLikeWindowsImage()
|
||||||
},
|
} else if (!image.hasMBR) {
|
||||||
isHidden: {
|
analytics.logEvent('Missing partition table', image)
|
||||||
type: 'boolean'
|
message = messages.warning.missingPartitionTable()
|
||||||
},
|
|
||||||
isDirectory: {
|
|
||||||
type: 'boolean'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
debug('FileSelector:getImageMetadata', file)
|
||||||
this.resolveModal = this.resolveModal.bind(this)
|
|
||||||
this.browsePath = this.browsePath.bind(this)
|
imageStream.getImageMetadata(file.path)
|
||||||
this.selectFile = this.selectFile.bind(this)
|
.then((imageMetadata) => {
|
||||||
this.highlightFile = this.highlightFile.bind(this)
|
debug('FileSelector:getImageMetadata', imageMetadata)
|
||||||
this.previousDirectory = this.previousDirectory.bind(this)
|
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 () {
|
render () {
|
||||||
const items = this.state.files
|
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: 'calc(100vh - 20px)',
|
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 (
|
return (
|
||||||
<rendition.Provider
|
<rendition.Provider style={ styles }>
|
||||||
style={ styles }>
|
{/*<RecentFiles flex="0 0 auto"
|
||||||
<RecentFiles selectFile={ this.selectFile } flex="0 0 auto" />
|
selectFile={ ::this.selectFile }
|
||||||
|
navigate={ ::this.navigate } />*/}
|
||||||
<Flex direction="column" grow="1">
|
<Flex direction="column" grow="1">
|
||||||
<Header flex="0 0 auto" alignItems="baseline">
|
<Header flex="0 0 auto" alignItems="baseline">
|
||||||
<rendition.Button
|
<rendition.Button
|
||||||
bg={ colors.secondary.background }
|
bg={ colors.secondary.background }
|
||||||
color={ colors.primary.color }
|
color={ colors.primary.color }
|
||||||
disabled={ !this.state.history.length }
|
onClick={ ::this.navigateUp }>
|
||||||
onClick={ this.previousDirectory }>
|
<span className="fas fa-angle-left" />
|
||||||
<Icon type={ 'faAngleLeft' } />
|
|
||||||
Back
|
Back
|
||||||
</rendition.Button>
|
</rendition.Button>
|
||||||
<Icon type={ 'faHdd' } />
|
<span className="fas fa-hdd" />
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
path={ this.state.path }
|
path={ this.state.path }
|
||||||
selectFile={ this.selectFile }
|
navigate={ ::this.navigate }
|
||||||
constraints={ this.props.constraints }
|
constraints={ this.props.constraints }
|
||||||
/>
|
/>
|
||||||
</Header>
|
</Header>
|
||||||
<Main flex="1">
|
<Main flex="1">
|
||||||
<Flex direction="column" grow="1">
|
<Flex direction="column" grow="1">
|
||||||
<FileListWrap path={ this.state.path }>
|
<FileList path={ this.state.path }
|
||||||
{
|
onHighlight={ ::this.onHighlight }
|
||||||
items.map((item) => {
|
onSelect={ ::this.selectFile }></FileList>
|
||||||
return (
|
|
||||||
<FileLink
|
|
||||||
key={ item.fullpath }
|
|
||||||
file={ item }
|
|
||||||
highlight={ _.get(this.state.highlighted, 'fullpath') === _.get(item, 'fullpath') }
|
|
||||||
highlightFile={ this.highlightFile }
|
|
||||||
selectFile={ this.selectFile }
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</FileListWrap>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Main>
|
</Main>
|
||||||
<Footer justifyContent="flex-end">
|
<Footer justifyContent="flex-end">
|
||||||
<rendition.Button onClick={ this.closeModal }>Cancel</rendition.Button>
|
<rendition.Button onClick={ ::this.close }>Cancel</rendition.Button>
|
||||||
<rendition.Button
|
<rendition.Button
|
||||||
primary
|
primary
|
||||||
onClick={ this.resolveModal }
|
onClick={ ::this.confirmSelection }>
|
||||||
disabled={ !this.state.highlighted }>
|
|
||||||
Select file
|
Select file
|
||||||
</rendition.Button>
|
</rendition.Button>
|
||||||
</Footer>
|
</Footer>
|
||||||
</Flex>
|
</Flex>
|
||||||
{ this.state.error ? errorModal : null }
|
|
||||||
</rendition.Provider>
|
</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 = {
|
FileSelector.propTypes = {
|
||||||
path: propTypes.string,
|
path: propTypes.string,
|
||||||
|
|
||||||
close: propTypes.func,
|
close: propTypes.func,
|
||||||
|
|
||||||
constraints: propTypes.arrayOf(propTypes.string)
|
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) {
|
if (modal) {
|
||||||
modal.close()
|
modal.close()
|
||||||
}
|
}
|
||||||
|
modal = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,133 +16,91 @@
|
|||||||
|
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const _ = require('lodash')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const Bluebird = require('bluebird')
|
|
||||||
const fs = Bluebird.promisifyAll(require('fs'))
|
|
||||||
|
|
||||||
/* eslint-disable lodash/prefer-lodash-method */
|
/* 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
|
* @summary Sort files by their names / stats
|
||||||
* @function
|
* @param {FileEntry} fileA - first file
|
||||||
* @public
|
* @param {FileEntry} fileB - second file
|
||||||
*
|
* @returns {Number}
|
||||||
* @description
|
|
||||||
* This is a promise for convenience, as it never fails with an exception/catch.
|
|
||||||
*
|
|
||||||
* @param {String} fullpath - full path
|
|
||||||
* @returns {Boolean}
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* files.existsAsync('/home/user/file').then(console.log)
|
* files.readdirAsync(dirname).then((files) => {
|
||||||
* > true
|
* return files.sort(sortFiles)
|
||||||
|
* })
|
||||||
*/
|
*/
|
||||||
exports.existsAsync = (fullpath) => {
|
const sortFiles = (fileA, fileB) => {
|
||||||
return new Bluebird((resolve, reject) => {
|
return (fileB.isDirectory - fileA.isDirectory) ||
|
||||||
return fs.accessAsync(fullpath, fs.constants.F_OK).then(() => {
|
collator.compare(fileA.basename, fileB.basename)
|
||||||
resolve(true)
|
|
||||||
}).catch(() => {
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get file metadata
|
* @summary FileEntry struct
|
||||||
* @function
|
* @class
|
||||||
* @private
|
* @type {FileEntry}
|
||||||
*
|
|
||||||
* @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)
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
exports.getFileMetadataSync = (dirname, basename = '') => {
|
class FileEntry {
|
||||||
// TODO(Shou): use path.parse object information here
|
/**
|
||||||
const fullpath = path.join(dirname, basename)
|
* @summary FileEntry
|
||||||
const pathObj = path.parse(fullpath)
|
* @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
|
this.path = filename
|
||||||
const isHidden = pathObj.base.startsWith('.')
|
this.dirname = components.dir
|
||||||
const stats = fs.statSync(fullpath)
|
this.basename = components.base
|
||||||
|
this.name = components.name
|
||||||
return {
|
this.ext = components.ext
|
||||||
basename: pathObj.base,
|
this.isHidden = components.name.startsWith('.')
|
||||||
dirname: pathObj.dir,
|
this.isFile = stats.isFile()
|
||||||
fullpath,
|
this.isDirectory = stats.isDirectory()
|
||||||
extension: pathObj.ext,
|
this.size = stats.size
|
||||||
name: pathObj.name,
|
this.stats = stats
|
||||||
isDirectory: stats.isDirectory(),
|
|
||||||
isHidden,
|
|
||||||
size: stats.size
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get file metadata asynchronously
|
* @summary Read a directory & stat all contents
|
||||||
* @function
|
* @param {String} dirpath - Directory path
|
||||||
* @private
|
* @returns {Array<FileEntry>}
|
||||||
*
|
|
||||||
* @param {String} fullpath - full path
|
|
||||||
* @returns {Promise<Object>} promise of file metadata
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* files.getFileMetadataAsync('/home/user').then((file) => {
|
* files.readdirAsync('/').then((files) => {
|
||||||
* console.log(`Is ${file.basename} a directory? ${file.isDirectory}`)
|
* // ...
|
||||||
* })
|
* })
|
||||||
*/
|
*/
|
||||||
exports.getFileMetadataAsync = (fullpath) => {
|
exports.readdirAsync = (dirpath) => {
|
||||||
const pathObj = path.parse(fullpath)
|
console.time('readdirAsync')
|
||||||
|
const dirname = path.resolve(dirpath)
|
||||||
// NOTE(Shou): this is not true for Windows
|
return fs.readdirAsync(dirname).then((ls) => {
|
||||||
const isHidden = pathObj.base.startsWith('.')
|
return ls.filter((filename) => {
|
||||||
|
return !filename.startsWith('.')
|
||||||
return fs.statAsync(fullpath).then((stats) => {
|
}).map((filename) => {
|
||||||
return {
|
return path.join(dirname, filename)
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}, [])
|
}).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))
|
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/settings/styles/settings";
|
||||||
@import "../pages/finish/styles/finish";
|
@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-face {
|
||||||
font-family: Roboto;
|
font-family: Roboto;
|
||||||
src: url('../../../node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff');
|
src: url('../../../node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff');
|
||||||
@ -71,7 +76,7 @@ $disabled-opacity: 0.2;
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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",
|
"name": "etcher",
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome": {
|
"@fortawesome/fontawesome-free-webfonts": {
|
||||||
"version": "1.1.5",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome/-/fontawesome-1.1.5.tgz"
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free-webfonts/-/fontawesome-free-webfonts-1.0.9.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"
|
|
||||||
},
|
},
|
||||||
"@types/angular": {
|
"@types/angular": {
|
||||||
"version": "1.6.45",
|
"version": "1.6.45",
|
||||||
|
@ -40,8 +40,7 @@
|
|||||||
"fsevents"
|
"fsevents"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome": "1.1.5",
|
"@fortawesome/fontawesome-free-webfonts": "^1.0.9",
|
||||||
"@fortawesome/fontawesome-free-solid": "5.0.9",
|
|
||||||
"@types/react": "16.3.14",
|
"@types/react": "16.3.14",
|
||||||
"@types/react-dom": "16.0.5",
|
"@types/react-dom": "16.0.5",
|
||||||
"angular": "1.6.3",
|
"angular": "1.6.3",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user