Skip to content

Commit

Permalink
feat: add walk/getByPath methods to file tree (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredLunde authored Apr 26, 2022
1 parent df530c6 commit 852e150
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 84 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ file tree when you initially load it.
| Name | Type | Required? | Description |
| -------- | ---------------------------------------------------- | --------- | ---------------------------------------------- |
| fileTree | `FileTree<Meta>` | Yes | A file tree |
| callback | `(state: FileTreeSnapshot) => Promise<void> \| void` | Yes | A callback that handles the file tree snapshot |
| observer | `(state: FileTreeSnapshot) => Promise<void> \| void` | Yes | A callback that handles the file tree snapshot |

[**⇗ Back to top**](#exploration)

Expand All @@ -635,6 +635,8 @@ file tree when you initially load it.
| Name | Description |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| getById | Get a node by its ID. |
| getByPath | Get a node by its path. Note that this requires walking the tree, which has O(n) complexity. |
| walk | Walks the tree starting at a given directory and calls a visitor function for each node. |
| expand | Expand a directory in the tree. |
| collapse | Collapse a directory in the tree. |
| remove | Remove a node and its descendants from the tree. |
Expand Down Expand Up @@ -896,8 +898,8 @@ Splits a path into an array of path segments.

### pathFx.normalize()

Normalize a path, taking care of `..` and `.`, and removing redundant slashes.
Unlike Node's `path`, this removes any trailing slashes.
Normalize a path, taking care of `..` and `.`, and removing redundant slashes while
preserving trailing slashes.

#### Arguments

Expand Down
34 changes: 29 additions & 5 deletions src/file-tree.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { createFileTree, defaultComparator, isFile, isPrompt } from ".";
import { createFileTree, defaultComparator, isFile } from ".";
import type { Dir, File } from ".";
import { isDir } from "./file-tree";
import { dirname } from "./path-fx";
import type { Branch } from "./tree/branch";
import { nodesById } from "./tree/nodes-by-id";
import type { Tree } from "./tree/tree";
Expand All @@ -22,6 +20,14 @@ describe("createFileTree()", () => {
expect(tree.visibleNodes.length).toBe(mockFs["/"].length);
});

it("should create a tree with visible nodes and an empty root path", async () => {
const tree = createFileTree(getNodesFromMockFs, { root: { name: "" } });

await waitForTree(tree);
expect(tree.visibleNodes.length).toBe(mockFs["/"].length);
expect(tree.visibleNodes.length).toBe(mockFs["/"].length);
});

it('should have nodes with a "depth" property that is correct', async () => {
const tree = createFileTree(getNodesFromMockFs);

Expand Down Expand Up @@ -627,6 +633,23 @@ describe("createFileTree()", () => {
expect(huskyHooks.expanded).toBe(true);
expect(huskyHooks.nodes).not.toBeUndefined();
});

it("should get a node by its path", async () => {
const tree = createFileTree(getNodesFromMockFs);
await waitForTree(tree);
expect(tree.getByPath("/./.gitignore")!.path).toBe("/.gitignore");
expect(tree.getByPath("/.gitignore")!.path).toBe("/.gitignore");
expect(tree.getByPath("/.gitignore/")!.path).toBe("/.gitignore");
expect(tree.getByPath("/.husky/../.gitignore")!.path).toBe("/.gitignore");
});

it("should get a buried node by its path", async () => {
const tree = createFileTree(getNodesFromMockFs);
await waitForTree(tree);
await tree.expand(tree.getById(tree.visibleNodes[1]) as Dir);
tree.collapse(tree.getById(tree.visibleNodes[1]) as Dir);
expect(tree.getByPath("/.husky/hooks")!.path).toBe("/.husky/hooks");
});
});

describe("file tree actions", () => {
Expand Down Expand Up @@ -658,7 +681,7 @@ describe("file tree actions", () => {

tree.newDir(tree.root, { name: "bar" });
expect(tree.getById(tree.root.nodes[0]).basename).toBe("");
expect(tree.getById(tree.root.nodes[0]).path).toBe("");
expect(tree.getById(tree.root.nodes[0]).path).toBe("/");
});

it("should create a prompt in a directory", async () => {
Expand Down Expand Up @@ -754,7 +777,8 @@ function waitForTree(tree: Tree<any>) {
}

function getNodesFromMockFs(parent: any, { createFile, createDir }: any) {
return mockFs[parent.data.name].map((stat) => {
const name = parent.data.name || "/";
return mockFs[name].map((stat) => {
if (stat.type === "file") {
return createFile({ name: stat.name });
}
Expand Down
140 changes: 107 additions & 33 deletions src/file-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function createFileTree<Meta = {}>(
return getNodes(parent as Dir<Meta>, factory);
},
comparator,
root: new Dir(null, root ? { ...root } : { name: "/" }),
root: new Dir(null, root ? { ...root } : { name: pathFx.SEP }),
});

return tree;
Expand Down Expand Up @@ -124,6 +124,65 @@ export class FileTree<Meta = {}> extends Tree<FileTreeData<Meta>> {
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<Meta> | 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<Meta>,
visitor: (
node: FileTreeNode<Meta>,
parent: FileTreeNode<Meta>
) => 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
Expand All @@ -132,7 +191,7 @@ export class FileTree<Meta = {}> extends Tree<FileTreeData<Meta>> {
* @param dir - The directory to produce the tree for
* @param produceFn - The function to produce the tree with
*/
public produce(
produce(
dir: Dir<Meta>,
produceFn: (
context: FileTreeFactory<Meta> & {
Expand Down Expand Up @@ -261,6 +320,9 @@ export class FileTree<Meta = {}> extends Tree<FileTreeData<Meta>> {

export class File<Meta = {}> extends Leaf<FileTreeData<Meta>> {
readonly $$type = "file";
private _basenameName?: string;
private _basename?: string;

/**
* The parent directory of the file
*/
Expand All @@ -274,23 +336,27 @@ export class File<Meta = {}> extends Leaf<FileTreeData<Meta>> {
* The basename of the file
*/
get basename() {
return pathFx.basename(this.data.name);
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 {
if (this.parentId > -1) {
return pathFx.join(this.parent!.path, this.basename);
}

return this.data.name;
return getPath(this);
}
}

export class Prompt<Meta = {}> extends Leaf<FileTreeData<Meta>> {
readonly $$type = "prompt";
export class Dir<Meta = {}> extends Branch<FileTreeData<Meta>> {
readonly $$type = "dir";
private _basenameName?: string;
private _basename?: string;

/**
* The parent directory of this directory
*/
Expand All @@ -300,24 +366,28 @@ export class Prompt<Meta = {}> extends Leaf<FileTreeData<Meta>> {
: (nodesById[this.parentId] as Dir<Meta>);
}

/**
* The basename of the directory
*/
get basename() {
return "";
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 prompt
* The full path of the directory
*/
get path(): string {
if (this.parentId > -1) {
return pathFx.join(this.parent!.path, this.basename);
}

return this.data.name;
return getPath(this);
}
}

export class Dir<Meta = {}> extends Branch<FileTreeData<Meta>> {
readonly $$type = "dir";
export class Prompt<Meta = {}> extends Leaf<FileTreeData<Meta>> {
readonly $$type = "prompt";
/**
* The parent directory of this directory
*/
Expand All @@ -327,23 +397,30 @@ export class Dir<Meta = {}> extends Branch<FileTreeData<Meta>> {
: (nodesById[this.parentId] as Dir<Meta>);
}

/**
* The basename of the directory
*/
get basename() {
return pathFx.basename(this.data.name);
return "";
}

/**
* The full path of the directory
* The full path of the prompt
*/
get path(): string {
if (this.parentId > -1) {
return pathFx.join(this.parent!.path, this.basename);
}
return getPath(this);
}
}

return this.data.name;
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);
}

/**
Expand Down Expand Up @@ -377,7 +454,7 @@ export function defaultComparator(a: FileTreeNode, b: FileTreeNode) {
*/
export function isPrompt<Meta>(
treeNode: FileTreeNode<Meta>
): treeNode is Prompt<Meta> & { readonly $$type: "prompt" } {
): treeNode is Prompt<Meta> {
return treeNode.constructor === Prompt;
}

Expand All @@ -403,10 +480,7 @@ export function isDir<Meta>(
return treeNode.constructor === Dir;
}

export type FileTreeNode<Meta = {}> =
| File<Meta>
| Dir<Meta>
| (Prompt<Meta> & { readonly $$type: "prompt" });
export type FileTreeNode<Meta = {}> = File<Meta> | Dir<Meta> | Prompt<Meta>;

export type FileTreeData<Meta = {}> = {
name: string;
Expand Down
24 changes: 14 additions & 10 deletions src/path-fx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,26 @@ describe("join()", () => {
it("should join paths", () => {
expect(pathFx.join("/foo", "bar")).toBe("/foo/bar");
expect(pathFx.join("/foo", "/bar")).toBe("/foo/bar");
expect(pathFx.join("/foo", "bar/")).toBe("/foo/bar");
expect(pathFx.join("/foo", "/bar/")).toBe("/foo/bar");
expect(pathFx.join("/foo", "bar/")).toBe("/foo/bar/");
expect(pathFx.join("/foo", "bar//", "/", "/")).toBe("/foo/bar/");
expect(pathFx.join("/foo", "/bar/")).toBe("/foo/bar/");
expect(pathFx.join("/foo", "bar/baz")).toBe("/foo/bar/baz");
expect(pathFx.join("/foo", "/bar/baz")).toBe("/foo/bar/baz");
expect(pathFx.join("/foo", "bar/baz/")).toBe("/foo/bar/baz");
expect(pathFx.join("/foo", "/bar/baz/")).toBe("/foo/bar/baz");
expect(pathFx.join("/foo", "bar/baz/")).toBe("/foo/bar/baz/");
expect(pathFx.join("/foo", "/bar/baz/")).toBe("/foo/bar/baz/");
expect(pathFx.join("/foo", "bar/baz/qux")).toBe("/foo/bar/baz/qux");
expect(pathFx.join("/foo", "/bar/baz/qux")).toBe("/foo/bar/baz/qux");
expect(pathFx.join("/foo", "bar/baz/qux/")).toBe("/foo/bar/baz/qux");
expect(pathFx.join("/foo", "/bar/baz/qux/")).toBe("/foo/bar/baz/qux");
expect(pathFx.join("/foo", "bar/baz/qux/")).toBe("/foo/bar/baz/qux/");
expect(pathFx.join("/foo", "/bar/baz/qux/")).toBe("/foo/bar/baz/qux/");
});

it("should join paths lower in the tree", () => {
expect(pathFx.join("/foo/bar", "../baz")).toBe("/foo/baz");
expect(pathFx.join("/foo/bar", "..")).toBe("/foo");
expect(pathFx.join("/foo/bar", "./")).toBe("/foo/bar");
expect(pathFx.join("/foo/bar", "./")).toBe("/foo/bar/");
expect(pathFx.join("/foo/bar", "../../baz")).toBe("/baz");
expect(pathFx.join("/foo/bar", "../../baz/qux")).toBe("/baz/qux");
expect(pathFx.join("/foo/bar", "../../../baz/qux/")).toBe("/baz/qux");
expect(pathFx.join("/foo/bar", "../../../baz/qux/")).toBe("/baz/qux/");
});
});
describe("relative()", () => {
Expand Down Expand Up @@ -121,8 +122,11 @@ describe("dirname()", () => {

describe("normalize()", () => {
it("should normalize the path", () => {
expect(pathFx.normalize("/foo/bar/baz/qux/")).toBe("/foo/bar/baz/qux");
expect(pathFx.normalize("/foo/bar/baz/qux/../../../")).toBe("/foo");
expect(pathFx.normalize("/foo/bar/baz/qux/")).toBe("/foo/bar/baz/qux/");
expect(pathFx.normalize("/foo/bar/baz/qux/../../../")).toBe("/foo/");
expect(pathFx.normalize("/foo/bar/baz/qux/../../..")).toBe("/foo");
expect(pathFx.normalize("")).toBe("");
expect(pathFx.normalize("/")).toBe("/");
});
});

Expand Down
Loading

0 comments on commit 852e150

Please sign in to comment.