feat(GUI): add electron-native file-picker component (#2333)

We add a file-picker written with Rendition/React. It is activated with
the `ETCHER_EXPERIMENTAL_FILE_PICKER` environment variable. Further
customisation can be done with the `ETCHER_FILE_BROWSER_CONSTRAIN_FOLDER`
variable that takes a path and allows one to constrain the file-picker to
a folder.

Related: https://github.com/resin-io/etcher/issues/2238
Related: https://github.com/resin-io/etcher/issues/2285
Change-Type: patch
Changelog-Entry: Add electron-native file-picker component.
This commit is contained in:
Benedict Aas 2018-05-16 20:34:04 +01:00 committed by GitHub
parent 7c97dc8004
commit 7782f94daa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1384 additions and 133 deletions

View File

@ -77,6 +77,7 @@ const app = angular.module('Etcher', [
require('./components/svg-icon'),
require('./components/warning-modal/warning-modal'),
require('./components/safe-webview'),
require('./components/file-selector'),
// Pages
require('./pages/main/main'),

View File

@ -0,0 +1,48 @@
/*
* 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'
module.exports = function (
$uibModalInstance
) {
/**
* @summary Close the modal
* @function
* @public
*
* @example
* FileSelectorController.close();
*/
this.close = () => {
$uibModalInstance.close()
}
/**
* @summary Folder to constrain the file picker to
* @function
* @public
*
* @returns {String} - folder to constrain by
*
* @example
* FileSelectorController.getFolderConstraint()
*/
this.getFolderConstraint = () => {
// TODO(Shou): get this dynamically from the mountpoint of a specific port in Etcher Pro
return process.env.ETCHER_FILE_BROWSER_CONSTRAIN_FOLDER
}
}

View File

@ -0,0 +1,620 @@
/*
* 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 _ = require('lodash')
const os = require('os')
const path = require('path')
const React = require('react')
const propTypes = require('prop-types')
const { default: styled } = require('styled-components')
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('../../../../../shared/files')
const selectionState = require('../../../../../shared/models/selection-state')
const imageStream = require('../../../../../sdk/image-stream')
const errors = require('../../../../../shared/errors')
const messages = require('../../../../../shared/messages')
const supportedFormats = require('../../../../../shared/supported-formats')
/**
* @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'
}
}
/**
* @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
* @function
* @type {ReactElement}
*/
const Icon = styled((props) => {
const { type, ...restProps } = props
return (
<span className={ props.className } dangerouslySetInnerHTML={ { __html: icons[type] } } { ...restProps } />
)
})`
color: ${props => props.color};
font-size: ${props => props.size};
`
/**
* @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 };
`
const FileLink = styled((props) => {
const icon = props.isDirectory ? 'faFolder' : 'faFileAlt'
return (
<Flex
direction="column"
alignItems="stretch"
className={ props.className }
onClick={ props.onClick }
onDoubleClick={ props.onDoubleClick }>
<Icon type={ icon } size="48px" />
<rendition.Button plaintext={ true }>
{ middleEllipsis(props.basename || '', FILENAME_CHAR_LIMIT) }
</rendition.Button>
<div>{ prettyBytes(props.size || 0) }</div>
</Flex>
)
})`
width: 80px;
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-child {
align-self: center;
line-height: 1;
margin-bottom: 6px;
}
> button,
> button:hover,
> button:focus {
color: inherit;
word-break: break-all;
}
> 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;
> * {
margin: 5px;
}
`
const Main = Flex.extend``
const Footer = Flex.extend`
margin: 10px 20px;
flex: 0 0 auto;
> * {
margin: 0 10px;
}
`
const FileListWrap = Flex.extend`
overflow-x: hidden;
overflow-y: auto;
padding: 0 20px;
`
class RecentFilesUnstyled extends React.PureComponent {
constructor (props) {
super(props)
this.state = {
recents: recentStorage.get('recents'),
favorites: recentStorage.get('favorites')
}
}
render () {
const existing = (fileObjs) => {
return _.filter(fileObjs, (fileObj) => {
return files.exists(fileObj.fullpath)
})
}
return (
<Flex className={ this.props.className }>
<h5>Recent</h5>
{
_.map(existing(this.state.recents), (file) => {
return (
<rendition.Button
key={ file.fullpath }
onClick={ () => this.props.selectFile(files.getFileMetadataSync(file.dirname)) }
plaintext={ true }>
{ middleEllipsis(path.basename(file.dirname), FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Button>
)
})
}
<h5>Favorite</h5>
{
_.map(existing(this.state.favorites.slice(0, 4)), (file) => {
return (
<rendition.Button
key={ file.fullpath }
onClick={ () => this.props.selectFile(files.getFileMetadataSync(file.fullpath)) }
plaintext={ true }>
{ middleEllipsis(file.basename, FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Button>
)
})
}
</Flex>
)
}
componentWillMount () {
window.addEventListener('storage', this.onStorage)
}
componentWillUnmount () {
window.removeEventListener('storage', this.onStorage)
}
onStorage (event) {
if (event.key === RECENT_FILES_KEY) {
this.setState(event.newValue)
}
}
}
const RecentFiles = styled(RecentFilesUnstyled)`
display: flex;
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;
}
> button {
margin-bottom: 10px;
text-align: start;
}
`
const labels = {
'/': 'Root'
}
const Breadcrumbs = styled((props) => {
const folderConstraint = props.constraint || path.parse(props.path).root
const dirs = files.subpaths(props.path).filter((subpath) => {
// Guard against displaying folders outside the constrained folder
return !path.relative(folderConstraint, subpath.fullpath).startsWith('..')
})
return (
<div className={ props.className }>
{ dirs.length > MAX_DIR_CRUMBS ? '... / ' : null }
{
_.map(dirs.slice(-MAX_DIR_CRUMBS), (dir, index) => {
return (
<rendition.Button
onClick={ () => props.selectFile(dir) }
plaintext={ true }>
<rendition.Txt bold={ index === dirs.length - 1 }>
{ middleEllipsis(labels[dir.fullpath] || dir.basename, FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Txt>
</rendition.Button>
)
})
}
</div>
)
})`
font-size: 18px;
& > button:not(:last-child)::after {
content: '/';
margin: 9px;
}
`
class FileSelector extends React.PureComponent {
constructor (props) {
super(props)
const fullpath = props.path || os.homedir()
this.state = {
path: fullpath,
files: [],
history: [],
highlighted: null,
error: null,
filters: []
}
// Filters schema
this.schema = {
type: 'object',
properties: {
basename: {
type: 'string'
},
isHidden: {
type: 'boolean'
},
isDirectory: {
type: 'boolean'
}
}
}
this.closeModal = this.closeModal.bind(this)
this.browsePath = this.browsePath.bind(this)
this.selectFile = this.selectFile.bind(this)
this.previousDirectory = this.previousDirectory.bind(this)
}
render () {
const items = rendition.SchemaSieve.filter(this.state.filters, 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" />
<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' } />
&nbsp;Back
</rendition.Button>
<Icon type={ 'faHdd' } />
<Breadcrumbs
path={ this.state.path }
selectFile={ this.selectFile }
constraint={ this.props.constraint }
/>
</Header>
<Main flex="1">
<Flex direction="column" grow="1">
<rendition.Filters
onFiltersUpdate={ filters => this.setFilters(filters) }
onViewsUpdate={ views => this.setViews(views) }
schema={ this.schema }
renderMode={ [] } />
<FileListWrap wrap="wrap">
{
items.map((item, index) => {
return (
<FileLink { ...item }
highlight={ _.get(this.state.highlighted, 'fullpath') === _.get(item, 'fullpath') }
onClick={ () => this.setState({ highlighted: item }) }
onDoubleClick={ _.partial(this.selectFile, item) }
/>
)
})
}
</FileListWrap>
</Flex>
</Main>
<Footer justifyContent="flex-end">
<rendition.Button onClick={ this.closeModal }>Cancel</rendition.Button>
<rendition.Button
primary
onClick={ _.partial(this.selectFile, this.state.highlighted) }
disabled={ !this.state.highlighted }>
Select file
</rendition.Button>
</Footer>
</Flex>
{ this.state.error ? errorModal : null }
</rendition.Provider>
)
}
componentDidMount () {
this.setFilesProgressively(this.state.path)
}
closeModal () {
this.props.close()
}
setFilesProgressively (dirname) {
return files.getDirectory(dirname).then((basenames) => {
const fileObjs = basenames.map((basename) => {
return {
dirname: this.state.path,
basename,
fullpath: path.join(dirname, basename)
}
})
this.setState({ files: fileObjs })
return files.getAllFilesMetadataAsync(dirname, basenames)
}).then((fileObjs) => {
this.setState({ files: fileObjs })
})
}
browsePath (file) {
analytics.logEvent('File browse', _.omit(file, [
'fullpath',
'basename',
'dirname'
]))
const folderConstraint = this.props.constraint || path.parse(this.state.path).root
// Guard against browsing outside the constrained folder
if (path.relative(folderConstraint, file.fullpath).startsWith('..')) {
const error = `Cannot browse outside constrained folder ${constrainedFolder}`
analytics.logException(new Error(error))
this.setState({ error })
return
}
this.setFilesProgressively(file.fullpath).then(() => {
this.setState({ path: file.fullpath })
})
}
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()
}
}
previousDirectory () {
analytics.logEvent('Prev directory', null)
const dir = this.state.history.shift()
this.setState({ history: this.state.history })
if (dir) {
this.browsePath(dir)
}
}
setFilters (filters) {
this.setState({ filters })
}
setViews (views) {
this.setState({ views })
}
}
FileSelector.propTypes = {
path: propTypes.string,
close: propTypes.func,
constraint: propTypes.string
}
module.exports = FileSelector

View File

@ -0,0 +1,37 @@
/*
* 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'
/* eslint-disable jsdoc/require-example */
/**
* @module Etcher.Components.SVGIcon
*/
const angular = require('angular')
const react2angular = require('react2angular').react2angular
const MODULE_NAME = 'Etcher.Components.FileSelector'
const angularFileSelector = angular.module(MODULE_NAME, [
require('../modal/modal')
])
angularFileSelector.component('fileSelector', react2angular(require('./file-selector/file-selector.jsx')))
angularFileSelector.controller('FileSelectorController', require('./controllers/file-selector'))
angularFileSelector.service('FileSelectorService', require('./services/file-selector'))
module.exports = MODULE_NAME

View File

@ -0,0 +1,52 @@
/*
* 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'
module.exports = function (ModalService, $q) {
let modal = null
/**
* @summary Open the file selector widget
* @function
* @public
*
* @example
* DriveSelectorService.open()
*/
this.open = () => {
modal = ModalService.open({
name: 'file-selector',
template: require('../templates/file-selector-modal.tpl.html'),
controller: 'FileSelectorController as selector',
size: 'file-selector-modal'
})
}
/**
* @summary Close the file selector widget
* @function
* @public
*
* @example
* DriveSelectorService.close()
*/
this.close = () => {
if (modal) {
modal.close()
}
}
}

View File

@ -0,0 +1,23 @@
/*
* 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.
*/
.modal-file-selector-modal {
width: calc(100vw - 10px);
> .modal-content {
height: calc(100vh - 10px);
}
}

View File

@ -0,0 +1 @@
<file-selector close="selector.close" constraint="selector.getFolderConstraint()" ></file-selector>

View File

@ -20,6 +20,7 @@
flex-direction: column;
margin: 0 auto;
height: auto;
overflow: hidden;
}
.modal-header {
@ -101,5 +102,4 @@
.modal-dialog {
margin: 0;
position: initial;
max-width: 50%;
}

View File

@ -30,6 +30,7 @@ const exceptionReporter = require('../../../modules/exception-reporter')
module.exports = function (
$timeout,
FileSelectorService,
WarningModalService
) {
/**
@ -155,16 +156,20 @@ module.exports = function (
this.openImageSelector = () => {
analytics.logEvent('Open image selector')
osDialog.selectImage().then((imagePath) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imagePath) {
analytics.logEvent('Image selector closed')
return
}
if (process.env.ETCHER_EXPERIMENTAL_FILE_PICKER) {
FileSelectorService.open()
} else {
osDialog.selectImage().then((imagePath) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imagePath) {
analytics.logEvent('Image selector closed')
return
}
this.selectImageByPath(imagePath)
}).catch(exceptionReporter.report)
this.selectImageByPath(imagePath)
}).catch(exceptionReporter.report)
}
}
/**

View File

@ -38,6 +38,7 @@ const MainPage = angular.module(MODULE_NAME, [
require('../../components/flash-error-modal/flash-error-modal'),
require('../../components/progress-button/progress-button'),
require('../../components/warning-modal/warning-modal'),
require('../../components/file-selector'),
require('../../os/open-external/open-external'),
require('../../os/dropzone/dropzone'),

View File

@ -37,6 +37,7 @@ $disabled-opacity: 0.2;
@import "../components/svg-icon/styles/svg-icon";
@import "../components/tooltip-modal/styles/tooltip-modal";
@import "../components/warning-modal/styles/warning-modal";
@import "../components/file-selector/styles/file-selector";
@import "../pages/main/styles/main";
@import "../pages/settings/styles/settings";
@import "../pages/finish/styles/finish";

View File

@ -6225,7 +6225,8 @@ body {
display: flex;
flex-direction: column;
margin: 0 auto;
height: auto; }
height: auto;
overflow: hidden; }
.modal-header {
display: flex;
@ -6277,8 +6278,7 @@ body {
.modal-dialog {
margin: 0;
position: initial;
max-width: 50%; }
position: initial; }
/*
* Copyright 2016 resin.io
@ -6487,6 +6487,26 @@ svg-icon {
max-height: 200px;
overflow-y: auto; }
/*
* 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.
*/
.modal-file-selector-modal {
width: calc(100vw - 10px); }
.modal-file-selector-modal > .modal-content {
height: calc(100vh - 10px); }
/*
* Copyright 2016 resin.io
*

675
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -78,6 +78,7 @@
"react2angular": "1.1.3",
"readable-stream": "2.3.3",
"redux": "3.5.2",
"rendition": "4.6.0",
"request": "2.81.0",
"resin-cli-form": "1.4.1",
"resin-cli-visuals": "1.4.1",
@ -85,6 +86,8 @@
"roboto-fontface": "0.9.0",
"semver": "5.1.1",
"speedometer": "1.0.0",
"styled-components": "3.2.3",
"styled-system": "1.1.7",
"sudo-prompt": "8.0.0",
"udif": "0.13.0",
"unbzip2-stream": "github:resin-io-modules/unbzip2-stream#core-streams",

View File

@ -35,6 +35,10 @@ require.extensions['.html'] = (module, filename) => {
})
}
// NOTE(Shou): since we don't test React yet we just ignore JSX files
// eslint-disable-next-line node/no-deprecated-api
require.extensions['.jsx'] = _.constant(null)
describe('Browser: MainPage', function () {
beforeEach(angular.mock.module(
require('../../../lib/gui/app/pages/main/main')