fix(file-tree): cleanup based on given path not what's visible

This commit is contained in:
Nicolas Meienberger 2026-01-02 16:40:25 +01:00
parent 745f5ea93a
commit 8a88d2c04f
2 changed files with 68 additions and 35 deletions

View file

@ -1,15 +1,16 @@
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
import { expect, test, describe } from "bun:test";
import { render, screen, fireEvent } from "@testing-library/react";
import { FileTree, type FileEntry } from "../file-tree";
describe("FileTree Selection Logic", () => {
const immichFiles: FileEntry[] = [
{ name: "immich", path: "/immich", type: "folder" },
{ name: "immich_photos", path: "/immich/immich_photos", type: "folder" },
{ name: "backups", path: "/immich/immich_photos/backups", type: "folder" },
{ name: "library", path: "/immich/immich_photos/library", type: "folder" },
{ name: "profile", path: "/immich/immich_photos/profile", type: "folder" },
{ name: "upload", path: "/immich/immich_photos/upload", type: "folder" },
const testFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
{ name: "photos", path: "/root/photos", type: "folder" },
{ name: "backups", path: "/root/photos/backups", type: "folder" },
{ name: "library", path: "/root/photos/library", type: "folder" },
{ name: "profile", path: "/root/photos/profile", type: "folder" },
{ name: "upload", path: "/root/photos/upload", type: "folder" },
];
test("selecting a folder simplifies to parent if it's the only child", async () => {
@ -20,50 +21,48 @@ describe("FileTree Selection Logic", () => {
render(
<FileTree
files={immichFiles}
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(immichFiles.map((f) => f.path))}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const immichPhotosCheckbox = screen
.getByText("immich_photos")
.parentElement?.querySelector('button[role="checkbox"]');
expect(immichPhotosCheckbox).toBeTruthy();
const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
expect(photosCheckbox).toBeTruthy();
fireEvent.click(immichPhotosCheckbox!);
fireEvent.click(photosCheckbox!);
expect(currentSelection.has("/immich")).toBe(true);
expect(currentSelection.has("/root")).toBe(true);
expect(currentSelection.size).toBe(1);
});
test("unselecting a child removes the parent from selection", async () => {
let currentSelection = new Set<string>(["/immich"]);
let currentSelection = new Set<string>(["/root"]);
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={immichFiles}
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(immichFiles.map((f) => f.path))}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(libraryCheckbox!);
expect(currentSelection.has("/immich")).toBe(false);
expect(currentSelection.has("/immich/immich_photos")).toBe(false);
expect(currentSelection.has("/root")).toBe(false);
expect(currentSelection.has("/root/photos")).toBe(false);
expect(currentSelection.has("/immich/immich_photos/backups")).toBe(true);
expect(currentSelection.has("/immich/immich_photos/profile")).toBe(true);
expect(currentSelection.has("/immich/immich_photos/upload")).toBe(true);
expect(currentSelection.has("/root/photos/backups")).toBe(true);
expect(currentSelection.has("/root/photos/profile")).toBe(true);
expect(currentSelection.has("/root/photos/upload")).toBe(true);
expect(currentSelection.size).toBe(3);
});
@ -75,11 +74,11 @@ describe("FileTree Selection Logic", () => {
const { rerender } = render(
<FileTree
files={immichFiles}
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(immichFiles.map((f) => f.path))}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
@ -91,16 +90,16 @@ describe("FileTree Selection Logic", () => {
rerender(
<FileTree
files={immichFiles}
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(immichFiles.map((f) => f.path))}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
}
expect(currentSelection.has("/immich")).toBe(true);
expect(currentSelection.has("/root")).toBe(true);
expect(currentSelection.size).toBe(1);
});
@ -133,4 +132,35 @@ describe("FileTree Selection Logic", () => {
expect(currentSelection.has("/root")).toBe(false);
expect(currentSelection.size).toBe(1);
});
test("simplifies existing deep paths when parent is selected", async () => {
const files: FileEntry[] = [
{ name: "hello", path: "/hello", type: "folder" },
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
{ name: "service", path: "/service", type: "folder" },
];
let currentSelection = new Set<string>(["/hello", "/hello_prev", "/service/app/data/upload"]);
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={files}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
/>,
);
const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
expect(serviceCheckbox).toBeTruthy();
fireEvent.click(serviceCheckbox!);
expect(currentSelection.has("/service")).toBe(true);
expect(currentSelection.has("/service/app/data/upload")).toBe(false);
expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
});
});

View file

@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => {
// Add new folders to collapsed set when file list changes
useEffect(() => {
setCollapsedFolders((prevSet) => {
let hasChanges = false;
const newSet = new Set(prevSet);
for (const item of fileList) {
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
newSet.add(item.fullPath);
hasChanges = true;
}
}
return newSet;
return hasChanges ? newSet : prevSet;
});
}, [fileList, expandedFolders]);
@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => {
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
for (const item of fileList) {
if (item.fullPath.startsWith(`${path}/`)) {
newSelection.delete(item.fullPath);
for (const selectedPath of newSelection) {
if (selectedPath.startsWith(`${path}/`)) {
newSelection.delete(selectedPath);
}
}
} else {
@ -488,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
<button
type="button"
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
<div
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
@ -497,7 +500,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</button>
</div>
);
});