Files
kandev/apps/web/components/task/file-tree-utils.ts
T
2026-08-05 10:57:24 +01:00

230 lines
8.3 KiB
TypeScript

import type { FileTreeNode } from "@/lib/types/backend";
/** Sort comparator: directories first, then alphabetical by name. */
export const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
};
/**
* Return the tree's root children sorted dirs-first / alpha. useTree iterates
* its top-level `nodes` directly (it only invokes `getChildren` to descend),
* so the root list must be pre-sorted by the caller — otherwise backend order
* leaks through and dirs interleave with files at depth 0.
*/
export function sortRootChildren(tree: FileTreeNode | null): FileTreeNode[] {
if (!tree?.children) return [];
return [...tree.children].sort(compareTreeNodes);
}
/**
* Merge a freshly-fetched tree node into an existing one, preserving
* already-loaded children so expanded folders don't collapse.
*/
export function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
if (!incoming.children) return { ...existing, ...incoming, children: existing.children };
if (!existing.children) return incoming;
const existingByPath = new Map(existing.children.map((c) => [c.path, c]));
const mergedChildren = incoming.children.map((inChild) => {
const exChild = existingByPath.get(inChild.path);
if (exChild && exChild.is_dir && inChild.is_dir) {
return mergeTreeNodes(exChild, inChild);
}
return inChild;
});
return { ...existing, ...incoming, children: mergedChildren };
}
/** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */
export function insertNodeInTree(
root: FileTreeNode,
parentPath: string,
node: FileTreeNode,
): FileTreeNode {
if (root.path === parentPath || (parentPath === "" && root.path === "")) {
const children = [...(root.children ?? []), node].sort(compareTreeNodes);
return { ...root, children };
}
if (!root.children) return root;
return { ...root, children: root.children.map((c) => insertNodeInTree(c, parentPath, node)) };
}
export function removeNodeFromTree(root: FileTreeNode, targetPath: string): FileTreeNode {
if (!root.children) return root;
const filtered = root.children.filter((c) => c.path !== targetPath);
return { ...root, children: filtered.map((c) => removeNodeFromTree(c, targetPath)) };
}
/** Rename a node in the tree, updating its name and path. */
function replacePathPrefix(path: string, oldPrefix: string, newPrefix: string): string {
if (path === oldPrefix) return newPrefix;
if (path.startsWith(`${oldPrefix}/`)) return `${newPrefix}${path.slice(oldPrefix.length)}`;
return path;
}
function renameSubtree(node: FileTreeNode, oldPath: string, newPath: string): FileTreeNode {
const nextPath = replacePathPrefix(node.path, oldPath, newPath);
const nextName = nextPath.split("/").pop() || nextPath;
const nextChildren = node.children?.map((child) => renameSubtree(child, oldPath, newPath));
return {
...node,
name: nextName,
path: nextPath,
children: nextChildren,
};
}
export function treeContainsPath(root: FileTreeNode, targetPath: string): boolean {
if (root.path === targetPath) return true;
if (!root.children) return false;
return root.children.some((child) => treeContainsPath(child, targetPath));
}
export function countFilesInTree(node: FileTreeNode): number {
if (!node.children || node.children.length === 0) return node.is_dir ? 0 : 1;
const base = node.is_dir ? 0 : 1;
return node.children.reduce((sum, child) => sum + countFilesInTree(child), base);
}
export function renameNodeInTree(
root: FileTreeNode,
oldPath: string,
newPath: string,
): FileTreeNode {
if (root.path === oldPath) {
return renameSubtree(root, oldPath, newPath);
}
if (!root.children) return root;
const nextChildren = root.children.map((c) => renameNodeInTree(c, oldPath, newPath));
return { ...root, children: nextChildren.sort(compareTreeNodes) };
}
/** Collect visible (expanded) node paths in DFS order for multi-select range computation. */
export function getVisiblePaths(tree: FileTreeNode, expandedPaths: ReadonlySet<string>): string[] {
const result: string[] = [];
function walk(node: FileTreeNode) {
// Skip the root node itself (it represents the workspace root)
if (node !== tree) result.push(node.path);
if (node.is_dir && (node === tree || expandedPaths.has(node.path)) && node.children) {
const sorted = [...node.children].sort(compareTreeNodes);
for (const child of sorted) walk(child);
}
}
walk(tree);
return result;
}
/** Find a node in the tree by path. */
export function findNodeByPath(root: FileTreeNode, targetPath: string): FileTreeNode | null {
if (root.path === targetPath) return root;
if (!root.children) return null;
for (const child of root.children) {
const found = findNodeByPath(child, targetPath);
if (found) return found;
}
return null;
}
/** Return the directory paths that must be expanded to reveal a file. */
export function getAncestorPaths(targetPath: string): string[] {
const parts = targetPath.split("/");
return Array.from({ length: Math.max(0, parts.length - 1) }, (_, index) =>
parts.slice(0, index + 1).join("/"),
);
}
/** Find the next loaded directory whose child must be fetched to reveal a file. */
export function findUnloadedAncestor(
tree: FileTreeNode,
targetPath: string,
ancestors: string[],
): FileTreeNode | null {
let children = tree.children;
for (const [index, path] of ancestors.entries()) {
const node = children?.find((candidate) => candidate.path === path);
if (!node) return null;
const nextPath = ancestors[index + 1] ?? targetPath;
if (!node.children?.some((child) => child.path === nextPath)) return node;
children = node.children;
}
return null;
}
/** Disambiguate a filename if it already exists in the used set. */
function deduplicateName(name: string, usedNames: Set<string>): string {
if (!usedNames.has(name)) return name;
const dotIndex = name.lastIndexOf(".");
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.slice(dotIndex) : "";
let counter = 1;
let candidate = `${base} (${counter})${ext}`;
while (usedNames.has(candidate)) {
counter++;
candidate = `${base} (${counter})${ext}`;
}
return candidate;
}
/** Compute deduplicated old→new path mappings for a move operation. */
export function computeMoveTargets(
root: FileTreeNode,
sourcePaths: string[],
targetDirPath: string,
): { oldPath: string; newPath: string }[] {
const targetNode = targetDirPath ? findNodeByPath(root, targetDirPath) : root;
const usedNames = new Set<string>();
if (targetNode?.children) {
for (const child of targetNode.children) usedNames.add(child.name);
}
const results: { oldPath: string; newPath: string }[] = [];
for (const path of sourcePaths) {
const node = findNodeByPath(root, path);
if (node) {
const safeName = deduplicateName(node.name, usedNames);
usedNames.add(safeName);
const newPath = targetDirPath ? `${targetDirPath}/${safeName}` : safeName;
results.push({ oldPath: path, newPath });
}
}
return results;
}
/** Move nodes from their current locations into a target directory. Returns the updated tree. */
export function moveNodesInTree(
root: FileTreeNode,
sourcePaths: string[],
targetDirPath: string,
): FileTreeNode {
// Collect existing names in target to avoid collisions
const targetNode = targetDirPath ? findNodeByPath(root, targetDirPath) : root;
const usedNames = new Set<string>();
if (targetNode?.children) {
for (const child of targetNode.children) usedNames.add(child.name);
}
// Collect the nodes to move, deduplicating names
const nodesToMove: FileTreeNode[] = [];
for (const path of sourcePaths) {
const node = findNodeByPath(root, path);
if (node) {
const safeName = deduplicateName(node.name, usedNames);
usedNames.add(safeName);
const newPath = targetDirPath ? `${targetDirPath}/${safeName}` : safeName;
nodesToMove.push(renameSubtree(node, path, newPath));
}
}
// Remove source nodes from tree
let updated = root;
for (const path of sourcePaths) {
updated = removeNodeFromTree(updated, path);
}
// Insert into target directory
for (const node of nodesToMove) {
updated = insertNodeInTree(updated, targetDirPath, node);
}
return updated;
}