From 3a905132919781e4c1012714220f895bd209186a Mon Sep 17 00:00:00 2001 From: Ben Reinhart Date: Sun, 20 Oct 2024 14:27:53 -0700 Subject: [PATCH] Tree data structure WIP --- packages/shared/src/types/apps.mts | 8 +- packages/web/src/components/apps/lib/tree.ts | 155 +++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/components/apps/lib/tree.ts diff --git a/packages/shared/src/types/apps.mts b/packages/shared/src/types/apps.mts index 83bd39e3..c321ca22 100644 --- a/packages/shared/src/types/apps.mts +++ b/packages/shared/src/types/apps.mts @@ -9,7 +9,7 @@ export type AppType = { updatedAt: number; }; -export type DirEntryType = { +export interface DirEntryType { type: 'directory'; // The full path relative to app root, e.g. src/assets path: string; @@ -19,9 +19,9 @@ export type DirEntryType = { basename: string; // null if not loaded children: FsEntryTreeType | null; -}; +} -export type FileEntryType = { +export interface FileEntryType { type: 'file'; // The full path relative to app root, e.g. src/components/input.tsx path: string; @@ -29,7 +29,7 @@ export type FileEntryType = { dirname: string; // The path basename relative to app root, e.g. input.tsx basename: string; -}; +} export type FsEntryTreeType = Array; diff --git a/packages/web/src/components/apps/lib/tree.ts b/packages/web/src/components/apps/lib/tree.ts new file mode 100644 index 00000000..e5b15a22 --- /dev/null +++ b/packages/web/src/components/apps/lib/tree.ts @@ -0,0 +1,155 @@ +import { DirEntryType, FileEntryType } from '@srcbook/shared'; + +const ROOT_PATH = '.'; + +class DirectoryNode implements DirEntryType { + path: string; + dirname: string; + basename: string; + children: Array | null = null; + + get type() { + return 'directory' as const; + } + + static fromEntry(entry: DirEntryType): DirectoryNode { + return new DirectoryNode({ + path: entry.path, + dirname: entry.dirname, + basename: entry.basename, + children: + entry.children === null + ? null + : entry.children.map((entry) => { + return entry.type === 'file' ? entry : DirectoryNode.fromEntry(entry); + }), + }); + } + + constructor(attributes: { + path: string; + dirname: string; + basename: string; + children: Array | null; + }) { + this.path = attributes.path; + this.dirname = attributes.dirname; + this.basename = attributes.basename; + this.children = attributes.children; + this.sortChildren(); + } + + isParentOf(node: { dirname: string }) { + return node.dirname === this.path; + } + + isAncestorOf(node: { path: string }) { + return this.path === ROOT_PATH || node.path.startsWith(this.path + '/'); + } + + private sortChildren() { + this.children?.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1; + if (b.type === 'directory' && a.type === 'file') return 1; + return a.basename.localeCompare(b.basename); + }); + } +} + +/** + * File tree is an immutable tree representing the directories and files + * of a given directory (the root node), represented by the path '.'. + */ +export class FileTree { + private root: DirectoryNode; + + static fromEntry(entry: DirEntryType) { + return new FileTree(DirectoryNode.fromEntry(entry)); + } + + constructor(root: DirectoryNode) { + this.root = root; + } + + find(path: string) { + return this.findNode(this.root, path); + } + + private findNode(dirNode: DirectoryNode, path: string): FileEntryType | DirEntryType | null { + if (dirNode.path === path) { + return dirNode; + } + + for (const child of dirNode.children ?? []) { + if (child.type === 'file' && child.path === path) { + return child; + } + + if (child.type === 'directory' && dirNode.isAncestorOf({ path })) { + return this.findNode(child, path); + } + } + + return null; + } + + insert(entry: FileEntryType | DirEntryType): FileTree { + const root = this.insertNode( + this.root, + entry.type === 'file' ? entry : DirectoryNode.fromEntry(entry), + ); + + return new FileTree(root); + } + + private insertNode(dirNode: DirectoryNode, node: FileEntryType | DirectoryNode) { + if (!dirNode.isAncestorOf(node)) { + // Structural sharing / traversal optimization + return dirNode; + } + + if (dirNode.isParentOf(node)) { + // If this is an updated node of one that exists inside the directory, + // make sure to replace it so that we do not have duplicate copies. + const children = (dirNode.children ?? []).filter((n) => n.path !== node.path); + children.push(node); + return new DirectoryNode({ ...dirNode, children }); + } + + // If the node to insert lives more than one level below this node but this node + // has not loaded its children, raise an error (we don't expect this to happen). + if (dirNode.children === null) { + throw new Error('Cannot insert node into a tree that has not loaded its children'); + } + + const children = dirNode.children.map((n): FileEntryType | DirectoryNode => { + // We know the node is not a child of dirNode here since we checked for that above. + return n.type === 'file' ? n : this.insertNode(n, node); + }); + + return new DirectoryNode({ ...dirNode, children }); + } + + remove(entry: FileEntryType | DirEntryType) { + return this.removeNode(this.root, entry); + } + + private removeNode(dirNode: DirectoryNode, node: FileEntryType | DirEntryType) { + if (!dirNode.isAncestorOf(node)) { + // Structural sharing / traversal optimization + return dirNode; + } + + if (dirNode.isParentOf(node)) { + const children = (dirNode.children ?? []).filter((n) => n.path !== node.path); + return new DirectoryNode({ ...dirNode, children }); + } + + const children = (dirNode.children ?? []).map((n): FileEntryType | DirectoryNode => { + // We know the node is not a child of dirNode here since we checked for that above. + return n.type === 'file' ? n : this.removeNode(n, node); + }); + + return new DirectoryNode({ ...dirNode, children }); + } +}