Files
nexu-io-open-design/apps/web/tests/components/DesignFilesPanel.test.tsx
Tom Huang 6be4e56544 feat(workspace): add plan mode and Excalidraw sketch flows (#4862)
* feat(daemon): add 'plan' session mode and update related functionality

- Introduced a new session mode 'plan' alongside existing 'design' and 'chat' modes, allowing for editable document creation.
- Updated various functions and interfaces to accommodate the new session mode, including normalization and usage in commands.
- Enhanced markdown rendering capabilities by integrating micromark and its GFM extension for improved markdown processing.
- Added new input types for question forms, expanding the range of user interactions.
- Updated UI components to reflect changes in session modes and ensure proper handling of next steps based on the current mode.

* feat(chat): integrate SessionModeToggle into ChatComposer and HomeHero

- Added SessionModeToggle component to both ChatComposer and HomeHero for improved session management.
- Updated HomeHero styles to accommodate the new mode switcher layout.
- Adjusted countdown timer in QuestionsPanel to extend the skip countdown from 120 seconds to 10 minutes, enhancing user experience.
- Added tests to ensure the countdown functionality works as expected.

* feat(FileViewer): implement synchronized scrolling for Markdown editor and preview

- Added functionality to synchronize scrolling between the Markdown editor and preview panes.
- Introduced new types and utility functions for managing scroll behavior.
- Enhanced the MarkdownViewer component to handle scroll events and maintain scroll position across different modes.
- Updated the component's state management to accommodate the new scrolling features.

* feat(excalidraw): integrate Excalidraw into the project

- Added @excalidraw/excalidraw as a dependency in package.json.
- Updated vitest configuration to include an alias for Excalidraw.
- Imported Excalidraw CSS in the layout component for styling.
- Modified AssistantMessage component to handle optional projectId.
- Enhanced FileOpsSummary to display delete operations.
- Implemented new Excalidraw scene management in SketchEditor and FileWorkspace components.
- Updated MarkdownViewer to support file mentions and improved file handling.
- Refactored various components to accommodate Excalidraw integration and ensure compatibility with existing features.

* feat(files): improve sketch and file handoff flows

* test: align post-merge expectations

* chore(nix): refresh pnpm deps hash

* fix(workspace): stabilize sketch persistence and ci checks

* fix(workspace): address review blockers in editable files

* fix(workspace): persist cleared sketch scenes

* test(workspace): type sketch editor mock scene

* fix(workspace): serialize sketch autosaves

* fix(workspace): keep sketch save revisions current

* test(e2e): stabilize project workspace smoke flows

* fix(analytics): preserve plan mode for BYOK runs

* test(e2e): stabilize new project rail interactions

* fix(files): stop bash delete parsing at shell operators

* test(e2e): stabilize ui cold-start suites

* fix(viewer): preserve absolute markdown image sources

* feat(workspace): preload sketches and enhance markdown save options

- Added functionality to preload persisted sketches before opening the tab.
- Introduced new MarkdownSaveOptions type to manage save behavior.
- Updated saveMarkdownText to handle options for refreshing files and showing saving state.
- Enhanced FileViewer to maintain focus and selection during metadata refresh.
- Implemented a Toast component for user feedback on save and export actions.

* fix(web): stabilize markdown and sketch editor polish

* fix(web): finish sketch editor merge resolution

* chore(nix): refresh pnpm deps hash

* fix(plan): bypass discovery and stabilize markdown sync

* fix(web): simplify scene retrieval in SketchEditor component

* feat(web): enhance markdown viewer with auto-save functionality

- Implemented passive auto-save status in the MarkdownViewer component, replacing the manual Save button with an auto-save indicator.
- Introduced new hooks and state management for tracking auto-save events and displaying the last saved time.
- Added support for synchronized scrolling between the markdown editor and preview.
- Created a new markdown-scroll-sync module to handle scroll synchronization logic.
- Updated localization files to include new strings for auto-save messages.
- Added a SketchEnginePrewarm component to optimize Excalidraw loading times.

* feat(web): enhance session mode toggle with cost indicators

- Added cost tiers for each session mode in the SessionModeToggle component, providing users with a visual representation of usage costs.
- Introduced a new ModeCostTag component to display cost information alongside session mode labels.
- Updated localization files to include new keys for cost labels and notes.
- Enhanced styling for cost indicators to improve user experience and clarity.
- Refactored EntryShell to open the new project modal instead of creating a blank project directly from the rail.
- Implemented a utility function in markdown-scroll-sync to check for vertical progression in block offsets.

* feat(web): add max height adjustment for session mode description card

- Introduced maxHeight prop to the ModeDescriptionCard component to control the height of the description card based on available space.
- Implemented useLayoutEffect in SessionModeToggle to dynamically calculate and set the maximum height of the description card, ensuring it does not overlap with the project tab bar.
- Updated tests to verify that session modes display their expected usage/cost correctly in the UI.
- Enhanced localization files to include new cost-related strings for various languages.

* feat(web): implement goBack function for improved navigation and update auto-open logic

- Added a new `goBack` function to handle in-app navigation, allowing users to return to the previous route instead of a hardcoded destination.
- Updated the `navigate` function to maintain history state for better back navigation.
- Refactored auto-open logic to prioritize produced artifacts, allowing markdown files to be opened alongside HTML files.
- Updated tests to cover new navigation behavior and artifact selection logic.
- Enhanced localization files to include new descriptions for workspace actions.

* refactor(web): remove create design system functionality and update design files panel actions

- Removed the `onCreateDesignSystem` prop and associated button from the DesignFilesPanel component.
- Updated the empty state actions to include a button for creating a new document via the `onPaste` function.
- Adjusted tests to reflect the removal of the design system creation action and ensure the new document button is functional.
- Enhanced the MarkdownViewer component by adding a placeholder for the text area and removing the header bar for a cleaner interface.
- Updated localization files to include a new placeholder string for the markdown editor.

* fix(web): restore markdown placeholder translations

* refactor(web): streamline MarkdownViewer and enhance localization

- Removed unnecessary state management and reload functionality from the MarkdownViewer component for improved performance.
- Added a placeholder text for the markdown editor in multiple localization files to enhance user guidance.
- Updated styles for the save state indicator in the viewer to improve visual clarity and alignment.
- Adjusted tests to reflect changes in the MarkdownViewer and ensure proper functionality.

* fix(web): adjust SketchEditor button size and remove shortcut hints

- Reduced the icon size in the SketchEditor component from 13 to 12 for better alignment.
- Updated the removeSketchMermaidShortcutHints function to also remove the submit shortcut hints from the dialog, enhancing the user interface by decluttering unnecessary elements.
- Adjusted tests to verify the absence of shortcut hints in the modal after updates.

* fix(web): address plan mode follow-up polish

* fix(web): align ci expectations after merge

* test(e2e): stabilize project workspace helpers

* test(e2e): scale settings visual timeout

* fix(prompts): lock ElevenLabs voice picker choices

* test(e2e): pin project workspace P0 worker

* test(e2e): use rail new project entry

* test(e2e): relax app restoration startup waits

* fix(web): limit markdown pipe escaping to tables

---------

Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
Co-authored-by: Amy <1184569493@qq.com>
2026-07-01 04:38:31 +00:00

708 lines
23 KiB
TypeScript

// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ComponentProps } from "react";
import {
DesignFilesPanel,
type DesignFilesNavState,
} from "../../src/components/DesignFilesPanel";
import type {
ProjectFile,
ProjectFileKind,
ProjectFolder,
} from "../../src/types";
import { VISUAL_STABILITY_STORAGE_KEY } from "../../src/utils/visualStability";
function folder(path: string): ProjectFolder {
return {
name: path.split("/").pop() ?? path,
path,
type: "dir",
size: 0,
mtime: 1700000000,
};
}
// Stub localStorage so the component's view-state persistence writes to an
// in-memory store. Cleared in beforeEach so no test bleeds state into the next.
const lsStore = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => lsStore.get(key) ?? null,
setItem: (key: string, value: string) => {
lsStore.set(key, value);
},
removeItem: (key: string) => {
lsStore.delete(key);
},
clear: () => {
lsStore.clear();
},
});
beforeEach(() => {
lsStore.clear();
});
function extForKind(kind: ProjectFileKind): string {
if (kind === "html") return "html";
if (kind === "image") return "png";
if (kind === "sketch") return "sketch.json";
if (kind === "text") return "txt";
if (kind === "code") return "ts";
if (kind === "pdf") return "pdf";
return "bin";
}
function file(
overrides: Partial<ProjectFile> & Pick<ProjectFile, "name">,
): ProjectFile {
return {
path: overrides.name,
type: "file",
size: 1024,
mtime: Date.now(),
kind: "html",
mime: "text/html",
...overrides,
};
}
function generateFiles(count: number): ProjectFile[] {
const kinds: ProjectFileKind[] = [
"html",
"image",
"sketch",
"text",
"code",
"pdf",
];
return Array.from({ length: count }, (_, i) => {
const kind = kinds[i % kinds.length]!;
return file({
name: `file-${i + 1}.${extForKind(kind)}`,
kind,
size: 1024 * (i + 1),
mtime: Date.now() - i * 60_000,
mime: "text/plain",
});
});
}
function renderPanel(
files: ProjectFile[],
overrides: Partial<ComponentProps<typeof DesignFilesPanel>> = {},
) {
const onOpenFile = vi.fn();
const onDeleteFiles = vi.fn();
const onClearUploadError = vi.fn();
const result = render(
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={onOpenFile}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={onDeleteFiles}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
onClearUploadError={onClearUploadError}
{...overrides}
/>,
);
return { ...result, onDeleteFiles, onOpenFile, onClearUploadError };
}
function sectionLabels(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>(".df-section-label"),
).map((el) => el.textContent ?? "");
}
describe("DesignFilesPanel sections", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("does not show grouping, sort, filter, or pagination chrome", () => {
renderPanel(generateFiles(60));
expect(screen.queryByRole("group", { name: "Group by" })).toBeNull();
expect(document.querySelector(".df-table")).toBeNull();
expect(document.querySelector(".df-th-sortable")).toBeNull();
expect(document.querySelector(".df-kind-filter")).toBeNull();
expect(document.querySelector(".df-pagination")).toBeNull();
expect(document.querySelector(".df-page-btn")).toBeNull();
});
it("renders a single-line toolbar with file actions and no up/refresh buttons", () => {
renderPanel([file({ name: "page.html", kind: "html" })]);
expect(document.querySelector(".df-topbar")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Up" })).toBeNull();
expect(screen.queryByRole("button", { name: "Refresh" })).toBeNull();
expect(document.querySelector(".df-up-btn")).toBeNull();
expect(document.querySelector(".df-refresh-control")).toBeNull();
expect(screen.getByTestId("design-files-upload-trigger")).toBeTruthy();
});
it("shows prioritized project starter actions in the empty state", () => {
const onNewSketch = vi.fn();
const onOpenBrowser = vi.fn();
const onPaste = vi.fn();
renderPanel([], {
onNewSketch,
onOpenBrowser,
onPaste,
});
fireEvent.click(screen.getByTestId("design-files-empty-new-sketch"));
fireEvent.click(screen.getByTestId("design-files-empty-open-browser"));
fireEvent.click(screen.getByTestId("design-files-empty-create-document"));
expect(onNewSketch).toHaveBeenCalledTimes(1);
expect(onOpenBrowser).toHaveBeenCalledTimes(1);
expect(onPaste).toHaveBeenCalledTimes(1);
expect(
screen.getByTestId("design-files-empty-create-document").textContent,
).toContain("Create document");
expect(
screen.queryByRole("button", { name: "Create new design system" }),
).toBeNull();
});
it("groups files into semantic sections by category", () => {
renderPanel([
file({ name: "page.html", kind: "html", mime: "text/html" }),
file({ name: "chart.png", kind: "image", mime: "image/png" }),
]);
const labels = sectionLabels();
expect(labels.some((text) => text.includes("Pages"))).toBe(true);
expect(labels.some((text) => text.includes("Images"))).toBe(true);
expect(screen.getByTestId("design-file-row-page.html")).toBeTruthy();
expect(screen.getByTestId("design-file-row-chart.png")).toBeTruthy();
});
it("splits stylesheets into their own section with a Stylesheet subtitle", () => {
renderPanel([
file({ name: "styles.css", kind: "code", mime: "text/css" }),
file({ name: "app.ts", kind: "code", mime: "text/typescript" }),
]);
const labels = sectionLabels();
expect(labels.some((text) => text.includes("Stylesheets"))).toBe(true);
expect(labels.some((text) => text.includes("Scripts"))).toBe(true);
const cssRow = screen.getByTestId("design-file-row-styles.css");
expect(cssRow.querySelector(".df-row-sub")?.textContent).toBe("Stylesheet");
const tsRow = screen.getByTestId("design-file-row-app.ts");
expect(tsRow.querySelector(".df-row-sub")?.textContent).toBe("Script");
});
it('shows the file size beside the modified time while preserving the type subtitle', () => {
renderPanel([file({ name: 'chart.png', kind: 'image', size: 4096 })]);
const row = screen.getByTestId('design-file-row-chart.png');
expect(row.querySelector('.df-row-sub')?.textContent).toBe('Image');
expect(row.querySelector('.df-row-size')?.textContent).toBe('4.0 KB');
});
it("shows the upload hint in the footer while idle", () => {
renderPanel([file({ name: "page.html", kind: "html" })]);
expect(document.querySelector(".df-drop-hint")).toBeTruthy();
expect(document.querySelector(".df-useful-info")).toBeNull();
});
it("types out the first useful-info tip in the footer while the agent runs", async () => {
localStorage.setItem(VISUAL_STABILITY_STORAGE_KEY, "1");
renderPanel([file({ name: "page.html", kind: "html" })], { running: true });
expect(document.querySelector(".df-drop-hint")).toBeNull();
expect(document.querySelector(".df-useful-info-label")?.textContent).toBe(
"Useful info",
);
// The tip types in character by character, so wait for the first word.
await waitFor(() =>
expect(
document.querySelector(".df-useful-info-tip")?.textContent,
).toContain("Double-click"),
);
});
});
describe("DesignFilesPanel large list", () => {
afterEach(() => cleanup());
it("renders all rows at once (no pagination)", () => {
const { container } = renderPanel(generateFiles(500));
expect(container.querySelectorAll(".df-file-row").length).toBe(500);
});
it("renders 500 files within a reasonable time", () => {
const files = generateFiles(500);
const start = performance.now();
renderPanel(files);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(2000);
});
});
describe("DesignFilesPanel selection", () => {
afterEach(() => cleanup());
it("shows the batch bar and passes every selected file to batch delete", () => {
const files = generateFiles(3);
const { container, onDeleteFiles } = renderPanel(files);
const rows = Array.from(container.querySelectorAll(".df-file-row"));
const firstName = rows[0]!
.getAttribute("data-testid")!
.replace(/^design-file-row-/, "");
const secondName = rows[1]!
.getAttribute("data-testid")!
.replace(/^design-file-row-/, "");
fireEvent.click(rows[0]!.querySelector(".df-row-check")!);
fireEvent.click(rows[1]!.querySelector(".df-row-check")!);
expect(
container.querySelector('[data-testid="design-files-batch-bar"]'),
).toBeTruthy();
fireEvent.click(
container.querySelector('[data-testid="design-files-batch-delete"]')!,
);
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]);
});
it("does not preview or open files from row controls", () => {
const files = generateFiles(1);
const { container, onOpenFile } = renderPanel(files);
const row = container.querySelector(".df-file-row")!;
fireEvent.click(row.querySelector(".df-row-check")!);
expect(
container.querySelector('[data-testid="design-file-preview"]'),
).toBeNull();
expect(onOpenFile).not.toHaveBeenCalled();
fireEvent.click(row.querySelector(".df-row-menu")!);
expect(
container.querySelector('[data-testid="design-file-preview"]'),
).toBeNull();
expect(onOpenFile).not.toHaveBeenCalled();
});
it("copies the local file path from the row menu", async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
try {
const { container } = renderPanel([
file({
name: "alpha.html",
localPath: "/tmp/open-design/projects/test-project/alpha.html",
}),
]);
fireEvent.click(screen.getByTestId("design-file-menu-alpha.html"));
fireEvent.click(
screen.getByRole("button", { name: "Copy local file path" }),
);
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
"/tmp/open-design/projects/test-project/alpha.html",
);
});
expect(
container.querySelector('[data-testid="design-file-preview"]'),
).toBeNull();
} finally {
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
}
});
it("uses non-control row targets to preview and open", () => {
const files = generateFiles(1);
const { container, onOpenFile } = renderPanel(files);
const row = container.querySelector(".df-file-row")!;
fireEvent.click(row.querySelector(".df-row-icon")!);
expect(
container.querySelector('[data-testid="design-file-preview"]')
?.textContent,
).toContain("file-1.html");
fireEvent.doubleClick(row.querySelector(".df-row-name-btn")!);
expect(onOpenFile).toHaveBeenCalledWith("file-1.html");
onOpenFile.mockClear();
fireEvent.doubleClick(row.querySelector(".df-row-time")!);
expect(onOpenFile).toHaveBeenCalledWith("file-1.html");
});
});
describe("DesignFilesPanel preview", () => {
afterEach(() => cleanup());
it("shows the file extension in the preview stats", () => {
const { container } = renderPanel([
file({ name: "chart.png", kind: "image", size: 4096 }),
]);
fireEvent.click(container.querySelector(".df-file-row .df-row-icon")!);
const stats =
container.querySelector(".df-preview-stats")?.textContent ?? "";
expect(stats).toContain("PNG");
});
it("renders sketch files with the static sketch preview instead of a broken image", async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
version: 1,
items: [
{
kind: "rect",
x: 20,
y: 16,
w: 120,
h: 72,
color: "#1c1b1a",
size: 2,
},
],
}),
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
),
);
vi.stubGlobal("fetch", fetchMock);
const sketchFile = file({
name: "board.sketch.json",
path: "board.sketch.json",
kind: "sketch",
mime: "application/json; charset=utf-8",
});
const { container } = renderPanel([sketchFile]);
fireEvent.click(container.querySelector(".df-file-row .df-row-name-btn")!);
await waitFor(() => {
expect(
container.querySelector('[data-testid="sketch-preview-svg"]'),
).toBeTruthy();
});
expect(container.querySelector(".df-preview-thumb img")).toBeNull();
expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/test-project/raw/board.sketch.json",
{ cache: "no-store" },
);
});
});
describe("DesignFilesPanel directory navigation", () => {
afterEach(() => {
cleanup();
});
it("collapses nested files into a single folder row at root with correct descendant count", () => {
renderPanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "assets/icons/star.svg", kind: "image" }),
]);
const dirRows = document.querySelectorAll(".df-dir-row");
expect(dirRows.length).toBe(1);
expect(dirRows[0]!.textContent).toContain("assets");
expect(dirRows[0]!.textContent).toContain("2");
});
it("pins folders into a Folders section", () => {
renderPanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]);
expect(sectionLabels().some((text) => text.includes("Folders"))).toBe(true);
});
it("clicking a folder row navigates into it and shows only basenames and nested dirs", () => {
renderPanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "assets/icons/star.svg", kind: "image" }),
]);
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(document.querySelector(".df-breadcrumbs")).toBeTruthy();
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"assets",
);
const fileRow = screen.getByTestId("design-file-row-assets/logo.png");
expect(fileRow.querySelector(".df-row-name")?.textContent).toBe("logo.png");
expect(fileRow.querySelector(".df-row-name")?.textContent).not.toContain(
"assets/",
);
const dirRows = document.querySelectorAll(".df-dir-row");
expect(dirRows.length).toBe(1);
expect(dirRows[0]!.textContent).toContain("icons");
});
it("always renders the root breadcrumb on the default-root view", () => {
// Regression: managed-storage projects have currentDir==='' and no
// rootDirName, which previously collapsed the whole breadcrumb nav to null
// and left the toolbar blank on the left for the most common path. The root
// crumb must always render, falling back to the t('designFiles.crumbs')
// label when no rootDirName exists.
renderPanel([file({ name: "top.html", kind: "html" })]);
expect(document.querySelector(".df-breadcrumbs")).toBeTruthy();
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"Project",
);
});
it("shows rootDirName as the root breadcrumb when one is provided", () => {
renderPanel([file({ name: "top.html", kind: "html" })], {
rootDirName: "my-folder",
});
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"my-folder",
);
});
it("clicking the root breadcrumb navigates back to root", () => {
renderPanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]);
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(document.querySelector(".df-breadcrumbs")).toBeTruthy();
fireEvent.click(document.querySelector(".df-breadcrumb-btn")!);
expect(
document.querySelector(".df-breadcrumb-current")?.textContent,
).not.toBe("assets");
expect(screen.getByTestId("design-file-row-top.html")).toBeTruthy();
expect(document.querySelectorAll(".df-dir-row").length).toBe(1);
});
it("includes subdirectory files in the flat root-level list", () => {
renderPanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]);
expect(document.querySelectorAll(".df-dir-row").length).toBe(1);
expect(screen.getByTestId("design-file-row-assets/logo.png")).toBeTruthy();
expect(screen.getByTestId("design-file-row-top.html")).toBeTruthy();
});
it("preserves the current directory when remounted with navState from a previous render", () => {
let saved: DesignFilesNavState | undefined;
function makePanel(nav?: DesignFilesNavState) {
return (
<DesignFilesPanel
projectId="test-project"
files={[
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]}
liveArtifacts={[]}
navState={nav}
onNavStateChange={(state) => {
saved = state;
}}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>
);
}
const { unmount } = render(makePanel());
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"assets",
);
unmount();
render(makePanel(saved));
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"assets",
);
expect(screen.getByTestId("design-file-row-assets/logo.png")).toBeTruthy();
});
it("navigates up one level via the parent breadcrumb", () => {
renderPanel([file({ name: "assets/icons/star.svg", kind: "image" })]);
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"icons",
);
const crumbs = Array.from(document.querySelectorAll(".df-breadcrumb-btn"));
fireEvent.click(crumbs[crumbs.length - 1]!);
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"assets",
);
});
it("clears selection when navigating into or out of a directory", () => {
renderPanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]);
const topRow = screen.getByTestId("design-file-row-top.html");
fireEvent.click(topRow.querySelector(".df-row-check")!);
expect(topRow.classList.contains("selected")).toBe(true);
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(document.querySelectorAll(".df-file-row.selected").length).toBe(0);
fireEvent.click(document.querySelector(".df-breadcrumb-btn")!);
expect(document.querySelectorAll(".df-file-row.selected").length).toBe(0);
});
it("resets currentDir automatically when all files in the current subdirectory are removed", () => {
function makePanel(files: ProjectFile[]) {
return (
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>
);
}
const { rerender } = render(
makePanel([
file({ name: "assets/logo.png", kind: "image" }),
file({ name: "top.html", kind: "html" }),
]),
);
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(document.querySelector(".df-breadcrumb-current")?.textContent).toBe(
"assets",
);
rerender(makePanel([file({ name: "top.html", kind: "html" })]));
expect(
document.querySelector(".df-breadcrumb-current")?.textContent,
).not.toBe("assets");
expect(screen.getByTestId("design-file-row-top.html")).toBeTruthy();
});
});
describe("DesignFilesPanel current-directory sync", () => {
afterEach(() => cleanup());
it("reports the active folder so new files are created under it, not the root", () => {
const onCurrentDirChange = vi.fn();
renderPanel(
[
file({ name: "top.html", kind: "html" }),
file({ name: "assets/logo.png", kind: "image" }),
],
{ onCurrentDirChange },
);
// Mounts at the root.
expect(onCurrentDirChange).toHaveBeenLastCalledWith("");
// Navigate into the folder — the parent must learn the new target dir, or
// upload / paste / new-sketch would create at the project root (#3358 regression).
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
expect(onCurrentDirChange).toHaveBeenLastCalledWith("assets");
});
});
describe("DesignFilesPanel persisted (empty) folders", () => {
afterEach(() => cleanup());
it("shows an empty persisted folder that has no files under it", () => {
// Only a root file + an empty persisted folder; the folder must still
// appear (it would vanish if we derived dirs from file paths alone).
renderPanel([file({ name: "top.html", kind: "html" })], {
folders: [folder("assets")],
});
const dirRows = [...document.querySelectorAll(".df-dir-row")];
expect(dirRows.some((r) => r.textContent?.includes("assets"))).toBe(true);
});
it("surfaces a nested empty persisted folder after navigating into its parent", () => {
renderPanel([], { folders: [folder("assets"), folder("assets/icons")] });
// Zero files, but the persisted folder still renders the tree (not the
// empty state), so 'assets' is navigable at the root.
const rootDirs = [
...document.querySelectorAll(".df-dir-row .df-row-name"),
].map((e) => e.textContent);
expect(rootDirs).toContain("assets");
fireEvent.click(document.querySelector(".df-dir-row .df-row-name-btn")!);
const nestedDirs = [
...document.querySelectorAll(".df-dir-row .df-row-name"),
].map((e) => e.textContent);
expect(nestedDirs).toContain("icons");
});
});