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 Dec 7, 2020
1 parent 56c9851 commit 6de2965
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## v1.9.0

- [plugin-ext-vscode] added support for the command `workbench.extensions.installExtension`. [#8745](https://github.com/eclipse-theia/theia/pull/8745)
- [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.9.0)</a>

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;
}
}
45 changes: 39 additions & 6 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,6 +149,9 @@ 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();
this.locationListRenderer.host.classList.add(NAVIGATION_LOCATION_LIST_PANEL_CLASS);
Expand All @@ -158,7 +165,7 @@ export abstract class FileDialog<T> extends AbstractDialog<T> {
}

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

protected createFileTreeFiltersRenderer(): FileDialogTreeFiltersRenderer | undefined {
Expand All @@ -176,6 +183,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 +193,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 +249,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 +270,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 +320,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
157 changes: 150 additions & 7 deletions packages/filesystem/src/browser/location/location-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,87 @@ 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,
host?: HTMLElement
host?: HTMLElement,
protected readonly fileService?: FileService
) {
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) {
setTimeout(() => locationListTextInput.focus());
}
}
};

protected readonly handleLocationChanged = (e: React.ChangeEvent<HTMLSelectElement>) => this.onLocationChanged(e);
protected readonly handleLocationKeydown = (e: React.KeyboardEvent<HTMLSelectElement>) => this.onLocationKeydown(e);
protected readonly handleTextInputOnChange = (e: React.ChangeEvent<HTMLInputElement>) => this.onTextInputChanged(e);
protected readonly handleTextInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => this.onTextInputKeyDown(e);
protected readonly handleTextInputOnBlur = () => this.onTextInputToggle(false);
protected readonly handleTextInputMouseDown = (e: React.MouseEvent<HTMLSpanElement>) => this.onTextInputToggle(e.currentTarget.id === 'select-input');

protected doRender(): 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 (
<>
{!!this.fileService && <span 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>}
{ this.doShowTextInput ?
<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}
/>
:
<select className={`theia-select ${LocationListRenderer.Styles.LOCATION_LIST_CLASS} ${!!this.fileService ? 'with-icon' : ''}`}
onKeyDown={this.handleLocationKeydown}
onChange={this.handleLocationChanged}>F
{...options}
</select>
}
</>
);
}

protected onTextInputToggle(shouldShowTextInput: boolean): void {
if (shouldShowTextInput !== this.doShowTextInput) {
this.doShowTextInput = shouldShowTextInput;
this.render();
}
};

/**
* Collects the available locations based on the currently selected, and appends the available drives to it.
*/
Expand Down Expand Up @@ -104,12 +157,88 @@ 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 onLocationKeydown(e: React.KeyboardEvent<HTMLSelectElement>): void {
// allow only alphanumeric and special characters
if (e.key.length === 1) {
e.preventDefault();
this.doShowTextInput = true;
this.render();
}
}

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 onTextInputChanged(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 onTextInputKeyDown(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.onTextInputToggle(false);
}
} else if (e.key === 'Escape') {
this.onTextInputToggle(false);
} 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> {
if (this.fileService) {
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 +247,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 6de2965

Please sign in to comment.