25- React File Manager Chapter XXV: Kernel Tree

hassanzohdy

Hasan Zohdy

Posted on September 20, 2022

25- React File Manager Chapter XXV: Kernel Tree

So we stopped at creating a new directory in the server successfully, now let's make a tree based kernel to inject all of our nodes inside it.

But before that let's clean our code a little bit

Cleaning Our Code

Navigate to FileManager.tsx the main component function, and update it with the following code:

import { Grid } from "@mantine/core";
import Content from "app/file-manager/components/Content";
import LoadingProgressBar from "app/file-manager/components/LoadingProgressBar";
import Sidebar from "app/file-manager/components/Sidebar";
import Toolbar from "app/file-manager/components/Toolbar";
import { KernelContext } from "app/file-manager/contexts";
import Kernel from "app/file-manager/Kernel";
import { useEffect, useRef } from "react";
import { BodyWrapper } from "./FileManager.styles";
import { FileManagerProps } from "./FileManager.types";

export default function FileManager({ rootPath }: FileManagerProps) {
  const { current: kernel } = useRef(new Kernel(rootPath as string));

  // load root directory
  useEffect(() => {
    if (!rootPath) return;

    kernel.load(rootPath);
  }, [rootPath, kernel]);

  return (
    <KernelContext.Provider value={kernel}>
      <LoadingProgressBar />
      <Toolbar />
      <BodyWrapper>
        <Grid>
          <Grid.Col span={3}>
            <Sidebar />
          </Grid.Col>
          <Grid.Col span={9}>
            <Content />
          </Grid.Col>
        </Grid>
      </BodyWrapper>
    </KernelContext.Provider>
  );
}

FileManager.defaultProps = {
  rootPath: "/",
};
Enter fullscreen mode Exit fullscreen mode

What we did here is we removed all defined states as it is not needed here anymore and all of our states are now inside the Kernel class.

Also we passed the rootPath to the kernel class and we're loading the root directory inside the useEffect hook.

Kernel Tree

Now let's add the constructor method to accept the rootPath and initialize the tree with it.

// app/file-manager/Kernel/Kernel.ts
import { Node } from "app/file-manager/Kernel/Node";
...
  /**
   * Constructor
   */
  public constructor(rootPath: string) {
    this.rootPath = rootPath;
  }
Enter fullscreen mode Exit fullscreen mode

Now let's define also our KernelTree class beside the Kernel class.

// app/file-manager/Kernel/KernelTree.ts
import Kernel from "./Kernel";
import { Node } from "./Kernel.types";

export default class KernelTree {
  /**
   * Root node
   */
  public root?: Node;

  /**
   * Constructor
   */
  constructor(public kernel: Kernel) {}
}
Enter fullscreen mode Exit fullscreen mode

We injected the Kernel to the constructor so we can use any method/property from the kernel directly.

Now let's update our Kernel class to use the KernelTree class.

// app/file-manager/Kernel/Kernel.ts
import events, { EventSubscription } from "@mongez/events";
import { createDirectory } from "../actions";
import fileManagerService from "../services/file-manager-service";
import { KernelEvents, Node } from "./Kernel.types";
import KernelTree from "./KernelTree";

export default class Kernel {
  ...
  /**
   * Kernel nodes tree
   */
  public tree: KernelTree;

  /**
   * Root node
   */
  public rootNode?: Node;

