import * as pathFx from "./path-fx";
import { Branch } from "./tree/branch";
import { Leaf } from "./tree/leaf";
import { nodesById } from "./tree/nodes-by-id";
import type { GetNodes as GetNodesBase } from "./tree/tree";
import { Tree } from "./tree/tree";
import type { FileTreeSnapshot } from "./types";
/**
* Create a file tree that can be used with the React API.
*
* @param getNodes - A function that returns the nodes of the file tree.
* @param config - Configuration options for the file tree.
* @param config.comparator - A function that compares two nodes for sorting.
* @param config.root - The root node data of the file tree.
*/
export function createFileTree(
getNodes: GetNodes,
config: FileTreeConfig = {}
) {
const { comparator = defaultComparator, root, restoreFromSnapshot } = config;
const tree = new FileTree({
async getNodes(parent) {
const factory: Omit, "createPrompt"> = {
createFile(data) {
return new File(parent, data);
},
createDir(data, expanded?: boolean) {
const path =
parent.id === -1
? data.name
: pathFx.join(
// @ts-expect-error: branch type but is dir
parent.path,
pathFx.basename(data.name)
);
return new Dir(
parent,
data,
expanded ??
!!(
restoreFromSnapshot &&
(restoreFromSnapshot.expandedPaths.includes(path) ||
restoreFromSnapshot.buriedPaths.includes(path))
)
);
},
};
return getNodes(parent as Dir, factory);
},
comparator,
root: new Dir(null, root ? { ...root } : { name: pathFx.SEP }),
});
return tree;
}
export class FileTree extends Tree> {
/**
* The root directory of the file tree
*/
declare root: Dir;
protected declare treeNodeMap: Map | Dir>;
declare nodesById: FileTreeNode[];
protected declare loadingBranches: Map, Promise>;
/**
* Get a node by its ID.
*
* @param id - The ID of the node
*/
declare getById: (id: number) => FileTreeNode | undefined;
/**
* Expand a directory in the tree.
*
* @param dir - The directory to expand
* @param options - Options for expanding the directory
*/
declare expand: (
dir: Dir,
options?: {
/**
* Ensure that the directory's parents are visible in the tree, as well.
*/
ensureVisible?: boolean;
/**
* Expand all of the directory's child directories.
*/
recursive?: boolean;
}
) => Promise;
/**
* Collapse a directory in the tree.
*
* @param dir - The directory to collapse
*/
declare collapse: (dir: Dir) => void;
/**
* Remove a node and its descendants from the tree.
*/
declare remove: (node: FileTreeNode) => void;
/**
* You can use this method to manually trigger a reload of a directory in the tree.
*
* @param dir - The branch to load nodes for
*/
declare loadNodes: (dir: Dir) => Promise;
constructor({
getNodes,
comparator,
root,
}: {
getNodes: GetNodesBase>;
comparator: (a: FileTreeNode, b: FileTreeNode) => number;
root: Dir;
}) {
super({ getNodes, root });
// @ts-expect-error
this.comparator = comparator;
}
/**
* Get a node in the tree by its path. Note that this requires walking the tree,
* which has O(n) complexity. It should therefore be avoided unless absolutely necessary.
*
* @param path - The path to search for in the tree
*/
getByPath(path: string) {
let found: FileTreeNode | undefined;
path = pathFx.removeTrailingSlashes(pathFx.normalize(path));
this.walk(this.root, (node) => {
if (node.path === path) {
found = node;
return false;
}
});
return found;
}
/**
* Walks the tree starting at a given directory and calls a visitor
* function for each node.
*
* @param dir - The directory to walk
* @param visitor - A function that is called for each node in the tree. Returning
* `false` will stop the walk.
* @example
* tree.walk(tree.root, node => {
* console.log(node.path);
*
* if (node.path === '/foo/bar') {
* return false
* }
* })
*/
walk(
dir: Dir,
visitor: (
node: FileTreeNode,
parent: FileTreeNode
) => boolean | void
) {
const nodeIds = !dir.nodes ? [] : [...dir.nodes];
let nodeId: number | undefined;
while ((nodeId = nodeIds.pop())) {
const node = this.getById(nodeId);
if (!node) continue;
const shouldContinue = visitor(node, dir);
if (shouldContinue === false) return;
if (isDir(node) && node.nodes) {
nodeIds.push(...node.nodes);
}
}
}
/**
* Produce a new tree with the given function applied to the given node.
* This is similar to `immer`'s produce function as you're working on a draft
* and can freely mutate the object.
*
* @param dir - The directory to produce the tree for
* @param produceFn - The function to produce the tree with
*/
produce(
dir: Dir,
produceFn: (
context: FileTreeFactory & {
/**
* The draft of the directory.
*/
get draft(): FileTreeNode[];
/**
* Insert a node into the draft.
*
* @param node - The node to insert
*/
insert>(node: NodeType): NodeType;
/**
* Revert the draft back to its original state.
*/
revert(): void;
}
) => void | (Dir | File)[]
) {
return this._produce(dir, (context) => {
const producer = produceFn({
get draft() {
return context.draft as (Dir | File)[];
},
insert(node) {
const insertedNode = context.insert(node, 0);
return insertedNode;
},
revert() {
context.revert();
},
createFile(data) {
return new File(dir, data);
},
createDir(data, expanded?: boolean) {
return new Dir(dir, data, expanded);
},
createPrompt() {
return new Prompt(dir, { name: "" });
},
});
return producer;
});
}
/**
* Move a node to a new parent.
*
* @param node - The node to move
* @param to - The new parent
*/
move(node: File | Dir, to: Dir) {
return super.move(node, to);
}
/**
* Create a new file in a given directory.
*
* @param inDir - The directory to create the file in
* @param withData - The data for the file
*/
newFile(inDir: Dir, withData: FileTreeData) {
const file = new File(inDir, withData);
this.produce(inDir, ({ insert }) => {
insert(file);
});
return file;
}
/**
* Create a new directory in a given directory.
*
* @param inDir - The directory to create the directory in
* @param withData - The data for the directory
* @param expanded - Whether the directory should be expanded by default
*/
newDir(inDir: Dir, withData: FileTreeData, expanded?: boolean) {
const dir = new Dir(inDir, withData, expanded);
this.produce(inDir, ({ insert }) => {
insert(dir);
});
return dir;
}
/**
* Create a new directory in a given directory.
*
* @param inDir - The directory to create the directory in
*/
newPrompt(inDir: Dir) {
const prompt = new Prompt(inDir, { name: "" });
this.produce(inDir, ({ insert }) => {
insert(prompt);
});
return prompt;
}
/**
* Rename a node.
*
* @param node - The node to rename
* @param newName - The new name for the node
*/
rename(node: File | Dir, newName: string) {
node.data.name = newName;
const parent = node.parent;
if (parent && parent.nodes) {
this.setNodes(parent, [...parent.nodes]);
}
}
}
export class File extends Leaf> {
readonly $$type = "file";
private _basenameName?: string;
private _basename?: string;
/**
* The parent directory of the file
*/
get parent(): Dir | null {
return this.parentId === -1
? null
: (nodesById[this.parentId] as Dir);
}
/**
* The basename of the file
*/
get basename() {
if (this._basenameName === this.data.name) {
return this._basename!;
}
this._basenameName = this.data.name;
return (this._basename = pathFx.basename(this.data.name));
}
/**
* The full path of the file
*/
get path(): string {
return getPath(this);
}
}
export class Dir extends Branch> {
readonly $$type = "dir";
private _basenameName?: string;
private _basename?: string;
/**
* The parent directory of this directory
*/
get parent(): Dir | null {
return this.parentId === -1
? null
: (nodesById[this.parentId] as Dir);
}
/**
* The basename of the directory
*/
get basename() {
if (this._basenameName === this.data.name) {
return this._basename!;
}
this._basenameName = this.data.name;
return (this._basename = pathFx.basename(this.data.name));
}
/**
* The full path of the directory
*/
get path(): string {
return getPath(this);
}
}
export class Prompt extends Leaf> {
readonly $$type = "prompt";
/**
* The parent directory of this directory
*/
get parent(): Dir | null {
return this.parentId === -1
? null
: (nodesById[this.parentId] as Dir);
}
get basename() {
return "";
}
/**
* The full path of the prompt
*/
get path(): string {
return getPath(this);
}
}
function getPath(node: FileTreeNode) {
if (node.parent) {
const parentPath = node.parent.path;
const hasTrailingSlash = parentPath[parentPath.length - 1] === pathFx.SEP;
const sep =
hasTrailingSlash || parentPath === "" || node.basename === ""
? ""
: pathFx.SEP;
return parentPath + sep + node.basename;
}
return pathFx.normalize(node.data.name);
}
/**
* A sort comparator for sorting path names
*
* @param a - A tree node
* @param b - A tree node to compare against `a`
*/
export function defaultComparator(a: FileTreeNode, b: FileTreeNode) {
if (a.constructor === b.constructor) {
return a.basename.localeCompare(b.basename);
}
if (isPrompt(a)) {
return -1;
} else if (isPrompt(b)) {
return 1;
} else if (isDir(a)) {
return -1;
} else if (isDir(b)) {
return 1;
}
return 0;
}
/**
* Returns `true` if the given node is a prompt
*
* @param treeNode - A tree node
*/
export function isPrompt(
treeNode: FileTreeNode
): treeNode is Prompt {
return treeNode.constructor === Prompt;
}
/**
* Returns `true` if the given node is a file
*
* @param treeNode - A tree node
*/
export function isFile(
treeNode: FileTreeNode
): treeNode is File {
return treeNode.constructor === File;
}
/**
* Returns `true` if the given node is a directory
*
* @param treeNode - A tree node
*/
export function isDir(
treeNode: FileTreeNode
): treeNode is Dir {
return treeNode.constructor === Dir;
}
export type FileTreeNode = File | Dir | Prompt;
export type FileTreeData = {
name: string;
meta?: Meta;
};
export type FileTreeFactory = {
/**
* Create a file node that can be inserted into the tree.
*
* @param data - The data to create a file with
*/
createFile(data: FileTreeData): File;
/**
* Create a directory node that can be inserted into the tree.
*
* @param data - The data to create a directory with
* @param expanded - Should the directory be expanded by default?
*/
createDir(data: FileTreeData, expanded?: boolean): Dir;
/**
* Create a prompt node that can be inserted into the tree.
*/
createPrompt(): Prompt;
};
export type GetNodes = {
/**
* Get the nodes for a given directory
*
* @param parent - The parent directory to get the nodes for
* @param factory - A factory to create nodes (file/dir) with
*/
(parent: Dir, factory: Omit, "createPrompt">):
| Promise[]>
| FileTreeNode[];
};
export type FileTreeConfig = {
/**
* A function that compares two nodes for sorting.
*/
comparator?: FileTree["comparator"];
/**
* The root node data
*/
root?: Omit, "type">;
/**
* Restore the tree from a snapshot
*/
restoreFromSnapshot?: FileTreeSnapshot;
};