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) {
/**
* @summary Open help page

View File

@ -16,8 +16,11 @@
'use strict'
const os = require('os')
const settings = require('../../../models/settings')
/* eslint-disable lodash/prefer-lodash-method */
module.exports = function (
$uibModalInstance
) {
@ -43,9 +46,25 @@ module.exports = function (
* @example
* FileSelectorController.getFolderConstraint()
*/
this.getFolderConstraint = () => {
this.getFolderConstraints = () => {
// TODO(Shou): get this dynamically from the mountpoint of a specific port in Etcher Pro
// TODO: Make this handle multiple constraints
return settings.get('fileBrowserConstraintPath')
return settings.has('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'
const _ = require('lodash')
const os = require('os')
const fs = require('fs')
const path = require('path')
const React = require('react')
@ -36,7 +35,7 @@ const {
const Storage = require('../../../models/storage')
const analytics = require('../../../modules/analytics')
const middleEllipsis = require('../../../utils/middle-ellipsis')
const files = require('../../../../../shared/files')
const files = require('../../../models/files')
const selectionState = require('../../../models/selection-state')
const imageStream = require('../../../../../sdk/image-stream')
const errors = require('../../../../../shared/errors')
@ -92,6 +91,9 @@ const colors = {
highlight: {
color: 'white',
background: '#2297de'
},
soft: {
color: '#4d5056'
}
}
@ -109,16 +111,25 @@ const icons = {
/**
* @summary Icon React component
* @function
* @class
* @type {ReactElement}
*/
const Icon = styled((props) => {
const { type, ...restProps } = props
class UnstyledIcon extends React.PureComponent {
render () {
const { type, ...restProps } = this.props
return (
<span className={ props.className } dangerouslySetInnerHTML={ { __html: icons[type] } } { ...restProps } />
)
})`
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};
`
@ -138,25 +149,44 @@ const Flex = styled.div`
flex-grow: ${ props => props.grow };
`
const FileLink = styled((props) => {
const icon = props.isDirectory ? 'faFolder' : 'faFileAlt'
class UnstyledFileLink extends React.PureComponent {
constructor (props) {
super(props)
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;
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;
@ -166,17 +196,19 @@ const FileLink = styled((props) => {
cursor: pointer;
border-radius: 5px;
> span:first-child {
> span:first-of-type {
align-self: center;
line-height: 1;
margin-bottom: 6px;
color: ${ props => props.highlight ? colors.highlight.color : colors.soft.color }
}
> button,
> button:hover,
> button:focus {
color: inherit;
> span:last-of-type {
display: flex;
justify-content: center;
text-align: center;
word-break: break-all;
font-size: 16px;
}
> 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-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)
@ -223,37 +309,31 @@ class RecentFilesUnstyled extends React.PureComponent {
}
render () {
const existing = (fileObjs) => {
return _.filter(fileObjs, (fileObj) => {
return fs.existsSync(fileObj.fullpath)
})
}
return (
<Flex className={ this.props.className }>
<h5>Recent</h5>
{
_.map(existing(this.state.recents), (file) => {
_.map(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>
<RecentFileLink
key={ file.dirname }
fullpath={ file.dirname }
title={ path.basename(file.dirname) }
selectFile={ this.props.selectFile }
/>
)
})
}
<h5>Favorite</h5>
{
_.map(existing(this.state.favorites.slice(0, 4)), (file) => {
_.map(this.state.favorites.slice(0, 4), (file) => {
return (
<rendition.Button
<RecentFileLink
key={ file.fullpath }
onClick={ () => this.props.selectFile(files.getFileMetadataSync(file.fullpath)) }
plaintext={ true }>
{ middleEllipsis(file.basename, FILENAME_CHAR_LIMIT_SHORT) }
</rendition.Button>
fullpath={ file.fullpath }
title={ file.basename }
selectFile={ this.props.selectFile }
/>
)
})
}
@ -269,6 +349,32 @@ class RecentFilesUnstyled extends React.PureComponent {
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)
@ -278,6 +384,7 @@ class RecentFilesUnstyled extends React.PureComponent {
const RecentFiles = styled(RecentFilesUnstyled)`
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-start;
width: 130px;
@ -293,43 +400,80 @@ const RecentFiles = styled(RecentFilesUnstyled)`
margin-bottom: 15px;
}
> h5:last-of-type {
margin-top: 20px;
}
> button {
margin-bottom: 10px;
text-align: start;
font-size: 16px;
}
`
const labels = {
'/': 'Root'
'/': 'Root',
'mountpoints': 'Mountpoints'
}
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('..')
})
class Crumb extends React.PureComponent {
constructor (props) {
super(props)
return (
<div className={ props.className }>
{ dirs.length > MAX_DIR_CRUMBS ? '... / ' : null }
{
_.map(dirs.slice(-MAX_DIR_CRUMBS), (dir, index) => {
return (
<rendition.Button
key={ dir.fullpath }
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>
)
})`
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 {
@ -342,15 +486,12 @@ class FileSelector extends React.PureComponent {
constructor (props) {
super(props)
const fullpath = props.path || os.homedir()
this.state = {
path: fullpath,
path: props.path,
files: [],
history: [],
highlighted: null,
error: null,
filters: []
error: null
}
// Filters schema
@ -370,13 +511,15 @@ class FileSelector extends React.PureComponent {
}
this.closeModal = this.closeModal.bind(this)
this.resolveModal = this.resolveModal.bind(this)
this.browsePath = this.browsePath.bind(this)
this.selectFile = this.selectFile.bind(this)
this.highlightFile = this.highlightFile.bind(this)
this.previousDirectory = this.previousDirectory.bind(this)
}
render () {
const items = rendition.SchemaSieve.filter(this.state.filters, this.state.files)
const items = this.state.files
const styles = {
display: 'flex',
@ -412,26 +555,21 @@ class FileSelector extends React.PureComponent {
<Breadcrumbs
path={ this.state.path }
selectFile={ this.selectFile }
constraint={ this.props.constraint }
constraints={ this.props.constraints }
/>
</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">
<FileListWrap path={ this.state.path }>
{
items.map((item, index) => {
items.map((item) => {
return (
<FileLink { ...item }
<FileLink
key={ item.fullpath }
file={ item }
highlight={ _.get(this.state.highlighted, 'fullpath') === _.get(item, 'fullpath') }
onClick={ () => this.setState({ highlighted: item }) }
onDoubleClick={ _.partial(this.selectFile, item) }
highlightFile={ this.highlightFile }
selectFile={ this.selectFile }
/>
)
})
@ -443,7 +581,7 @@ class FileSelector extends React.PureComponent {
<rendition.Button onClick={ this.closeModal }>Cancel</rendition.Button>
<rendition.Button
primary
onClick={ _.partial(this.selectFile, this.state.highlighted) }
onClick={ this.resolveModal }
disabled={ !this.state.highlighted }>
Select file
</rendition.Button>
@ -462,20 +600,21 @@ class FileSelector extends React.PureComponent {
this.props.close()
}
resolveModal () {
this.selectFile(this.state.highlighted)
}
setFilesProgressively (dirname) {
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)
}).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 })
})
}
@ -487,11 +626,16 @@ class FileSelector extends React.PureComponent {
'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
if (path.relative(folderConstraint, file.fullpath).startsWith('..')) {
const error = `Cannot browse outside constrained folder ${constrainedFolder}`
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
@ -595,6 +739,10 @@ class FileSelector extends React.PureComponent {
}
}
highlightFile (file) {
this.setState({ highlighted: file })
}
previousDirectory () {
analytics.logEvent('Prev directory', null)
const dir = this.state.history.shift()
@ -604,14 +752,6 @@ class FileSelector extends React.PureComponent {
this.browsePath(dir)
}
}
setFilters (filters) {
this.setState({ filters })
}
setViews (views) {
this.setState({ views })
}
}
FileSelector.propTypes = {
@ -619,7 +759,7 @@ FileSelector.propTypes = {
close: propTypes.func,
constraint: propTypes.string
constraints: propTypes.arrayOf(propTypes.string)
}
module.exports = FileSelector

View File

@ -18,6 +18,6 @@
width: calc(100vw - 10px);
> .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 */
/**
* @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
* @function
@ -53,7 +78,7 @@ exports.getFileMetadataSync = (dirname, basename = '') => {
basename: pathObj.base,
dirname: pathObj.dir,
fullpath,
extension: pathObj.ext.replace('.', ''),
extension: pathObj.ext,
name: pathObj.name,
isDirectory: stats.isDirectory(),
isHidden,
@ -85,7 +110,7 @@ exports.getFileMetadataAsync = (fullpath) => {
basename: pathObj.base,
dirname: pathObj.dir,
fullpath,
extension: pathObj.ext.replace('.', ''),
extension: pathObj.ext,
name: pathObj.name,
isDirectory: stats.isDirectory(),
isHidden,
@ -109,15 +134,14 @@ exports.getFileMetadataAsync = (fullpath) => {
* files.getAllFilesMetadataAsync(os.homedir(), [ 'file1.txt', 'file2.txt' ])
*/
exports.getAllFilesMetadataAsync = (dirname, basenames) => {
return Bluebird.all(basenames.map((basename) => {
const metadata = exports.getFileMetadataAsync(path.join(dirname, basename))
return metadata.reflect()
})).reduce((fileMetas, inspection) => {
if (inspection.isFulfilled()) {
return fileMetas.concat(inspection.value())
}
return fileMetas
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)
})
})
}, [])
}

View File

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

View File

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