Merge pull request #2362 from resin-io/file-picker-performance

fix(GUI): file-picker performance and design improvements
This commit is contained in:
Jonas Hermsmeier 2018-05-30 14:18:17 +02:00 committed by GitHub
commit dfdb92957e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 331 additions and 138 deletions

View File

@ -326,6 +326,13 @@ app.config(($provide) => {
}) })
}) })
app.config(($locationProvider) => {
// NOTE(Shou): this seems to invoke a minor perf decrease when set to true
$locationProvider.html5Mode({
rewriteLinks: false
})
})
app.controller('HeaderController', function (OSOpenExternalService) { app.controller('HeaderController', function (OSOpenExternalService) {
/** /**
* @summary Open help page * @summary Open help page

View File

@ -16,8 +16,11 @@
'use strict' 'use strict'
const os = require('os')
const settings = require('../../../models/settings') const settings = require('../../../models/settings')
/* eslint-disable lodash/prefer-lodash-method */
module.exports = function ( module.exports = function (
$uibModalInstance $uibModalInstance
) { ) {
@ -43,9 +46,25 @@ module.exports = function (
* @example * @example
* FileSelectorController.getFolderConstraint() * FileSelectorController.getFolderConstraint()
*/ */
this.getFolderConstraint = () => { this.getFolderConstraints = () => {
// TODO(Shou): get this dynamically from the mountpoint of a specific port in Etcher Pro // TODO(Shou): get this dynamically from the mountpoint of a specific port in Etcher Pro
// TODO: Make this handle multiple constraints return settings.has('fileBrowserConstraintPath')
return settings.get('fileBrowserConstraintPath') ? settings.get('fileBrowserConstraintPath').split(',')
: []
}
/**
* @summary Get initial path
* @function
* @public
*
* @returns {String} - path
*
* @example
* <file-selector path="FileSelectorController.getPath()"></file-selector>
*/
this.getPath = () => {
const [ constraint ] = this.getFolderConstraints()
return constraint || os.homedir()
} }
} }

View File

@ -17,7 +17,6 @@
'use strict' 'use strict'
const _ = require('lodash') const _ = require('lodash')
const os = require('os')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const React = require('react') const React = require('react')
@ -36,7 +35,7 @@ const {
const Storage = require('../../../models/storage') const Storage = require('../../../models/storage')
const analytics = require('../../../modules/analytics') const analytics = require('../../../modules/analytics')
const middleEllipsis = require('../../../utils/middle-ellipsis') const middleEllipsis = require('../../../utils/middle-ellipsis')
const files = require('../../../../../shared/files') const files = require('../../../models/files')
const selectionState = require('../../../models/selection-state') const selectionState = require('../../../models/selection-state')
const imageStream = require('../../../../../sdk/image-stream') const imageStream = require('../../../../../sdk/image-stream')
const errors = require('../../../../../shared/errors') const errors = require('../../../../../shared/errors')
@ -92,6 +91,9 @@ const colors = {
highlight: { highlight: {
color: 'white', color: 'white',
background: '#2297de' background: '#2297de'
},
soft: {
color: '#4d5056'
} }
} }
@ -109,16 +111,25 @@ const icons = {
/** /**
* @summary Icon React component * @summary Icon React component
* @function * @class
* @type {ReactElement} * @type {ReactElement}
*/ */
const Icon = styled((props) => { class UnstyledIcon extends React.PureComponent {
const { type, ...restProps } = props render () {
const { type, ...restProps } = this.props
return ( return (
<span className={ props.className } dangerouslySetInnerHTML={ { __html: icons[type] } } { ...restProps } /> <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}; color: ${props => props.color};
font-size: ${props => props.size}; font-size: ${props => props.size};
` `
@ -138,25 +149,44 @@ const Flex = styled.div`
flex-grow: ${ props => props.grow }; flex-grow: ${ props => props.grow };
` `
const FileLink = styled((props) => { class UnstyledFileLink extends React.PureComponent {
const icon = props.isDirectory ? 'faFolder' : 'faFileAlt' constructor (props) {
super(props)
return ( this.highlightFile = this.highlightFile.bind(this)
<Flex this.selectFile = this.selectFile.bind(this)
direction="column" }
alignItems="stretch"
className={ props.className } render () {
onClick={ props.onClick } const icon = this.props.file.isDirectory ? 'faFolder' : 'faFileAlt'
onDoubleClick={ props.onDoubleClick }>
<Icon type={ icon } size="48px" /> return (
<rendition.Button plaintext={ true }> <Flex
{ middleEllipsis(props.basename || '', FILENAME_CHAR_LIMIT) } direction="column"
</rendition.Button> alignItems="stretch"
<div>{ prettyBytes(props.size || 0) }</div> className={ this.props.className }
</Flex> onClick={ this.highlightFile }
) onDoubleClick={ this.selectFile }>
})` <Icon type={ icon } size="48px" />
width: 80px; <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; max-height: 128px;
margin: 5px 10px; margin: 5px 10px;
padding: 5px; padding: 5px;
@ -166,17 +196,19 @@ const FileLink = styled((props) => {
cursor: pointer; cursor: pointer;
border-radius: 5px; border-radius: 5px;
> span:first-child { > span:first-of-type {
align-self: center; align-self: center;
line-height: 1; line-height: 1;
margin-bottom: 6px; margin-bottom: 6px;
color: ${ props => props.highlight ? colors.highlight.color : colors.soft.color }
} }
> button, > span:last-of-type {
> button:hover, display: flex;
> button:focus { justify-content: center;
color: inherit; text-align: center;
word-break: break-all; word-break: break-all;
font-size: 16px;
} }
> div:last-child { > div:last-child {
@ -206,12 +238,66 @@ const Footer = Flex.extend`
} }
` `
const FileListWrap = 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-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 0 20px; 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 { class RecentFilesUnstyled extends React.PureComponent {
constructor (props) { constructor (props) {
super(props) super(props)
@ -223,37 +309,31 @@ class RecentFilesUnstyled extends React.PureComponent {
} }
render () { render () {
const existing = (fileObjs) => {
return _.filter(fileObjs, (fileObj) => {
return fs.existsSync(fileObj.fullpath)
})
}
return ( return (
<Flex className={ this.props.className }> <Flex className={ this.props.className }>
<h5>Recent</h5> <h5>Recent</h5>
{ {
_.map(existing(this.state.recents), (file) => { _.map(this.state.recents, (file) => {
return ( return (
<rendition.Button <RecentFileLink
key={ file.fullpath } key={ file.dirname }
onClick={ () => this.props.selectFile(files.getFileMetadataSync(file.dirname)) } fullpath={ file.dirname }
plaintext={ true }> title={ path.basename(file.dirname) }
{ middleEllipsis(path.basename(file.dirname), FILENAME_CHAR_LIMIT_SHORT) } selectFile={ this.props.selectFile }
</rendition.Button> />
) )
}) })
} }
<h5>Favorite</h5> <h5>Favorite</h5>
{ {
_.map(existing(this.state.favorites.slice(0, 4)), (file) => { _.map(this.state.favorites.slice(0, 4), (file) => {
return ( return (
<rendition.Button <RecentFileLink
key={ file.fullpath } key={ file.fullpath }
onClick={ () => this.props.selectFile(files.getFileMetadataSync(file.fullpath)) } fullpath={ file.fullpath }
plaintext={ true }> title={ file.basename }
{ middleEllipsis(file.basename, FILENAME_CHAR_LIMIT_SHORT) } selectFile={ this.props.selectFile }
</rendition.Button> />
) )
}) })
} }
@ -269,6 +349,32 @@ class RecentFilesUnstyled extends React.PureComponent {
window.removeEventListener('storage', this.onStorage) 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) { onStorage (event) {
if (event.key === RECENT_FILES_KEY) { if (event.key === RECENT_FILES_KEY) {
this.setState(event.newValue) this.setState(event.newValue)
@ -278,6 +384,7 @@ class RecentFilesUnstyled extends React.PureComponent {
const RecentFiles = styled(RecentFilesUnstyled)` const RecentFiles = styled(RecentFilesUnstyled)`
display: flex; display: flex;
flex: 0 0 auto;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
width: 130px; width: 130px;
@ -293,43 +400,80 @@ const RecentFiles = styled(RecentFilesUnstyled)`
margin-bottom: 15px; margin-bottom: 15px;
} }
> h5:last-of-type {
margin-top: 20px;
}
> button { > button {
margin-bottom: 10px; margin-bottom: 10px;
text-align: start; text-align: start;
font-size: 16px;
} }
` `
const labels = { const labels = {
'/': 'Root' '/': 'Root',
'mountpoints': 'Mountpoints'
} }
const Breadcrumbs = styled((props) => { class Crumb extends React.PureComponent {
const folderConstraint = props.constraint || path.parse(props.path).root constructor (props) {
const dirs = files.subpaths(props.path).filter((subpath) => { super(props)
// Guard against displaying folders outside the constrained folder
return !path.relative(folderConstraint, subpath.fullpath).startsWith('..')
})
return ( this.selectFile = this.selectFile.bind(this)
<div className={ props.className }> }
{ dirs.length > MAX_DIR_CRUMBS ? '... / ' : null }
{ render () {
_.map(dirs.slice(-MAX_DIR_CRUMBS), (dir, index) => { return (
return ( <rendition.Button
<rendition.Button onClick={ this.selectFile }
key={ dir.fullpath } plaintext={ true }>
onClick={ () => props.selectFile(dir) } <rendition.Txt bold={ this.props.bold }>
plaintext={ true }> { middleEllipsis(labels[this.props.dir.fullpath] || this.props.dir.basename, FILENAME_CHAR_LIMIT_SHORT) }
<rendition.Txt bold={ index === dirs.length - 1 }> </rendition.Txt>
{ middleEllipsis(labels[dir.fullpath] || dir.basename, FILENAME_CHAR_LIMIT_SHORT) } </rendition.Button>
</rendition.Txt> )
</rendition.Button> }
)
}) selectFile () {
} this.props.selectFile(this.props.dir)
</div> }
) }
})`
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; font-size: 18px;
& > button:not(:last-child)::after { & > button:not(:last-child)::after {
@ -342,15 +486,12 @@ class FileSelector extends React.PureComponent {
constructor (props) { constructor (props) {
super(props) super(props)
const fullpath = props.path || os.homedir()
this.state = { this.state = {
path: fullpath, path: props.path,
files: [], files: [],
history: [], history: [],
highlighted: null, highlighted: null,
error: null, error: null
filters: []
} }
// Filters schema // Filters schema
@ -370,13 +511,15 @@ class FileSelector extends React.PureComponent {
} }
this.closeModal = this.closeModal.bind(this) this.closeModal = this.closeModal.bind(this)
this.resolveModal = this.resolveModal.bind(this)
this.browsePath = this.browsePath.bind(this) this.browsePath = this.browsePath.bind(this)
this.selectFile = this.selectFile.bind(this) this.selectFile = this.selectFile.bind(this)
this.highlightFile = this.highlightFile.bind(this)
this.previousDirectory = this.previousDirectory.bind(this) this.previousDirectory = this.previousDirectory.bind(this)
} }
render () { render () {
const items = rendition.SchemaSieve.filter(this.state.filters, this.state.files) const items = this.state.files
const styles = { const styles = {
display: 'flex', display: 'flex',
@ -412,26 +555,21 @@ class FileSelector extends React.PureComponent {
<Breadcrumbs <Breadcrumbs
path={ this.state.path } path={ this.state.path }
selectFile={ this.selectFile } selectFile={ this.selectFile }
constraint={ this.props.constraint } constraints={ this.props.constraints }
/> />
</Header> </Header>
<Main flex="1"> <Main flex="1">
<Flex direction="column" grow="1"> <Flex direction="column" grow="1">
<rendition.Filters <FileListWrap path={ this.state.path }>
onFiltersUpdate={ filters => this.setFilters(filters) }
onViewsUpdate={ views => this.setViews(views) }
schema={ this.schema }
renderMode={ [] } />
<FileListWrap wrap="wrap">
{ {
items.map((item, index) => { items.map((item) => {
return ( return (
<FileLink { ...item } <FileLink
key={ item.fullpath } key={ item.fullpath }
file={ item }
highlight={ _.get(this.state.highlighted, 'fullpath') === _.get(item, 'fullpath') } highlight={ _.get(this.state.highlighted, 'fullpath') === _.get(item, 'fullpath') }
onClick={ () => this.setState({ highlighted: item }) } highlightFile={ this.highlightFile }
onDoubleClick={ _.partial(this.selectFile, item) } selectFile={ this.selectFile }
/> />
) )
}) })
@ -443,7 +581,7 @@ class FileSelector extends React.PureComponent {
<rendition.Button onClick={ this.closeModal }>Cancel</rendition.Button> <rendition.Button onClick={ this.closeModal }>Cancel</rendition.Button>
<rendition.Button <rendition.Button
primary primary
onClick={ _.partial(this.selectFile, this.state.highlighted) } onClick={ this.resolveModal }
disabled={ !this.state.highlighted }> disabled={ !this.state.highlighted }>
Select file Select file
</rendition.Button> </rendition.Button>
@ -462,20 +600,21 @@ class FileSelector extends React.PureComponent {
this.props.close() this.props.close()
} }
resolveModal () {
this.selectFile(this.state.highlighted)
}
setFilesProgressively (dirname) { setFilesProgressively (dirname) {
return fs.readdirAsync(dirname).then((basenames) => { return fs.readdirAsync(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) return files.getAllFilesMetadataAsync(dirname, basenames)
}).then((fileObjs) => { }).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 }) this.setState({ files: fileObjs })
}) })
} }
@ -487,11 +626,16 @@ class FileSelector extends React.PureComponent {
'dirname' 'dirname'
])) ]))
const folderConstraint = this.props.constraint || path.parse(this.state.path).root const folderConstraints = this.props.constraints.length
? this.props.constraints
: [ path.parse(this.props.path).root ]
// Guard against browsing outside the constrained folder // Guard against browsing outside the constrained folder
if (path.relative(folderConstraint, file.fullpath).startsWith('..')) { const isWithinAnyConstraint = folderConstraints.some((folderConstraint) => {
const error = `Cannot browse outside constrained folder ${constrainedFolder}` return !path.relative(folderConstraint, file.fullpath).startsWith('..')
})
if (!isWithinAnyConstraint) {
const error = `Cannot browse outside constrained folders ${folderConstraints}`
analytics.logException(new Error(error)) analytics.logException(new Error(error))
this.setState({ error }) this.setState({ error })
return return
@ -595,6 +739,10 @@ class FileSelector extends React.PureComponent {
} }
} }
highlightFile (file) {
this.setState({ highlighted: file })
}
previousDirectory () { previousDirectory () {
analytics.logEvent('Prev directory', null) analytics.logEvent('Prev directory', null)
const dir = this.state.history.shift() const dir = this.state.history.shift()
@ -604,14 +752,6 @@ class FileSelector extends React.PureComponent {
this.browsePath(dir) this.browsePath(dir)
} }
} }
setFilters (filters) {
this.setState({ filters })
}
setViews (views) {
this.setState({ views })
}
} }
FileSelector.propTypes = { FileSelector.propTypes = {
@ -619,7 +759,7 @@ FileSelector.propTypes = {
close: propTypes.func, close: propTypes.func,
constraint: propTypes.string constraints: propTypes.arrayOf(propTypes.string)
} }
module.exports = FileSelector module.exports = FileSelector

View File

@ -18,6 +18,6 @@
width: calc(100vw - 10px); width: calc(100vw - 10px);
> .modal-content { > .modal-content {
height: calc(100vh - 10px); height: calc(100vh - 20px);
} }
} }

View File

@ -1 +1,4 @@
<file-selector close="selector.close" constraint="selector.getFolderConstraint()" ></file-selector> <file-selector
constraints="selector.getFolderConstraints()"
path="selector.getPath()"
close="selector.close"></file-selector>

View File

@ -23,6 +23,31 @@ const fs = Bluebird.promisifyAll(require('fs'))
/* eslint-disable lodash/prefer-lodash-method */ /* eslint-disable lodash/prefer-lodash-method */
/**
* @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}
*
* @example
* files.existsAsync('/home/user/file').then(console.log)
* > true
*/
exports.existsAsync = (fullpath) => {
return new Bluebird((resolve, reject) => {
return fs.accessAsync(fullpath, fs.constants.F_OK).then(() => {
resolve(true)
}).catch(() => {
resolve(false)
})
})
}
/** /**
* @summary Get file metadata * @summary Get file metadata
* @function * @function
@ -53,7 +78,7 @@ exports.getFileMetadataSync = (dirname, basename = '') => {
basename: pathObj.base, basename: pathObj.base,
dirname: pathObj.dir, dirname: pathObj.dir,
fullpath, fullpath,
extension: pathObj.ext.replace('.', ''), extension: pathObj.ext,
name: pathObj.name, name: pathObj.name,
isDirectory: stats.isDirectory(), isDirectory: stats.isDirectory(),
isHidden, isHidden,
@ -85,7 +110,7 @@ exports.getFileMetadataAsync = (fullpath) => {
basename: pathObj.base, basename: pathObj.base,
dirname: pathObj.dir, dirname: pathObj.dir,
fullpath, fullpath,
extension: pathObj.ext.replace('.', ''), extension: pathObj.ext,
name: pathObj.name, name: pathObj.name,
isDirectory: stats.isDirectory(), isDirectory: stats.isDirectory(),
isHidden, isHidden,
@ -109,15 +134,14 @@ exports.getFileMetadataAsync = (fullpath) => {
* files.getAllFilesMetadataAsync(os.homedir(), [ 'file1.txt', 'file2.txt' ]) * files.getAllFilesMetadataAsync(os.homedir(), [ 'file1.txt', 'file2.txt' ])
*/ */
exports.getAllFilesMetadataAsync = (dirname, basenames) => { exports.getAllFilesMetadataAsync = (dirname, basenames) => {
return Bluebird.all(basenames.map((basename) => { return Bluebird.reduce(basenames, (fileMetas, basename) => {
const metadata = exports.getFileMetadataAsync(path.join(dirname, basename)) return new Bluebird((resolve, reject) => {
return metadata.reflect() exports.getFileMetadataAsync(path.join(dirname, basename)).then((metadata) => {
})).reduce((fileMetas, inspection) => { resolve(fileMetas.concat(metadata))
if (inspection.isFulfilled()) { }).catch(() => {
return fileMetas.concat(inspection.value()) resolve(fileMetas)
} })
})
return fileMetas
}, []) }, [])
} }

View File

@ -6505,7 +6505,7 @@ svg-icon {
.modal-file-selector-modal { .modal-file-selector-modal {
width: calc(100vw - 10px); } width: calc(100vw - 10px); }
.modal-file-selector-modal > .modal-content { .modal-file-selector-modal > .modal-content {
height: calc(100vh - 10px); } height: calc(100vh - 20px); }
/* /*
* Copyright 2016 resin.io * Copyright 2016 resin.io

View File

@ -18,7 +18,7 @@
const m = require('mochainon') const m = require('mochainon')
const path = require('path') const path = require('path')
const files = require('../../lib/shared/files') const files = require('../../../lib/gui/app/models/files')
describe('Shared: Files', function () { describe('Shared: Files', function () {
describe('.splitPath()', function () { describe('.splitPath()', function () {