// ***************************************************************************** // 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-only 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 { 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>(); protected cachedDirectories = new Map(); protected directoryResolvedEmitter = new Emitter(); readonly onDirectoryDidResolve = this.directoryResolvedEmitter.event; constructor(protected readonly fileService: FileService) { } tryResolveChildDirectories(inputAsURI: URI): string[] | undefined { 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: string): Promise { return this.fileService.resolve(new URI(directoryToResolve)).then(({ children }) => { if (children) { // Store children as native filesystem paths so they can be matched // and displayed directly without needing URI-to-native conversion later. const childDirectories = children.filter(child => child.isDirectory) .map(directory => new Path(`${directory.resource.path}/`).fsPath()); this.cachedDirectories.set(directoryToResolve, childDirectories); this.directoryResolvedEmitter.fire({ parent: directoryToResolve, children: childDirectories }); } }).catch(e => { // no-op }); } } export const LocationListRendererFactory = Symbol('LocationListRendererFactory'); export interface LocationListRendererFactory { (options: LocationListRendererOptions): 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: boolean) { this._doShowTextInput = doShow; if (doShow) { this.initResolveDirectoryCache(); } } protected lastUniqueTextInputLocation: URI | undefined; protected previousAutocompleteMatch: string; protected doAttemptAutocomplete = true; constructor( @inject(LocationListRendererOptions) readonly options: LocationListRendererOptions ) { super(options.host); this.service = options.model; this.doLoadDrives(); this.doAfterRender = this.doAfterRender.bind(this); } @postConstruct() protected init(): void { this.doInit(); } protected async doInit(): Promise { const homeDirWithPrefix = await this.variablesServer.getHomeDirUri(); this.homeDir = (new URI(homeDirWithPrefix)).path.toString(); } override render(): void { if (!this.toDispose.disposed) { this.hostRoot.render(this.doRender()); } } protected initResolveDirectoryCache(): void { 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 = this.toFileURI(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: React.ChangeEvent) => this.onLocationChanged(e); protected readonly handleTextInputOnChange = (e: React.ChangeEvent) => this.trySuggestDirectory(e); protected readonly handleTextInputKeyDown = (e: React.KeyboardEvent) => this.handleControlKeys(e); protected readonly handleIconKeyDown = (e: React.KeyboardEvent) => this.toggleInputOnKeyDown(e); protected readonly handleTextInputOnBlur = () => this.toggleToSelectInput(); protected readonly handleTextInputMouseDown = (e: React.MouseEvent) => this.toggleToTextInputOnMouseDown(e); protected override doRender(): React.ReactElement { return ( <> {this.renderInputIcon()} {this.doShowTextInput ? this.renderTextInput() : this.renderSelectInput() } ); } protected renderInputIcon(): React.ReactNode { return ( ); } protected renderTextInput(): React.ReactNode { return ( ); } protected renderSelectInput(): React.ReactNode { const options = this.collectLocations().map(value => this.renderLocation(value)); return ( ); } protected toggleInputOnKeyDown(e: React.KeyboardEvent): void { if (e.key === 'Enter') { this.doShowTextInput = true; this.render(); } } protected toggleToTextInputOnMouseDown(e: React.MouseEvent): void { if (e.currentTarget.id === 'select-input') { e.preventDefault(); this.doShowTextInput = true; this.render(); } } protected toggleToSelectInput(): void { if (this.doShowTextInput) { this.doShowTextInput = false; this.render(); } } /** * Collects the available locations based on the currently selected, and appends the available drives to it. */ protected collectLocations(): LocationListRenderer.Location[] { const location = this.service.location; const locations: LocationListRenderer.Location[] = (!!location ? location.allLocations : []).map(uri => ({ uri })); if (this._drives) { const drives = this._drives.map(uri => ({ uri, isDrive: true })); // `URI.allLocations` returns with the URI without the trailing slash unlike `FileUri.create(fsPath)`. // to be able to compare file:///path/to/resource with file:///path/to/resource/. const toUriString = (uri: 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)); // Ignore drives which are already discovered as a location based on the current model root URI. if (index === -1) { // Make sure, it does not have the trailing slash. locations.push({ uri: new URI(toUriString(drive.uri)), isDrive: true }); } else { // This is necessary for Windows to be able to show `/e:/` as a drive and `c:` as "non-drive" in the same way. // `URI.path.toString()` Vs. `URI.displayName` behaves a bit differently on Windows. // https://github.com/eclipse-theia/theia/pull/3038#issuecomment-425944189 locations[index].isDrive = true; } }); } this.doLoadDrives(); return locations; } /** * Asynchronously loads the drives (if not yet available) and triggers a UI update on success with the new values. */ protected doLoadDrives(): void { if (!this._drives) { this.service.drives().then(drives => { // If the `drives` are empty, something already went wrong. if (drives.length > 0) { this._drives = drives; this.render(); } }); } } protected renderLocation(location: LocationListRenderer.Location): React.ReactNode { const { uri, isDrive } = location; const value = uri.toString(); return ; } protected onLocationChanged(e: React.ChangeEvent): void { 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: URI): void { if (this.lastUniqueTextInputLocation === undefined) { this.lastUniqueTextInputLocation = this.service.location; } // prevent consecutive repeated locations from being added to location history if (this.lastUniqueTextInputLocation?.path.toString() !== newLocation.path.toString()) { this.lastUniqueTextInputLocation = newLocation; this.service.location = newLocation; } } protected trySuggestDirectory(e: React.ChangeEvent): void { if (this.doAttemptAutocomplete) { const inputElement = e.currentTarget; const { value } = inputElement; if (this.looksLikeFilePath(value) && !value.endsWith('/') && !value.endsWith('\\')) { const expandedPath = Path.untildify(value, this.homeDir); const valueAsURI = this.toFileURI(expandedPath); const autocompleteDirectories = this.directoryCache.tryResolveChildDirectories(valueAsURI); if (autocompleteDirectories) { this.tryRenderFirstMatch(inputElement, autocompleteDirectories); } } } } protected tryRenderFirstMatch(inputElement: HTMLInputElement, children: string[]): void { 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 = contractedPath.length; } } } protected looksLikeFilePath(value: string): boolean { return value.startsWith('/') || value.startsWith('~/') || /^[a-zA-Z]:[/\\]/.test(value); } protected toFileURI(filePath: string): URI { return URI.fromFilePath(filePath); } protected handleControlKeys(e: React.KeyboardEvent): void { this.doAttemptAutocomplete = e.key !== 'Backspace'; if (e.key === 'Enter') { const locationTextInput = this.locationTextInput; if (locationTextInput) { // expand '~' if present and remove extra whitespace and any trailing slashes or periods. const sanitizedInput = locationTextInput.value.trim().replace(/[\/\\.]*$/, ''); const untildifiedInput = Path.untildify(sanitizedInput, this.homeDir); const uri = this.toFileURI(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; } override dispose(): void { 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; } }