import { FlatTreeControl } from '@angular/cdk/tree'; import { CommonModule } from '@angular/common'; import { Component, ElementRef, OnDestroy, OnInit, computed, output, signal, viewChild, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTreeFlatDataSource, MatTreeFlattener, MatTreeModule, } from '@angular/material/tree'; import { message, open } from '@tauri-apps/api/dialog'; import { UnlistenFn, listen } from '@tauri-apps/api/event'; import { FileEntry, readDir } from '@tauri-apps/api/fs'; import { OpenFolderMessage } from '../messages/openfolder.message'; export interface FileOrFolder { isDirectory: boolean; name: string; children?: FileOrFolder[]; path?: string; } interface FileNode { file: FileOrFolder; expandable: boolean; level: number; } @Component({ selector: 'app-file-tree', standalone: true, imports: [CommonModule, MatButtonModule, MatIconModule, MatTreeModule], templateUrl: './file-tree.component.html', styleUrl: './file-tree.component.scss', }) export class FileTreeComponent implements OnInit, OnDestroy { fileSelected = output(); protected fileSelector = viewChild>('fileSelector'); // File tree protected hasChild = (_: number, node: FileNode) => node.expandable; private _transformer = (node: FileOrFolder, level: number) => ({ expandable: !!node.children && node.children.length > 0, file: node, level: level, }); protected treeControl = new FlatTreeControl( (node) => node.level, (node) => node.expandable ); private treeFlattener = new MatTreeFlattener( this._transformer, (node) => node.level, (node) => node.expandable, (node) => node.children ); // Use computed signals to create datasource protected dataSource = computed(() => { const dataSource = new MatTreeFlatDataSource( this.treeControl, this.treeFlattener ); dataSource.data = this.files(); return dataSource; }); // Folder selection protected selectedDirectory = signal(null); protected files = signal([]); protected isFileSelected = signal(false); protected workspaceName = computed(() => { const directory = this.selectedDirectory(); if (directory) { const directorySplit = directory.split('/'); return directorySplit[directorySplit.length - 1]; } return null; }); private unlisten?: UnlistenFn; async ngOnInit() { this.unlisten = await listen( 'openfolder', async (event: OpenFolderMessage) => { await this.selectDirectory(); } ); } async ngOnDestroy() { if (this.unlisten) { this.unlisten(); } } async selectDirectory() { try { await this.selectDirectoryNative(); } catch (err) { // Tauri failed, try using browser to select files this.fileSelector()?.nativeElement.click(); } } private async selectDirectoryNative() { const selectedDirectory = await open({ directory: true, multiple: false, }); if (Array.isArray(selectedDirectory)) { message('Only a single folder can be selected at a time'); return; } else { this.selectedDirectory.set(selectedDirectory); } if (selectedDirectory) { const entries = await readDir(selectedDirectory, { recursive: true, }); this.files.set( entries.sort(this.sortFiles).map((entry) => this.mapEntry(entry)) ); this.isFileSelected.set(true); } } protected selectFilesBrowser() { const files = this.fileSelector()?.nativeElement.files; if (files && files.length > 0) { const mappedFiles: FileOrFolder[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.webkitRelativePath?.includes('/')) { // Got a file in a folder, so put it into the appropriate folder in the tree const splitFilePath = file.webkitRelativePath.split('/'); let currentChildren: FileOrFolder[] | undefined = mappedFiles; for (let j = 0; j < splitFilePath.length - 1; j++) { const relativePath = splitFilePath[j]; let matchingChild: FileOrFolder | undefined = currentChildren?.find( (mappedFile) => mappedFile.name === relativePath ); if (!matchingChild) { matchingChild = { isDirectory: true, name: relativePath, children: [], }; currentChildren?.push(matchingChild); } currentChildren = matchingChild.children; } currentChildren?.push({ isDirectory: false, name: file.name }); } else { mappedFiles.push({ isDirectory: false, name: file.name }); } } this.recursiveSort(mappedFiles); this.files.set(mappedFiles); this.isFileSelected.set(true); } } private mapEntry(entry: FileEntry): FileOrFolder { return { isDirectory: entry.children != null, name: entry.name || '', children: entry.children ?.sort(this.sortFiles) .map((entry) => this.mapEntry(entry)), path: entry.path, }; } private sortFiles = (a: { name?: string }, b: { name?: string }) => new Intl.Collator(undefined, { numeric: true }).compare(a.name!, b.name!); private recursiveSort(files: FileOrFolder[]) { for (const file of files) { if (file.children) { this.recursiveSort(file.children); } } files.sort(this.sortFiles); } }