mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-12 22:59:21 +00:00
504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
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/frontend";
|
|
|
|
// 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<typeof vi.fn> & Record<string, unknown>;
|
|
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<string, unknown>) =>
|
|
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<string, string> = {},
|
|
): DataTransfer {
|
|
const store: Record<string, string> = { ...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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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(
|
|
<LocalFilePane
|
|
onRemoteItemsDropped={vi.fn()}
|
|
onUploadToRemote={onUploadToRemote}
|
|
/>,
|
|
);
|
|
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(
|
|
<LocalFilePane
|
|
onRemoteItemsDropped={vi.fn()}
|
|
onUploadToRemote={onUploadToRemote}
|
|
/>,
|
|
);
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
|
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(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
|
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`],
|
|
});
|
|
});
|
|
});
|