desktop/src/@batch-flask/ui/file/file-explorer/file-explorer.component.ts (220 lines of code) (raw):

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, Output, } from "@angular/core"; import { FileSystemService } from "@batch-flask/electron"; import { Activity, ActivityService } from "@batch-flask/ui/activity"; import { DialogService } from "@batch-flask/ui/dialogs"; import { FileNavigator, FileTreeNode } from "@batch-flask/ui/file/file-navigator"; import { LoadingStatus } from "@batch-flask/ui/loading"; import { SplitPaneConfig } from "@batch-flask/ui/split-pane"; import { CloudPathUtils, FileUrlUtils } from "@batch-flask/utils"; import * as path from "path"; import { Subscription, of } from "rxjs"; import { FileViewerConfig } from "../file-viewer"; import { CurrentNode, FileExplorerWorkspace, FileSource, OpenedFile } from "./file-explorer-workspace"; import "./file-explorer.scss"; export interface FileNavigatorEntry { name: string; navigator: FileNavigator; } export enum FileExplorerSelectable { none = 1, file = 2, folder = 4, all = 6, } export interface FileDeleteEvent { navigator: FileNavigator; path: string; isDirectory: boolean; } export interface FileDropEvent { path: string; files: File[]; } export interface FileExplorerConfig { /** * If the file explorer should show the tree view on the left * @default true */ showTreeView?: boolean; /** * If the explorer should just select the file(not open) * @default FileExplorerSelectable.none */ selectable?: FileExplorerSelectable; /** * If the explorer allows dropping external files * @default false */ canDropExternalFiles?: boolean; viewer?: FileViewerConfig; } const fileExplorerDefaultConfig: FileExplorerConfig = { showTreeView: true, selectable: FileExplorerSelectable.none, canDropExternalFiles: false, viewer: null, }; /** * File explorer is a combination of the tree view and the file preview. */ @Component({ selector: "bl-file-explorer", templateUrl: "file-explorer.html", changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileExplorerComponent implements OnChanges, OnDestroy { @Input() public set data(data: FileExplorerWorkspace | FileNavigator) { if (data instanceof FileExplorerWorkspace) { this.workspace = data; } else { this.workspace = new FileExplorerWorkspace(data); } this._updateWorkspaceEvents(); } @Input() public set config(config: FileExplorerConfig) { this._config = { ...fileExplorerDefaultConfig, ...config }; } public get config() { return this._config; } @Input() public autoExpand = false; @Input() public activeFile: string; @Output() public activeFileChange = new EventEmitter<string>(); @Output() public dropFiles = new EventEmitter<FileDropEvent>(); public LoadingStatus = LoadingStatus; public currentSource: FileSource; public currentNode: CurrentNode; public workspace: FileExplorerWorkspace; public splitPaneConfig: SplitPaneConfig; private _workspaceSubs: Subscription[] = []; private _config: FileExplorerConfig = fileExplorerDefaultConfig; constructor( private changeDetector: ChangeDetectorRef, private dialogService: DialogService, private fs: FileSystemService, private activityService: ActivityService) { this._updateSplitPanelConfig(); } public ngOnChanges(inputs) { // Todo Remove? if (inputs.config) { this._updateSplitPanelConfig(); } } public ngOnDestroy() { this._clearWorkspaceSubs(); } /** * Triggered when a file/folder is selected in the table view * It will either navigate to the given item or select it depending on the settings. * @param node Tree node that got selected */ public nodeSelected(node: FileTreeNode) { if (!this._updateActiveItem(node)) { this.navigateTo(node.path, this.currentSource); } } public navigateTo(path: string, source: FileSource) { this.workspace.navigateTo(path, source); source.navigator.getNode(path).subscribe((node) => { this._updateActiveItem(node); }); } public goBack() { this.workspace.goBack(); } public handleDrop(event: FileDropEvent) { this.dialogService.confirm(`Upload files`, { description: `Files will be uploaded to /${event.path}`, yes: () => { this._uploadFiles(event); return of(null); }, }); } public trackSource(index, source: FileSource) { return source.name; } public trackOpenedFile(index, file: OpenedFile) { return `${file.source.name}/${file.path}`; } public handleDelete(event: FileDeleteEvent) { const { path } = event; const description = event.isDirectory ? `All files will be deleted from the folder: ${path}` : `The file '${FileUrlUtils.getFileName(path)}' will be deleted.`; this.dialogService.confirm(`Delete files`, { description: description, yes: () => { if (event.isDirectory) { const name = `Deleting folder ${event.path}`; // get the initializer for a folder deletion activity from the FileNavigator const initializer = () => { return event.navigator.createFolderDeletionActivityInitializer(event.path); }; // create a folder deletion activity, and run it const activity = new Activity(name, initializer); this.activityService.exec(activity); } else { const name = `Deleting file ${event.path}`; // if the event is not a directory, create a simple file deletion activity const initializer = () => { return event.navigator.deleteFile(event.path); }; // run the activity const activity = new Activity(name, initializer); this.activityService.exec(activity); } }, }); } private _updateActiveItem(node: FileTreeNode): boolean { // eslint-disable-next-line no-bitwise if (node.isDirectory && (this.config.selectable & FileExplorerSelectable.folder)) { this.activeFileChange.emit(node.path); return true; // eslint-disable-next-line no-bitwise } else if (!node.isDirectory && (this.config.selectable & FileExplorerSelectable.file)) { this.activeFileChange.emit(node.path); return true; } return false; } private _updateWorkspaceEvents() { this._clearWorkspaceSubs(); this._workspaceSubs.push(this.workspace.currentSource.subscribe((source) => { this.currentSource = source; this.changeDetector.markForCheck(); })); this._workspaceSubs.push(this.workspace.currentNode.subscribe((node) => { this.currentNode = { ...node, treeNode: new FileTreeNode(node.treeNode) } as any; this.changeDetector.markForCheck(); })); } private _clearWorkspaceSubs() { this._workspaceSubs.forEach(x => x.unsubscribe()); this._workspaceSubs = []; } private _updateSplitPanelConfig() { this.splitPaneConfig = { firstPane: { minSize: 200, hidden: !this.config.showTreeView, }, secondPane: { minSize: 300, }, initialDividerPosition: 250, }; this.changeDetector.markForCheck(); } private async _getFilesToUpload(base: string, files: any[]) { const result = []; for (const file of files) { const stats = await this.fs.lstat(file.path); const filename = path.basename(file.path); if (stats.isFile()) { result.push({ localPath: file.path, remotePath: CloudPathUtils.join(base, filename) }); } else { const dirFiles = await this.fs.readdir(file.path); for (const dirFile of dirFiles) { result.push({ localPath: path.join(file.path, dirFile), remotePath: CloudPathUtils.join(base, file.name, CloudPathUtils.normalize(dirFile)), }); } } } return result; } private _createUploadActivities(files: any[]) { return files.map((file) => { const filename = path.basename(file.localPath); const activity = new Activity(`Uploading ${filename}`, () => { return this.currentSource.navigator.uploadFile(file.remotePath, file.localPath); }); activity.done.subscribe(() => this.currentSource.navigator.loadFile(file.remotePath).subscribe()); return activity; }); } private async _uploadFiles(event: FileDropEvent) { const files = await this._getFilesToUpload(event.path, event.files); const activities = this._createUploadActivities(files); const name = `Uploading ${files.length} files`; const activity = new Activity(name, () => of(activities)); this.activityService.exec(activity); } }