230 lines
8.3 KiB
TypeScript
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;
|
|
}
|