/********************************************************************************
 * Copyright (C) 2017 TypeFox and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License v. 2.0 are satisfied: GNU General Public License, version 2
 * with the GNU Classpath Exception which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 ********************************************************************************/ import URI from '@theia/core/lib/common/uri' ; import { LocationService } from './location-service' ; import * as React from '@theia/core/shared/react' ; import * as ReactDOM from '@theia/core/shared/react-dom' ; import { FileService } from '../file-service' ; import { DisposableCollection , Emitter , Path } from '@theia/core/lib/common' ; import { injectable , inject , postConstruct } from '@theia/core/shared/inversify' ; import { FileDialogModel } from '../file-dialog/file-dialog-model' ; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables' ; import { ReactRenderer } from '@theia/core/lib/browser/widgets/react-renderer' ; import { codicon } from '@theia/core/lib/browser' ; interface AutoSuggestDataEvent { parent : string ; children : string [ ] ; } class ResolvedDirectoryCache { protected pendingResolvedDirectories = new Map < string , Promise < void > > ( ) ; protected cachedDirectories = new Map < string , string [ ] > ( ) ; protected directoryResolvedEmitter = new Emitter < AutoSuggestDataEvent > ( ) ; readonly onDirectoryDidResolve = this . directoryResolvedEmitter . event ; constructor ( protected readonly fileService ) { } tryResolveChildDirectories ( inputAsURI ) { const parentDirectory = inputAsURI . path . dir . toString ( ) ; const cachedDirectories = this . cachedDirectories . get ( parentDirectory ) ; const pendingDirectories = this . pendingResolvedDirectories . get ( parentDirectory ) ; if ( cachedDirectories ) { return cachedDirectories ; } else if ( ! pendingDirectories ) { this . pendingResolvedDirectories . set ( parentDirectory , this . createResolutionPromise ( parentDirectory ) ) ; } return undefined ; } protected async createResolutionPromise ( directoryToResolve ) { return this . fileService . resolve ( new URI ( directoryToResolve ) ) . then ( ( { children } ) => { if ( children ) { const childDirectories = children . filter ( child => child . isDirectory ) . map ( directory => `${ directory . resource . path }/` ) ; this . cachedDirectories . set ( directoryToResolve , childDirectories ) ; this . directoryResolvedEmitter . fire ( { parent : directoryToResolve , children : childDirectories } ) ; } } ) . catch ( e => { } ) ; } } export const LocationListRendererFactory = Symbol ( 'LocationListRendererFactory' ) ; export interface LocationListRendererFactory { ( options ) : LocationListRenderer ; } export const LocationListRendererOptions = Symbol ( 'LocationListRendererOptions' ) ; export interface LocationListRendererOptions { model : FileDialogModel ; host ? : HTMLElement ; } @ injectable ( ) export class LocationListRenderer extends ReactRenderer { @ inject ( FileService ) protected readonly fileService : FileService ; @ inject ( EnvVariablesServer ) protected readonly variablesServer : EnvVariablesServer ; protected directoryCache : ResolvedDirectoryCache ; protected service : LocationService ; protected toDisposeOnNewCache = new DisposableCollection ( ) ; protected _drives : URI [ ] | undefined ; protected _doShowTextInput = false ; protected homeDir : string ; get doShowTextInput ( ) : boolean { return this . _doShowTextInput ; } set doShowTextInput ( doShow ) { this . _doShowTextInput = doShow ; if ( doShow ) { this . initResolveDirectoryCache ( ) ; } } protected lastUniqueTextInputLocation : URI | undefined ; protected previousAutocompleteMatch : string ; protected doAttemptAutocomplete = true ; constructor ( @ inject ( LocationListRendererOptions ) readonly options ) { super ( options . host ) ; this . service = options . model ; this . doLoadDrives ( ) ; } @ postConstruct ( ) async init ( ) { const homeDirWithPrefix = await this . variablesServer . getHomeDirUri ( ) ; this . homeDir = ( new URI ( homeDirWithPrefix ) ) . path . toString ( ) ; } render ( ) { ReactDOM . render ( this . doRender ( ) , this . host , this . doAfterRender ) ; } protected initResolveDirectoryCache ( ) { this . toDisposeOnNewCache . dispose ( ) ; this . directoryCache = new ResolvedDirectoryCache ( this . fileService ) ; this . toDisposeOnNewCache . push ( this . directoryCache . onDirectoryDidResolve ( ( { parent , children } ) => { if ( this . locationTextInput ) { const expandedPath = Path . untildify ( this . locationTextInput . value , this . homeDir ) ; const inputParent = ( new URI ( expandedPath ) ) . path . dir . toString ( ) ; if ( inputParent === parent ) { this . tryRenderFirstMatch ( this . locationTextInput , children ) ; } } } ) ) ; } protected doAfterRender = ( ) : void => { const locationList = this . locationList ; const locationListTextInput = this . locationTextInput ; if ( locationList ) { const currentLocation = this . service . location ; locationList . value = currentLocation ? currentLocation . toString ( ) : '' ; } else if ( locationListTextInput ) { locationListTextInput . focus ( ) ; } } ; protected readonly handleLocationChanged = ( e ) => this . onLocationChanged ( e ) ; protected readonly handleTextInputOnChange = ( e ) => this . trySuggestDirectory ( e ) ; protected readonly handleTextInputKeyDown = ( e ) => this . handleControlKeys ( e ) ; protected readonly handleIconKeyDown = ( e ) => this . toggleInputOnKeyDown ( e ) ; protected readonly handleTextInputOnBlur = ( ) => this . toggleToSelectInput ( ) ; protected readonly handleTextInputMouseDown = ( e ) => this . toggleToTextInputOnMouseDown ( e ) ; protected doRender ( ) { return ( < > { this . renderInputIcon ( ) } { this . doShowTextInput ? this . renderTextInput ( ) : this . renderSelectInput ( ) } < />); } protected renderInputIcon ( ) { return ( < span onMouseDown = { this . handleTextInputMouseDown } onKeyDown = { this . handleIconKeyDown } className = { LocationListRenderer . Styles . LOCATION_INPUT_TOGGLE_CLASS } tabIndex = { 0 } id = { `${ this . doShowTextInput ? 'text-input' : 'select-input' }` } title = { this . doShowTextInput ? LocationListRenderer . Tooltips . TOGGLE_SELECT_INPUT : LocationListRenderer . Tooltips . TOGGLE_TEXT_INPUT } > < i className = { codicon ( this . doShowTextInput ? 'folder-opened' : 'edit' ) } /> < /span>); } protected renderTextInput ( ) { return ( < input className = { 'theia-select ' + LocationListRenderer . Styles . LOCATION_TEXT_INPUT_CLASS } defaultValue = { this . service . location ?. path . toString ( ) } onBlur = { this . handleTextInputOnBlur } onChange = { this . handleTextInputOnChange } onKeyDown = { this . handleTextInputKeyDown } spellCheck = { false } / > ) ; } protected renderSelectInput ( ) { const options = this . collectLocations ( ) . map ( value => this . renderLocation ( value ) ) ; return ( < select className = { `theia-select ${ LocationListRenderer . Styles . LOCATION_LIST_CLASS }` } onChange = { this . handleLocationChanged } > { ... options } < /select>); } protected toggleInputOnKeyDown ( e ) { if ( e . key === 'Enter' ) { this . doShowTextInput = true ; this . render ( ) ; } } protected toggleToTextInputOnMouseDown ( e ) { if ( e . currentTarget . id === 'select-input' ) { e . preventDefault ( ) ; this . doShowTextInput = true ; this . render ( ) ; } } protected toggleToSelectInput ( ) { if ( this . doShowTextInput ) { this . doShowTextInput = false ; this . render ( ) ; } } protected collectLocations ( ) { const location = this . service . location ; const locations = ( ! ! location ? location . allLocations : [ ] ) . map ( uri => ( { uri } ) ) ; if ( this . _drives ) { const drives = this . _drives . map ( uri => ( { uri , isDrive : true } ) ) ; const toUriString = ( uri ) => { const toString = uri . toString ( ) ; return toString . endsWith ( '/' ) ? toString . slice ( 0 , - 1 ) : toString ; } ; drives . forEach ( drive => { const index = locations . findIndex ( loc => toUriString ( loc . uri ) === toUriString ( drive . uri ) ) ; if ( index === - 1 ) { locations . push ( { uri : new URI ( toUriString ( drive . uri ) ) , isDrive : true } ) ; } else { locations [ index ] . isDrive = true ; } } ) ; } this . doLoadDrives ( ) ; return locations ; } protected doLoadDrives ( ) { if ( ! this . _drives ) { this . service . drives ( ) . then ( drives => { if ( drives . length > 0 ) { this . _drives = drives ; this . render ( ) ; } } ) ; } } protected renderLocation ( location ) { const { uri , isDrive } = location ; const value = uri . toString ( ) ; return < option value = { value } key = { uri . toString ( ) } > { isDrive ? uri . path . toString ( ) : uri . displayName } < /option>; } protected onLocationChanged ( e ) { const locationList = this . locationList ; if ( locationList ) { const value = locationList . value ; const uri = new URI ( value ) ; this . trySetNewLocation ( uri ) ; e . preventDefault ( ) ; e . stopPropagation ( ) ; } } protected trySetNewLocation ( newLocation ) { if ( this . lastUniqueTextInputLocation === undefined ) { this . lastUniqueTextInputLocation = this . service . location ; } if ( this . lastUniqueTextInputLocation ?. path . toString ( ) !== newLocation . path . toString ( ) ) { this . lastUniqueTextInputLocation = newLocation ; this . service . location = newLocation ; } } protected trySuggestDirectory ( e ) { if ( this . doAttemptAutocomplete ) { const inputElement = e . currentTarget ; const { value } = inputElement ; if ( ( value . startsWith ( '/' ) || value . startsWith ( '~/' ) ) && value . slice ( - 1 ) !== '/' ) { const expandedPath = Path . untildify ( value , this . homeDir ) ; const valueAsURI = new URI ( expandedPath ) ; const autocompleteDirectories = this . directoryCache . tryResolveChildDirectories ( valueAsURI ) ; if ( autocompleteDirectories ) { this . tryRenderFirstMatch ( inputElement , autocompleteDirectories ) ; } } } } protected tryRenderFirstMatch ( inputElement , children ) { const { value , selectionStart } = inputElement ; if ( this . locationTextInput ) { const expandedPath = Path . untildify ( value , this . homeDir ) ; const firstMatch = children ?. find ( child => child . includes ( expandedPath ) ) ; if ( firstMatch ) { const contractedPath = value . startsWith ( '~' ) ? Path . tildify ( firstMatch , this . homeDir ) : firstMatch ; this . locationTextInput . value = contractedPath ; this . locationTextInput . selectionStart = selectionStart ; this . locationTextInput . selectionEnd = firstMatch . length ; } } } protected handleControlKeys ( e ) { this . doAttemptAutocomplete = e . key !== 'Backspace' ; if ( e . key === 'Enter' ) { const locationTextInput = this . locationTextInput ; if ( locationTextInput ) { const sanitizedInput = locationTextInput . value . trim ( ) . replace ( /[\/\\.]*$/ , '' ) ; const untildifiedInput = Path . untildify ( sanitizedInput , this . homeDir ) ; const uri = new URI ( untildifiedInput ) ; this . trySetNewLocation ( uri ) ; this . toggleToSelectInput ( ) ; } } else if ( e . key === 'Escape' ) { this . toggleToSelectInput ( ) ; } else if ( e . key === 'Tab' ) { e . preventDefault ( ) ; const textInput = this . locationTextInput ; if ( textInput ) { textInput . selectionStart = textInput . value . length ; } } e . stopPropagation ( ) ; } get locationList ( ) : HTMLSelectElement | undefined { const locationList = this . host . getElementsByClassName ( LocationListRenderer . Styles . LOCATION_LIST_CLASS ) [ 0 ] ; if ( locationList instanceof HTMLSelectElement ) { return locationList ; } return undefined ; } get locationTextInput ( ) : HTMLInputElement | undefined { const locationTextInput = this . host . getElementsByClassName ( LocationListRenderer . Styles . LOCATION_TEXT_INPUT_CLASS ) [ 0 ] ; if ( locationTextInput instanceof HTMLInputElement ) { return locationTextInput ; } return undefined ; } dispose ( ) { super . dispose ( ) ; this . toDisposeOnNewCache . dispose ( ) ; } } export namespace LocationListRenderer { export namespace Styles { export const LOCATION_LIST_CLASS = 'theia-LocationList' ; export const LOCATION_INPUT_TOGGLE_CLASS = 'theia-LocationInputToggle' ; export const LOCATION_TEXT_INPUT_CLASS = 'theia-LocationTextInput' ; } export namespace Tooltips { export const TOGGLE_TEXT_INPUT = 'Switch to text-based input' ; export const TOGGLE_SELECT_INPUT = 'Switch to location list' ; } export interface Location { uri : URI ; isDrive ? : boolean ; } }