472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { Emitter, Event } from "@coder/events";
|
|
import { client as ideClient } from "@coder/ide/src/fill/client";
|
|
import { $, addClass, append } from "vs/base/browser/dom";
|
|
import { HighlightedLabel } from "vs/base/browser/ui/highlightedlabel/highlightedLabel";
|
|
import { ObjectTree } from "vs/base/browser/ui/tree/objectTree";
|
|
import { ITreeElement, ITreeNode, ITreeRenderer, TreeFilterResult, TreeVisibility } from "vs/base/browser/ui/tree/tree";
|
|
import { KeyCode } from "vs/base/common/keyCodes";
|
|
import { URI } from "vs/base/common/uri";
|
|
import { getIconClasses } from "vs/editor/common/services/getIconClasses";
|
|
import { IModelService } from "vs/editor/common/services/modelService";
|
|
import { IModeService } from "vs/editor/common/services/modeService";
|
|
import { FileKind } from "vs/platform/files/common/files";
|
|
import { IThemeService } from "vs/platform/theme/common/themeService";
|
|
import { workbench } from "./workbench";
|
|
import "./dialog.scss";
|
|
|
|
declare var __non_webpack_require__: typeof require;
|
|
|
|
export enum DialogType {
|
|
NewFolder,
|
|
Save,
|
|
Open,
|
|
}
|
|
|
|
export interface CommonDialogOptions {
|
|
readonly title?: string;
|
|
readonly defaultPath?: string;
|
|
readonly buttonLabel?: string;
|
|
}
|
|
|
|
export interface OpenDialogOptions extends CommonDialogOptions {
|
|
readonly properties: {
|
|
readonly openFile: true;
|
|
readonly openDirectory?: boolean;
|
|
readonly showHiddenFiles?: boolean;
|
|
} | {
|
|
readonly openDirectory: true;
|
|
readonly showHiddenFiles?: boolean;
|
|
readonly openFile?: boolean;
|
|
};
|
|
}
|
|
|
|
export interface SaveDialogOptions extends CommonDialogOptions {
|
|
readonly type: DialogType.Save;
|
|
readonly nameFieldLabel?: string;
|
|
}
|
|
|
|
export type DialogOptions = OpenDialogOptions | SaveDialogOptions;
|
|
|
|
export const showOpenDialog = (options: OpenDialogOptions): Promise<string> => {
|
|
return new Promise<string>((resolve, reject): void => {
|
|
const dialog = new Dialog(DialogType.Open, options);
|
|
dialog.onSelect((e) => {
|
|
dialog.dispose();
|
|
resolve(e);
|
|
});
|
|
dialog.onError((e) => {
|
|
dialog.dispose();
|
|
reject(e);
|
|
});
|
|
});
|
|
|
|
};
|
|
|
|
interface DialogEntry {
|
|
readonly fullPath: string;
|
|
readonly name: string;
|
|
readonly isDirectory: boolean;
|
|
readonly size: number;
|
|
readonly lastModified: string;
|
|
}
|
|
|
|
class Dialog {
|
|
private _path: string | undefined;
|
|
|
|
private static readonly UpperDirId = "..";
|
|
|
|
private readonly filesNode: HTMLElement;
|
|
private readonly pathNode: HTMLElement;
|
|
|
|
private readonly entryList: ObjectTree<DialogEntry, string>;
|
|
private readonly background: HTMLElement;
|
|
private readonly root: HTMLElement;
|
|
|
|
private readonly selectEmitter: Emitter<string>;
|
|
private readonly errorEmitter: Emitter<Error>;
|
|
|
|
public constructor(
|
|
private readonly type: DialogType,
|
|
private readonly options: DialogOptions,
|
|
) {
|
|
this.selectEmitter = new Emitter();
|
|
this.errorEmitter = new Emitter();
|
|
|
|
this.background = document.createElement("div");
|
|
this.background.style.position = "absolute";
|
|
this.background.style.top = "0";
|
|
this.background.style.left = "0";
|
|
this.background.style.bottom = "0";
|
|
this.background.style.right = "0";
|
|
this.background.style.zIndex = "5";
|
|
this.background.style.display = "flex";
|
|
this.background.style.alignItems = "center";
|
|
this.background.style.justifyContent = "center";
|
|
this.background.style.background = "rgba(0, 0, 0, 0.25)";
|
|
|
|
this.root = document.createElement("div");
|
|
this.root.style.width = "850px";
|
|
this.root.style.height = "600px";
|
|
this.background.appendChild(this.root);
|
|
document.body.appendChild(this.background);
|
|
this.root.classList.add("dialog");
|
|
|
|
const setProperty = (vari: string, id: string): void => {
|
|
const getColor = (id: string): string | undefined => {
|
|
const ts = workbench.serviceCollection.get<IThemeService>(IThemeService) as IThemeService;
|
|
const c = ts.getTheme().getColor(id);
|
|
if (!c) {
|
|
return;
|
|
}
|
|
|
|
return c.toString();
|
|
};
|
|
const c = getColor(id);
|
|
if (c) {
|
|
this.root.style.setProperty(vari, c);
|
|
}
|
|
};
|
|
setProperty("--primary", "sideBar.background");
|
|
setProperty("--list-active-selection-background", "list.activeSelectionBackground");
|
|
setProperty("--list-active-selection-foreground", "list.activeSelectionForeground");
|
|
setProperty("--list-hover-background", "list.hoverBackground");
|
|
setProperty("--header-background", "sideBarSectionHeader.background");
|
|
setProperty("--header-foreground", "sideBarSectionHeader.foreground");
|
|
setProperty("--border", "panel.border");
|
|
|
|
this.background.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
const titleNode = document.createElement("div");
|
|
titleNode.classList.add("title");
|
|
let title: string | undefined;
|
|
switch (this.type) {
|
|
// case DialogType.NewFolder:
|
|
// title = "New Folder";
|
|
// break;
|
|
case DialogType.Open:
|
|
title = "Open File";
|
|
break;
|
|
case DialogType.Save:
|
|
title = "Save File";
|
|
break;
|
|
default:
|
|
throw new Error("Uncased type");
|
|
}
|
|
titleNode.innerText = options.title || title;
|
|
this.root.appendChild(titleNode);
|
|
|
|
const navItems = document.createElement("div");
|
|
navItems.classList.add("nav");
|
|
|
|
this.pathNode = document.createElement("div");
|
|
this.pathNode.classList.add("path");
|
|
navItems.appendChild(this.pathNode);
|
|
this.root.appendChild(navItems);
|
|
|
|
const headingsNode = document.createElement("div");
|
|
headingsNode.className = "headings dialog-grid";
|
|
["Name", "Size", "Last Modified"].forEach(e => {
|
|
const header = document.createElement("div");
|
|
header.innerText = e;
|
|
headingsNode.appendChild(header);
|
|
});
|
|
this.root.appendChild(headingsNode);
|
|
|
|
const fileAreaNode = document.createElement("div");
|
|
fileAreaNode.classList.add("file-area");
|
|
fileAreaNode.classList.add("show-file-icons");
|
|
|
|
this.filesNode = document.createElement("div");
|
|
this.filesNode.className = "files-list";
|
|
this.entryList = new ObjectTree<DialogEntry, string>(this.filesNode, {
|
|
getHeight: (entry: DialogEntry): number => {
|
|
return 20;
|
|
},
|
|
getTemplateId: (entry: DialogEntry): string => {
|
|
return "dialog-entry";
|
|
},
|
|
}, [new DialogEntryRenderer()], {
|
|
openController: {
|
|
shouldOpen: (event): boolean => {
|
|
return true;
|
|
},
|
|
},
|
|
keyboardNavigationLabelProvider: {
|
|
getKeyboardNavigationLabel: (element): string => {
|
|
return element.name;
|
|
},
|
|
mightProducePrintableCharacter: (event): boolean => {
|
|
if (event.ctrlKey || event.metaKey) {
|
|
// ignore ctrl/cmd-combination but not shift/alt-combinatios
|
|
return false;
|
|
}
|
|
// weak check for certain ranges. this is properly implemented in a subclass
|
|
// with access to the KeyboardMapperFactory.
|
|
if ((event.keyCode >= KeyCode.KEY_A && event.keyCode <= KeyCode.KEY_Z)
|
|
|| (event.keyCode >= KeyCode.KEY_0 && event.keyCode <= KeyCode.KEY_9)
|
|
|| event.keyCode === KeyCode.US_DOT || event.keyCode === KeyCode.US_SLASH || event.keyCode === KeyCode.US_MINUS) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
},
|
|
automaticKeyboardNavigation: true,
|
|
enableKeyboardNavigation: true,
|
|
multipleSelectionSupport: false,
|
|
openOnSingleClick: false,
|
|
filter: {
|
|
filter: (): TreeFilterResult<string> => {
|
|
// tslint:disable-next-line:no-any
|
|
(<any>this.entryList)._options.simpleKeyboardNavigation = true;
|
|
// tslint:disable-next-line:no-any
|
|
const pat = (<any>this.entryList).typeFilterController.filter._pattern;
|
|
|
|
return {
|
|
data: pat,
|
|
visibility: TreeVisibility.Visible,
|
|
};
|
|
},
|
|
},
|
|
filterOnType: true,
|
|
});
|
|
// tslint:disable-next-line:no-any
|
|
(<any>this.entryList).focusNavigationFilter = (node: ITreeNode<DialogEntry, string>): boolean => {
|
|
if (node.filterData) {
|
|
return node.element.name.toLowerCase().startsWith(node.filterData.toLowerCase()!);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
this.entryList.onDidOpen((event) => {
|
|
const element = event.elements[0]!;
|
|
if (!element) {
|
|
const fv = this.filterValue;
|
|
|
|
if (fv === Dialog.UpperDirId) {
|
|
this.path = path.dirname(this._path!);
|
|
}
|
|
|
|
if (fv.startsWith("/")) {
|
|
fs.stat(fv, (err, stats) => {
|
|
if (err) {
|
|
return;
|
|
}
|
|
|
|
if (stats.isDirectory()) {
|
|
this.path = fv;
|
|
}
|
|
});
|
|
}
|
|
|
|
return;
|
|
}
|
|
if (element.isDirectory) {
|
|
this.path = element.fullPath;
|
|
} else {
|
|
// Open
|
|
this.selectEmitter.emit(element.fullPath);
|
|
}
|
|
});
|
|
fileAreaNode.appendChild(this.entryList.getHTMLElement());
|
|
this.root.appendChild(fileAreaNode);
|
|
|
|
const buttonsNode = document.createElement("div");
|
|
buttonsNode.className = "buttons";
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.innerText = "Cancel";
|
|
cancelBtn.addEventListener("click", () => {
|
|
this.errorEmitter.emit(new Error("Cancelled"));
|
|
});
|
|
buttonsNode.appendChild(cancelBtn);
|
|
const confirmBtn = document.createElement("button");
|
|
confirmBtn.innerText = "Confirm";
|
|
confirmBtn.addEventListener("click", () => {
|
|
if (this._path) {
|
|
this.selectEmitter.emit(this._path);
|
|
}
|
|
});
|
|
buttonsNode.appendChild(confirmBtn);
|
|
this.root.appendChild(buttonsNode);
|
|
this.entryList.layout();
|
|
|
|
this.path = options.defaultPath || "/";
|
|
}
|
|
|
|
public get onSelect(): Event<string> {
|
|
return this.selectEmitter.event;
|
|
}
|
|
|
|
public get onError(): Event<Error> {
|
|
return this.errorEmitter.event;
|
|
}
|
|
|
|
public dispose(): void {
|
|
this.selectEmitter.dispose();
|
|
this.errorEmitter.dispose();
|
|
this.entryList.dispose();
|
|
this.background.remove();
|
|
}
|
|
|
|
private buildPath(): void {
|
|
while (this.pathNode.lastChild) {
|
|
this.pathNode.removeChild(this.pathNode.lastChild);
|
|
}
|
|
|
|
if (!this._path) {
|
|
throw new Error("cannot build path node without valid path");
|
|
}
|
|
|
|
const pathParts = ["", ...this._path.split("/").filter((p) => p.length > 0)];
|
|
|
|
for (let i = 0; i < pathParts.length; i++) {
|
|
const pathPartNode = document.createElement("div");
|
|
pathPartNode.classList.add("path-part");
|
|
pathPartNode.innerText = pathParts[i].length > 0 ? pathParts[i] : "/";
|
|
|
|
if (i === pathParts.length - 1) {
|
|
pathPartNode.classList.add("active");
|
|
}
|
|
|
|
pathPartNode.addEventListener("click", () => {
|
|
this.path = "/" + pathParts.slice(0, i + 1).join("/");
|
|
});
|
|
|
|
this.pathNode.appendChild(pathPartNode);
|
|
}
|
|
}
|
|
|
|
private set path(directory: string) {
|
|
const ts = Date.now();
|
|
this.list(directory).then((value) => {
|
|
this._path = directory;
|
|
this.buildPath();
|
|
|
|
while (this.filesNode.lastChild) {
|
|
this.filesNode.removeChild(this.filesNode.lastChild);
|
|
}
|
|
|
|
const items = value.filter((v) => {
|
|
if (v.name.startsWith(".")) {
|
|
const props = (this.options as OpenDialogOptions).properties;
|
|
if (props && props.showHiddenFiles) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
this.entryList.setChildren(null, items.map((i: DialogEntry): ITreeElement<DialogEntry> => ({ element: i })));
|
|
this.entryList.domFocus();
|
|
this.entryList.setFocus([null]);
|
|
// Clears the input on refresh
|
|
// tslint:disable-next-line:no-any
|
|
(<any>this.entryList).typeFilterController.onInput("");
|
|
}).catch((ex) => {
|
|
this.errorEmitter.emit(ex);
|
|
});
|
|
}
|
|
|
|
private get filterValue(): string {
|
|
// tslint:disable-next-line:no-any
|
|
return (<any>this.entryList).typeFilterController.filter._pattern;
|
|
}
|
|
|
|
private async list(directory: string): Promise<ReadonlyArray<DialogEntry>> {
|
|
return ideClient.evaluate((_helper, directory) => {
|
|
const fs = __non_webpack_require__("fs") as typeof import("fs");
|
|
const util = __non_webpack_require__("util") as typeof import("util");
|
|
const path = __non_webpack_require__("path") as typeof import("path");
|
|
|
|
return util.promisify(fs.readdir)(directory).then((paths) => {
|
|
paths = paths.sort();
|
|
|
|
return Promise.all(paths.map(p => util.promisify(fs.stat)(path.join(directory, p)))).then((stats) => {
|
|
return {
|
|
paths,
|
|
stats,
|
|
};
|
|
});
|
|
}).then(({ paths, stats }) => {
|
|
return stats.map((stat, index): DialogEntry => {
|
|
return {
|
|
fullPath: path.join(directory, paths[index]),
|
|
name: paths[index],
|
|
isDirectory: stat.isDirectory(),
|
|
lastModified: stat.mtime.toDateString(),
|
|
size: stat.size,
|
|
};
|
|
});
|
|
});
|
|
}, directory);
|
|
}
|
|
}
|
|
|
|
interface DialogEntryData {
|
|
icon: HTMLElement;
|
|
size: HTMLElement;
|
|
lastModified: HTMLElement;
|
|
label: HighlightedLabel;
|
|
}
|
|
|
|
class DialogEntryRenderer implements ITreeRenderer<DialogEntry, string, DialogEntryData> {
|
|
public get templateId(): string {
|
|
return "dialog-entry";
|
|
}
|
|
|
|
public renderTemplate(container: HTMLElement): DialogEntryData {
|
|
addClass(container, "dialog-entry");
|
|
addClass(container, "dialog-grid");
|
|
|
|
const wrapper = append(container, $(".dialog-entry-info"));
|
|
const icon: HTMLElement = append(wrapper, $("div"));
|
|
const name = append(wrapper, $(".dialog-entry-name"));
|
|
const label = new HighlightedLabel(name, false);
|
|
append(container, wrapper);
|
|
const size = append(container, $(".dialog-entry-size"));
|
|
const mtime = append(container, $(".dialog-entry-mtime"));
|
|
|
|
return {
|
|
icon,
|
|
size,
|
|
lastModified: mtime,
|
|
label,
|
|
};
|
|
}
|
|
|
|
public renderElement(node: ITreeNode<DialogEntry, string>, index: number, templateData: DialogEntryData): void {
|
|
templateData.icon.className = "dialog-entry-icon monaco-icon-label";
|
|
const classes = getIconClasses(
|
|
workbench.serviceCollection.get<IModelService>(IModelService) as IModelService,
|
|
workbench.serviceCollection.get<IModeService>(IModeService) as IModeService,
|
|
URI.file(node.element.name),
|
|
node.element.isDirectory ? FileKind.FOLDER : FileKind.FILE,
|
|
);
|
|
templateData.icon.hidden = classes.length === 0;
|
|
classes.forEach((c) => {
|
|
try {
|
|
templateData.icon.classList.add(c);
|
|
} catch (ex) {
|
|
// Nothin needed. Sometimes bad classes are given
|
|
}
|
|
});
|
|
templateData.label.set(node.element.name, typeof node.filterData === "string" && node.element.name.toLowerCase().startsWith(node.filterData.toLowerCase()) ? [{
|
|
start: 0,
|
|
end: node.filterData.length,
|
|
}] : []);
|
|
templateData.size.innerText = node.element.size.toString();
|
|
templateData.lastModified.innerText = node.element.lastModified;
|
|
}
|
|
|
|
public disposeTemplate(templateData: DialogEntryData): void {
|
|
// throw new Error("Method not implemented.");
|
|
}
|
|
}
|