import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; import { LocalFilePane } from "../../src/frontend/LocalFilePane"; import { LOCAL_FILES_DRAG_MIME, REMOTE_FILES_DRAG_MIME, } from "../../src/frontend/local-transfer-utils"; import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; // jsdom has no layout, so render every row instead of a virtual window. vi.mock("@tanstack/react-virtual", () => ({ useVirtualizer: ({ count }: { count: number }) => ({ getTotalSize: () => count * 34, getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 34, size: 34, })), measureElement: () => {}, }), })); const sonnerToast = vi.hoisted(() => { const fn = vi.fn() as ReturnType & Record; fn.success = vi.fn(); fn.error = vi.fn(); fn.info = vi.fn(); fn.warning = vi.fn(); fn.loading = vi.fn(); fn.dismiss = vi.fn(); return fn; }); vi.mock("sonner", () => ({ toast: sonnerToast })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, opts?: Record) => opts?.count !== undefined ? `${key}:${opts.count}` : key, }), initReactI18next: { type: "3rdParty", init: () => {} }, })); const HOME = "/Users/max"; const entries: LocalFileEntry[] = [ { name: "projects", path: `${HOME}/projects`, type: "directory", size: 0, modifiedTimestamp: 1_700_000_000_000, hidden: false, }, { name: "notes.txt", path: `${HOME}/notes.txt`, type: "file", size: 1234, modifiedTimestamp: 1_700_000_000_000, hidden: false, }, { name: ".zshrc", path: `${HOME}/.zshrc`, type: "file", size: 42, modifiedTimestamp: 1_700_000_000_000, hidden: true, }, ]; function installElectronApi() { const list = vi.fn(async (dirPath: string) => ({ success: true as const, path: dirPath, parent: dirPath === "/" ? null : dirPath.split("/").slice(0, -1).join("/") || "/", entries: dirPath === HOME ? entries : [], })); const api = { isElectron: true, localFs: { home: vi.fn(async () => ({ success: true as const, home: HOME, separator: "/", platform: "darwin", })), list, mkdir: vi.fn(), createFile: vi.fn(async () => ({ success: true as const, path: "" })), rename: vi.fn(async () => ({ success: true as const, path: "" })), trash: vi.fn(async () => ({ success: true as const, trashed: 1, failed: [], })), ensureDir: vi.fn(), walk: vi.fn(), reveal: vi.fn(), open: vi.fn(), }, localTransfer: { upload: vi.fn(), download: vi.fn(), cancel: vi.fn(), onProgress: vi.fn(() => () => {}), }, }; (window as unknown as { electronAPI: unknown }).electronAPI = api; (window as unknown as { IS_ELECTRON: boolean }).IS_ELECTRON = true; return api; } function makeDataTransfer( types: string[], data: Record = {}, ): DataTransfer { const store: Record = { ...data }; return { types, dropEffect: "none", effectAllowed: "all", files: [] as unknown as FileList, items: [] as unknown as DataTransferItemList, getData: (type: string) => store[type] ?? "", setData: (type: string, value: string) => { store[type] = value; if (!types.includes(type)) types.push(type); }, clearData: vi.fn(), setDragImage: vi.fn(), } as unknown as DataTransfer; } describe("LocalFilePane", () => { beforeEach(() => { localStorage.clear(); installElectronApi(); }); afterEach(() => { delete (window as unknown as { electronAPI?: unknown }).electronAPI; }); it("lists the home directory on mount and remembers the last path", async () => { render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); expect( screen.getByText("fileManager.localItemCount:3"), ).toBeInTheDocument(); expect(localStorage.getItem("termix:file-manager:local-pane:path")).toBe( HOME, ); }); it("navigates into a folder on double click", async () => { const api = installElectronApi(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const row = document.querySelector(`[data-local-path="${HOME}/projects"]`); expect(row).not.toBeNull(); fireEvent.click(row!, { detail: 2 }); await waitFor(() => expect(api.localFs.list).toHaveBeenCalledWith(`${HOME}/projects`), ); }); it("accepts remote-grid drops and hands paths to the parent", async () => { const onRemoteItemsDropped = vi.fn(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const pane = screen.getByTestId("local-file-pane"); const payload = JSON.stringify({ type: "internal_files", files: ["/srv/app/a.log", "/srv/app/dir"], }); const dataTransfer = makeDataTransfer( [REMOTE_FILES_DRAG_MIME, "text/plain"], { "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" }, ); fireEvent.dragEnter(pane, { dataTransfer }); expect( screen.getByText("fileManager.dropToDownloadHere"), ).toBeInTheDocument(); fireEvent.drop(pane, { dataTransfer }); expect(onRemoteItemsDropped).toHaveBeenCalledWith( ["/srv/app/a.log", "/srv/app/dir"], HOME, ); expect( screen.queryByText("fileManager.dropToDownloadHere"), ).not.toBeInTheDocument(); }); it("drops onto a folder row download into that folder", async () => { const onRemoteItemsDropped = vi.fn(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const folderRow = document.querySelector( `[data-local-path="${HOME}/projects"]`, )!; const payload = JSON.stringify({ type: "internal_files", files: ["/srv/app/a.log"], }); const dataTransfer = makeDataTransfer( [REMOTE_FILES_DRAG_MIME, "text/plain"], { "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" }, ); fireEvent.dragOver(folderRow, { dataTransfer }); fireEvent.drop(folderRow, { dataTransfer }); expect(onRemoteItemsDropped).toHaveBeenCalledWith( ["/srv/app/a.log"], `${HOME}/projects`, ); }); it("hides dotfiles when hidden files are toggled off", async () => { render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); expect( document.querySelector(`[data-local-path="${HOME}/.zshrc"]`), ).not.toBeNull(); fireEvent.click(screen.getByTitle("fileManager.localHideHidden")); expect( document.querySelector(`[data-local-path="${HOME}/.zshrc"]`), ).toBeNull(); expect( screen.getByText("fileManager.localItemCount:2"), ).toBeInTheDocument(); }); it("opens a context menu for a row with entry actions", async () => { const onUploadToRemote = vi.fn(); render( , ); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const row = document.querySelector( `[data-local-path="${HOME}/notes.txt"]`, )!; fireEvent.contextMenu(row, { clientX: 40, clientY: 50 }); const menu = screen.getByTestId("local-file-context-menu"); expect(menu).toBeInTheDocument(); expect(screen.getByText("fileManager.localOpen")).toBeInTheDocument(); expect(screen.getByText("fileManager.rename")).toBeInTheDocument(); expect( screen.getByText("fileManager.localMoveToTrash"), ).toBeInTheDocument(); // Background-only actions are not offered for a row. expect(screen.queryByText("fileManager.newFolder")).not.toBeInTheDocument(); fireEvent.click(screen.getByText("fileManager.localUploadToRemote")); expect(onUploadToRemote).toHaveBeenCalledWith([`${HOME}/notes.txt`]); expect( screen.queryByTestId("local-file-context-menu"), ).not.toBeInTheDocument(); }); it("acts on the whole selection when right-clicking a selected row", async () => { const onUploadToRemote = vi.fn(); render( , ); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const projects = document.querySelector( `[data-local-path="${HOME}/projects"]`, )!; const notes = document.querySelector( `[data-local-path="${HOME}/notes.txt"]`, )!; fireEvent.click(projects); fireEvent.click(notes, { metaKey: true }); fireEvent.contextMenu(notes, { clientX: 40, clientY: 50 }); fireEvent.click(screen.getByText("fileManager.localUploadToRemoteMany:2")); expect(onUploadToRemote).toHaveBeenCalledWith( expect.arrayContaining([`${HOME}/projects`, `${HOME}/notes.txt`]), ); expect(onUploadToRemote.mock.calls[0][0]).toHaveLength(2); }); it("offers folder-level actions on the background and creates a file", async () => { const api = installElectronApi(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const body = screen .getByText("fileManager.localItemCount:3") .closest('[data-testid="local-file-pane"]')! .querySelector(".thin-scrollbar")!; fireEvent.contextMenu(body, { clientX: 100, clientY: 200 }); expect(screen.getByText("fileManager.newFolder")).toBeInTheDocument(); expect(screen.getByText("fileManager.refresh")).toBeInTheDocument(); expect(screen.queryByText("fileManager.rename")).not.toBeInTheDocument(); fireEvent.click(screen.getByText("fileManager.newFile")); const input = screen.getByPlaceholderText("fileManager.newFile"); fireEvent.change(input, { target: { value: "todo.md" } }); fireEvent.keyDown(input, { key: "Enter" }); await waitFor(() => expect(api.localFs.createFile).toHaveBeenCalledWith(HOME, "todo.md"), ); }); it("renames inline from the context menu", async () => { const api = installElectronApi(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const row = document.querySelector( `[data-local-path="${HOME}/notes.txt"]`, )!; fireEvent.contextMenu(row, { clientX: 40, clientY: 50 }); fireEvent.click(screen.getByText("fileManager.rename")); const input = screen.getByDisplayValue("notes.txt"); fireEvent.change(input, { target: { value: "renamed.txt" } }); fireEvent.keyDown(input, { key: "Enter" }); await waitFor(() => expect(api.localFs.rename).toHaveBeenCalledWith( `${HOME}/notes.txt`, "renamed.txt", ), ); }); it("asks for confirmation before moving to the trash", async () => { const api = installElectronApi(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const row = document.querySelector( `[data-local-path="${HOME}/notes.txt"]`, )!; fireEvent.contextMenu(row, { clientX: 40, clientY: 50 }); fireEvent.click(screen.getByText("fileManager.localMoveToTrash")); // Nothing is trashed until the toast's confirm action is clicked. expect(api.localFs.trash).not.toHaveBeenCalled(); expect(sonnerToast).toHaveBeenCalledWith( "fileManager.localTrashConfirmSingle", expect.objectContaining({ action: expect.anything() }), ); const call = sonnerToast.mock.calls.find( (c) => c[0] === "fileManager.localTrashConfirmSingle", )!; (call[1] as { action: { onClick: () => void } }).action.onClick(); await waitFor(() => expect(api.localFs.trash).toHaveBeenCalledWith([`${HOME}/notes.txt`]), ); }); it("shows a pinned .. entry that goes up on double click and accepts drops", async () => { const api = installElectronApi(); const onRemoteItemsDropped = vi.fn(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const parentRow = document.querySelector("[data-parent-entry]"); expect(parentRow).not.toBeNull(); // It is the first row, above the real entries. const firstRow = screen .getByTestId("local-file-pane") .querySelector(".thin-scrollbar > *"); expect(firstRow).toBe(parentRow); const payload = JSON.stringify({ type: "internal_files", files: ["/srv/app/a.log"], }); const dataTransfer = makeDataTransfer( [REMOTE_FILES_DRAG_MIME, "text/plain"], { "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" }, ); fireEvent.dragOver(parentRow!, { dataTransfer }); fireEvent.drop(parentRow!, { dataTransfer }); expect(onRemoteItemsDropped).toHaveBeenCalledWith( ["/srv/app/a.log"], "/Users", ); fireEvent.click(parentRow!, { detail: 2 }); await waitFor(() => expect(api.localFs.list).toHaveBeenCalledWith("/Users"), ); }); it("lets the header context menu hide and show columns", async () => { render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const kindHeader = screen.getByText("fileManager.kind"); const kindCellsBefore = document.querySelectorAll( `[data-local-path="${HOME}/notes.txt"] > span`, ).length; fireEvent.contextMenu(kindHeader, { clientX: 300, clientY: 60 }); const menu = screen.getByTestId("column-visibility-menu"); expect(menu).toBeInTheDocument(); fireEvent.click( screen.getByRole("menuitemcheckbox", { name: "fileManager.kind" }), ); // The sortable header button is gone; only the menu's checkbox remains. expect( screen.queryByRole("button", { name: "fileManager.kind" }), ).toBeNull(); expect( document.querySelectorAll(`[data-local-path="${HOME}/notes.txt"] > span`) .length, ).toBe(kindCellsBefore - 1); expect( JSON.parse( localStorage.getItem("termix:file-manager:columns:local:hidden")!, ), ).toEqual(["kind"]); }); it("ignores OS file drags (those belong to the remote grid)", async () => { const onRemoteItemsDropped = vi.fn(); render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const pane = screen.getByTestId("local-file-pane"); const dataTransfer = makeDataTransfer(["Files"]); fireEvent.dragEnter(pane, { dataTransfer }); expect( screen.queryByText("fileManager.dropToDownloadHere"), ).not.toBeInTheDocument(); fireEvent.drop(pane, { dataTransfer }); expect(onRemoteItemsDropped).not.toHaveBeenCalled(); }); it("puts a typed payload on drags that start in the pane", async () => { render(); await waitFor(() => expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), ); const row = document.querySelector(`[data-local-path="${HOME}/notes.txt"]`); expect(row).not.toBeNull(); const dataTransfer = makeDataTransfer([]); fireEvent.dragStart(row!, { dataTransfer }); expect(dataTransfer.types).toContain(LOCAL_FILES_DRAG_MIME); expect(JSON.parse(dataTransfer.getData("text/plain"))).toEqual({ type: "local_files", paths: [`${HOME}/notes.txt`], }); }); });