mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -0,0 +1,503 @@
|
||||
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 "@/types/electron";
|
||||
|
||||
// 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`],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user