f.path === file.path,
- ) && "opacity-50",
+ dragPaths.has(file.path) && "opacity-50",
deletingPaths?.has(file.path) &&
"opacity-40 pointer-events-none",
)}
title={file.name}
onClick={(e) => handleFileClick(file, e)}
+ onMouseEnter={() =>
+ file.type === "directory" &&
+ onDirectoryHover?.(file)
+ }
+ onMouseLeave={() =>
+ file.type === "directory" &&
+ onDirectoryLeave?.(file)
+ }
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -1413,18 +1482,16 @@ export function FileManagerGrid({
{listVirtualizer.getVirtualItems().map((vItem) => {
const file = files[vItem.index];
if (!file) return null;
- const isSelected = selectedFiles.some(
- (f) => f.path === file.path,
- );
+ const isSelected = selectedPaths.has(file.path);
const isEditing = editingFile?.path === file.path;
return (
f.path === file.path) &&
- "opacity-50",
+ dragPaths.has(file.path) && "opacity-50",
deletingPaths?.has(file.path) &&
"opacity-40 pointer-events-none",
)}
onClick={(e) => handleFileClick(file, e)}
+ onMouseEnter={() =>
+ file.type === "directory" && onDirectoryHover?.(file)
+ }
+ onMouseLeave={() =>
+ file.type === "directory" && onDirectoryLeave?.(file)
+ }
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
diff --git a/src/frontend/api/ssh-file-operations-api.ts b/src/frontend/api/ssh-file-operations-api.ts
index 63035de..02b9a72 100644
--- a/src/frontend/api/ssh-file-operations-api.ts
+++ b/src/frontend/api/ssh-file-operations-api.ts
@@ -12,7 +12,10 @@ import {
resolveConnectionOrigin,
} from "@termix-ssh/plugin-sdk/ui";
import type { FileItem, SSHHost } from "../host-types";
-import { getCachedFileList } from "../lib/file-list-request-cache";
+import {
+ getCachedFileList,
+ getFileListEpoch,
+} from "../lib/file-list-request-cache";
import {
getCachedFileContent,
invalidateCachedFileContent,
@@ -234,16 +237,25 @@ export async function keepSSHAlive(
export async function listSSHFiles(
sessionId: string,
path: string,
- options: { force?: boolean } = {},
+ options: { force?: boolean; prefetch?: boolean } = {},
): Promise<{ files: FileItem[]; path: string }> {
- return getCachedFileList(
+ const epoch = getFileListEpoch(sessionId);
+ const result = getCachedFileList(
sessionId,
path,
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/listFiles",
- { params: { sessionId, path } },
+ {
+ params: {
+ sessionId,
+ path,
+ epoch: String(epoch),
+ ...(options.force ? { force: true } : {}),
+ ...(options.prefetch ? { prefetch: "true" } : {}),
+ },
+ },
);
return response.data || { files: [], path };
} catch (error) {
@@ -252,6 +264,15 @@ export async function listSSHFiles(
},
options.force,
);
+ if (options.prefetch || options.force) return result;
+ // A caller may have joined a speculative read that deliberately skipped sudo.
+ return result.catch((error: unknown) => {
+ const data = (
+ error as { response?: { data?: { prefetchSkippedSudo?: boolean } } }
+ ).response?.data;
+ if (!data?.prefetchSkippedSudo) throw error;
+ return listSSHFiles(sessionId, path, { force: true });
+ });
}
export async function identifySSHSymlink(
diff --git a/src/frontend/file-list.ts b/src/frontend/file-list.ts
new file mode 100644
index 0000000..14c2edc
--- /dev/null
+++ b/src/frontend/file-list.ts
@@ -0,0 +1,36 @@
+import type { FileItem } from "./host-types";
+
+export type SortBy = "name" | "modified" | "size";
+
+/** Filters by name substring, directories first, then sorts. Returns a new array. */
+export function filterAndSortFiles(
+ files: FileItem[],
+ searchQuery: string,
+ sortBy: SortBy,
+ sortOrder: "asc" | "desc",
+): FileItem[] {
+ const q = searchQuery.toLowerCase();
+ const collator = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: "base",
+ });
+ return files
+ .filter((file) => !q || file.name.toLowerCase().includes(q))
+ .sort((a, b) => {
+ if (a.type === "directory" && b.type !== "directory") return -1;
+ if (a.type !== "directory" && b.type === "directory") return 1;
+ let result = 0;
+ switch (sortBy) {
+ case "name":
+ result = collator.compare(a.name, b.name);
+ break;
+ case "modified":
+ result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0);
+ break;
+ case "size":
+ result = (a.size || 0) - (b.size || 0);
+ break;
+ }
+ return sortOrder === "desc" ? -result : result;
+ });
+}
diff --git a/src/frontend/lib/directory-prefetch.ts b/src/frontend/lib/directory-prefetch.ts
new file mode 100644
index 0000000..17c721f
--- /dev/null
+++ b/src/frontend/lib/directory-prefetch.ts
@@ -0,0 +1,125 @@
+import type { FileItem } from "../host-types";
+
+export function createDirectoryPrefetch({
+ read,
+ isCached,
+ run,
+}: {
+ read: (path: string) => Promise;
+ isCached: (path: string) => boolean;
+ run: (task: () => Promise) => boolean;
+}) {
+ let disposed = false;
+ let foreground = false;
+ let active: string | undefined;
+ let queue: string[] = [];
+ let hoverPath: string | undefined;
+ let hoverTimer: ReturnType | undefined;
+ let predictionTimer: ReturnType | undefined;
+ let drainTimer: ReturnType | undefined;
+
+ function drain() {
+ if (disposed || foreground || active) return;
+ const path = queue.shift();
+ if (!path) return;
+ if (isCached(path)) {
+ drain();
+ return;
+ }
+ active = path;
+ // Admission can defer the callback; navigation must still be able to veto it.
+ const admitted = run(async () => {
+ try {
+ if (!disposed && !foreground && active === path && !isCached(path)) {
+ await read(path);
+ }
+ } finally {
+ active = undefined;
+ if (!disposed && !foreground) drainTimer = setTimeout(drain, 0);
+ }
+ });
+ if (!admitted) {
+ active = undefined;
+ queue = [];
+ }
+ }
+
+ function enqueue(path: string, priority: boolean) {
+ if (disposed || foreground || active === path || isCached(path)) return;
+ queue = queue.filter((entry) => entry !== path);
+ if (priority) queue.unshift(path);
+ else queue.push(path);
+ queue = queue.slice(0, 2);
+ drain();
+ }
+
+ function cancelIntent(path: string) {
+ if (hoverPath === path) {
+ clearTimeout(hoverTimer);
+ hoverTimer = undefined;
+ hoverPath = undefined;
+ }
+ queue = queue.filter((entry) => entry !== path);
+ }
+
+ function clear() {
+ clearTimeout(hoverTimer);
+ clearTimeout(predictionTimer);
+ clearTimeout(drainTimer);
+ hoverTimer = predictionTimer = undefined;
+ drainTimer = undefined;
+ hoverPath = undefined;
+ queue = [];
+ }
+
+ return {
+ intent(path: string) {
+ if (hoverPath) cancelIntent(hoverPath);
+ enqueue(path, true);
+ },
+ hover(path: string) {
+ if (hoverPath) cancelIntent(hoverPath);
+ if (disposed || foreground) return;
+ hoverPath = path;
+ hoverTimer = setTimeout(() => {
+ hoverTimer = undefined;
+ enqueue(path, true);
+ }, 100);
+ },
+ cancelIntent,
+ predict(parent: string, entries: readonly FileItem[]) {
+ clearTimeout(predictionTimer);
+ if (disposed || foreground) return;
+ const prefix = parent === "/" ? "/" : `${parent.replace(/\/$/, "")}/`;
+ const paths: string[] = [];
+ for (const entry of entries) {
+ const name = entry.path.slice(prefix.length);
+ if (
+ entry.type === "directory" &&
+ entry.path.startsWith(prefix) &&
+ name &&
+ name !== "." &&
+ name !== ".." &&
+ !name.includes("/") &&
+ !paths.includes(entry.path) &&
+ !isCached(entry.path)
+ ) {
+ paths.push(entry.path);
+ if (paths.length === 2) break;
+ }
+ }
+ predictionTimer = setTimeout(() => {
+ predictionTimer = undefined;
+ for (const path of paths) enqueue(path, false);
+ }, 200);
+ },
+ setForeground(value: boolean) {
+ foreground = value;
+ if (value) clear();
+ },
+ dispose() {
+ disposed = true;
+ clear();
+ },
+ };
+}
diff --git a/src/frontend/lib/file-list-request-cache.ts b/src/frontend/lib/file-list-request-cache.ts
index 711b832..6bee675 100644
--- a/src/frontend/lib/file-list-request-cache.ts
+++ b/src/frontend/lib/file-list-request-cache.ts
@@ -7,6 +7,13 @@ export interface FileListResult {
}
const cache = createKeyedRequestCache(5_000, 150);
+const epochs = new Map();
+let epochCounter = 0;
+const MAX_EPOCH_SESSIONS = 200;
+
+export function getFileListEpoch(sessionId: string): number {
+ return epochs.get(sessionId) ?? 0;
+}
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
@@ -22,16 +29,24 @@ export function getCachedFileList(
export function peekCachedFileList(
sessionId: string,
path: string,
- maxAgeMs = 30_000,
+ // Stale entries are fine: callers repaint instantly, then force a refresh.
+ maxAgeMs = 300_000,
): FileListResult | null {
return cache.peek(keyFor(sessionId, path), maxAgeMs);
}
export function invalidateCachedFileList(
sessionId: string,
- path: string,
+ _path: string,
): void {
- cache.invalidate(keyFor(sessionId, path));
+ // Globally monotonic, so evicting an old session's entry can never reuse a value.
+ epochs.delete(sessionId);
+ epochs.set(sessionId, ++epochCounter);
+ if (epochs.size > MAX_EPOCH_SESSIONS) {
+ epochs.delete(epochs.keys().next().value as string);
+ }
+ // A move or directory deletion can invalidate prefetched descendants too.
+ cache.invalidateMatching((key) => key.startsWith(`${sessionId}\0`));
}
export function updateCachedFileList(
diff --git a/src/frontend/lib/keyed-request-cache.ts b/src/frontend/lib/keyed-request-cache.ts
index 80dd2db..dc232b8 100644
--- a/src/frontend/lib/keyed-request-cache.ts
+++ b/src/frontend/lib/keyed-request-cache.ts
@@ -7,6 +7,7 @@ export interface KeyedRequestCache {
peek(key: string, maxAgeMs?: number): T | null;
set(key: string, value: T): void;
invalidate(key?: string): void;
+ invalidateMatching(matches: (key: string) => boolean): void;
}
/** Bounded per-key cache with in-flight request coalescing. */
@@ -15,8 +16,7 @@ export function createKeyedRequestCache(
maxEntries = 100,
): KeyedRequestCache {
const values = new Map();
- const inflight = new Map>();
- const versions = new Map();
+ const inflight = new Map; forced: boolean }>();
const store = (key: string, value: T) => {
values.delete(key);
@@ -36,18 +36,24 @@ export function createKeyedRequestCache(
}
const pending = inflight.get(key);
- if (pending) return pending;
+ if (pending && (!options.force || pending.forced)) return pending.promise;
- const version = versions.get(key) ?? 0;
+ // Only the request still registered for this key may write the cache, so
+ // invalidate/set/forced replacement make older reads stale without metadata.
+ const entry = {
+ promise: undefined as unknown as Promise,
+ forced: !!options.force,
+ };
const request = loader()
.then((value) => {
- if ((versions.get(key) ?? 0) === version) store(key, value);
+ if (inflight.get(key) === entry) store(key, value);
return value;
})
.finally(() => {
- if (inflight.get(key) === request) inflight.delete(key);
+ if (inflight.get(key) === entry) inflight.delete(key);
});
- inflight.set(key, request);
+ entry.promise = request;
+ inflight.set(key, entry);
return request;
},
@@ -60,20 +66,23 @@ export function createKeyedRequestCache(
},
set(key, value) {
- versions.set(key, (versions.get(key) ?? 0) + 1);
inflight.delete(key);
store(key, value);
},
invalidate(key) {
if (key === undefined) {
- for (const activeKey of inflight.keys()) {
- versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
- }
values.clear();
inflight.clear();
} else {
- versions.set(key, (versions.get(key) ?? 0) + 1);
+ values.delete(key);
+ inflight.delete(key);
+ }
+ },
+
+ invalidateMatching(matches) {
+ for (const key of new Set([...values.keys(), ...inflight.keys()])) {
+ if (!matches(key)) continue;
values.delete(key);
inflight.delete(key);
}
diff --git a/tests/backend/home-directory.test.ts b/tests/backend/home-directory.test.ts
index 5c013c2..da93c54 100644
--- a/tests/backend/home-directory.test.ts
+++ b/tests/backend/home-directory.test.ts
@@ -19,7 +19,11 @@ beforeEach(() => vi.resetAllMocks());
function setup(home: string, owned = true) {
const sftp = {
realpath: vi.fn((_path, done) => done(null, home)),
- readdir: vi.fn((_path, done) => done(null, [])),
+ opendir: vi.fn((_path, done) => done(null, Buffer.from("directory"))),
+ readdir: vi.fn((_handle, _options, done) =>
+ done(Object.assign(new Error("EOF"), { code: 1 })),
+ ),
+ close: vi.fn((_handle, done) => done(null)),
};
vi.mocked(getSessionSftp).mockResolvedValue(sftp as never);
let handler!: (req: Request, res: Response) => Promise;
@@ -37,18 +41,19 @@ function setup(home: string, owned = true) {
sshSessions: {
s: { isConnected: true, activeOperations: 0 } as SSHSession,
},
- activeListRequests: {},
verifySessionOwnership: () => owned,
},
);
- const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn() };
+ const json = vi.fn();
+ const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json };
return {
sftp,
res,
- list: (path?: string) =>
+ json,
+ list: (path?: string, response = res) =>
handler(
{ query: { sessionId: "s", path } } as unknown as Request,
- res as unknown as Response,
+ response as unknown as Response,
),
};
}
@@ -57,30 +62,58 @@ describe("remote login directory", () => {
it.each(["/root", "/srv/users/alice", "/"])(
"lists the actual SFTP directory %s",
async (home) => {
- const { sftp, res, list } = setup(home);
+ const { sftp, res, json, list } = setup(home);
await list(".");
await vi.waitFor(() =>
- expect(res.json).toHaveBeenCalledWith({ files: [], path: home }),
+ expect(json).toHaveBeenCalledWith({ files: [], path: home }),
);
expect(sftp.realpath).toHaveBeenCalledWith(".", expect.any(Function));
- expect(sftp.readdir).toHaveBeenCalledWith(home, expect.any(Function));
+ expect(sftp.opendir).toHaveBeenCalledWith(home, expect.any(Function));
expect(execChannel).not.toHaveBeenCalled();
},
);
it("preserves an explicit root instead of replacing it with home", async () => {
- const { sftp, res, list } = setup("/home/alice");
+ const { sftp, res, json, list } = setup("/home/alice");
await list("/");
await vi.waitFor(() =>
- expect(res.json).toHaveBeenCalledWith({ files: [], path: "/" }),
+ expect(json).toHaveBeenCalledWith({ files: [], path: "/" }),
);
expect(sftp.realpath).not.toHaveBeenCalled();
});
+ it("shares one listing between concurrent requests for the same path", async () => {
+ const { sftp, res, json, list } = setup("/home/alice");
+ const second = { ...res, json: vi.fn() };
+ await Promise.all([list("/data"), list("/data", second)]);
+ expect(sftp.opendir).toHaveBeenCalledTimes(1);
+ expect(json).toHaveBeenCalledExactlyOnceWith({ files: [], path: "/data" });
+ expect(second.json).toHaveBeenCalledExactlyOnceWith({
+ files: [],
+ path: "/data",
+ });
+ });
it("checks ownership before discovering the home directory", async () => {
+ // placeholder-anchor
const { res, list } = setup("/home/alice", false);
await list(".");
expect(res.status).toHaveBeenCalledWith(403);
expect(getSessionSftp).not.toHaveBeenCalled();
});
+ it("uses the existing shell fallback when a handle read fails", async () => {
+ const { sftp, json, list } = setup("/home/alice");
+ sftp.readdir.mockImplementation((_handle, _options, done) =>
+ done(new Error("read failed")),
+ );
+ vi.mocked(execChannel).mockImplementation((_session, command, done) => {
+ expect(command).toBe("command ls -la --color=never '/data'");
+ done(new Error("fallback reached"), undefined as never);
+ });
+ await list("/data");
+ await vi.waitFor(() =>
+ expect(json).toHaveBeenCalledWith({ error: "fallback reached" }),
+ );
+ expect(execChannel).toHaveBeenCalledTimes(1);
+ expect(sftp.close).toHaveBeenCalledTimes(1);
+ });
it("falls back to the shell login directory when SFTP is unavailable", async () => {
vi.mocked(getSessionSftp).mockRejectedValue(new Error("SFTP unavailable"));
vi.mocked(execChannel).mockImplementation((_session, command, done) => {
diff --git a/tests/backend/list-request-sharing.test.ts b/tests/backend/list-request-sharing.test.ts
new file mode 100644
index 0000000..a0e1fd9
--- /dev/null
+++ b/tests/backend/list-request-sharing.test.ts
@@ -0,0 +1,615 @@
+import { EventEmitter } from "node:events";
+import type { Express, Request, Response } from "express";
+import type { PluginContext } from "@termix-ssh/plugin-sdk/backend";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { registerFileListingRoutes } from "../../src/backend/list-routes.js";
+import { readDirectory } from "../../src/backend/read-directory.js";
+import { resolveHomeDirectory } from "../../src/backend/home-directory.js";
+import {
+ execChannel,
+ getSessionSftp,
+ type SSHSession,
+} from "../../src/backend/session.js";
+
+vi.mock("../../src/backend/read-directory.js", () => ({
+ readDirectory: vi.fn(),
+}));
+vi.mock("../../src/backend/home-directory.js", () => ({
+ resolveHomeDirectory: vi.fn(),
+}));
+vi.mock("../../src/backend/session.js", () => ({
+ getSessionSftp: vi.fn(),
+ execChannel: vi.fn(),
+}));
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSessionSftp).mockResolvedValue({} as never);
+ vi.mocked(resolveHomeDirectory).mockResolvedValue("/home/user");
+});
+
+const session = () =>
+ ({ isConnected: true, activeOperations: 0 }) as SSHSession;
+
+function setup(sshSessions = { s: session() }) {
+ let actor = "user";
+ let handler!: (req: Request, res: Response) => Promise;
+ const verifySessionOwnership = vi.fn(() => true);
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
+ registerFileListingRoutes(
+ {
+ get: (_path, cb) => {
+ handler = cb;
+ },
+ } as Express,
+ {
+ ctx: {
+ currentActor: () => actor,
+ log,
+ } as unknown as PluginContext,
+ sshSessions,
+ verifySessionOwnership,
+ },
+ );
+ return {
+ sshSessions,
+ log,
+ verifySessionOwnership,
+ setActor: (next: string) => {
+ actor = next;
+ },
+ request(
+ path = "/data",
+ force?: unknown,
+ query: { epoch?: unknown; prefetch?: unknown } = {},
+ ) {
+ const res = Object.assign(new EventEmitter(), {
+ statusCode: 200,
+ headersSent: false,
+ writableEnded: false,
+ destroyed: false,
+ status: vi.fn(function (code: number) {
+ this.statusCode = code;
+ return this;
+ }),
+ json: vi.fn(function () {
+ this.headersSent = true;
+ this.writableEnded = true;
+ return this;
+ }),
+ });
+ const done = handler(
+ {
+ query: { sessionId: "s", path, force, ...query },
+ } as unknown as Request,
+ res as unknown as Response,
+ );
+ return { res, done };
+ },
+ };
+}
+
+const complete = (index: number, error: Error | null = null) => {
+ vi.mocked(readDirectory).mock.calls[index][2](error, []);
+};
+const reads = async (count: number) => {
+ await vi.waitFor(() => expect(readDirectory).toHaveBeenCalledTimes(count));
+};
+
+describe("list request sharing", () => {
+ it("isolates epochs while sharing requests in the same epoch", async () => {
+ const { request, sshSessions } = setup();
+ const old = request("/data", undefined, { epoch: "1" });
+ const fresh = request("/data", undefined, { epoch: "2" });
+ const waiter = request("/data", undefined, { epoch: "2" });
+ await reads(2);
+ complete(1);
+ await Promise.all([fresh.done, waiter.done]);
+ expect(old.res.json).not.toHaveBeenCalled();
+ complete(0);
+ await old.done;
+ expect(sshSessions.s.activeOperations).toBe(0);
+ });
+
+ it("times out a hung read so joined requests are released", async () => {
+ vi.useFakeTimers();
+ try {
+ const { request, sshSessions } = setup();
+ const first = request("/data");
+ const joined = request("/data");
+ await reads(1);
+ await vi.advanceTimersByTimeAsync(30_000);
+ await Promise.all([first.done, joined.done]);
+ expect(first.res.status).toHaveBeenCalledWith(504);
+ expect(joined.res.status).toHaveBeenCalledWith(504);
+ expect(sshSessions.s.activeOperations).toBe(0);
+ complete(0);
+ expect(sshSessions.s.activeOperations).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it.each([undefined, "", "-1", "1.5", "1e2", " 1", "1\n", true, 1, ["1"], {}])(
+ "treats invalid or missing epoch %j as zero",
+ async (epoch) => {
+ const { request } = setup();
+ const first = request("/data", undefined, { epoch: "0" });
+ const second = request("/data", undefined, { epoch });
+ await reads(1);
+ complete(0);
+ await Promise.all([first.done, second.done]);
+ expect(second.res.json).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each([true, false])(
+ "isolates speculative and navigation reads (prefetch first: %s)",
+ async (prefetchFirst) => {
+ const { request, sshSessions } = setup();
+ sshSessions.s.sudoPassword = "stored-password";
+ const first = request("/data", undefined, {
+ prefetch: prefetchFirst ? "true" : undefined,
+ });
+ const second = request("/data", undefined, {
+ prefetch: prefetchFirst ? undefined : "true",
+ });
+ const speculative = prefetchFirst ? first : second;
+ const navigation = prefetchFirst ? second : first;
+ await reads(2);
+ complete(
+ prefetchFirst ? 0 : 1,
+ Object.assign(new Error("Permission denied"), { code: 3 }),
+ );
+ await speculative.done;
+ expect(speculative.res.statusCode).toBe(403);
+ expect(speculative.res.json).toHaveBeenCalledExactlyOnceWith({
+ needsSudo: true,
+ prefetchSkippedSudo: true,
+ path: "/data",
+ });
+ expect(execChannel).not.toHaveBeenCalled();
+ expect(navigation.res.json).not.toHaveBeenCalled();
+ complete(prefetchFirst ? 1 : 0);
+ await navigation.done;
+ expect(navigation.res.statusCode).toBe(200);
+ expect(sshSessions.s.activeOperations).toBe(0);
+ expect(sshSessions.s.sudoPassword).toBe("stored-password");
+ },
+ );
+
+ it.each(["false", "TRUE", true, ["true"]])(
+ "only the exact string true marks prefetch (%j is navigation)",
+ async (prefetch) => {
+ const { request } = setup();
+ const first = request();
+ const second = request("/data", undefined, { prefetch });
+ await reads(1);
+ complete(0);
+ await Promise.all([first.done, second.done]);
+ expect(second.res.json).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("shares prefetch reads and skips sudo after shell permission denial too", async () => {
+ const { request, sshSessions } = setup();
+ sshSessions.s.sudoPassword = "stored-password";
+ const first = request("/data", undefined, { prefetch: "true" });
+ const waiter = request("/data", undefined, { prefetch: "true" });
+ const stream = Object.assign(new EventEmitter(), {
+ stderr: new EventEmitter(),
+ write: vi.fn(),
+ });
+ vi.mocked(execChannel).mockImplementation((_session, _command, done) => {
+ done(null, stream as never);
+ });
+ await reads(1);
+ complete(0, new Error("SFTP unavailable"));
+ stream.stderr.emit("data", Buffer.from("Permission denied"));
+ stream.emit("close", 1);
+ await Promise.all([first.done, waiter.done]);
+ expect(execChannel).toHaveBeenCalledExactlyOnceWith(
+ sshSessions.s,
+ "command ls -la --color=never '/data'",
+ expect.any(Function),
+ );
+ expect(stream.write).not.toHaveBeenCalled();
+ for (const { res } of [first, waiter]) {
+ expect(res.statusCode).toBe(403);
+ expect(res.json).toHaveBeenCalledExactlyOnceWith({
+ needsSudo: true,
+ prefetchSkippedSudo: true,
+ path: "/data",
+ });
+ }
+ expect(sshSessions.s.activeOperations).toBe(0);
+ });
+
+ it("retains original info events without paths or per-listing timing", async () => {
+ const { request, log } = setup();
+ const listing = request("/private/path");
+ await reads(1);
+ complete(0);
+ await listing.done;
+ expect(log.info).toHaveBeenCalledExactlyOnceWith(
+ "Opening SFTP channel for listFiles: s",
+ );
+ });
+
+ it.each(["timeout", "resolved"])(
+ "waits up to 5000ms for symlink targets (%s)",
+ async (kind) => {
+ vi.useFakeTimers();
+ try {
+ const { request, sshSessions } = setup();
+ const readlink = vi.fn();
+ vi.mocked(getSessionSftp).mockResolvedValue({ readlink } as never);
+ const listing = request();
+ await Promise.resolve();
+ vi.mocked(readDirectory).mock.calls[0][2](null, [
+ {
+ filename: "link",
+ attrs: {
+ mode: 0o120777,
+ size: 1,
+ mtime: 1,
+ uid: 1,
+ gid: 1,
+ isDirectory: () => false,
+ isSymbolicLink: () => true,
+ },
+ } as never,
+ ]);
+ await vi.advanceTimersByTimeAsync(kind === "timeout" ? 4999 : 1000);
+ expect(listing.res.json).not.toHaveBeenCalled();
+ if (kind === "timeout") await vi.advanceTimersByTimeAsync(1);
+ else readlink.mock.calls[0][1](null, "/target");
+ await listing.done;
+ expect(listing.res.json).toHaveBeenCalledExactlyOnceWith({
+ files: [
+ expect.objectContaining({
+ linkTarget: kind === "timeout" ? undefined : "/target",
+ }),
+ ],
+ path: "/data",
+ });
+ readlink.mock.calls[0][1](null, "/late-target");
+ await vi.advanceTimersByTimeAsync(5000);
+ expect(listing.res.json).toHaveBeenCalledTimes(1);
+ expect(sshSessions.s.activeOperations).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+
+ it("shares concurrent reads, but never stores a settled result", async () => {
+ const { request, sshSessions } = setup();
+ const first = request();
+ const second = request();
+ await reads(1);
+ expect(sshSessions.s.activeOperations).toBe(1);
+ complete(0);
+ await Promise.all([first.done, second.done]);
+ for (const { res } of [first, second]) {
+ expect(res.json).toHaveBeenCalledExactlyOnceWith({
+ files: [],
+ path: "/data",
+ });
+ expect(res.statusCode).toBe(200);
+ }
+ expect(sshSessions.s.activeOperations).toBe(0);
+ const next = request();
+ await reads(2);
+ complete(1);
+ await next.done;
+ });
+
+ it("keeps the real result for waiters after the first client disconnects", async () => {
+ const { request, sshSessions } = setup();
+ const first = request();
+ const waiter = request();
+ await reads(1);
+ first.res.destroyed = true;
+ first.res.emit("close");
+ await Promise.resolve();
+ expect(waiter.res.json).not.toHaveBeenCalled();
+ const laterWaiter = request();
+ vi.mocked(readDirectory).mock.calls[0][2](null, [
+ {
+ filename: "visible.txt",
+ attrs: {
+ mode: 0o100644,
+ size: 7,
+ mtime: 1,
+ uid: 1,
+ gid: 1,
+ isDirectory: () => false,
+ isSymbolicLink: () => false,
+ },
+ } as never,
+ ]);
+ await Promise.all([first.done, waiter.done, laterWaiter.done]);
+ expect(first.res.json).not.toHaveBeenCalled();
+ for (const { res } of [waiter, laterWaiter]) {
+ expect(res.statusCode).toBe(200);
+ expect(res.json).toHaveBeenCalledExactlyOnceWith({
+ files: [
+ expect.objectContaining({
+ name: "visible.txt",
+ path: "/data/visible.txt",
+ }),
+ ],
+ path: "/data",
+ });
+ }
+ expect(readDirectory).toHaveBeenCalledTimes(1);
+ expect(sshSessions.s.activeOperations).toBe(0);
+ });
+
+ it("does not write to a closed waiter or an already ended response", async () => {
+ const { request } = setup();
+ const first = request();
+ const closed = request();
+ const ended = request();
+ closed.res.emit("close");
+ ended.res.writableEnded = true;
+ await reads(1);
+ complete(0);
+ await Promise.all([first.done, closed.done, ended.done]);
+ expect(first.res.json).toHaveBeenCalledTimes(1);
+ expect(closed.res.json).not.toHaveBeenCalled();
+ expect(ended.res.json).not.toHaveBeenCalled();
+ });
+
+ it("isolates a replacement session under the same session id", async () => {
+ const { request, sshSessions } = setup();
+ const oldSession = sshSessions.s;
+ const first = request();
+ await reads(1);
+ sshSessions.s = session();
+ const second = request();
+ await reads(2);
+ complete(1);
+ await second.done;
+ expect(first.res.json).not.toHaveBeenCalled();
+ complete(0);
+ await first.done;
+ expect(getSessionSftp).toHaveBeenNthCalledWith(1, oldSession);
+ expect(getSessionSftp).toHaveBeenNthCalledWith(2, sshSessions.s);
+ });
+
+ it("isolates route registrations even with the same actual session", async () => {
+ const sharedSessions = { s: session() };
+ const first = setup(sharedSessions).request();
+ const second = setup(sharedSessions).request();
+ await reads(2);
+ complete(0);
+ complete(1);
+ await Promise.all([first.done, second.done]);
+ });
+
+ it("isolates actors and exact paths without delimiter collisions", async () => {
+ const { request, setActor, verifySessionOwnership } = setup();
+ setActor("user:/a");
+ const first = request("/data");
+ setActor("user");
+ const second = request("/a:/data");
+ const third = request("/data");
+ const fourth = request("/data/");
+ await reads(4);
+ expect(verifySessionOwnership).toHaveBeenCalledTimes(4);
+ for (let index = 0; index < 4; index++) complete(index);
+ await Promise.all([first.done, second.done, third.done, fourth.done]);
+ });
+
+ it.each(["ownership", "connection"])(
+ "validates each waiter's %s before joining",
+ async (kind) => {
+ const { request, verifySessionOwnership, sshSessions } = setup();
+ const first = request();
+ await reads(1);
+ if (kind === "ownership") verifySessionOwnership.mockReturnValue(false);
+ else sshSessions.s.isConnected = false;
+ const denied = request();
+ await denied.done;
+ expect(denied.res.statusCode).toBe(kind === "ownership" ? 403 : 400);
+ expect(readDirectory).toHaveBeenCalledTimes(1);
+ complete(0);
+ await first.done;
+ },
+ );
+
+ it.each(["ownership", "connection", "session"])(
+ "rechecks %s after home resolution before joining",
+ async (kind) => {
+ const { request, verifySessionOwnership, sshSessions } = setup();
+ const first = request("/home/user");
+ await reads(1);
+ let resolve!: (path: string) => void;
+ vi.mocked(resolveHomeDirectory).mockReturnValue(
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const waiter = request(".");
+ if (kind === "ownership") verifySessionOwnership.mockReturnValue(false);
+ else if (kind === "connection") sshSessions.s.isConnected = false;
+ else sshSessions.s = session();
+ resolve("/home/user");
+ await waiter.done;
+ expect(waiter.res.statusCode).toBe(kind === "ownership" ? 403 : 400);
+ expect(readDirectory).toHaveBeenCalledTimes(1);
+ complete(0);
+ await first.done;
+ },
+ );
+
+ it("shares home and explicit paths after resolution", async () => {
+ const { request } = setup();
+ const first = request("/home/user");
+ const second = request(".");
+ await reads(1);
+ // Let home resolution finish before completing the shared listing.
+ await Promise.resolve();
+ complete(0);
+ await Promise.all([first.done, second.done]);
+ expect(readDirectory).toHaveBeenCalledTimes(1);
+ expect(second.res.json).toHaveBeenCalledWith({
+ files: [],
+ path: "/home/user",
+ });
+ });
+
+ it.each([true, false])(
+ "force bypass never replaces or deletes normal pending work (force finishes first: %s)",
+ async (forceFirst) => {
+ const { request } = setup();
+ const normal = request();
+ const forced = request("/data", "true");
+ const normalWaiter = request();
+ await reads(2);
+ if (forceFirst) {
+ complete(1);
+ await forced.done;
+ expect(normal.res.json).not.toHaveBeenCalled();
+ const laterWaiter = request();
+ expect(getSessionSftp).toHaveBeenCalledTimes(2);
+ complete(0);
+ await Promise.all([normal.done, normalWaiter.done, laterWaiter.done]);
+ } else {
+ complete(0);
+ await Promise.all([normal.done, normalWaiter.done]);
+ const newer = request();
+ await reads(3);
+ complete(1);
+ await forced.done;
+ const newerWaiter = request();
+ expect(getSessionSftp).toHaveBeenCalledTimes(3);
+ complete(2);
+ await Promise.all([newer.done, newerWaiter.done]);
+ }
+ expect(forced.res.json).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("only the exact string true forces a separate read", async () => {
+ const { request } = setup();
+ const requests = [
+ request(),
+ request("/data", "false"),
+ request("/data", true),
+ request("/data", ["true"]),
+ ];
+ await reads(1);
+ complete(0);
+ await Promise.all(requests.map(({ done }) => done));
+ });
+
+ it("isolates privilege changes and old settlement/close cannot delete newer work", async () => {
+ const { request, sshSessions } = setup();
+ const first = request();
+ await reads(1);
+ sshSessions.s.sudoPassword = "first-password";
+ const second = request();
+ await reads(2);
+ sshSessions.s.sudoPassword = "replacement-password";
+ const third = request();
+ await reads(3);
+ complete(0);
+ complete(1);
+ await Promise.all([first.done, second.done]);
+ first.res.emit("close");
+ second.res.emit("close");
+ const waiter = request();
+ expect(getSessionSftp).toHaveBeenCalledTimes(3);
+ complete(2);
+ await Promise.all([third.done, waiter.done]);
+ expect(waiter.res.json).toHaveBeenCalledExactlyOnceWith({
+ files: [],
+ path: "/data",
+ });
+ });
+
+ it("uses captured sudo credentials and does not clear newer credentials on old auth failure", async () => {
+ const { request, sshSessions } = setup();
+ sshSessions.s.sudoPassword = "captured-password";
+ const first = request();
+ const waiter = request();
+ await reads(1);
+ sshSessions.s.sudoPassword = "replacement-password";
+ const streams: Array<
+ EventEmitter & { stderr: EventEmitter; write: ReturnType }
+ > = [];
+ vi.mocked(execChannel).mockImplementation((_session, _command, done) => {
+ const stream = Object.assign(new EventEmitter(), {
+ stderr: new EventEmitter(),
+ write: vi.fn(),
+ });
+ streams.push(stream);
+ done(null, stream as never);
+ });
+ complete(0, new Error("Permission denied"));
+ streams[0].stderr.emit("data", Buffer.from("Permission denied"));
+ streams[0].emit("close", 1);
+ expect(streams[1].write).toHaveBeenCalledExactlyOnceWith(
+ "captured-password\n",
+ );
+ streams[1].emit("data", Buffer.from("Sorry, try again"));
+ streams[1].emit("close", 1);
+ await Promise.all([first.done, waiter.done]);
+ expect(sshSessions.s.sudoPassword).toBe("replacement-password");
+ expect(first.res.statusCode).toBe(403);
+ expect(waiter.res.statusCode).toBe(403);
+ expect(waiter.res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ sudoFailed: true }),
+ );
+ });
+
+ it("does not adopt sudo credentials enabled while an unprivileged listing is pending", async () => {
+ const { request, sshSessions } = setup();
+ const first = request();
+ await reads(1);
+ sshSessions.s.sudoPassword = "new-password";
+ vi.mocked(execChannel).mockImplementation((_session, _command, done) => {
+ const stream = Object.assign(new EventEmitter(), {
+ stderr: new EventEmitter(),
+ });
+ done(null, stream as never);
+ queueMicrotask(() => {
+ stream.stderr.emit("data", Buffer.from("Permission denied"));
+ stream.emit("close", 1);
+ });
+ });
+ complete(0, new Error("Permission denied"));
+ await first.done;
+ expect(execChannel).toHaveBeenCalledTimes(1);
+ expect(first.res.statusCode).toBe(403);
+ expect(first.res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ needsSudo: true }),
+ );
+ });
+
+ it("settles a shared fallback error once and releases its pending entry", async () => {
+ const { request, sshSessions } = setup();
+ const first = request();
+ const waiter = request();
+ await reads(1);
+ vi.mocked(execChannel).mockImplementation((_session, _command, done) => {
+ done(new Error("fallback failed"), undefined as never);
+ });
+ complete(0, new Error("SFTP failed"));
+ await Promise.all([first.done, waiter.done]);
+ for (const { res } of [first, waiter]) {
+ expect(res.statusCode).toBe(500);
+ expect(res.json).toHaveBeenCalledExactlyOnceWith({
+ error: "fallback failed",
+ });
+ }
+ expect(sshSessions.s.activeOperations).toBe(0);
+ const next = request();
+ await reads(2);
+ complete(1);
+ await next.done;
+ });
+});
diff --git a/tests/backend/read-directory.test.ts b/tests/backend/read-directory.test.ts
new file mode 100644
index 0000000..ebf59d2
--- /dev/null
+++ b/tests/backend/read-directory.test.ts
@@ -0,0 +1,307 @@
+import type { FileEntry, SFTPWrapper } from "ssh2";
+import { describe, expect, it, vi } from "vitest";
+import { readDirectory } from "../../src/backend/read-directory.js";
+
+type ReadCallback = (
+ error: Error | null,
+ entries?: FileEntry[] | false,
+) => void;
+const eof = () => Object.assign(new Error("EOF"), { code: 1 });
+const entry = (index: number): FileEntry => ({
+ filename: `file-${index}`,
+ longname: "",
+ attrs: {} as FileEntry["attrs"],
+});
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+function setup() {
+ const handle = Buffer.from("directory");
+ const replies: ReadCallback[] = [];
+ let active = 0;
+ let maximum = 0;
+ const sftp = {
+ opendir: vi.fn(
+ (_path: string, done: (error: Error | null, handle?: Buffer) => void) =>
+ done(null, handle),
+ ),
+ readdir: vi.fn(
+ (_handle: Buffer, _options: { full: boolean }, done: ReadCallback) => {
+ active++;
+ maximum = Math.max(maximum, active);
+ replies.push((error, entries) => {
+ active--;
+ done(error, entries);
+ });
+ },
+ ),
+ // Deliberately leave CLOSE unanswered: successful data must not wait for it.
+ close: vi.fn((_handle: Buffer, _done: (error?: Error) => void) => {
+ expect(active).toBe(0);
+ }),
+ };
+ const done = vi.fn();
+ const onCloseError = vi.fn();
+ return {
+ sftp,
+ handle,
+ replies,
+ done,
+ onCloseError,
+ maximum: () => maximum,
+ start: async () => {
+ readDirectory(
+ sftp as unknown as SFTPWrapper,
+ "/data",
+ done,
+ onCloseError,
+ );
+ await flush();
+ },
+ };
+}
+
+describe("pipelined SFTP directory reads", () => {
+ it("deduplicates filenames across concurrent replies, keeping the first issued entry", async () => {
+ const f = setup();
+ const first = entry(0);
+ const duplicate = { ...first, longname: "later version" };
+ await f.start();
+ f.replies[1](null, [duplicate, entry(1), duplicate]);
+ await flush();
+ f.replies[0](null, [first]);
+ await flush();
+ f.replies[2](eof());
+ f.replies[3](eof());
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(null, [first, entry(1)]);
+ expect(f.done.mock.calls[0][1][0]).toBe(first);
+ expect(f.maximum()).toBe(2);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("collects all 484 entries in issued batch order with at most two pending reads", async () => {
+ const f = setup();
+ const entries = Array.from({ length: 484 }, (_, index) => entry(index));
+ await f.start();
+ expect(f.replies).toHaveLength(2);
+ f.replies[1](null, entries.slice(120, 240));
+ await flush();
+ f.replies[0](null, entries.slice(0, 120));
+ await flush();
+ f.replies[3](null, entries.slice(360));
+ await flush();
+ f.replies[2](null, entries.slice(240, 360));
+ await flush();
+ f.replies[5](eof());
+ await flush();
+ expect(f.done).not.toHaveBeenCalled();
+ expect(f.sftp.close).not.toHaveBeenCalled();
+ f.replies[4](eof());
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(null, entries);
+ expect(f.maximum()).toBe(2);
+ expect(f.sftp.readdir).toHaveBeenCalledTimes(6);
+ expect(f.sftp.readdir).toHaveBeenCalledWith(
+ f.handle,
+ { full: true },
+ expect.any(Function),
+ );
+ expect(f.sftp.close).toHaveBeenCalledExactlyOnceWith(
+ f.handle,
+ expect.any(Function),
+ );
+ });
+
+ it.each(["status", "false"])(
+ "drains late data after %s EOF without issuing more reads",
+ async (kind) => {
+ const f = setup();
+ await f.start();
+ if (kind === "status") f.replies[1](eof());
+ else f.replies[1](null, false);
+ await flush();
+ expect(f.done).not.toHaveBeenCalled();
+ expect(f.sftp.close).not.toHaveBeenCalled();
+ expect(f.replies).toHaveLength(2);
+ f.replies[0](null, [entry(0)]);
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(null, [entry(0)]);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ expect(f.replies).toHaveLength(2);
+ },
+ );
+
+ it.each(["status", "false"])(
+ "lists an empty directory terminated by %s EOF",
+ async (kind) => {
+ const f = setup();
+ await f.start();
+ for (const reply of f.replies) {
+ if (kind === "status") reply(eof());
+ else reply(null, false);
+ }
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(null, []);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each(["status", "false"])(
+ "does not swallow an error arriving after %s EOF",
+ async (kind) => {
+ const f = setup();
+ const error = new Error("late read failure");
+ await f.start();
+ if (kind === "status") f.replies[1](eof());
+ else f.replies[1](null, false);
+ await flush();
+ f.replies[0](error);
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("discards partial data on error and drains the outstanding read before closing", async () => {
+ const f = setup();
+ const error = new Error("read failure");
+ await f.start();
+ f.replies[0](null, [entry(0)]);
+ await flush();
+ f.replies[2](error);
+ await flush();
+ expect(f.done).not.toHaveBeenCalled();
+ expect(f.sftp.close).not.toHaveBeenCalled();
+ f.replies[1](null, [entry(1)]);
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []);
+ expect(f.replies).toHaveLength(3);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(["callback", "throw"])(
+ "reports opendir %s failure without reading or closing",
+ async (kind) => {
+ const f = setup();
+ const error = new Error("open failure");
+ f.sftp.opendir.mockImplementation((_path, done) => {
+ if (kind === "throw") throw error;
+ done(error);
+ });
+ await f.start();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []);
+ expect(f.sftp.readdir).not.toHaveBeenCalled();
+ expect(f.sftp.close).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([0, 1])(
+ "drains the other read when read %s throws synchronously",
+ async (throwIndex) => {
+ const f = setup();
+ const error = new Error("synchronous read failure");
+ const queueRead = f.sftp.readdir.getMockImplementation()!;
+ if (throwIndex === 1) f.sftp.readdir.mockImplementationOnce(queueRead);
+ f.sftp.readdir.mockImplementationOnce(() => {
+ throw error;
+ });
+ await f.start();
+ expect(f.replies).toHaveLength(1);
+ expect(f.sftp.close).not.toHaveBeenCalled();
+ f.replies[0](eof());
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each(["success", "read failure"])(
+ "logs an asynchronous close failure after %s without delaying the result",
+ async (kind) => {
+ const f = setup();
+ const readError = new Error("read failure");
+ const closeError = new Error("close failure");
+ await f.start();
+ f.replies[0](kind === "success" ? eof() : readError);
+ f.replies[1](eof());
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(
+ kind === "success" ? null : readError,
+ [],
+ );
+ f.sftp.close.mock.calls[0][1](closeError);
+ expect(f.onCloseError).toHaveBeenCalledExactlyOnceWith(closeError);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ expect(f.done).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each(["success", "read failure"])(
+ "logs a synchronous close throw after %s without replacing the result",
+ async (kind) => {
+ const f = setup();
+ const readError = new Error("read failure");
+ const closeError = new Error("close throw");
+ f.sftp.close.mockImplementation(() => {
+ throw closeError;
+ });
+ await f.start();
+ f.replies[0](kind === "success" ? eof() : readError);
+ f.replies[1](eof());
+ await flush();
+ expect(f.onCloseError).toHaveBeenCalledExactlyOnceWith(closeError);
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(
+ kind === "success" ? null : readError,
+ [],
+ );
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("fails an empty successful batch instead of spinning or treating it as EOF", async () => {
+ const f = setup();
+ await f.start();
+ f.replies[0](null, []);
+ await flush();
+ expect(f.replies).toHaveLength(2);
+ expect(f.sftp.close).not.toHaveBeenCalled();
+ f.replies[1](null, [entry(0)]);
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(
+ new Error("SFTP readdir returned an empty batch without EOF"),
+ [],
+ );
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("continues through dot-only batches and filters them from the result", async () => {
+ const f = setup();
+ await f.start();
+ f.replies[0](null, [
+ { ...entry(0), filename: "." },
+ { ...entry(0), filename: ".." },
+ ]);
+ await flush();
+ f.replies[2](eof());
+ await flush();
+ f.replies[1](null, [entry(0)]);
+ await flush();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(null, [entry(0)]);
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("handles synchronously delivered batches without recursive callbacks", async () => {
+ const f = setup();
+ let batch = 0;
+ f.sftp.readdir.mockImplementation((_handle, _options, done) => {
+ if (batch < 484) done(null, [entry(batch++)]);
+ else done(eof());
+ });
+ await f.start();
+ expect(f.done).toHaveBeenCalledExactlyOnceWith(
+ null,
+ Array.from({ length: 484 }, (_, index) => entry(index)),
+ );
+ expect(f.sftp.close).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/frontend/FileManager-prefetch-auth.test.tsx b/tests/frontend/FileManager-prefetch-auth.test.tsx
new file mode 100644
index 0000000..f77047b
--- /dev/null
+++ b/tests/frontend/FileManager-prefetch-auth.test.tsx
@@ -0,0 +1,383 @@
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, render } from "@testing-library/react";
+import type { FileItem, SSHHost } from "../../src/frontend/host-types";
+
+const mocks = vi.hoisted(() => ({
+ t: (key: string) => key,
+ get: vi.fn(),
+ connect: vi.fn(),
+ verifyTotp: vi.fn(),
+ verifyBrowser: vi.fn(),
+ grid: vi.fn(
+ (
+ _props: React.ComponentProps<
+ typeof import("../../src/frontend/FileManagerGrid").FileManagerGrid
+ >,
+ ) => null,
+ ),
+ toolbar: vi.fn(
+ (
+ _props: React.ComponentProps<
+ typeof import("../../src/frontend/FileManagerToolbar").FileManagerToolbar
+ >,
+ ) => null,
+ ),
+ dialogs: vi.fn(
+ (
+ _props: React.ComponentProps<
+ typeof import("../../src/frontend/FileManagerDialogs").FileManagerDialogs
+ >,
+ ) => null,
+ ),
+ passphrase: vi.fn(
+ (_props: { onSubmit: (value: string) => Promise }) => null,
+ ),
+}));
+
+vi.mock("../../src/frontend/api/client", () => ({
+ fileManagerApi: () => ({ get: mocks.get }),
+ getFileManagerApiForSession: () => ({ get: mocks.get }),
+ handleApiError: (error: unknown) => {
+ throw error;
+ },
+}));
+vi.mock(
+ "../../src/frontend/api/ssh-file-operations-api",
+ async (importOriginal) => ({
+ ...(await importOriginal