fix(file-tree): add missing path segments

This commit is contained in:
Nicolas Meienberger 2026-03-26 23:47:49 +01:00
parent 28d5c05225
commit a7c6808583
6 changed files with 174 additions and 4 deletions

View file

@ -1,6 +1,6 @@
/** 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, within } from "@testing-library/react";
import { afterEach, expect, test, describe } from "bun:test";
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
import { useState } from "react";
import { FileTree, type FileEntry } from "../file-tree";
@ -39,6 +39,10 @@ const getSelectedPaths = () => {
return JSON.parse(selectedPaths ?? "[]") as string[];
};
afterEach(() => {
cleanup();
});
describe("FileTree Pagination", () => {
const testFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
@ -181,6 +185,19 @@ describe("FileTree Pagination", () => {
expect(screen.queryByText("Load more files")).toBeNull();
});
test("renders missing ancestor folders for nested paths", () => {
render(
<FileTree
files={[
{ name: "subdir", path: "/project/subdir", type: "folder" },
{ name: "file1", path: "/project/subdir/file1", type: "file" },
]}
/>,
);
expect(screen.getByRole("button", { name: "project" })).toBeTruthy();
});
});
describe("FileTree Selection Logic", () => {

View file

@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
import { fromAny } from "@total-typescript/shoehorn";
await mock.module("@tanstack/react-router", () => ({
useNavigate: () => mock(() => {}),
}));
import { RestoreForm } from "../restore-form";
class MockEventSource {
addEventListener() {}
close() {}
onerror: ((event: Event) => void) | null = null;
constructor(public url: string) {}
}
const originalEventSource = globalThis.EventSource;
beforeEach(() => {
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
});
afterEach(() => {
globalThis.EventSource = originalEventSource;
cleanup();
});
describe("RestoreForm", () => {
test("restores the selected ancestor folder path from a broader display root", async () => {
let restoreRequestBody: unknown;
server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => {
return HttpResponse.json({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "deep.tx", path: "/mnt/project/subdir/deep.tx", type: "file" },
],
});
}),
http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => {
restoreRequestBody = await request.json();
return HttpResponse.json({
success: true,
message: "Snapshot restored successfully",
filesRestored: 1,
filesSkipped: 0,
});
}),
);
render(
<RestoreForm
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
queryBasePath="/mnt/project/subdir"
displayBasePath="/mnt"
/>,
);
const row = await screen.findByRole("button", { name: "project" });
await userEvent.click(within(row).getByRole("checkbox"));
await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" }));
await waitFor(() => {
expect(restoreRequestBody).toEqual({
snapshotId: "snap-1",
include: ["/mnt/project"],
overwrite: "always",
});
});
});
});

View file

@ -20,7 +20,7 @@ const snapshotFiles = {
import { SnapshotTreeBrowser } from "../snapshot-tree-browser";
const mockListSnapshotFiles = () => {
const mockListSnapshotFiles = (response = snapshotFiles) => {
const requests: SnapshotFilesRequest[] = [];
server.use(
@ -34,7 +34,7 @@ const mockListSnapshotFiles = () => {
limit: url.searchParams.get("limit"),
});
return HttpResponse.json(snapshotFiles);
return HttpResponse.json(response);
}),
);
@ -66,6 +66,49 @@ describe("SnapshotTreeBrowser", () => {
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});
test("renders ancestor folders when the query root is nested multiple levels below the display root", async () => {
mockListSnapshotFiles({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
],
});
renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/subdir",
displayBasePath: "/mnt",
});
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});
test("returns the ancestor folder path when selecting above the query root", async () => {
mockListSnapshotFiles({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
],
});
let selectedPaths: Set<string> | undefined;
renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/subdir",
displayBasePath: "/mnt",
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
});
const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");
await userEvent.click(checkbox);
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
});
test("shows selected folder state when full paths are provided from the parent", async () => {
mockListSnapshotFiles();

View file

@ -627,6 +627,10 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
const depth = segments.length - 1;
const name = segments[segments.length - 1];
if (!name) {
continue;
}
if (!fileMap.has(file.path)) {
const isFile = file.type === "file";
fileMap.set(file.path, {
@ -638,6 +642,33 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
size: file.size,
});
}
let parentPath = file.path;
while (true) {
const lastSlashIndex = parentPath.lastIndexOf("/");
if (lastSlashIndex <= 0) {
break;
}
parentPath = parentPath.slice(0, lastSlashIndex);
if (fileMap.has(parentPath)) {
continue;
}
const parentSegments = parentPath.split("/").filter((segment) => segment);
const parentName = parentSegments[parentSegments.length - 1];
if (!parentName) {
continue;
}
fileMap.set(parentPath, {
kind: "folder",
id: fileMap.size,
name: parentName,
fullPath: parentPath,
depth: parentSegments.length - 1,
});
}
}
// Convert map to array and sort

View file

@ -23,6 +23,7 @@ import { SnapshotFileBrowser } from "../snapshot-file-browser";
afterEach(() => {
cleanup();
mock.restore();
});
describe("SnapshotFileBrowser", () => {

View file

@ -78,6 +78,7 @@ const mockScheduleDetailsRequests = () => {
afterEach(() => {
cleanup();
mock.restore();
});
describe("ScheduleDetailsPage", () => {