215 lines
5.9 KiB
TypeScript
215 lines
5.9 KiB
TypeScript
import { FlatTreeControl } from '@angular/cdk/tree';
|
|
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;
|
|
browserFile?: File;
|
|
}
|
|
|
|
interface FileNode {
|
|
file: FileOrFolder;
|
|
expandable: boolean;
|
|
level: number;
|
|
}
|
|
|
|
const collator = new Intl.Collator(undefined, { numeric: true });
|
|
|
|
@Component({
|
|
selector: 'app-file-tree',
|
|
imports: [MatButtonModule, MatIconModule, MatTreeModule],
|
|
templateUrl: './file-tree.component.html',
|
|
styleUrl: './file-tree.component.scss',
|
|
})
|
|
export class FileTreeComponent implements OnInit, OnDestroy {
|
|
fileSelected = output<FileOrFolder>();
|
|
|
|
protected fileSelector =
|
|
viewChild<ElementRef<HTMLInputElement>>('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<FileNode>(
|
|
(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<string | null>(null);
|
|
protected files = signal<FileOrFolder[]>([]);
|
|
protected isFileSelected = signal<boolean>(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,
|
|
path: file.webkitRelativePath,
|
|
children: [],
|
|
browserFile: file,
|
|
};
|
|
currentChildren?.push(matchingChild);
|
|
}
|
|
currentChildren = matchingChild.children;
|
|
}
|
|
currentChildren?.push({
|
|
isDirectory: false,
|
|
name: file.name,
|
|
path: file.webkitRelativePath,
|
|
browserFile: file,
|
|
});
|
|
} else {
|
|
mappedFiles.push({
|
|
isDirectory: false,
|
|
name: file.name,
|
|
path: file.webkitRelativePath,
|
|
browserFile: file,
|
|
});
|
|
}
|
|
}
|
|
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 }) =>
|
|
collator.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);
|
|
}
|
|
}
|