feat: convert file manager into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 16:27:29 -05:00
1 parent b76da9ab52
commit c85c1fdb86
259 files changed
+54155 -16791

No files matched your search

@@ -0,0 +1,91 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: { permissions?: string[]; users?: string[] } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["file-manager.use"],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers.js";
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent as core's SQLite bootstrap created them before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_file_manager_recent_user ON file_manager_recent (user_id, host_id);
CREATE TABLE file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_file_manager_pinned_user ON file_manager_pinned (user_id, host_id);
CREATE TABLE file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_file_manager_shortcuts_user ON file_manager_shortcuts (user_id, host_id);
CREATE TABLE transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
dest_host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_transfer_recent_user ON transfer_recent (user_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting the four file-manager tables", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1')",
);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO file_manager_recent (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
)
.run("user-1", 1, "notes.txt", "/home/user/notes.txt");
sqlite
.prepare(
"INSERT INTO file_manager_pinned (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
)
.run("user-1", 1, "logs", "/var/log");
sqlite
.prepare(
"INSERT INTO file_manager_shortcuts (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
)
.run("user-1", 1, "Home", "/home/user");
sqlite
.prepare(
"INSERT INTO transfer_recent (user_id, source_host_id, dest_host_id, dest_path, dest_path_label) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", 1, 2, "/backups", "Backups");
},
});
expect(db.applied).toEqual(["0001_adopt_file_manager_tables"]);
for (const legacy of [
"file_manager_recent",
"file_manager_pinned",
"file_manager_shortcuts",
"transfer_recent",
]) {
expect(tableExists(legacy)).toBe(false);
}
for (const adopted of [
"p_file_manager_recent",
"p_file_manager_pinned",
"p_file_manager_shortcuts",
"p_file_manager_transfer_recent",
]) {
expect(tableExists(adopted)).toBe(true);
}
expect(
db.sqlite.prepare("SELECT name, path FROM p_file_manager_recent").all(),
).toEqual([{ name: "notes.txt", path: "/home/user/notes.txt" }]);
expect(
db.sqlite.prepare("SELECT name, path FROM p_file_manager_pinned").all(),
).toEqual([{ name: "logs", path: "/var/log" }]);
expect(
db.sqlite
.prepare("SELECT name, path FROM p_file_manager_shortcuts")
.all(),
).toEqual([{ name: "Home", path: "/home/user" }]);
expect(
db.sqlite
.prepare(
"SELECT dest_path, dest_path_label FROM p_file_manager_transfer_recent",
)
.all(),
).toEqual([{ dest_path: "/backups", dest_path_label: "Backups" }]);
expect(indexOn("p_file_manager_recent")).toEqual([
"idx_file_manager_recent_user",
]);
expect(indexOn("p_file_manager_pinned")).toEqual([
"idx_file_manager_pinned_user",
]);
expect(indexOn("p_file_manager_shortcuts")).toEqual([
"idx_file_manager_shortcuts_user",
]);
expect(indexOn("p_file_manager_transfer_recent")).toEqual([
"idx_transfer_recent_user",
]);
});
it("creates the tables fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("file_manager_recent")).toBe(false);
expect(tableExists("p_file_manager_recent")).toBe(true);
expect(tableExists("p_file_manager_pinned")).toBe(true);
expect(tableExists("p_file_manager_shortcuts")).toBe(true);
expect(tableExists("p_file_manager_transfer_recent")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_file_manager_recent").all(),
).toEqual([]);
});
});
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers.js";
let server: TestServer;
beforeEach(async () => {
server = await startServer();
});
afterEach(async () => {
await server.close();
});
describe("recent/pinned/shortcuts", () => {
it("round-trips a recent file", async () => {
server.db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
const add = await server.request("POST", "/recent", {
body: { hostId: 1, path: "/etc/hosts", name: "hosts" },
});
expect(add.status).toBe(200);
const list = await server.request("GET", "/recent?hostId=1");
expect(list.status).toBe(200);
expect(list.body).toEqual([
expect.objectContaining({ name: "hosts", path: "/etc/hosts" }),
]);
});
it("refuses to pin the same path twice", async () => {
server.db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
const first = await server.request("POST", "/pinned", {
body: { hostId: 1, path: "/var/log", name: "logs" },
});
expect(first.status).toBe(200);
const second = await server.request("POST", "/pinned", {
body: { hostId: 1, path: "/var/log", name: "logs" },
});
expect(second.status).toBe(409);
});
});
describe("file-manager.use", () => {
it("refuses every route without it", async () => {
await server.close();
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/status?sessionId=x"],
["POST", "/connect"],
["GET", "/recent?hostId=1"],
["POST", "/recent"],
["GET", "/pinned?hostId=1"],
["POST", "/pinned"],
["GET", "/shortcuts?hostId=1"],
["POST", "/shortcuts"],
["GET", "/activeTransfers"],
] as const) {
const res = await server.request(method, path, {
body: { hostId: 1, path: "/tmp", name: "tmp" },
});
expect(res.status, `${method} ${path}`).toBe(403);
expect(res.body.required).toBe("file-manager.use");
}
});
});
@@ -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`],
});
});
});
@@ -0,0 +1,221 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "sonner";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SftpTransferTab } from "../../src/frontend/SftpTransferTab";
const api = vi.hoisted(() => ({
addTransferRecent: vi.fn(),
browseSSHDirectory: vi.fn(),
changeSSHPermissions: vi.fn(),
createSSHFolder: vi.fn(),
deleteSSHItem: vi.fn(),
ensureSSHSessionForHost: vi.fn(),
getSSHHosts: vi.fn(),
getTransferProgressPercent: vi.fn(() => undefined),
renameSSHItem: vi.fn(),
transferToHost: vi.fn(),
beginTransferProgressMonitoring: vi.fn(),
}));
vi.mock("@/main-axios", () => ({
addTransferRecent: api.addTransferRecent,
getSSHHosts: api.getSSHHosts,
getTransferProgressPercent: api.getTransferProgressPercent,
transferToHost: api.transferToHost,
}));
vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({
browseSSHDirectory: api.browseSSHDirectory,
changeSSHPermissions: api.changeSSHPermissions,
createSSHFolder: api.createSSHFolder,
deleteSSHItem: api.deleteSSHItem,
ensureSSHSessionForHost: api.ensureSSHSessionForHost,
renameSSHItem: api.renameSSHItem,
}));
vi.mock("../../src/frontend/transferProgressMonitor", () => ({
beginTransferProgressMonitoring: api.beginTransferProgressMonitoring,
}));
vi.mock("../../src/frontend/transferMetricsFormat", () => ({
createFormatTransferMetrics: () => () => "",
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
beforeEach(() => {
vi.clearAllMocks();
api.getSSHHosts.mockResolvedValue([
{
id: 1,
name: "prod",
ip: "10.0.0.1",
enableFileManager: true,
connectionType: "ssh",
defaultPath: "/srv",
},
{
id: 2,
name: "backup",
ip: "10.0.0.2",
enableFileManager: true,
connectionType: "ssh",
defaultPath: "/srv",
},
]);
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
state: "ready",
sessionId: String(host.id),
}));
api.browseSSHDirectory.mockImplementation(
async (sessionId: string, path: string) => ({
status: "ok",
path,
files: [
{
name: `remote-${sessionId}.txt`,
type: "file",
size: 24,
modified: "2026-07-18T10:30:00.000Z",
},
],
}),
);
api.transferToHost.mockResolvedValue({ transferId: "transfer-1" });
api.beginTransferProgressMonitoring.mockReturnValue({
toastId: "toast-1",
waitForCompletion: Promise.resolve({
transferId: "transfer-1",
status: "success",
phase: "transferring",
}),
});
});
async function selectHosts() {
const selects = await screen.findAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "1" } });
fireEvent.change(selects[1], { target: { value: "2" } });
await screen.findByText("remote-1.txt");
await screen.findByText("remote-2.txt");
}
describe("SftpTransferTab", () => {
it("loads file manager-enabled hosts into both host pickers", async () => {
render(<SftpTransferTab />);
const selects = await screen.findAllByRole("combobox");
expect(selects).toHaveLength(2);
expect(api.getSSHHosts).toHaveBeenCalled();
});
it("copies a source server file to the destination server via the context menu", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
await waitFor(() => {
// A single file transfer sends the full destination path, filename
// included, rather than just the directory. See #1304: sending a file on
// its own used to drop it.
expect(api.transferToHost).toHaveBeenCalledWith(
"1",
["/srv/remote-1.txt"],
"2",
"/srv/remote-1.txt",
false,
"auto",
);
});
});
it("records the destination as a transfer recent after a successful copy", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
await waitFor(() => {
expect(api.addTransferRecent).toHaveBeenCalledWith(1, 2, "/srv", "/srv");
});
});
it("blocks a same-host transfer where the destination is inside the source path", async () => {
render(<SftpTransferTab />);
const selects = await screen.findAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "1" } });
fireEvent.change(selects[1], { target: { value: "1" } });
const rows = await screen.findAllByText("remote-1.txt");
// Destination pane starts at the same "/srv" listing as the source, so
// browsing into the selected source file's own path (as if it were a
// folder) makes the destination nested inside the source selection.
const destPathInput = screen.getAllByDisplayValue("/srv")[1];
fireEvent.change(destPathInput, {
target: { value: "/srv/remote-1.txt" },
});
fireEvent.keyDown(destPathInput, { key: "Enter" });
await waitFor(() =>
expect(api.browseSSHDirectory).toHaveBeenCalledTimes(3),
);
fireEvent.contextMenu(rows[0]);
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"sftpTransfer.destinationInsideSource",
);
});
expect(api.transferToHost).not.toHaveBeenCalled();
});
it("renames a remote file from the row context menu", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.rename"));
const nameInput = screen.getByDisplayValue("remote-1.txt");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "renamed.txt");
await userEvent.click(screen.getByText("sftpTransfer.save"));
await waitFor(() => {
expect(api.renameSSHItem).toHaveBeenCalledWith(
"1",
"/srv/remote-1.txt",
"renamed.txt",
);
});
});
it("deletes a remote file after confirming", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.delete"));
const confirmButtons = await screen.findAllByText("sftpTransfer.delete");
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(api.deleteSSHItem).toHaveBeenCalledWith(
"1",
"/srv/remote-1.txt",
false,
);
});
});
});
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
const panels = [...declared(contributes.panels), ...tabs];
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
});
it("registers the files and sftp tabs", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs().sort()).toEqual(["files", "sftp"]);
});
it("registers the sftp rail item pointing at the sftp tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const railItems = rendered.registered.railItems();
expect(railItems.map((item) => item.id)).toContain("sftp");
});
it("registers a files host action opening the files tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const hostActions = rendered.registered.hostActions();
expect(hostActions).toContainEqual(
expect.objectContaining({ id: "files", tabType: "files" }),
);
});
it("registers the files host editor section", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.hostEditorSections()).toContain("files");
});
it("registers files.openHost and files.openEditor actions", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([
"files.openEditor",
"files.openHost",
]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fileManagerApiMock = vi.hoisted(() => ({
post: vi.fn(async () => ({ data: { complete: false } })),
}));
vi.mock("@/main-axios", () => ({
authApi: { defaults: {} },
fileManagerApi: { defaults: {} },
getFileManagerApiForSession: () => fileManagerApiMock,
handleApiError: (error: unknown) => {
throw error;
},
setSessionOrigin: vi.fn(),
clearSessionOrigin: vi.fn(),
}));
vi.mock("@/lib/connection-origin", () => ({
resolveConnectionOrigin: vi.fn(),
}));
vi.mock("@/lib/frontend-logger", () => ({
fileLogger: {
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("@/lib/file-list-request-cache", () => ({
getCachedFileList: vi.fn(),
}));
vi.mock("@/lib/file-content-request-cache", () => ({
getCachedFileContent: vi.fn(),
invalidateCachedFileContent: vi.fn(),
}));
import { uploadSSHFile } from "../../../src/frontend/api/ssh-file-operations-api";
describe("chunked SSH file uploads", () => {
beforeEach(() => {
fileManagerApiMock.post.mockClear();
});
it("sends raw chunks with the byte offset expected by the server", async () => {
const fileSize = 1.5 * 1024 * 1024 * 1024 + 1;
const file = {
size: fileSize,
slice: vi.fn(() => new Blob(["chunk"])),
} as unknown as File;
await uploadSSHFile("session-1", "/uploads", "archive.img", file);
expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193);
expect(fileManagerApiMock.post).toHaveBeenNthCalledWith(
1,
"/ssh/uploadFileChunk",
expect.any(Blob),
{
params: {
sessionId: "session-1",
path: "/uploads",
fileName: "archive.img",
offset: 0,
totalSize: fileSize,
},
headers: { "Content-Type": "application/octet-stream" },
timeout: 0,
},
);
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
"/ssh/uploadFileChunk",
expect.any(Blob),
expect.objectContaining({
params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }),
}),
);
});
});
@@ -0,0 +1,61 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TransferProgressBar } from "../../../src/frontend/components/TransferProgressBar";
afterEach(cleanup);
describe("TransferProgressBar", () => {
it("exposes determinate progress and drives the fill with a transform", () => {
const { container } = render(
<TransferProgressBar value={42} label="Uploading archive" />,
);
const progress = screen.getByRole("progressbar", {
name: "Uploading archive",
});
expect(progress.getAttribute("aria-valuenow")).toBe("42");
expect(progress.getAttribute("aria-busy")).toBe("false");
expect(
container
.querySelector<HTMLElement>(".transfer-progress-fill")
?.style.getPropertyValue("--transfer-progress"),
).toBe("0.42");
});
it("clamps invalid percentages before exposing them", () => {
const { rerender } = render(
<TransferProgressBar value={140} label="Uploading archive" />,
);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"100",
);
rerender(<TransferProgressBar value={-20} label="Uploading archive" />);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"0",
);
});
it("marks unknown and stalled progress without inventing a percentage", () => {
const { container } = render(
<TransferProgressBar label="Reconnecting" stalled />,
);
const progress = screen.getByRole("progressbar");
expect(progress.hasAttribute("aria-valuenow")).toBe(false);
expect(progress.getAttribute("aria-busy")).toBe("true");
expect(progress.getAttribute("data-stalled")).toBe("true");
expect(
container
.querySelector(".transfer-progress-fill")
?.classList.contains("transfer-progress-indeterminate"),
).toBe(true);
expect(
container
.querySelector(".transfer-progress-fill")
?.classList.contains("transfer-progress-stalled"),
).toBe(true);
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import {
formatFileSize,
runWithConcurrency,
} from "../../src/frontend/file-manager-utils.js";
describe("formatFileSize", () => {
it("returns a dash for undefined or null", () => {
expect(formatFileSize(undefined)).toBe("-");
expect(formatFileSize(null as unknown as number)).toBe("-");
});
it("returns 0 B for zero", () => {
expect(formatFileSize(0)).toBe("0 B");
});
it("formats bytes without decimals", () => {
expect(formatFileSize(512)).toBe("512 B");
expect(formatFileSize(1023)).toBe("1023 B");
});
it("formats kilobytes with one decimal under 10", () => {
expect(formatFileSize(1024)).toBe("1.0 KB");
expect(formatFileSize(1536)).toBe("1.5 KB");
});
it("rounds to whole numbers at or above 10 units", () => {
expect(formatFileSize(10 * 1024)).toBe("10 KB");
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
});
it("scales up to larger units", () => {
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
});
it("limits concurrent work", async () => {
let active = 0;
let maxActive = 0;
const completed: number[] = [];
await runWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 1));
completed.push(item);
active -= 1;
});
expect(maxActive).toBe(2);
expect(completed.sort()).toEqual([1, 2, 3, 4, 5]);
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveFilePreviewKind } from "../../src/frontend/file-preview-preload";
describe("resolveFilePreviewKind", () => {
it.each([
["photo.webp", "image"],
["movie.webm", "video"],
["voice.m4a", "audio"],
["README.md", "markdown"],
["manual.pdf", "pdf"],
["notes.txt", "text"],
["app.tsx", "code"],
["Dockerfile", "code"],
["LICENSE", "unknown"],
] as const)("classifies %s as %s", (filename, expected) => {
expect(resolveFilePreviewKind(filename)).toBe(expected);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useDragAndDrop } from "../../../src/frontend/hooks/useDragAndDrop.js";
function makeEntry(name: string, isDirectory: boolean) {
return { name, isDirectory, isFile: !isDirectory } as FileSystemEntry;
}
function makeDropEvent(entries: FileSystemEntry[], files: File[] = []) {
const items = entries.map((entry) => ({
webkitGetAsEntry: () => entry,
}));
const dataTransfer = {
items,
files: Object.assign(files, { item: (i: number) => files[i] }),
};
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer,
} as unknown as React.DragEvent;
}
describe("useDragAndDrop", () => {
it("hands directory entries to onItemsDropped", () => {
const onItemsDropped = vi.fn();
const onFilesDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped, onItemsDropped }),
);
const dir = makeEntry("myfolder", true);
act(() => result.current.dragHandlers.onDrop(makeDropEvent([dir])));
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
expect(onFilesDropped).not.toHaveBeenCalled();
});
it("reads entries before state updates clear dataTransfer", () => {
const onItemsDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped: vi.fn(), onItemsDropped }),
);
const dir = makeEntry("myfolder", true);
const event = makeDropEvent([dir]);
// Mimic the browser neutering dataTransfer once the handler unwinds.
act(() => {
result.current.dragHandlers.onDrop(event);
(event.dataTransfer as unknown as { items: unknown[] }).items = [];
});
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
});
it("falls back to plain file upload when no directory is dropped", () => {
const onFilesDropped = vi.fn();
const onItemsDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped, onItemsDropped }),
);
const file = new File(["hi"], "a.txt");
act(() =>
result.current.dragHandlers.onDrop(
makeDropEvent([makeEntry("a.txt", false)], [file]),
),
);
expect(onItemsDropped).not.toHaveBeenCalled();
expect(onFilesDropped).toHaveBeenCalled();
});
it("rejects files over the size limit", () => {
const onError = vi.fn();
const onFilesDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped, onError, maxFileSize: 1 }),
);
const big = new File(["x"], "big.bin");
Object.defineProperty(big, "size", { value: 5 * 1024 * 1024 });
act(() => result.current.dragHandlers.onDrop(makeDropEvent([], [big])));
expect(onFilesDropped).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useFileSelection } from "../../../src/frontend/hooks/useFileSelection.js";
type FileItem = {
name: string;
type: "file" | "directory" | "link";
path: string;
};
const f = (name: string): FileItem => ({
name,
type: "file",
path: `/dir/${name}`,
});
describe("useFileSelection", () => {
it("single-selects, replacing prior selection", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a")));
act(() => result.current.selectFile(f("b")));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
expect(result.current.getSelectedCount()).toBe(1);
});
it("multi-selects and toggles off on repeat", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a"), true));
act(() => result.current.selectFile(f("b"), true));
expect(result.current.getSelectedCount()).toBe(2);
act(() => result.current.selectFile(f("a"), true));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
});
it("reports isSelected by path", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a")));
expect(result.current.isSelected(f("a"))).toBe(true);
expect(result.current.isSelected(f("z"))).toBe(false);
});
it("selects a contiguous range regardless of direction", () => {
const { result } = renderHook(() => useFileSelection());
const files = [f("a"), f("b"), f("c"), f("d")];
act(() => result.current.selectRange(files, files[3], files[1]));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
"b",
"c",
"d",
]);
});
it("selects all and clears", () => {
const { result } = renderHook(() => useFileSelection());
const files = [f("a"), f("b")];
act(() => result.current.selectAll(files));
expect(result.current.getSelectedCount()).toBe(2);
act(() => result.current.clearSelection());
expect(result.current.getSelectedCount()).toBe(0);
});
it("toggleSelection adds then removes", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.toggleSelection(f("a")));
expect(result.current.isSelected(f("a"))).toBe(true);
act(() => result.current.toggleSelection(f("a")));
expect(result.current.isSelected(f("a"))).toBe(false);
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { usePaneWidth } from "../../../src/frontend/hooks/usePaneWidth";
const KEY = "test:pane-width";
function mouse(type: string, clientX: number) {
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
}
const opts = { storageKey: KEY, defaultWidth: 224, minWidth: 160 };
describe("usePaneWidth", () => {
beforeEach(() => localStorage.clear());
it("starts at the default and restores a stored width", () => {
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
localStorage.setItem(KEY, "300");
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(300);
localStorage.setItem(KEY, "10"); // below min → ignored
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
});
it("follows the drag, clamps to the minimum, and persists on release", () => {
const { result } = renderHook(() => usePaneWidth(opts));
act(() => {
result.current.startResize({
clientX: 400,
preventDefault() {},
stopPropagation() {},
} as unknown as React.MouseEvent);
});
expect(result.current.isResizing).toBe(true);
act(() => mouse("mousemove", 460));
expect(result.current.width).toBe(284);
act(() => mouse("mousemove", 100));
expect(result.current.width).toBe(160);
act(() => mouse("mouseup", 100));
expect(result.current.isResizing).toBe(false);
expect(localStorage.getItem(KEY)).toBe("160");
});
it("resets to the default", () => {
localStorage.setItem(KEY, "300");
const { result } = renderHook(() => usePaneWidth(opts));
act(() => result.current.resetWidth());
expect(result.current.width).toBe(224);
expect(localStorage.getItem(KEY)).toBe("224");
});
});
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useResizableColumns } from "../../../src/frontend/hooks/useResizableColumns";
const columns = [
{ key: "modified", labelKey: "m", defaultWidth: 120, minWidth: 70 },
{ key: "size", labelKey: "s", defaultWidth: 80, minWidth: 56, maxWidth: 200 },
];
const STORAGE_KEY = "test:columns";
function mouse(type: string, clientX: number) {
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
}
describe("useResizableColumns", () => {
beforeEach(() => localStorage.clear());
it("starts from defaults and builds a grid template with a flexible name column", () => {
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(result.current.gridTemplateColumns).toBe(
"minmax(140px, 1fr) 120px 80px",
);
});
it("widens a column when its left-edge handle is dragged left and persists", () => {
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
const handle = result.current.getHandleProps("size");
act(() => {
handle.onMouseDown({
clientX: 500,
preventDefault() {},
stopPropagation() {},
} as unknown as React.MouseEvent);
});
expect(result.current.resizingKey).toBe("size");
act(() => mouse("mousemove", 460));
expect(result.current.widths.size).toBe(120);
act(() => mouse("mouseup", 460));
expect(result.current.resizingKey).toBeNull();
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
modified: 120,
size: 120,
});
});
it("clamps to min/max and restores stored widths", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ modified: 10, size: 9999, junk: "x" }),
);
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(result.current.widths).toEqual({ modified: 70, size: 200 });
});
it("hides and shows columns, dropping them from the template", () => {
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
act(() => result.current.toggleColumn("size"));
expect(result.current.isVisible("size")).toBe(false);
expect(result.current.gridTemplateColumns).toBe("minmax(140px, 1fr) 120px");
expect(result.current.visibleColumns.map((c) => c.key)).toEqual([
"modified",
]);
expect(JSON.parse(localStorage.getItem(`${STORAGE_KEY}:hidden`)!)).toEqual([
"size",
]);
// A fresh mount restores the hidden set; toggling again shows it.
const remount = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(remount.result.current.isVisible("size")).toBe(false);
act(() => remount.result.current.toggleColumn("size"));
expect(remount.result.current.gridTemplateColumns).toBe(
"minmax(140px, 1fr) 120px 80px",
);
});
it("resets a column to its default on double click", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ modified: 150 }));
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(result.current.widths.modified).toBe(150);
act(() => {
result.current.getHandleProps("modified").onDoubleClick({
preventDefault() {},
stopPropagation() {},
} as unknown as React.MouseEvent);
});
expect(result.current.widths.modified).toBe(120);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).modified).toBe(120);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import {
getCachedFileContent,
invalidateCachedFileContent,
shouldPrefetchFileContent,
} from "../../../src/frontend/lib/file-content-request-cache";
describe("file content request cache", () => {
it("coalesces reads and reloads after invalidation", async () => {
const loader = vi.fn(async () => ({ content: "hello", path: "/note" }));
await Promise.all([
getCachedFileContent("session", "/note", loader),
getCachedFileContent("session", "/note", loader),
]);
expect(loader).toHaveBeenCalledOnce();
invalidateCachedFileContent("session", "/note");
await getCachedFileContent("session", "/note", loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it("only prefetches known small files on an unconstrained network", () => {
expect(
shouldPrefetchFileContent(
{ name: "note", path: "/note", type: "file", size: 512 * 1024 },
512 * 1024,
),
).toBe(true);
expect(
shouldPrefetchFileContent(
{ name: "large", path: "/large", type: "file", size: 512 * 1024 + 1 },
512 * 1024,
),
).toBe(false);
expect(
shouldPrefetchFileContent(
{ name: "unknown", path: "/unknown", type: "file" },
512 * 1024,
),
).toBe(false);
expect(
shouldPrefetchFileContent(
{ name: "note", path: "/note", type: "file", size: 100 },
0,
),
).toBe(false);
});
});
@@ -0,0 +1,383 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME,
UnsafeLocalNameError,
assertSafeLocalComponent,
beginRemoteFilesDrag,
buildLocalDestination,
clampTransferConcurrency,
describeLocalKind,
getTransferConcurrency,
runWithConcurrency,
setTransferConcurrency,
DEFAULT_TRANSFER_CONCURRENCY,
TRANSFER_CONCURRENCY_STORAGE_KEY,
formatLocalModified,
isLocalFilesDrag,
isRemoteFilesDrag,
joinLocalPath,
joinRemotePath,
parseInternalFilesDragPayload,
parseLocalFilesDragPayload,
planRemoteDirectories,
remoteBaseName,
remoteDirForRelativePath,
serializeLocalFilesDragPayload,
sortLocalEntries,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron";
describe("drag payloads", () => {
it("round-trips local file payloads", () => {
const raw = serializeLocalFilesDragPayload(["/Users/max/a.txt", "/tmp/b"]);
expect(parseLocalFilesDragPayload(raw)).toEqual([
"/Users/max/a.txt",
"/tmp/b",
]);
});
it("rejects foreign or malformed payloads", () => {
expect(parseLocalFilesDragPayload(null)).toBeNull();
expect(parseLocalFilesDragPayload("not json")).toBeNull();
expect(
parseLocalFilesDragPayload(
JSON.stringify({ type: "internal_files", files: ["/x"] }),
),
).toBeNull();
expect(
parseLocalFilesDragPayload(
JSON.stringify({ type: "local_files", paths: [] }),
),
).toBeNull();
expect(
parseLocalFilesDragPayload(
JSON.stringify({ type: "local_files", paths: [1, "", "/ok"] }),
),
).toEqual(["/ok"]);
});
it("parses the remote grid's internal payload", () => {
expect(
parseInternalFilesDragPayload(
JSON.stringify({ type: "internal_files", files: ["/srv/a", "/srv/b"] }),
),
).toEqual(["/srv/a", "/srv/b"]);
expect(
parseInternalFilesDragPayload(
JSON.stringify({ type: "local_files", paths: ["/x"] }),
),
).toBeNull();
});
it("recognises drag origins from dataTransfer types", () => {
expect(
isLocalFilesDrag({ types: [LOCAL_FILES_DRAG_MIME, "text/plain"] }),
).toBe(true);
expect(isLocalFilesDrag({ types: ["Files"] })).toBe(false);
expect(isRemoteFilesDrag({ types: [REMOTE_FILES_DRAG_MIME] })).toBe(true);
expect(isRemoteFilesDrag({ types: ["text/plain"] })).toBe(false);
expect(isRemoteFilesDrag(null)).toBe(false);
});
});
describe("path helpers", () => {
it("joins remote paths without duplicate slashes", () => {
expect(joinRemotePath("/", "a", "b")).toBe("/a/b");
expect(joinRemotePath("/home/ubuntu/", "/proj/", "x.txt")).toBe(
"/home/ubuntu/proj/x.txt",
);
expect(joinRemotePath("/home", "")).toBe("/home");
});
it("joins local paths with the platform separator", () => {
expect(joinLocalPath("/Users/max", "a.txt", "/")).toBe("/Users/max/a.txt");
expect(joinLocalPath("/Users/max/", "a.txt", "/")).toBe("/Users/max/a.txt");
expect(joinLocalPath("/", "a.txt", "/")).toBe("/a.txt");
expect(joinLocalPath("C:\\Users\\max", "a.txt", "\\")).toBe(
"C:\\Users\\max\\a.txt",
);
});
it("derives base names", () => {
expect(remoteBaseName("/srv/app/file.log")).toBe("file.log");
expect(remoteBaseName("/srv/app/")).toBe("app");
expect(remoteBaseName("/")).toBe("/");
});
it("maps relative paths to their remote directory", () => {
expect(remoteDirForRelativePath("/dst", "file.txt")).toBe("/dst");
expect(remoteDirForRelativePath("/dst", "proj/src/main.rs")).toBe(
"/dst/proj/src",
);
});
});
describe("download destination safety", () => {
const WIN = "\\";
const POSIX = "/";
it("keeps ordinary nested folders under the selected directory (Windows)", () => {
expect(
buildLocalDestination(
"C:\\Downloads\\selected",
"docs/2026/report.pdf",
WIN,
),
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
expect(
buildLocalDestination("C:\\Downloads\\selected\\", "a.txt", WIN),
).toBe("C:\\Downloads\\selected\\a.txt");
});
it("rejects backslash traversal in a POSIX file name on Windows", () => {
// The reviewer's reproduction: "..\\outside.txt" is a legal POSIX name.
expect(() =>
buildLocalDestination("C:\\Downloads\\selected", "..\\outside.txt", WIN),
).toThrow(UnsafeLocalNameError);
expect(() =>
buildLocalDestination(
"C:\\Downloads\\selected",
"sub/..\\..\\x.txt",
WIN,
),
).toThrow(UnsafeLocalNameError);
expect(() =>
buildLocalDestination("C:\\Downloads\\selected", "dir\\file.txt", WIN),
).toThrow(UnsafeLocalNameError);
});
it("rejects absolute and drive-qualified names on Windows", () => {
for (const name of [
"C:\\Windows\\evil.dll",
"C:evil.txt",
"D:",
"\\\\server\\share\\x",
"\\absolute.txt",
]) {
expect(() =>
buildLocalDestination("C:\\Downloads\\selected", name, WIN),
).toThrow(UnsafeLocalNameError);
}
});
it("rejects names Windows cannot store: reserved devices, trailing dots/spaces, control chars", () => {
for (const name of [
"CON",
"nul.txt",
"COM1",
"report.",
"report ",
"a\u0007b",
"q?.txt",
"a|b",
]) {
expect(() => assertSafeLocalComponent(name, WIN)).toThrow(
UnsafeLocalNameError,
);
}
expect(assertSafeLocalComponent("console.log", WIN)).toBe("console.log");
expect(assertSafeLocalComponent("nulled.txt", WIN)).toBe("nulled.txt");
});
it("rejects traversal and separators on every platform, but allows POSIX-legal backslashes on POSIX", () => {
for (const rel of ["..", "../x", "a/../../x", "./x", "a\0b", ""]) {
expect(() => buildLocalDestination("/home/max/dl", rel, POSIX)).toThrow(
UnsafeLocalNameError,
);
}
// On macOS/Linux a backslash is just a character in a file name and the
// result is still inside the selected folder.
expect(
buildLocalDestination("/home/max/dl", "..\\outside.txt", POSIX),
).toBe("/home/max/dl/..\\outside.txt");
expect(buildLocalDestination("/", "etc/hosts", POSIX)).toBe("/etc/hosts");
});
});
describe("planRemoteDirectories", () => {
it("lists every ancestor once, shallowest first", () => {
const dirs = planRemoteDirectories(
["proj/src/main.rs", "proj/README.md", "proj/src/lib/mod.rs", "top.txt"],
["proj/empty", "other/nested/leaf"],
);
expect(dirs).toEqual([
"other",
"proj",
"other/nested",
"proj/empty",
"proj/src",
"other/nested/leaf",
"proj/src/lib",
]);
});
it("returns nothing for flat file drops", () => {
expect(planRemoteDirectories(["a.txt", "b.txt"])).toEqual([]);
});
});
describe("remote rows dragged out of the grid", () => {
it("allows both a move (within the grid) and a copy (download onto the local pane)", () => {
const store: Record<string, string> = {};
const dataTransfer: Pick<DataTransfer, "effectAllowed" | "setData"> = {
effectAllowed: "uninitialized",
setData: (type: string, value: string) => {
store[type] = value;
},
};
beginRemoteFilesDrag(dataTransfer, ["/srv/a.txt", "/srv/dir"]);
// Chromium silently drops nothing when dropEffect ("copy" on the local
// pane) is not part of effectAllowed, so "move" alone breaks downloads.
expect(dataTransfer.effectAllowed).toBe("copyMove");
expect(isRemoteFilesDrag({ types: Object.keys(store) })).toBe(true);
expect(parseInternalFilesDragPayload(store["text/plain"])).toEqual([
"/srv/a.txt",
"/srv/dir",
]);
});
});
describe("local entry presentation", () => {
const entry = (
name: string,
type: LocalFileEntry["type"],
size = 0,
modifiedTimestamp = 0,
): LocalFileEntry => ({
name,
path: `/x/${name}`,
type,
size,
modifiedTimestamp,
hidden: name.startsWith("."),
});
it("formats modified times like the remote grid (ls -l style)", () => {
const now = new Date(2026, 8, 12, 10, 0); // Sep 12 2026
expect(
formatLocalModified(new Date(2026, 8, 11, 16, 25).getTime(), now),
).toBe("Sep 11 16:25");
// Single-digit days are space-padded, minutes zero-padded.
expect(formatLocalModified(new Date(2026, 7, 7, 9, 5).getTime(), now)).toBe(
"Aug 7 09:05",
);
// Older than six months: year instead of time, two spaces like ls.
expect(
formatLocalModified(new Date(2025, 11, 24, 18, 30).getTime(), now),
).toBe("Dec 24 2025");
expect(formatLocalModified(undefined, now)).toBe("--");
expect(formatLocalModified(Number.NaN, now)).toBe("--");
});
it("describes kinds", () => {
expect(describeLocalKind(entry("docs", "directory"))).toBe("folder");
expect(describeLocalKind(entry("archive.tar.GZ", "file"))).toBe("gz");
expect(describeLocalKind(entry(".env", "file"))).toBe("file");
expect(describeLocalKind(entry("Makefile", "file"))).toBe("file");
expect(describeLocalKind(entry("ln", "link"))).toBe("link");
});
it("sorts folders first, then by the chosen field", () => {
const entries = [
entry("b.txt", "file", 20, 2),
entry("zeta", "directory", 0, 1),
entry("a.txt", "file", 10, 3),
entry("alpha", "directory", 0, 4),
];
expect(sortLocalEntries(entries, "name", "asc").map((e) => e.name)).toEqual(
["alpha", "zeta", "a.txt", "b.txt"],
);
expect(
sortLocalEntries(entries, "size", "desc").map((e) => e.name),
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
expect(
sortLocalEntries(entries, "modified", "asc").map((e) => e.name),
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
});
});
describe("parallel transfers", () => {
beforeEach(() => localStorage.clear());
it("clamps and persists the concurrency preference", () => {
expect(getTransferConcurrency()).toBe(DEFAULT_TRANSFER_CONCURRENCY);
expect(clampTransferConcurrency(0)).toBe(1);
expect(clampTransferConcurrency(99)).toBe(8);
expect(clampTransferConcurrency("3.7")).toBe(3);
expect(clampTransferConcurrency("nope")).toBe(DEFAULT_TRANSFER_CONCURRENCY);
expect(setTransferConcurrency(6)).toBe(6);
expect(localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY)).toBe("6");
expect(getTransferConcurrency()).toBe(6);
});
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((r) => (resolve = r));
return { promise, resolve };
};
it("never runs more than `limit` workers at once and processes everything", async () => {
const gates = Array.from({ length: 6 }, deferred);
const started: number[] = [];
let active = 0;
let peak = 0;
const run = runWithConcurrency([0, 1, 2, 3, 4, 5], 3, async (i) => {
started.push(i);
active += 1;
peak = Math.max(peak, active);
await gates[i].promise;
active -= 1;
});
await Promise.resolve();
expect(started).toEqual([0, 1, 2]); // exactly `limit` dispatched
gates[1].resolve();
await new Promise((r) => setTimeout(r, 0));
expect(started).toEqual([0, 1, 2, 3]); // a free lane picks up the next item
for (const g of gates) g.resolve();
await run;
expect(started).toEqual([0, 1, 2, 3, 4, 5]);
expect(peak).toBe(3);
});
it("stops dispatching once asked to, letting in-flight items finish", async () => {
let stop = false;
const done: number[] = [];
await runWithConcurrency(
[1, 2, 3, 4, 5],
2,
async (i) => {
await new Promise((r) => setTimeout(r, 1));
done.push(i);
if (i === 2) stop = true;
},
() => stop,
);
expect(done.length).toBeLessThan(5);
expect(done).toContain(1);
expect(done).toContain(2);
});
it("does not reject the batch when a single item fails (the worker reports it)", async () => {
const failed: number[] = [];
await runWithConcurrency([1, 2, 3], 2, async (i) => {
try {
if (i === 2) throw new Error("boom");
} catch {
failed.push(i);
}
});
expect(failed).toEqual([2]);
});
it("handles an empty list and a limit larger than the list", async () => {
let calls = 0;
await runWithConcurrency([], 4, async () => {
calls += 1;
});
await runWithConcurrency([1], 8, async () => {
calls += 1;
});
expect(calls).toBe(1);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { FileItem } from "@/types/index";
import {
addOptimisticItem,
childPath,
removePaths,
renameOptimisticItem,
restoreItems,
} from "../../src/frontend/optimistic-file-list";
const files: FileItem[] = [
{ name: "alpha.txt", path: "/work/alpha.txt", type: "file", size: 10 },
{ name: "docs", path: "/work/docs", type: "directory" },
];
describe("optimistic file list updates", () => {
it("builds child paths without duplicate separators", () => {
expect(childPath("/", "file.txt")).toBe("/file.txt");
expect(childPath("/work/", "file.txt")).toBe("/work/file.txt");
});
it("adds and rolls back a provisional item", () => {
const added = addOptimisticItem(files, "/work", "new.txt", "file", 0);
expect(added.at(-1)).toMatchObject({
name: "new.txt",
path: "/work/new.txt",
type: "file",
size: 0,
});
expect(removePaths(added, new Set(["/work/new.txt"]))).toEqual(files);
});
it("renames immediately and can reverse the same item", () => {
const renamed = renameOptimisticItem(files, "/work/alpha.txt", "beta.txt");
expect(renamed[0]).toMatchObject({
name: "beta.txt",
path: "/work/beta.txt",
size: 10,
});
expect(
renameOptimisticItem(renamed, "/work/beta.txt", "alpha.txt"),
).toEqual(files);
});
it("restores only failed deletions without duplicating survivors", () => {
const remaining = removePaths(files, new Set(["/work/docs"]));
expect(restoreItems(remaining, [files[1], files[0]])).toEqual(files);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
hasSameHostTransferConflict,
joinRemotePath,
normalizeRemoteDir,
} from "../../src/frontend/sftp-transfer-utils";
describe("sftp transfer utilities", () => {
it("normalizes and joins remote paths", () => {
expect(normalizeRemoteDir(" /home/user// ")).toBe("/home/user");
expect(joinRemotePath("/home/user/", "docs/readme.md")).toBe(
"/home/user/docs/readme.md",
);
expect(joinRemotePath("/", "tmp/file.txt")).toBe("/tmp/file.txt");
expect(joinRemotePath("home/user", "logs")).toBe("/home/user/logs");
});
it("detects same-host destination conflicts", () => {
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app")).toBe(true);
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app/logs")).toBe(
true,
);
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/releases")).toBe(
false,
);
});
});
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const success = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => success(...args),
},
}));
vi.mock("@/main-axios.ts", () => ({}));
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(),
}));
import { showTransferCompletionToast } from "../../src/frontend/transferProgressMonitor.tsx";
describe("transfer completion toast", () => {
beforeEach(() => {
success.mockReset();
});
it("clears the infinite duration inherited from the progress toast", () => {
showTransferCompletionToast(
{
transferId: "transfer-1",
status: "success",
phase: "verifying",
},
((key: string) => key) as never,
"progress-toast",
);
expect(success).toHaveBeenCalledWith("transfer.transferSuccess", {
id: "progress-toast",
description: undefined,
className: "!pr-10 !pl-4 transfer-progress-toast",
duration: undefined,
});
});
});