Skip to content

Commit

Permalink
File dialog enhancements
Browse files Browse the repository at this point in the history
- Add text input to locationList Renderer
- Add 'navigate upward' icon
- Fix icon focus behavior when disabled

Signed-off-by: Kenneth Marut <kenneth.marut@ericsson.com>
  • Loading branch information
kenneth-marut-work committed Jan 4, 2021
1 parent 39887bf commit b5c2cee
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 27 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Change Log

## v1.10.0

- [filesystem] add text input and nagivate up icon to file dialog [#8748](https://github.com/eclipse-theia/theia/pull/8748)

<a name="breaking_changes_1.9.0">[Breaking Changes:](#breaking_changes_1.10.0)</a>

- [filesystem] `FileDialog` and `LocationListRenderer` now require `FileService` to be passed into constructor for text-based file-dialog navigation in browser [#8748](https://github.com/eclipse-theia/theia/pull/8748)

## v1.9.0 - 16/12/2020

- [cli] updated error reporting for the `download-plugins` script [#8798](https://github.com/eclipse-theia/theia/pull/8798)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,9 @@ export class FileDialogModel extends FileTreeModel {
private isFileStatNodeSelectable(node: FileStatNode): boolean {
return !(!node.fileStat.isDirectory && this._disableFileSelection);
}

canNavigateUpward(): boolean {
const treeRoot = this.tree.root;
return FileStatNode.is(treeRoot) && !treeRoot.uri.path.isRoot;
}
}
50 changes: 42 additions & 8 deletions packages/filesystem/src/browser/file-dialog/file-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { FileDialogWidget } from './file-dialog-widget';
import { FileDialogTreeFiltersRenderer, FileDialogTreeFilters } from './file-dialog-tree-filters-renderer';
import URI from '@theia/core/lib/common/uri';
import { Panel } from '@phosphor/widgets';
import { FileService } from '../file-service';

export const OpenFileDialogFactory = Symbol('OpenFileDialogFactory');
export interface OpenFileDialogFactory {
Expand All @@ -43,6 +44,7 @@ export const NAVIGATION_PANEL_CLASS = 'theia-NavigationPanel';
export const NAVIGATION_BACK_CLASS = 'theia-NavigationBack';
export const NAVIGATION_FORWARD_CLASS = 'theia-NavigationForward';
export const NAVIGATION_HOME_CLASS = 'theia-NavigationHome';
export const NAVIGATION_UP_CLASS = 'theia-NavigationUp';
export const NAVIGATION_LOCATION_LIST_PANEL_CLASS = 'theia-LocationListPanel';

export const FILTERS_PANEL_CLASS = 'theia-FiltersPanel';
Expand Down Expand Up @@ -116,13 +118,15 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
protected readonly back: HTMLSpanElement;
protected readonly forward: HTMLSpanElement;
protected readonly home: HTMLSpanElement;
protected readonly up: HTMLSpanElement;
protected readonly locationListRenderer: LocationListRenderer;
protected readonly treeFiltersRenderer: FileDialogTreeFiltersRenderer | undefined;
protected readonly treePanel: Panel;

constructor(
@inject(FileDialogProps) readonly props: FileDialogProps,
@inject(FileDialogWidget) readonly widget: FileDialogWidget
@inject(FileDialogWidget) readonly widget: FileDialogWidget,
@inject(FileService) readonly fileService: FileService
) {
super(props);
this.treePanel = new Panel();
Expand All @@ -145,8 +149,12 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
navigationPanel.appendChild(this.home = createIconButton('fa', 'fa-home'));
this.home.classList.add(NAVIGATION_HOME_CLASS);
this.home.title = 'Go To Initial Location';
navigationPanel.appendChild(this.up = createIconButton('fa', 'fa-level-up'));
this.up.classList.add(NAVIGATION_UP_CLASS);
this.up.title = 'Navigate Up One Directory';

this.locationListRenderer = this.createLocationListRenderer();
const locationListRendererHost = document.createElement('div');
this.locationListRenderer = this.createLocationListRenderer(locationListRendererHost);
this.locationListRenderer.host.classList.add(NAVIGATION_LOCATION_LIST_PANEL_CLASS);
navigationPanel.appendChild(this.locationListRenderer.host);

Expand All @@ -157,8 +165,8 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
return this.widget.model;
}

protected createLocationListRenderer(): LocationListRenderer {
return new LocationListRenderer(this.model);
protected createLocationListRenderer(host?: HTMLElement): LocationListRenderer {
return new LocationListRenderer(this.model, this.fileService, host);
}

protected createFileTreeFiltersRenderer(): FileDialogTreeFiltersRenderer | undefined {
Expand All @@ -176,6 +184,7 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
setEnabled(this.home, !!this.model.initialLocation
&& !!this.model.location
&& this.model.initialLocation.toString() !== this.model.location.toString());
setEnabled(this.up, this.model.canNavigateUpward());
this.locationListRenderer.render();

if (this.treeFiltersRenderer) {
Expand All @@ -185,6 +194,24 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
this.widget.update();
}

protected handleEnter(event: KeyboardEvent): boolean | void {
if (event.target instanceof HTMLTextAreaElement || this.targetIsDirectoryInput(event.target)) {
return false;
}
this.accept();
}

protected handleEscape(event: KeyboardEvent): boolean | void {
if (event.target instanceof HTMLTextAreaElement || this.targetIsDirectoryInput(event.target)) {
return false;
}
this.close();
}

protected targetIsDirectoryInput(target: EventTarget | null): boolean {
return target instanceof HTMLInputElement && target.classList.contains(LocationListRenderer.Styles.LOCATION_TEXT_INPUT_CLASS);
}

protected appendFiltersPanel(): void {
if (this.treeFiltersRenderer) {
const filtersPanel = document.createElement('div');
Expand Down Expand Up @@ -223,6 +250,11 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
this.model.location = this.model.initialLocation;
}
}, 'click');
this.addKeyListener(this.up, Key.ENTER, () => {
if (this.model.location) {
this.model.location = this.model.location.parent;
}
}, 'click');
super.onAfterAttach(msg);
}

Expand All @@ -239,9 +271,10 @@ export class OpenFileDialog extends FileDialog<MaybeArray<FileStatNode>> {

constructor(
@inject(OpenFileDialogProps) readonly props: OpenFileDialogProps,
@inject(FileDialogWidget) readonly widget: FileDialogWidget
@inject(FileDialogWidget) readonly widget: FileDialogWidget,
@inject(FileService) readonly fileService: FileService
) {
super(props, widget);
super(props, widget, fileService);
if (props.canSelectFiles !== undefined) {
this.widget.disableFileSelection = !props.canSelectFiles;
}
Expand Down Expand Up @@ -288,9 +321,10 @@ export class SaveFileDialog extends FileDialog<URI | undefined> {

constructor(
@inject(SaveFileDialogProps) readonly props: SaveFileDialogProps,
@inject(FileDialogWidget) readonly widget: FileDialogWidget
@inject(FileDialogWidget) readonly widget: FileDialogWidget,
@inject(FileService) readonly fileService: FileService
) {
super(props, widget);
super(props, widget, fileService);
widget.addClass(SAVE_DIALOG_CLASS);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/filesystem/src/browser/file-tree/file-tree-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export class FileTreeModel extends TreeModelImpl implements LocationService {
const node = DirNode.createRoot(fileStat);
this.navigateTo(node);
}
}).catch(() => {
// no-op, allow failures for file dialog text input
});
} else {
this.navigateTo(undefined);
Expand Down
170 changes: 164 additions & 6 deletions packages/filesystem/src/browser/location/location-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,111 @@ import URI from '@theia/core/lib/common/uri';
import { LocationService } from './location-service';
import { ReactRenderer } from '@theia/core/lib/browser/widgets/react-renderer';
import * as React from 'react';

import * as ReactDOM from 'react-dom';
import { FileService } from '../file-service';
export class LocationListRenderer extends ReactRenderer {

protected _drives: URI[] | undefined;
protected doShowTextInput = false;
protected lastUniqueTextInputLocation: URI | undefined;
protected previousAutocompleteMatch: string;
protected doAttemptAutocomplete = true;

constructor(
protected readonly service: LocationService,
protected readonly fileService: FileService,
host?: HTMLElement
) {
super(host);
this.doLoadDrives();
}

render(): void {
super.render();
ReactDOM.render(<React.Fragment>{this.doRender()}</React.Fragment>, this.host, this.doAfterRender);
}

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<HTMLSelectElement>) => this.onLocationChanged(e);
protected readonly handleTextInputOnChange = (e: React.ChangeEvent<HTMLInputElement>) => this.trySuggestDirectory(e);
protected readonly handleTextInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => this.handleControlKeys(e);
protected readonly handleTextInputOnBlur = () => this.toggleToSelectInput();
protected readonly handleTextInputMouseDown = (e: React.MouseEvent<HTMLSpanElement>) => this.toggleToTextInputOnMouseDown(e);

protected doRender(): React.ReactNode {
return (
<>
{this.renderInputIcon()}
{ this.doShowTextInput
? this.renderTextInput()
: this.renderSelectInput()
}
</>
);
}

protected renderInputIcon(): React.ReactNode {
return (
<span
// onMouseDown is used since it will fire before 'onBlur'. This prevents
// a re-render when textinput is in focus and user clicks toggle icon
onMouseDown={this.handleTextInputMouseDown}
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={this.doShowTextInput ? 'fa fa-folder-open' : 'fa fa-edit'} />
</span>
);
}

protected renderTextInput(): React.ReactNode {
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(): React.ReactNode {
const options = this.collectLocations().map(value => this.renderLocation(value));
return <select className={'theia-select ' + LocationListRenderer.Styles.LOCATION_LIST_CLASS} onChange={this.handleLocationChanged}>{...options}</select>;
return (
<select className={`theia-select ${LocationListRenderer.Styles.LOCATION_LIST_CLASS}`}
onChange={this.handleLocationChanged}>
{...options}
</select>
);
}

protected toggleToTextInputOnMouseDown(e: React.MouseEvent<HTMLSpanElement>): 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();
}
}

/**
Expand Down Expand Up @@ -104,12 +183,77 @@ export class LocationListRenderer extends ReactRenderer {
if (locationList) {
const value = locationList.value;
const uri = new URI(value);
this.service.location = uri;
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 async trySuggestDirectory(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
if (this.doAttemptAutocomplete) {
const locationTextInput = this.locationTextInput;
const { value, selectionStart } = e.currentTarget;
if (locationTextInput && value.slice(-1) !== '/') {
const valueAsURI = new URI(value);
const autocompleteDirectories = await this.gatherAlphabetizedDirectories(valueAsURI);
const firstMatch = autocompleteDirectories?.find(child => child.includes(value));
if (firstMatch) {
locationTextInput.value = firstMatch;
locationTextInput.selectionStart = selectionStart;
locationTextInput.selectionEnd = firstMatch.length;
}
}
}
}

protected async handleControlKeys(e: React.KeyboardEvent<HTMLInputElement>): Promise<void> {
this.doAttemptAutocomplete = e.key !== 'Backspace';
if (e.key === 'Enter') {
const locationTextInput = this.locationTextInput;
if (locationTextInput) {
// remove extra whitespace and any trailing slashes or periods.
const sanitizedInput = locationTextInput.value.trim().replace(/[\/\\.]*$/, '');
const uri = new URI(sanitizedInput);
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.preventDefault();
e.stopPropagation();
}

protected async gatherAlphabetizedDirectories(currentValue: URI): Promise<string[] | undefined> {
const truncatedLocation = currentValue.path.dir.toString();
try {
const { children } = await this.fileService.resolve(new URI(truncatedLocation));
if (children) {
return children.filter(child => child.isDirectory)
.map(directory => `${directory.resource.path}/`)
.sort();
}
} catch (e) {
// no-op
}
}

get locationList(): HTMLSelectElement | undefined {
const locationList = this.host.getElementsByClassName(LocationListRenderer.Styles.LOCATION_LIST_CLASS)[0];
if (locationList instanceof HTMLSelectElement) {
Expand All @@ -118,12 +262,26 @@ export class LocationListRenderer extends ReactRenderer {
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;
}
}

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 {
Expand Down
Loading

0 comments on commit b5c2cee

Please sign in to comment.