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,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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user