  /**
   * Constructor
   */
  public constructor(rootPath: string) {
    this.rootPath = rootPath;

    this.tree = new KernelTree(this);
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

The concept of Kernel Tree

The concept of the kernel tree is to inject all of our nodes inside it, so we can easily access any node directly, update it, delete it or update its children list.

So Our KernelTree class will have the following features:

  • Set Node: Add the given node to the tree, if the node already exists, then update it.
  • Get Node: Get the node by its path or the node itself.
  • Delete Node: Delete the node by its path or the node itself.
  • Get Parent Node: Get the parent node of the given node.
  • Order Node Children: Order the children of the given node by the given order.
  • Define node children as as directories and files.

Now let's start implementing the KernelTree class.

// app/file-manager/Kernel/KernelTree.ts
import Kernel from "./Kernel";
import { Node } from "./Kernel.types";

export default class KernelTree {
  /**
   * Root node
   */
  public root?: Node;

  /**
   * Constructor
   */
  constructor(public kernel: Kernel) {}

  /**
   * Set root node
   */
  public setRootNode(root: Node) {
    this.root = root;
    this.kernel.trigger("nodeChange", this.root);

    this.prepareNode(this.root);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we added setRootNode to define the top node that will have everything inside it, also we called prepareNode which will do two things, to split the node children as directories and files and also order the children by the name alphabetically.

Preparing Node

As stated earlier, we'll split the node children as directories and files and also order the children by the name alphabetically.

// app/file-manager/Kernel/KernelTree.ts

...

  /**
   * Prepare the given node
   */
  public prepareNode(node: Node) {
    if (!node.children) return;

    this.reorderChildren(node);

    // set children directories
    node.directories = node.children.filter(child => child.isDirectory);

    // set children files
    node.files = node.children.filter(child => !child.isDirectory);
  }

  /**
   * Reorder node children by child name
   */
  public reorderChildren(node: Node) {
    node.children?.sort((a, b) => {
      if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) return 1;
      if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) return -1;
      return 0;
    });
  }
Enter fullscreen mode Exit fullscreen mode

But the Node type does not have directories and files properties, so let's add them.

// app/file-manager/Kernel/Kernel.types.tsx
/**
 * File Manager node is the primary data structure for the File Manager.
 * It can be a directory or a file.
 * It contains the following properties:
 */
export type Node = {
  /**
   * Node Name
   */
  name: string;
  /**
   * Node full path to root
   */
  path: string;
  /**
   * Node size in bits
   */
  size: number;
  /**
   * Is node directory
   */
  isDirectory: boolean;
  /**
   * Node children
   * This should be present (event with empty array) if the node is directory
   */
  children?: Node[];
  /**
   * Get children directories
   */
šŸ‘‰šŸ»  directories?: Node[];
  /**
   * Get children files
   */
šŸ‘‰šŸ»  files?: Node[];
};
Enter fullscreen mode Exit fullscreen mode

Updating the Kernel tree

Now we need to update the tree once the node is loaded from the server so let's jump into the load method.

  /**
   * Load the given path
   */
  public load(path: string): Promise<Node> {
    // trigger loading event
    this.trigger("loading");

    return new Promise((resolve, reject) => {
      fileManagerService
        .list(path)
        .then(response => {
          this.currentDirectoryPath = path;

          if (response.data.node.path === this.rootPath) {
            šŸ‘‰šŸ» this.tree.setRootNode(response.data.node);
            this.rootNode = response.data.node;
          } else {
            šŸ‘‰šŸ» this.tree.setNode(response.data.node);
          }

          // trigger load event as the directory has been loaded successfully.
          this.trigger("load", response.data.node);

          // if the current directory is not as the same loaded directory path,
          // then we'll trigger directory changed event.
          if (response.data.node.path !== this.currentDirectoryNode?.path) {
            this.trigger("directoryChange", response.data.node);
          }

          this.currentDirectoryNode = response.data.node;

          resolve(this.currentDirectoryNode as Node);
        })
        .catch(reject);
    });
  }
Enter fullscreen mode Exit fullscreen mode

Now we can update the tree node as we passed the node inside it, so once the node is loaded we check if its the root node, then we update the root node otherwise update the loaded node only.

Let's create setNode method inside the KernelTree class.

// app/file-manager/Kernel/KernelTree.ts
  /**
   * Add the given node to the tree
   */
  public setNode(node: Node) {
    // first find the parent node
    let parentNode = this.parentNode(node);

    // if it has no parent, which should not happen, then mark the root as parent
    if (!parentNode) {
      parentNode = this.root;
    }

    // if there is no parent, then do nothing and just return
    if (!parentNode) return;

    // a flag to determine if the given node was already existing but has been changed
    let nodeHasChanged = false;
    // a flag to determine if the parent node is changed
    let parentHasChanged = false;

    // now check if the node already exists in its parent
    if (this.parentHas(parentNode, node)) {
      // if it exists, replace it
      parentNode.children = parentNode.children?.map(child => {
        if (child.path === node.path) {
          if (this.nodeHasChanged(child, node)) {
            nodeHasChanged = true;
            parentHasChanged = true;
          }

          return node;
        }

        return child;
      });
    } else {
      // it means the node does not exist in the parent, then push it to the parent's children
      parentNode?.children?.push(node);
      this.kernel.trigger("newNode", node);
      parentHasChanged = true;
      // prepare the node
      this.prepareNode(node);
    }

    // this will be only triggered if the node has changed
    if (nodeHasChanged) {
      this.prepareNode(node);
      this.kernel.trigger("nodeChange", node);
    }

    // this will be only triggered if the parent node has changed
    if (parentHasChanged) {
      this.prepareNode(parentNode);
      // as the parent node has changed thus the root node will be marked as changed as well
      // we may later make it recursive to mark all the parent nodes as changed
      this.prepareNode(this.root as Node);

      this.kernel.trigger("nodeChange", parentNode);
      this.kernel.trigger("nodeChange", this.root);
    }
  }
Enter fullscreen mode Exit fullscreen mode

The code is self explanatory, we check if the node already exists in the parent, if it does then we replace it, otherwise we push it to the parent's children.

Let's add also the following methods:

  • parentNode to get the parent node of the given node
  • parentHas to check if the parent node has the given node
  • nodeHasChanged to check if the given node has changed
// app/file-manager/Kernel/KernelTree.ts

  /**
   * Check if the given parent has the given node
   */
  public parentHas(parent: Node, node: Node): boolean {
    return parent.children?.some(child => child.path === node.path) ?? false;
  }

  /**
   * Get parent node
   */
  public parentNode(node: Node): Node | undefined {
    return this.findNode(this.getParentPath(node.path));
  }

  /**
   * Find node for the given path recursively in the tree
   */
  public findNode(path: string): Node | undefined {
    // loop starting from the tree root
    const currentNode = this.root;

    const findNode = (node?: Node): Node | undefined => {
      if (node?.path === path) {
        return node;
      }

      if (!node?.children) return undefined;

      for (const child of node.children) {
        const foundNode = findNode(child);

        if (foundNode) return foundNode;
      }
    };

    return findNode(currentNode);
  }

  /**
   * Check if the given node has been changed
   */
  public nodeHasChanged(oldNode: Node, newNode: Node): boolean {
    return JSON.stringify(oldNode) !== JSON.stringify(newNode);
  }

  /**
   * Get the parent path of the given path
   */
  protected getParentPath(path: string): string {
    if (!path) return "/";

    // get the parent path by splitting the path and removing the last item
    return path.split("/").slice(0, -1).join("/");
  }
Enter fullscreen mode Exit fullscreen mode

One last thing to do is to update the kernel events

// Kernel.types

/**
 * Kernel events
 */
export type KernelEvents =
  | "loading"
  | "load"
  | "directoryChange"
  | "nodeChange"
  | "nodeDestroy"
  | "newNode";
Enter fullscreen mode Exit fullscreen mode

We added nodeChange that will be triggered when a node is changed, nodeDestroy that will be triggered when a node is destroyed, and newNode that will be triggered when a new node is added.

Next Chapter

In the next chapter we'll enhance the create directory, the sidebar and the content to watch for node changes.

Article Repository

You can see chapter files in Github Repository

Don't forget the main branch has the latest updated code.

Tell me where you are now

If you're following up with me this series, tell me where are you now and what you're struggling with, i'll try to help you as much as i can.

Salam.

šŸ’– šŸ’Ŗ šŸ™… šŸš©
hassanzohdy
Hasan Zohdy

Posted on September 20, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related