diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 5ee09bfde..d8b2f40af 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -71,6 +71,75 @@ function requireEntryName(name, what) { return safeName; } +// Asserts that `candidate` (already absolute) lies strictly inside `root` +// after normalisation on the *current* platform. `pathImpl` is injectable so +// the Windows rules can be exercised in tests on any OS. Traversal that +// survives normalisation ("..\\x" is a plain file name on POSIX but a parent +// reference on Windows) is caught here, as are absolute / drive-qualified +// names that path.join or path.resolve would let take over. +function assertWithinRoot(rootPath, candidate, pathImpl = path) { + if (typeof rootPath !== "string" || !rootPath.trim()) { + throw new LocalFileError("EINVAL", "A download folder is required"); + } + const root = pathImpl.normalize(pathImpl.resolve(rootPath)); + const target = pathImpl.normalize(pathImpl.resolve(candidate)); + const rel = pathImpl.relative(root, target); + const escapes = + rel === "" || + rel === ".." || + rel.startsWith(`..${pathImpl.sep}`) || + pathImpl.isAbsolute(rel) || + // A different drive on Windows yields an absolute relative path; a UNC + // or drive-qualified segment must never survive either. + rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === ""); + if (escapes) { + throw new LocalFileError( + "EINVAL", + `"${pathImpl.basename(candidate)}" would be written outside the selected folder`, + ); + } + return target; +} + +// Resolve a deliberately selected linked root once, but never follow links +// supplied as descendants of a downloaded tree. +async function prepareDownloadPath(rootPath, candidate, directory = false) { + const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate)); + const selected = path.resolve(rootPath); + const realRoot = await fsp.realpath(selected); + if (!(await fsp.stat(realRoot)).isDirectory()) { + throw new LocalFileError("EINVAL", "The download root is not a directory"); + } + const parts = path.relative(selected, target).split(path.sep); + let current = realRoot; + for (let i = 0; i < parts.length; i++) { + current = path.join(current, parts[i]); + const needsDirectory = directory || i < parts.length - 1; + if (needsDirectory) { + await fsp.mkdir(current).catch((error) => { + if (error.code !== "EEXIST") throw error; + }); + } + const stat = await fsp.lstat(current).catch((error) => { + if (error.code === "ENOENT" && !needsDirectory) return null; + throw error; + }); + if (stat?.isSymbolicLink()) { + throw new LocalFileError( + "EINVAL", + "Downloads cannot follow links inside the selected folder", + ); + } + if (needsDirectory && !stat.isDirectory()) { + throw new LocalFileError( + "ENOTDIR", + "A download parent is not a directory", + ); + } + } + return { root: realRoot, path: current }; +} + async function pathExists(target) { try { await fsp.lstat(target); @@ -610,8 +679,15 @@ async function downloadToLocal( event, options, ) { - const { transferId, origin, deviceId, body, destPath, expectedSize } = - options || {}; + const { + transferId, + origin, + deviceId, + body, + destPath, + rootPath, + expectedSize, + } = options || {}; const overwrite = options?.overwrite === true; if (!transferId || !destPath) { @@ -623,7 +699,10 @@ async function downloadToLocal( deviceId, }); - const absDest = normalizeLocalPath(destPath); + // The renderer builds destPath from remote names; never trust that it + // stayed inside the folder the user picked. + const destination = await prepareDownloadPath(rootPath, destPath); + const absDest = destination.path; if (activeDestinations.has(absDest)) { throw new LocalFileError( "EBUSY", @@ -632,7 +711,10 @@ async function downloadToLocal( } activeDestinations.add(absDest); - const partialPath = partialPathFor(absDest, transferId); + const partialPath = partialPathFor( + path.join(destination.root, path.basename(absDest)), + transferId, + ); const report = makeProgressReporter(event.sender, transferId); const state = { cancelled: false, @@ -650,7 +732,6 @@ async function downloadToLocal( `"${path.basename(absDest)}" already exists`, ); } - await fsp.mkdir(path.dirname(absDest), { recursive: true }); request = createNetRequest(net, event, "POST", url); for (const [key, value] of Object.entries(toHeaderMap(headers))) { @@ -723,6 +804,7 @@ async function downloadToLocal( request.end(); }); + await prepareDownloadPath(destination.root, absDest); await publishDownload( partialPath, absDest, @@ -838,9 +920,15 @@ function createLocalFileHandlers({ return { trashed: targetPaths.length - failed.length, failed }; }), - [IPC.ENSURE_DIR]: wrap(async (_event, dirPath) => { - const target = normalizeLocalPath(dirPath); - await fsp.mkdir(target, { recursive: true }); + [IPC.ENSURE_DIR]: wrap(async (_event, dirPath, rootPath) => { + let target = normalizeLocalPath(dirPath); + // Transfers pass the folder the user picked; the directory skeleton of + // a downloaded tree must stay inside it. + if (rootPath !== undefined) { + target = (await prepareDownloadPath(rootPath, target, true)).path; + } else { + await fsp.mkdir(target, { recursive: true }); + } return { path: target }; }), @@ -915,6 +1003,7 @@ module.exports = { createTargetResolver, publishDownload, defaultPublishFs, + assertWithinRoot, walkPaths, listDirectory, }; diff --git a/electron/preload.js b/electron/preload.js index 2691df15b..560648522 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -140,7 +140,8 @@ contextBridge.exposeInMainWorld("electronAPI", { rename: (oldPath, newName) => ipcRenderer.invoke("local-fs:rename", oldPath, newName), trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths), - ensureDir: (dirPath) => ipcRenderer.invoke("local-fs:ensure-dir", dirPath), + ensureDir: (dirPath, rootPath) => + ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath), exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths), walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths), reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath), diff --git a/src/backend/tests/electron/local-files.test.ts b/src/backend/tests/electron/local-files.test.ts index 0cc53eaa9..ed4123eaf 100644 --- a/src/backend/tests/electron/local-files.test.ts +++ b/src/backend/tests/electron/local-files.test.ts @@ -27,6 +27,11 @@ type PublishFs = { }; const localFiles = require("../../../../electron/local-files.cjs") as { + assertWithinRoot: ( + rootPath: string, + candidate: string, + pathImpl?: typeof path, + ) => string; publishDownload: ( partialPath: string, absDest: string, @@ -257,9 +262,43 @@ describe("local-files download boundary", () => { origin: "local", body: { sessionId: "1", path: "/remote/file.bin" }, destPath: dest, + rootPath: root, ...extra, }); + it("rejects existing linked parents for downloads and directory creation", async () => { + const selected = path.join(root, "selected"); + const outside = path.join(root, "outside"); + await fsp.mkdir(selected); + await fsp.mkdir(outside); + await fsp.symlink(outside, path.join(selected, "linked"), "junction"); + const result = await download(path.join(selected, "linked", "file.bin"), { + rootPath: selected, + }); + expect(result.success).toBe(false); + const mkdir = await handlers[localFiles.IPC.ENSURE_DIR]( + fakeEvent, + path.join(selected, "linked", "new"), + selected, + ); + expect(mkdir.success).toBe(false); + expect(await fsp.readdir(outside)).toEqual([]); + }); + + it("allows an explicitly selected linked root", async () => { + const actual = path.join(root, "actual"); + const selected = path.join(root, "selected"); + await fsp.mkdir(actual); + await fsp.symlink(actual, selected, "junction"); + const result = await download(path.join(selected, "nested", "file.bin"), { + rootPath: selected, + }); + expect(result.success).toBe(true); + expect(await fsp.readFile(path.join(actual, "nested", "file.bin"))).toEqual( + payload, + ); + }); + it("ignores renderer-supplied url/headers and talks to the resolved backend only", async () => { const dest = path.join(root, "a.bin"); const result = await download(dest, { @@ -312,6 +351,7 @@ describe("local-files download boundary", () => { origin: "local", body: {}, destPath: dest, + rootPath: root, }); await new Promise((r) => setTimeout(r, 100)); await fsp.writeFile(dest, "someone else wrote this"); @@ -325,6 +365,41 @@ describe("local-files download boundary", () => { } }); + it("rejects a destination parent replaced by a link during download", async () => { + const selected = path.join(root, "selected"); + const nested = path.join(selected, "nested"); + const outside = path.join(root, "outside"); + await fsp.mkdir(nested, { recursive: true }); + await fsp.mkdir(outside); + const slow = await startBackend(payload, { delayMs: 300 }); + const slowHandlers = localFiles.createLocalFileHandlers({ + net: fakeNet, + shell: {}, + localBaseUrl: slow.url, + }); + try { + const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { + transferId: "parent-race", + origin: "local", + body: {}, + rootPath: selected, + destPath: path.join(nested, "file.bin"), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + await fsp.rename(nested, path.join(selected, "original")); + await fsp.symlink(outside, nested, "junction"); + const result = await pending; + expect(result.success).toBe(false); + expect(await fsp.readdir(outside)).toEqual([]); + expect((await fsp.readdir(selected)).sort()).toEqual([ + "nested", + "original", + ]); + } finally { + await slow.close(); + } + }); + it("uses a transfer-unique temp file and rejects a concurrent download to the same destination", async () => { const slow = await startBackend(payload, { delayMs: 300 }); const slowHandlers = localFiles.createLocalFileHandlers({ @@ -339,6 +414,7 @@ describe("local-files download boundary", () => { origin: "local", body: {}, destPath: dest, + rootPath: root, }); await new Promise((r) => setTimeout(r, 50)); const second = await slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { @@ -346,6 +422,7 @@ describe("local-files download boundary", () => { origin: "local", body: {}, destPath: dest, + rootPath: root, }); expect(second.success).toBe(false); expect(second.code).toBe("EBUSY"); @@ -387,6 +464,7 @@ describe("local-files download boundary", () => { origin: "local", body: {}, destPath: path.join(root, "never.bin"), + rootPath: root, }); expect(bad.success).toBe(false); expect(bad.error).toMatch(/no route/); @@ -434,6 +512,134 @@ function windowsLikeFs( }; } +describe("download destination containment", () => { + const win = path.win32; + const root = "C:\\Downloads\\selected"; + + it("accepts ordinary nested destinations on Windows", () => { + expect( + localFiles.assertWithinRoot(root, "C:\\Downloads\\selected\\a.txt", win), + ).toBe("C:\\Downloads\\selected\\a.txt"); + expect( + localFiles.assertWithinRoot( + root, + "C:\\Downloads\\selected\\docs\\2026\\report.pdf", + win, + ), + ).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf"); + // case-insensitive drive/dir comparison, like the filesystem + expect( + localFiles.assertWithinRoot(root, "c:\\downloads\\SELECTED\\b.txt", win), + ).toBe("c:\\downloads\\SELECTED\\b.txt"); + }); + + it("rejects backslash traversal that normalises out of the root on Windows", () => { + // The reviewer's reproduction: selected\..\outside.txt -> Downloads\outside.txt + expect(() => + localFiles.assertWithinRoot( + root, + "C:\\Downloads\\selected\\..\\outside.txt", + win, + ), + ).toThrow(/outside the selected folder/); + expect(() => + localFiles.assertWithinRoot( + root, + "C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt", + win, + ), + ).toThrow(/outside the selected folder/); + // the root itself is not a valid file destination + expect(() => localFiles.assertWithinRoot(root, root, win)).toThrow( + /outside the selected folder/, + ); + }); + + it("rejects absolute, drive-qualified and UNC destinations on Windows", () => { + for (const dest of [ + "C:\\Windows\\evil.dll", + "D:\\Downloads\\selected\\x.txt", + "\\\\server\\share\\x.txt", + "C:\\Downloads\\selected-other\\x.txt", + ]) { + expect(() => localFiles.assertWithinRoot(root, dest, win)).toThrow( + /outside the selected folder/, + ); + } + }); + + it("requires a root and rejects POSIX traversal too", () => { + expect(() => localFiles.assertWithinRoot("", "/tmp/x", path.posix)).toThrow( + /download folder is required/, + ); + expect(() => + localFiles.assertWithinRoot( + "/home/max/dl", + "/home/max/dl/../x", + path.posix, + ), + ).toThrow(/outside the selected folder/); + expect( + localFiles.assertWithinRoot( + "/home/max/dl", + "/home/max/dl/..\\x", + path.posix, + ), + ).toBe("/home/max/dl/..\\x"); + }); + + it("refuses a download whose destination escapes the root, before any network or disk write", async () => { + const payload = crypto.randomBytes(1024); + const backend = await startBackend(payload); + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-contain-")); + const selected = path.join(tmp, "selected"); + await fsp.mkdir(selected); + try { + const handlers = localFiles.createLocalFileHandlers({ + net: fakeNet, + shell: {}, + localBaseUrl: backend.url, + }); + const requestsBefore = backend.seen.length; + const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { + transferId: "escape-1", + origin: "local", + body: { sessionId: "1", path: "/remote/file.bin" }, + destPath: path.join(selected, "..", "outside.txt"), + rootPath: selected, + }); + expect(result.success).toBe(false); + expect(result.code).toBe("EINVAL"); + expect(backend.seen.length).toBe(requestsBefore); + expect(await fsp.readdir(tmp)).toEqual(["selected"]); + + // and the same for the directory skeleton + const dir = await handlers[localFiles.IPC.ENSURE_DIR]( + fakeEvent, + path.join(selected, "..", "escaped-dir"), + selected, + ); + expect(dir.success).toBe(false); + expect(dir.code).toBe("EINVAL"); + expect(await fsp.readdir(tmp)).toEqual(["selected"]); + + // a missing root is refused as well + const noRoot = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { + transferId: "escape-2", + origin: "local", + body: { sessionId: "1", path: "/remote/file.bin" }, + destPath: path.join(selected, "ok.bin"), + }); + expect(noRoot.success).toBe(false); + expect(noRoot.code).toBe("EINVAL"); + expect(await fsp.readdir(selected)).toEqual([]); + } finally { + backend.close(); + await fsp.rm(tmp, { recursive: true, force: true }); + } + }); +}); + describe("local-files replace primitive (Windows-safe overwrite)", () => { let root: string; beforeEach(async () => { @@ -557,6 +763,7 @@ describe("local-files replace primitive (Windows-safe overwrite)", () => { origin: "local", body: { sessionId: "1", path: "/remote/file.bin" }, destPath: dest, + rootPath: root, overwrite: true, }); diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4c68b473e..50cb4a0a5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -210,7 +210,10 @@ export interface ElectronAPI { newName: string, ) => Promise>; trash: (paths: string[]) => Promise>; - ensureDir: (dirPath: string) => Promise>; + ensureDir: ( + dirPath: string, + rootPath?: string, + ) => Promise>; exists: (paths: string[]) => Promise>; walk: (paths: string[]) => Promise>; reveal: (targetPath: string) => Promise>; @@ -297,6 +300,8 @@ export interface LocalDownloadRequest { origin: LocalTransferOrigin; body: Record; destPath: string; + /** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */ + rootPath: string; expectedSize?: number; /** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */ overwrite?: boolean; diff --git a/src/ui/api/local-transfer-api.ts b/src/ui/api/local-transfer-api.ts new file mode 100644 index 000000000..2a6d44e01 --- /dev/null +++ b/src/ui/api/local-transfer-api.ts @@ -0,0 +1,164 @@ +// Local disk <-> remote SFTP transfers for the desktop app's dual-pane file +// manager. The bytes never pass through the renderer: the Electron main +// process streams them between disk and the backend's existing +// `uploadFileStream` / `downloadFileStream` routes (see +// electron/local-files.cjs), and we only orchestrate + relay progress here. +// +// The renderer deliberately does not choose the URL or the auth headers. It +// names the origin that owns the SSH session ("local" embedded backend or the +// configured Remote Sync server) and the main process resolves the rest, so a +// compromised renderer cannot turn the transfer bridge into an HTTP client. + +import type { + LocalTransferOrigin, + LocalTransferProgress, +} from "@/types/electron"; +import { getSessionOrigin } from "@/main-axios"; +import { getDeviceId } from "@/lib/device-id"; + +export interface LocalTransferProgressEvent { + transferred: number; + total?: number; +} + +type ProgressListener = (event: LocalTransferProgressEvent) => void; + +/** Error from the main process; `code` is e.g. "EEXIST" or "EBUSY". */ +export class LocalTransferError extends Error { + code?: string; + constructor(message: string, code?: string) { + super(message); + this.name = "LocalTransferError"; + this.code = code; + } +} + +const progressListeners = new Map(); +let unsubscribeProgress: (() => void) | null = null; + +function ensureProgressSubscription() { + if (unsubscribeProgress) return; + const api = window.electronAPI?.localTransfer; + if (!api) return; + unsubscribeProgress = api.onProgress((payload: LocalTransferProgress) => { + const listener = progressListeners.get(payload.transferId); + listener?.({ transferred: payload.transferred, total: payload.total }); + }); +} + +function requireTransferApi() { + const api = window.electronAPI?.localTransfer; + if (!api) { + throw new Error("Local transfers are only available in the desktop app"); + } + return api; +} + +function transferOriginFor(sessionId: string): LocalTransferOrigin { + return getSessionOrigin(sessionId); +} + +export function createLocalTransferId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Streams a file from the local disk into `remoteDir` on the SSH session, + * using the same multipart route the in-browser upload uses. + */ +export async function uploadLocalFileToSession(options: { + sessionId: string; + remoteDir: string; + localPath: string; + fileName: string; + hostId?: number; + transferId?: string; + onProgress?: ProgressListener; +}): Promise { + const api = requireTransferApi(); + ensureProgressSubscription(); + + const transferId = + options.transferId ?? createLocalTransferId("local-upload"); + + const fields: Record = { + sessionId: options.sessionId, + path: options.remoteDir, + }; + if (options.hostId !== undefined) fields.hostId = String(options.hostId); + + if (options.onProgress) { + progressListeners.set(transferId, options.onProgress); + } + try { + const result = await api.upload({ + transferId, + origin: transferOriginFor(options.sessionId), + deviceId: getDeviceId() ?? undefined, + fields, + localPath: options.localPath, + fileName: options.fileName, + }); + if (result.success !== true) { + throw new LocalTransferError( + result.error || "Upload failed", + result.code, + ); + } + } finally { + progressListeners.delete(transferId); + } +} + +/** + * Streams a remote file from the SSH session straight to `destPath` on the + * local disk. Refuses to replace an existing file (code "EEXIST") unless + * `overwrite` is set. + */ +export async function downloadSessionFileToLocal(options: { + sessionId: string; + remotePath: string; + destPath: string; + /** The folder the user picked; the main process refuses any destination outside it. */ + rootPath: string; + expectedSize?: number; + overwrite?: boolean; + transferId?: string; + onProgress?: ProgressListener; +}): Promise { + const api = requireTransferApi(); + ensureProgressSubscription(); + + const transferId = + options.transferId ?? createLocalTransferId("local-download"); + + if (options.onProgress) { + progressListeners.set(transferId, options.onProgress); + } + try { + const result = await api.download({ + transferId, + origin: transferOriginFor(options.sessionId), + deviceId: getDeviceId() ?? undefined, + body: { sessionId: options.sessionId, path: options.remotePath }, + destPath: options.destPath, + rootPath: options.rootPath, + expectedSize: options.expectedSize, + overwrite: options.overwrite === true, + }); + if (result.success !== true) { + throw new LocalTransferError( + result.error || "Download failed", + result.code, + ); + } + } finally { + progressListeners.delete(transferId); + } +} + +export async function cancelLocalTransfer(transferId: string): Promise { + const api = window.electronAPI?.localTransfer; + if (!api) return; + await api.cancel(transferId); +} diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 49c99720f..5957ab7f9 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -29,6 +29,9 @@ import { useTranslation } from "react-i18next"; import { FileManagerDialogs } from "./FileManagerDialogs.tsx"; import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { FileManagerToolbar } from "./FileManagerToolbar.tsx"; +import { LocalFilePane } from "./LocalFilePane.tsx"; +import { useLocalTransfers } from "./hooks/useLocalTransfers.ts"; +import { isLocalFileBrowserAvailable } from "@/lib/local-files.ts"; import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx"; import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx"; import { TerminalWindow } from "./components/TerminalWindow.tsx"; @@ -107,6 +110,11 @@ import { restoreItems, } from "./optimistic-file-list"; +const LOCAL_PANE_OPEN_STORAGE_KEY = "termix:file-manager:local-pane:open"; +const LOCAL_PANE_WIDTH_STORAGE_KEY = "termix:file-manager:local-pane:width"; +const LOCAL_PANE_MIN_WIDTH = 300; +const LOCAL_PANE_DEFAULT_WIDTH = 440; + const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024; function FileManagerContent({ @@ -195,6 +203,74 @@ function FileManagerContent({ const [showPassphraseDialog, setShowPassphraseDialog] = useState(false); const [pinnedFiles, setPinnedFiles] = useState>(new Set()); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); + // Desktop-only Local | Remote split view (Termius-style dual pane). + const localPaneAvailable = isLocalFileBrowserAvailable(); + const [localPaneOpen, setLocalPaneOpen] = useState(() => { + try { + return localStorage.getItem(LOCAL_PANE_OPEN_STORAGE_KEY) === "true"; + } catch { + return false; + } + }); + const [localPaneRefreshToken, setLocalPaneRefreshToken] = useState(0); + const [localPaneWidth, setLocalPaneWidth] = useState(() => { + try { + const saved = Number(localStorage.getItem(LOCAL_PANE_WIDTH_STORAGE_KEY)); + return Number.isFinite(saved) && saved >= LOCAL_PANE_MIN_WIDTH + ? saved + : LOCAL_PANE_DEFAULT_WIDTH; + } catch { + return LOCAL_PANE_DEFAULT_WIDTH; + } + }); + const panesRowRef = useRef(null); + const startLocalPaneResize = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = localPaneWidth; + const rowWidth = panesRowRef.current?.getBoundingClientRect().width ?? 0; + const maxWidth = Math.max( + LOCAL_PANE_MIN_WIDTH, + rowWidth ? rowWidth * 0.65 : Number.POSITIVE_INFINITY, + ); + let latest = startWidth; + const onMove = (e: MouseEvent) => { + latest = Math.min( + maxWidth, + Math.max(LOCAL_PANE_MIN_WIDTH, startWidth + (e.clientX - startX)), + ); + setLocalPaneWidth(latest); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + try { + localStorage.setItem(LOCAL_PANE_WIDTH_STORAGE_KEY, String(latest)); + } catch { + // storage unavailable + } + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [localPaneWidth], + ); + const toggleLocalPane = useCallback(() => { + setLocalPaneOpen((prev) => { + const next = !prev; + try { + localStorage.setItem(LOCAL_PANE_OPEN_STORAGE_KEY, String(next)); + } catch { + // storage unavailable + } + return next; + }); + }, []); const [trashOpen, setTrashOpen] = useState(false); const [hasConnectionError, setHasConnectionError] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -962,6 +1038,46 @@ function FileManagerContent({ return () => window.removeEventListener("file-manager:refresh", handler); }, [currentHost?.id, handleRefreshDirectory]); + const localTransfers = useLocalTransfers({ + sshSessionId, + hostId: currentHost?.id, + ensureSSHConnection, + onRemoteChanged: (remoteDir) => { + if (sshSessionId) invalidateCachedFileList(sshSessionId, remoteDir); + handleRefreshDirectory(); + setSidebarRefreshTrigger((prev) => prev + 1); + }, + onLocalChanged: () => setLocalPaneRefreshToken((prev) => prev + 1), + }); + + // Entries dragged from the local pane onto the remote grid (or onto one of + // its folders) upload into that folder, preserving directory structure. + const handleLocalFilesDrop = useCallback( + (localPaths: string[], targetDir?: FileItem) => { + const remoteDir = targetDir?.path ?? currentPathRef.current; + void localTransfers.uploadLocalPaths(localPaths, remoteDir); + }, + [localTransfers], + ); + + // Remote rows dragged onto the local pane download into the folder shown + // there (or the folder row they were dropped on). + const handleRemoteItemsDroppedToLocal = useCallback( + (remotePaths: string[], localDir: string) => { + const known = new Map(files.map((f) => [f.path, f])); + const items: FileItem[] = remotePaths.map( + (p) => + known.get(p) ?? { + name: p.split("/").filter(Boolean).pop() || p, + path: p, + type: "file", + }, + ); + void localTransfers.downloadRemoteItems(items, localDir); + }, + [files, localTransfers], + ); + useEffect(() => { const handler = (event: Event) => { const detail = ( @@ -3269,9 +3385,13 @@ function FileManagerContent({ handleFilesDropped={handleFilesDropped} handleCreateNewFolder={handleCreateNewFolder} handleCreateNewFile={handleCreateNewFile} + showLocalPaneToggle={localPaneAvailable} + localPaneOpen={localPaneAvailable && localPaneOpen} + onToggleLocalPane={toggleLocalPane} />
@@ -3308,7 +3428,37 @@ function FileManagerContent({
-
+ {localPaneAvailable && localPaneOpen && ( + <> +
+ +
+ {/* Drag handle between the local and remote panes */} +
+
+
+ + )} + +
{ diff --git a/src/ui/features/file-manager/FileManagerGrid.tsx b/src/ui/features/file-manager/FileManagerGrid.tsx index 414412ebf..2f8b03a55 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -32,9 +32,33 @@ import { useTranslation } from "react-i18next"; import type { FileItem } from "@/types/index"; import type { CreateIntent } from "./file-manager-types.ts"; import { formatFileSize } from "./file-manager-utils.ts"; +import { + REMOTE_FILES_DRAG_MIME, + isLocalFilesDrag, + parseLocalFilesDragPayload, +} from "./local-transfer-utils.ts"; +import { + useResizableColumns, + type ResizableColumnSpec, +} from "./hooks/useResizableColumns.ts"; +import { ColumnResizeHandle } from "./components/ColumnResizeHandle.tsx"; + +// Fixed list-view columns after the flexible name column; user-resizable. +const LIST_COLUMNS: ResizableColumnSpec[] = [ + { key: "modified", defaultWidth: 120, minWidth: 70 }, + { key: "owner", defaultWidth: 150, minWidth: 60 }, + { key: "size", defaultWidth: 80, minWidth: 56 }, + { key: "permissions", defaultWidth: 90, minWidth: 70 }, +]; +const LIST_COLUMNS_STORAGE_KEY = "termix:file-manager:columns:remote"; interface DragState { - type: "none" | "internal" | "external"; + /** + * internal: rows of this grid being moved around + * external: files dragged in from the OS + * local: entries dragged from the desktop app's local pane + */ + type: "none" | "internal" | "external" | "local"; files: FileItem[]; draggedFiles?: FileItem[]; target?: FileItem; @@ -50,6 +74,10 @@ interface FileManagerGridProps { onSelectionChange: (files: FileItem[]) => void; onRefresh: () => void; onUpload?: (files: FileList) => void; + /** OS drop that contains at least one directory (needs a recursive walk). */ + onUploadItems?: (entries: FileSystemEntry[]) => void; + /** Entries dragged from the local pane; `targetDir` when dropped on a folder. */ + onLocalFilesDrop?: (localPaths: string[], targetDir?: FileItem) => void; onDownload?: (files: FileItem[]) => void; onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void; viewMode?: "grid" | "list"; @@ -178,6 +206,8 @@ export function FileManagerGrid({ onSelectionChange, onRefresh, onUpload, + onUploadItems, + onLocalFilesDrop, onDownload, onContextMenu, viewMode = "grid", @@ -257,6 +287,11 @@ export function FileManagerGrid({ enabled: viewMode === "list" && files.length > 0, }); + const listColumns = useResizableColumns({ + storageKey: LIST_COLUMNS_STORAGE_KEY, + columns: LIST_COLUMNS, + }); + const gridVirtualizer = useVirtualizer({ count: gridRowCount, getScrollElement: () => gridRef.current, @@ -350,6 +385,8 @@ export function FileManagerGrid({ files: filesToDrag.map((f) => f.path), }; e.dataTransfer.setData("text/plain", JSON.stringify(dragData)); + // Lets sibling panes recognise this drag before the payload is readable. + e.dataTransfer.setData(REMOTE_FILES_DRAG_MIME, "1"); e.dataTransfer.effectAllowed = "move"; }; @@ -363,6 +400,20 @@ export function FileManagerGrid({ ) { setDragState((prev) => ({ ...prev, target: targetFile })); e.dataTransfer.dropEffect = "move"; + } else if (isLocalFilesDrag(e.dataTransfer)) { + e.dataTransfer.dropEffect = "copy"; + const nextTarget = + targetFile.type === "directory" ? targetFile : undefined; + if ( + dragState.type !== "local" || + dragState.target?.path !== nextTarget?.path + ) { + setDragState((prev) => ({ + ...prev, + type: "local", + target: nextTarget, + })); + } } }; @@ -379,6 +430,20 @@ export function FileManagerGrid({ e.preventDefault(); e.stopPropagation(); + if (isLocalFilesDrag(e.dataTransfer)) { + const localPaths = parseLocalFilesDragPayload( + e.dataTransfer.getData("text/plain"), + ); + setDragState({ type: "none", files: [], counter: 0 }); + if (localPaths) { + onLocalFilesDrop?.( + localPaths, + targetFile.type === "directory" ? targetFile : undefined, + ); + } + return; + } + if (dragState.type !== "internal" || dragState.files.length === 0) { setDragState((prev) => ({ ...prev, target: undefined })); return; @@ -433,9 +498,12 @@ export function FileManagerGrid({ const isInternalDrag = dragState.type === "internal"; if (!isInternalDrag) { + const nextType = isLocalFilesDrag(e.dataTransfer) + ? "local" + : "external"; setDragState((prev) => ({ ...prev, - type: "external", + type: nextType, counter: prev.counter + 1, })); } @@ -450,13 +518,17 @@ export function FileManagerGrid({ const isInternalDrag = dragState.type === "internal"; - if (!isInternalDrag && dragState.type === "external") { + if ( + !isInternalDrag && + (dragState.type === "external" || dragState.type === "local") + ) { setDragState((prev) => { const newCounter = prev.counter - 1; return { ...prev, counter: newCounter, - type: newCounter <= 0 ? "none" : "external", + type: newCounter <= 0 ? "none" : prev.type, + target: newCounter <= 0 ? undefined : prev.target, }; }); } @@ -729,15 +801,39 @@ export function FileManagerGrid({ if (dragState.type === "internal") { setDragState({ type: "none", files: [], counter: 0 }); - } else if (dragState.type === "external") { - if (onUpload && e.dataTransfer.files.length > 0) { - onUpload(e.dataTransfer.files); + return; + } + + // Read everything off dataTransfer before any setState: the browser + // clears it once the handler unwinds and a state flush can get there + // first. + const localPaths = isLocalFilesDrag(e.dataTransfer) + ? parseLocalFilesDragPayload(e.dataTransfer.getData("text/plain")) + : null; + const files = e.dataTransfer.files; + const entries: FileSystemEntry[] = []; + if (onUploadItems && e.dataTransfer.items?.length > 0) { + for (const item of Array.from(e.dataTransfer.items)) { + const entry = item.webkitGetAsEntry?.(); + if (entry) entries.push(entry); } } setDragState({ type: "none", files: [], counter: 0 }); + + if (localPaths) { + onLocalFilesDrop?.(localPaths); + return; + } + if (onUploadItems && entries.some((entry) => entry.isDirectory)) { + onUploadItems(entries); + return; + } + if (onUpload && files.length > 0) { + onUpload(files); + } }, - [onUpload, dragState], + [onUpload, onUploadItems, onLocalFilesDrop, dragState], ); const handleFileClick = (file: FileItem, event: React.MouseEvent) => { @@ -949,7 +1045,7 @@ export function FileManagerGrid({ className={cn( "absolute inset-0 overflow-y-auto thin-scrollbar", compact ? "p-2" : "p-4", - dragState.type === "external" && + (dragState.type === "external" || dragState.type === "local") && "bg-muted/20 border-2 border-dashed border-primary", )} onClick={handleGridClick} @@ -964,12 +1060,15 @@ export function FileManagerGrid({ onContextMenu={(e) => onContextMenu?.(e)} tabIndex={0} > - {dragState.type === "external" && ( + {(dragState.type === "external" || + (dragState.type === "local" && !dragState.target)) && (

- {t("fileManager.dragFilesToUpload")} + {dragState.type === "local" + ? t("fileManager.dropToUploadHere") + : t("fileManager.dragFilesToUpload")}

@@ -1120,12 +1219,13 @@ export function FileManagerGrid({
onSortChange?.("name")} > {t("fileManager.name")} @@ -1137,10 +1237,13 @@ export function FileManagerGrid({ ))}
onSortChange?.("modified")} > - {t("fileManager.modified")} + + {t("fileManager.modified")} {sortBy === "modified" && (sortOrder === "asc" ? ( @@ -1148,12 +1251,18 @@ export function FileManagerGrid({ ))}
-
+
+ + {t("fileManager.owner")} +
onSortChange?.("size")} > - {t("fileManager.size")} + + {t("fileManager.size")} {sortBy === "size" && (sortOrder === "asc" ? ( @@ -1161,13 +1270,21 @@ export function FileManagerGrid({ ))}
-
{t("fileManager.permissions")}
+
+ + + {t("fileManager.permissions")} + +
{createIntent && ( )}
- + {file.modified || "—"} @@ -1270,7 +1390,7 @@ export function FileManagerGrid({ : "—"} - + {file.permissions || "—"}
@@ -1456,10 +1576,12 @@ function CreateIntentListItem({ intent, onConfirm, onCancel, + gridTemplateColumns, }: { intent: CreateIntent; onConfirm?: (name: string) => void; onCancel?: () => void; + gridTemplateColumns?: string; }) { const { t } = useTranslation(); const [inputName, setInputName] = useState(intent.currentName); @@ -1503,7 +1625,11 @@ function CreateIntentListItem({ return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > diff --git a/src/ui/features/file-manager/FileManagerToolbar.tsx b/src/ui/features/file-manager/FileManagerToolbar.tsx index f795277e3..705d0ab92 100644 --- a/src/ui/features/file-manager/FileManagerToolbar.tsx +++ b/src/ui/features/file-manager/FileManagerToolbar.tsx @@ -8,6 +8,7 @@ import { Folder, FolderPlus, Grid3X3, + Laptop, Layout, List, Plus, @@ -65,6 +66,10 @@ type FileManagerToolbarProps = { handleFilesDropped: (fileList: FileList) => void; handleCreateNewFolder: () => void; handleCreateNewFile: () => void; + /** Desktop app only: show the Local | Remote split-view toggle. */ + showLocalPaneToggle?: boolean; + localPaneOpen?: boolean; + onToggleLocalPane?: () => void; }; function Breadcrumb({ @@ -218,6 +223,9 @@ export function FileManagerToolbar({ handleFilesDropped, handleCreateNewFolder, handleCreateNewFile, + showLocalPaneToggle = false, + localPaneOpen = false, + onToggleLocalPane, }: FileManagerToolbarProps) { return (
@@ -310,6 +318,23 @@ export function FileManagerToolbar({ />
+ {showLocalPaneToggle && ( + + )} +
+ ); + })} +
+ + ); +} diff --git a/src/ui/features/file-manager/LocalFilePane.tsx b/src/ui/features/file-manager/LocalFilePane.tsx new file mode 100644 index 000000000..c73570313 --- /dev/null +++ b/src/ui/features/file-manager/LocalFilePane.tsx @@ -0,0 +1,1038 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + ArrowDown, + ArrowUp, + ChevronLeft, + ChevronRight, + Download, + Eye, + EyeOff, + File, + Folder, + FolderOpen, + FolderPlus, + Home, + Link2, + RefreshCw, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils.ts"; +import { Button } from "@/components/button.tsx"; +import { Input } from "@/components/input.tsx"; +import type { LocalFileEntry } from "@/types/electron"; +import { + createLocalFile, + createLocalFolder, + getLocalHome, + listLocalDirectory, + openLocalPath, + renameLocalEntry, + revealLocalPath, + trashLocalPaths, +} from "@/lib/local-files.ts"; +import { copyToClipboard } from "@/lib/clipboard.ts"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx"; +import { + useResizableColumns, + type ResizableColumnSpec, +} from "./hooks/useResizableColumns.ts"; +import { ColumnResizeHandle } from "./components/ColumnResizeHandle.tsx"; +import { formatFileSize } from "./file-manager-utils.ts"; +import { + LOCAL_FILES_DRAG_MIME, + type LocalSortField, + describeLocalKind, + formatLocalModified, + isRemoteFilesDrag, + parseInternalFilesDragPayload, + serializeLocalFilesDragPayload, + sortLocalEntries, +} from "./local-transfer-utils.ts"; + +const LAST_PATH_STORAGE_KEY = "termix:file-manager:local-pane:path"; +const SHOW_HIDDEN_STORAGE_KEY = "termix:file-manager:local-pane:hidden"; +const ROW_HEIGHT = 34; +const COLUMNS_STORAGE_KEY = "termix:file-manager:columns:local"; +const LOCAL_COLUMNS: ResizableColumnSpec[] = [ + { key: "modified", defaultWidth: 130, minWidth: 70 }, + { key: "size", defaultWidth: 72, minWidth: 56 }, + { key: "kind", defaultWidth: 64, minWidth: 48 }, +]; + +export interface LocalFilePaneProps { + /** Bump to force a re-read of the current directory. */ + refreshToken?: number; + onClose?: () => void; + /** + * Remote grid items were dropped on this pane (or on one of its folders). + * `remotePaths` are the dragged remote paths; `localDir` is where they go. + */ + onRemoteItemsDropped: (remotePaths: string[], localDir: string) => void; + /** + * "Upload to server" from the context menu: sends the given local paths to + * the remote pane's current folder. Omit to hide the action. + */ + onUploadToRemote?: (localPaths: string[]) => void; + onPathChange?: (localPath: string) => void; +} + +function readStoredPath(): string | null { + try { + return localStorage.getItem(LAST_PATH_STORAGE_KEY); + } catch { + return null; + } +} + +function readStoredShowHidden(): boolean { + try { + return localStorage.getItem(SHOW_HIDDEN_STORAGE_KEY) !== "false"; + } catch { + return true; + } +} + +function LocalEntryIcon({ entry }: { entry: LocalFileEntry }) { + if (entry.type === "directory") { + return ; + } + if (entry.type === "link") { + return ; + } + return ; +} + +export function LocalFilePane({ + refreshToken, + onClose, + onRemoteItemsDropped, + onUploadToRemote, + onPathChange, +}: LocalFilePaneProps) { + const { t } = useTranslation(); + const { confirmWithToast } = useConfirmation(); + const [homePath, setHomePath] = useState(null); + const [currentPath, setCurrentPath] = useState(null); + const [parentPath, setParentPath] = useState(null); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pathInput, setPathInput] = useState(""); + const [showHidden, setShowHidden] = useState(readStoredShowHidden); + const [sortBy, setSortBy] = useState("name"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selected, setSelected] = useState>(new Set()); + const [anchorPath, setAnchorPath] = useState(null); + const historyRef = useRef([]); + const historyIndexRef = useRef(-1); + const [nav, setNav] = useState({ canBack: false, canForward: false }); + const [creating, setCreating] = useState<"folder" | "file" | null>(null); + const [newEntryName, setNewEntryName] = useState(""); + const [renaming, setRenaming] = useState<{ + path: string; + name: string; + } | null>(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + entries: LocalFileEntry[]; + visible: boolean; + }>({ x: 0, y: 0, entries: [], visible: false }); + const [dropTarget, setDropTarget] = useState< + { kind: "pane" } | { kind: "folder"; path: string } | null + >(null); + const dragCounter = useRef(0); + const scrollRef = useRef(null); + const newEntryInputRef = useRef(null); + const renameInputRef = useRef(null); + const currentPathRef = useRef(null); + currentPathRef.current = currentPath; + + const visibleEntries = useMemo(() => { + const filtered = showHidden + ? entries + : entries.filter((entry) => !entry.hidden); + return sortLocalEntries(filtered, sortBy, sortOrder); + }, [entries, showHidden, sortBy, sortOrder]); + + const columns = useResizableColumns({ + storageKey: COLUMNS_STORAGE_KEY, + columns: LOCAL_COLUMNS, + }); + const rowStyle = { gridTemplateColumns: columns.gridTemplateColumns }; + + const virtualizer = useVirtualizer({ + count: visibleEntries.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 12, + }); + + const load = useCallback( + async (dirPath: string) => { + setLoading(true); + setError(null); + try { + const listing = await listLocalDirectory(dirPath); + setCurrentPath(listing.path); + setParentPath(listing.parent); + setEntries(listing.entries); + setPathInput(listing.path); + setSelected(new Set()); + setAnchorPath(null); + try { + localStorage.setItem(LAST_PATH_STORAGE_KEY, listing.path); + } catch { + // storage unavailable + } + onPathChange?.(listing.path); + return listing.path; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setLoading(false); + } + }, + [onPathChange], + ); + + const syncNav = useCallback(() => { + setNav({ + canBack: historyIndexRef.current > 0, + canForward: historyIndexRef.current < historyRef.current.length - 1, + }); + }, []); + + const navigateTo = useCallback( + async (dirPath: string, { record = true }: { record?: boolean } = {}) => { + const resolved = await load(dirPath); + if (resolved && record) { + const kept = historyRef.current.slice(0, historyIndexRef.current + 1); + if (kept[kept.length - 1] !== resolved) kept.push(resolved); + historyRef.current = kept; + historyIndexRef.current = kept.length - 1; + syncNav(); + } + }, + [load, syncNav], + ); + + // Initial load: last visited folder, falling back to the home directory. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const info = await getLocalHome(); + if (cancelled) return; + setHomePath(info.home); + const start = readStoredPath() || info.home; + let resolved = await load(start); + if (!resolved && start !== info.home) { + resolved = await load(info.home); + } + historyRef.current = [resolved || info.home]; + historyIndexRef.current = 0; + syncNav(); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // External refresh requests (e.g. after a download finished). + const lastRefreshToken = useRef(refreshToken); + useEffect(() => { + if (refreshToken === lastRefreshToken.current) return; + lastRefreshToken.current = refreshToken; + if (currentPathRef.current) void load(currentPathRef.current); + }, [refreshToken, load]); + + useEffect(() => { + try { + localStorage.setItem(SHOW_HIDDEN_STORAGE_KEY, String(showHidden)); + } catch { + // storage unavailable + } + }, [showHidden]); + + useEffect(() => { + if (creating) newEntryInputRef.current?.focus(); + }, [creating]); + + useEffect(() => { + if (!renaming) return; + const input = renameInputRef.current; + if (!input) return; + input.focus(); + // Select the stem so typing replaces the name but keeps the extension. + const dot = renaming.name.lastIndexOf("."); + input.setSelectionRange(0, dot > 0 ? dot : renaming.name.length); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renaming?.path]); + + const goBack = () => { + if (historyIndexRef.current <= 0) return; + historyIndexRef.current -= 1; + syncNav(); + void navigateTo(historyRef.current[historyIndexRef.current], { + record: false, + }); + }; + + const goForward = () => { + if (historyIndexRef.current >= historyRef.current.length - 1) return; + historyIndexRef.current += 1; + syncNav(); + void navigateTo(historyRef.current[historyIndexRef.current], { + record: false, + }); + }; + + const goUp = () => { + if (parentPath) void navigateTo(parentPath); + }; + + const goHome = () => { + if (homePath) void navigateTo(homePath); + }; + + const refresh = () => { + if (currentPath) void load(currentPath); + }; + + const submitPathInput = () => { + const trimmed = pathInput.trim(); + if (!trimmed || trimmed === currentPath) return; + void navigateTo(trimmed); + }; + + const toggleSort = (field: LocalSortField) => { + if (field === sortBy) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortBy(field); + setSortOrder("asc"); + } + }; + + const handleRowClick = (entry: LocalFileEntry, event: React.MouseEvent) => { + event.stopPropagation(); + if (event.detail === 2) { + openEntry(entry); + return; + } + + if (event.shiftKey && anchorPath) { + const paths = visibleEntries.map((e) => e.path); + const a = paths.indexOf(anchorPath); + const b = paths.indexOf(entry.path); + if (a !== -1 && b !== -1) { + const [from, to] = a < b ? [a, b] : [b, a]; + setSelected(new Set(paths.slice(from, to + 1))); + return; + } + } + + if (event.metaKey || event.ctrlKey) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(entry.path)) next.delete(entry.path); + else next.add(entry.path); + return next; + }); + setAnchorPath(entry.path); + return; + } + + setSelected(new Set([entry.path])); + setAnchorPath(entry.path); + }; + + const handleRowDragStart = ( + event: React.DragEvent, + entry: LocalFileEntry, + ) => { + const paths = selected.has(entry.path) + ? Array.from(selected) + : [entry.path]; + if (!selected.has(entry.path)) { + setSelected(new Set([entry.path])); + setAnchorPath(entry.path); + } + const payload = serializeLocalFilesDragPayload(paths); + event.dataTransfer.setData(LOCAL_FILES_DRAG_MIME, payload); + event.dataTransfer.setData("text/plain", payload); + event.dataTransfer.effectAllowed = "copy"; + }; + + // ---- Drop target handling (remote grid -> local) ---- + + const handlePaneDragEnter = (event: React.DragEvent) => { + if (!isRemoteFilesDrag(event.dataTransfer)) return; + event.preventDefault(); + dragCounter.current += 1; + setDropTarget((prev) => prev ?? { kind: "pane" }); + }; + + const handlePaneDragOver = (event: React.DragEvent) => { + if (!isRemoteFilesDrag(event.dataTransfer)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handlePaneDragLeave = (event: React.DragEvent) => { + if (!isRemoteFilesDrag(event.dataTransfer)) return; + event.preventDefault(); + dragCounter.current = Math.max(0, dragCounter.current - 1); + if (dragCounter.current === 0) setDropTarget(null); + }; + + const finishDrop = (event: React.DragEvent, localDir: string | null) => { + const remotePaths = parseInternalFilesDragPayload( + event.dataTransfer.getData("text/plain"), + ); + dragCounter.current = 0; + setDropTarget(null); + if (!remotePaths || !localDir) return; + onRemoteItemsDropped(remotePaths, localDir); + }; + + const handlePaneDrop = (event: React.DragEvent) => { + if (!isRemoteFilesDrag(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + finishDrop(event, currentPath); + }; + + const handleFolderDragOver = ( + event: React.DragEvent, + entry: LocalFileEntry, + ) => { + if (entry.type !== "directory" || !isRemoteFilesDrag(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setDropTarget((prev) => + prev?.kind === "folder" && prev.path === entry.path + ? prev + : { kind: "folder", path: entry.path }, + ); + }; + + const handleFolderDragLeave = ( + event: React.DragEvent, + entry: LocalFileEntry, + ) => { + if (entry.type !== "directory" || !isRemoteFilesDrag(event.dataTransfer)) { + return; + } + event.stopPropagation(); + setDropTarget((prev) => + prev?.kind === "folder" && prev.path === entry.path + ? { kind: "pane" } + : prev, + ); + }; + + const handleFolderDrop = (event: React.DragEvent, entry: LocalFileEntry) => { + if (entry.type !== "directory" || !isRemoteFilesDrag(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + finishDrop(event, entry.path); + }; + + const reportError = (err: unknown, fallbackKey: string) => { + const message = err instanceof Error ? err.message : String(err ?? ""); + toast.error(t(fallbackKey), message ? { description: message } : undefined); + }; + + const submitNewEntry = async () => { + const kind = creating; + const name = newEntryName.trim(); + setCreating(null); + setNewEntryName(""); + if (!kind || !name || !currentPath) return; + try { + if (kind === "folder") await createLocalFolder(currentPath, name); + else await createLocalFile(currentPath, name); + await load(currentPath); + } catch (err) { + reportError(err, "fileManager.localCreateFailed"); + } + }; + + const startRename = (entry: LocalFileEntry) => { + setRenaming({ path: entry.path, name: entry.name }); + }; + + const submitRename = async () => { + const current = renaming; + setRenaming(null); + if (!current || !currentPath) return; + const nextName = current.name.trim(); + const entry = entries.find((e) => e.path === current.path); + if (!entry || !nextName || nextName === entry.name) return; + try { + await renameLocalEntry(entry.path, nextName); + await load(currentPath); + } catch (err) { + reportError(err, "fileManager.localRenameFailed"); + } + }; + + const openEntry = (entry: LocalFileEntry) => { + if (entry.type === "directory") { + void navigateTo(entry.path); + } else { + void openLocalPath(entry.path).catch(() => { + void revealLocalPath(entry.path); + }); + } + }; + + const copyEntryPaths = (targets: LocalFileEntry[]) => { + if (targets.length === 0) return; + void copyToClipboard(targets.map((e) => e.path).join("\n")).then((ok) => { + if (ok) { + toast.success( + targets.length === 1 + ? t("fileManager.pathCopiedToClipboard") + : t("fileManager.pathsCopiedToClipboard", { + count: targets.length, + }), + ); + } else { + toast.error(t("fileManager.failedToCopyPath")); + } + }); + }; + + const trashEntries = (targets: LocalFileEntry[]) => { + if (targets.length === 0 || !currentPath) return; + const hasDirectory = targets.some((e) => e.type === "directory"); + const message = + targets.length === 1 + ? t( + hasDirectory + ? "fileManager.localTrashConfirmFolder" + : "fileManager.localTrashConfirmSingle", + { name: targets[0].name }, + ) + : t("fileManager.localTrashConfirmMany", { count: targets.length }); + + void confirmWithToast( + message, + async () => { + try { + const result = await trashLocalPaths(targets.map((e) => e.path)); + if (result.failed.length === 0) { + toast.success( + t("fileManager.localTrashed", { count: result.trashed }), + ); + } else { + toast.error(t("fileManager.localTrashFailed"), { + description: result.failed + .map((f) => `${f.path}: ${f.error}`) + .join("\n"), + }); + } + } catch (err) { + reportError(err, "fileManager.localTrashFailed"); + } + await load(currentPath); + }, + "destructive", + ); + }; + + const selectedEntries = () => + visibleEntries.filter((e) => selected.has(e.path)); + + const openContextMenu = (event: React.MouseEvent, entry?: LocalFileEntry) => { + event.preventDefault(); + event.stopPropagation(); + let targets: LocalFileEntry[] = []; + if (entry) { + if (selected.has(entry.path)) { + targets = selectedEntries(); + } else { + setSelected(new Set([entry.path])); + setAnchorPath(entry.path); + targets = [entry]; + } + } + setContextMenu({ + x: event.clientX, + y: event.clientY, + entries: targets, + visible: true, + }); + }; + + const closeContextMenu = useCallback( + () => setContextMenu((prev) => ({ ...prev, visible: false })), + [], + ); + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Inline editors handle their own keys. + if ((event.target as HTMLElement).tagName === "INPUT") return; + const targets = selectedEntries(); + if (event.key === "Enter" && targets.length === 1) { + event.preventDefault(); + openEntry(targets[0]); + } else if (event.key === "F2" && targets.length === 1) { + event.preventDefault(); + startRename(targets[0]); + } else if ( + (event.key === "Delete" || + (event.key === "Backspace" && (event.metaKey || event.ctrlKey))) && + targets.length > 0 + ) { + event.preventDefault(); + trashEntries(targets); + } else if (event.key === "F5") { + event.preventDefault(); + refresh(); + } else if (event.key === "Escape") { + setSelected(new Set()); + setAnchorPath(null); + } else if ( + event.key === "a" && + (event.metaKey || event.ctrlKey) && + visibleEntries.length > 0 + ) { + event.preventDefault(); + setSelected(new Set(visibleEntries.map((e) => e.path))); + } + }; + + const showPaneOverlay = dropTarget?.kind === "pane"; + + const sortIndicator = (field: LocalSortField) => + sortBy === field ? ( + sortOrder === "asc" ? ( + + ) : ( + + ) + ) : null; + + return ( +
+ {/* Header */} +
+ + {t("fileManager.localFiles")} + +
+ + + + +
+ + setPathInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitPathInput(); + if (e.key === "Escape" && currentPath) setPathInput(currentPath); + }} + onBlur={() => currentPath && setPathInput(currentPath)} + spellCheck={false} + className="h-7 flex-1 min-w-0 text-xs font-mono bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50" + placeholder={t("fileManager.localPathPlaceholder")} + /> + +
+ + + + +
+ + {onClose && ( + + )} +
+ + {/* Column headers */} +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + {/* Body */} +
{ + setSelected(new Set()); + setAnchorPath(null); + }} + onContextMenu={(e) => openContextMenu(e)} + > + {creating && ( +
+
+ {creating === "folder" ? ( + + ) : ( + + )} + setNewEntryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submitNewEntry(); + if (e.key === "Escape") { + setCreating(null); + setNewEntryName(""); + } + }} + onBlur={() => void submitNewEntry()} + onClick={(e) => e.stopPropagation()} + placeholder={ + creating === "folder" + ? t("fileManager.newFolder") + : t("fileManager.newFile") + } + className="flex-1 min-w-0 border border-accent-brand/60 bg-card px-2 py-0.5 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50" + /> +
+
+ )} + + {error ? ( +
+

+ {t("fileManager.localCannotOpenFolder")} +

+

{error}

+ +
+ ) : visibleEntries.length === 0 && !loading ? ( +
+ + + {t("fileManager.emptyFolder")} + +
+ ) : ( +
+ {virtualizer.getVirtualItems().map((vItem) => { + const entry = visibleEntries[vItem.index]; + if (!entry) return null; + const isSelected = selected.has(entry.path); + const isFolderTarget = + dropTarget?.kind === "folder" && dropTarget.path === entry.path; + return ( +
+
handleRowClick(entry, e)} + onContextMenu={(e) => openContextMenu(e, entry)} + onDragStart={(e) => handleRowDragStart(e, entry)} + onDragOver={(e) => handleFolderDragOver(e, entry)} + onDragLeave={(e) => handleFolderDragLeave(e, entry)} + onDrop={(e) => handleFolderDrop(e, entry)} + title={entry.path} + > +
+ + + + {renaming?.path === entry.path ? ( + + setRenaming({ + path: entry.path, + name: e.target.value, + }) + } + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") void submitRename(); + if (e.key === "Escape") setRenaming(null); + }} + onBlur={() => void submitRename()} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + draggable={false} + onDragStart={(e) => e.preventDefault()} + className="flex-1 min-w-0 border border-accent-brand/60 bg-card px-2 py-0.5 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto" + /> + ) : ( + + {entry.name} + {entry.type === "link" && entry.linkTarget && ( + + → {entry.linkTarget} + + )} + + )} +
+ + {formatLocalModified(entry.modifiedTimestamp)} + + + {entry.type === "directory" + ? "--" + : formatFileSize(entry.size)} + + + {describeLocalKind(entry)} + +
+
+ ); + })} +
+ )} + + {showPaneOverlay && ( +
+
+ +

+ {t("fileManager.dropToDownloadHere")} +

+
+
+ )} +
+ + + onUploadToRemote?.(targets.map((e) => e.path)) + } + onReveal={(entry) => { + const target = entry?.path ?? currentPath; + if (target) void revealLocalPath(target); + }} + onRename={startRename} + onCopyPath={copyEntryPaths} + onNewFolder={() => setCreating("folder")} + onNewFile={() => setCreating("file")} + onRefresh={refresh} + onToggleHidden={() => setShowHidden((prev) => !prev)} + onDelete={trashEntries} + /> + + {/* Footer */} +
+ + {t("fileManager.localItemCount", { count: visibleEntries.length })} + + {selected.size > 0 && ( + + {t("fileManager.localSelectedCount", { count: selected.size })} + + )} +
+
+ ); +} diff --git a/src/ui/features/file-manager/components/ColumnResizeHandle.tsx b/src/ui/features/file-manager/components/ColumnResizeHandle.tsx new file mode 100644 index 000000000..fdae7a68f --- /dev/null +++ b/src/ui/features/file-manager/components/ColumnResizeHandle.tsx @@ -0,0 +1,43 @@ +import type React from "react"; +import { cn } from "@/lib/utils.ts"; + +interface ColumnResizeHandleProps { + onMouseDown: (event: React.MouseEvent) => void; + onDoubleClick: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; + "data-resizing"?: string; + title?: string; +} + +/** + * Thin grab area on the left edge of a list-view header cell. The parent cell + * must be `relative`. Shows a hairline on hover and while dragging. + */ +export function ColumnResizeHandle({ + title, + "data-resizing": resizing, + ...handlers +}: ColumnResizeHandleProps) { + return ( +
+
+
+ ); +} diff --git a/src/ui/features/file-manager/components/LocalTransferProgressToast.tsx b/src/ui/features/file-manager/components/LocalTransferProgressToast.tsx new file mode 100644 index 000000000..f7272be0c --- /dev/null +++ b/src/ui/features/file-manager/components/LocalTransferProgressToast.tsx @@ -0,0 +1,94 @@ +import { Button } from "@/components/button.tsx"; +import { useTranslation } from "react-i18next"; +import { formatFileSize } from "../file-manager-utils.ts"; + +export interface LocalTransferBatchStatus { + direction: "upload" | "download"; + totalFiles: number; + completedFiles: number; + currentFileName?: string; + bytesDone: number; + totalBytes: number; + mbPerSec?: number; + cancelling?: boolean; +} + +interface LocalTransferProgressToastProps { + status: LocalTransferBatchStatus; + onCancel?: () => void; +} + +export function LocalTransferProgressToast({ + status, + onCancel, +}: LocalTransferProgressToastProps) { + const { t } = useTranslation(); + const percent = + status.totalBytes > 0 + ? Math.min(100, Math.round((status.bytesDone / status.totalBytes) * 100)) + : undefined; + + const title = + status.direction === "upload" + ? t("fileManager.localUploadingProgress", { + current: Math.min(status.completedFiles + 1, status.totalFiles), + total: status.totalFiles, + }) + : t("fileManager.localDownloadingProgress", { + current: Math.min(status.completedFiles + 1, status.totalFiles), + total: status.totalFiles, + }); + + return ( +
+
+
+

{title}

+ {status.currentFileName && ( +

+ {status.currentFileName} +

+ )} +
+ {onCancel && ( + + )} +
+
+ {percent === undefined ? ( +
+ ) : ( +
+ )} +
+
+ + {formatFileSize(status.bytesDone)} /{" "} + {formatFileSize(status.totalBytes)} + + + {status.mbPerSec !== undefined + ? `${status.mbPerSec.toFixed(status.mbPerSec >= 10 ? 0 : 1)} MB/s` + : ""} + +
+
+ ); +} diff --git a/src/ui/features/file-manager/hooks/useLocalTransfers.ts b/src/ui/features/file-manager/hooks/useLocalTransfers.ts new file mode 100644 index 000000000..034ab4eb5 --- /dev/null +++ b/src/ui/features/file-manager/hooks/useLocalTransfers.ts @@ -0,0 +1,521 @@ +import { useCallback, useRef } from "react"; +import { createElement } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { FileItem } from "@/types/index"; +import { + cancelLocalTransfer, + createLocalTransferId, + createSSHFolder, + downloadSessionFileToLocal, + listSSHFiles, + uploadLocalFileToSession, +} from "@/main-axios.ts"; +import { + ensureLocalDirectory, + getLocalHome, + localPathsExist, + walkLocalPaths, +} from "@/lib/local-files.ts"; +import { + UnsafeLocalNameError, + buildLocalDestination, + joinRemotePath, + planRemoteDirectories, + remoteBaseName, + remoteDirForRelativePath, +} from "../local-transfer-utils.ts"; +import { + LocalTransferProgressToast, + type LocalTransferBatchStatus, +} from "../components/LocalTransferProgressToast.tsx"; + +interface UseLocalTransfersOptions { + sshSessionId: string | null; + hostId?: number; + ensureSSHConnection: () => Promise; + /** Called after uploads so the remote listing can refresh. */ + onRemoteChanged: (remoteDir: string) => void; + /** Called after downloads so the local pane can refresh. */ + onLocalChanged: (localDir: string) => void; +} + +interface RemoteDownloadPlanEntry { + remotePath: string; + /** "/"-separated, includes the dragged root's own name. */ + relativePath: string; + size?: number; +} + +/** + * Asks whether existing local files may be replaced. Resolves `true` only on + * an explicit "Replace"; dismissing, auto-close and "Skip" all mean skip, so + * the batch can never overwrite silently. + */ +function askReplaceExisting( + t: ReturnType["t"], + count: number, +): Promise { + return new Promise((resolve) => { + let settled = false; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + resolve(value); + }; + toast.warning(t("fileManager.localReplaceExistingPrompt", { count }), { + duration: 15000, + action: { + label: t("fileManager.localReplace"), + onClick: () => settle(true), + }, + cancel: { + label: t("fileManager.localSkipExisting"), + onClick: () => settle(false), + }, + onDismiss: () => settle(false), + onAutoClose: () => settle(false), + }); + }); +} + +class TransferCancelledError extends Error { + constructor() { + super("Transfer cancelled"); + this.name = "TransferCancelledError"; + } +} + +function createSpeedometer() { + let lastBytes = 0; + let lastTime = Date.now(); + let mbPerSec: number | undefined; + return (bytesDone: number) => { + const now = Date.now(); + const deltaMs = now - lastTime; + if (deltaMs >= 300) { + const deltaBytes = bytesDone - lastBytes; + if (deltaBytes >= 0) { + mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000; + } + lastBytes = bytesDone; + lastTime = now; + } + return mbPerSec; + }; +} + +/** + * Orchestrates batched local<->remote transfers for the dual-pane file + * manager: expands folders, creates directory skeletons, streams files one by + * one through the Electron main process, and reports aggregate progress in a + * single toast with a cancel button. + */ +export function useLocalTransfers({ + sshSessionId, + hostId, + ensureSSHConnection, + onRemoteChanged, + onLocalChanged, +}: UseLocalTransfersOptions) { + const { t } = useTranslation(); + const batchCounter = useRef(0); + + const runBatch = useCallback( + async ( + direction: "upload" | "download", + totalFiles: number, + totalBytes: number, + work: (ctx: { + isCancelled: () => boolean; + setCurrentTransfer: (id: string | null) => void; + report: ( + completedFiles: number, + bytesDone: number, + currentFileName?: string, + ) => void; + }) => Promise<{ failed: string[] }>, + ) => { + batchCounter.current += 1; + const toastId = `local-transfer-${batchCounter.current}`; + let cancelled = false; + let cancelling = false; + let currentTransfer: string | null = null; + const speed = createSpeedometer(); + + const status: LocalTransferBatchStatus = { + direction, + totalFiles, + completedFiles: 0, + bytesDone: 0, + totalBytes, + }; + + const render = () => { + toast.loading( + createElement(LocalTransferProgressToast, { + status: { ...status, cancelling }, + onCancel: () => { + cancelled = true; + cancelling = true; + render(); + if (currentTransfer) void cancelLocalTransfer(currentTransfer); + }, + }), + { id: toastId, duration: Infinity }, + ); + }; + render(); + + try { + const { failed } = await work({ + isCancelled: () => cancelled, + setCurrentTransfer: (id) => { + currentTransfer = id; + }, + report: (completedFiles, bytesDone, currentFileName) => { + status.completedFiles = completedFiles; + status.bytesDone = bytesDone; + status.currentFileName = currentFileName; + status.mbPerSec = speed(bytesDone); + render(); + }, + }); + + toast.dismiss(toastId); + if (cancelled) { + toast.info(t("fileManager.localTransferCancelled")); + return; + } + const key = direction === "upload" ? "Upload" : "Download"; + if (failed.length === 0) { + toast.success( + t(`fileManager.local${key}Complete`, { count: totalFiles }), + ); + } else if (failed.length === totalFiles) { + toast.error(t(`fileManager.local${key}Failed`)); + } else { + toast.warning( + t(`fileManager.local${key}Partial`, { + done: totalFiles - failed.length, + failed: failed.length, + }), + ); + } + } catch (error) { + toast.dismiss(toastId); + if (error instanceof TransferCancelledError || cancelled) { + toast.info(t("fileManager.localTransferCancelled")); + return; + } + const message = + error instanceof Error ? error.message : String(error ?? ""); + toast.error( + direction === "upload" + ? t("fileManager.localUploadFailed") + : t("fileManager.localDownloadFailed"), + message ? { description: message } : undefined, + ); + console.error(`Local ${direction} batch failed:`, error); + } + }, + [t], + ); + + /** Uploads local files/folders (by absolute path) into a remote directory. */ + const uploadLocalPaths = useCallback( + async (localPaths: string[], remoteDir: string) => { + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + if (localPaths.length === 0) return; + + let plan; + try { + plan = await walkLocalPaths(localPaths); + } catch (error) { + toast.error( + t("fileManager.localUploadFailed"), + error instanceof Error ? { description: error.message } : undefined, + ); + return; + } + if (plan.files.length === 0 && plan.emptyDirs.length === 0) { + toast.info(t("fileManager.localNothingToTransfer")); + return; + } + + const sessionId = sshSessionId; + await runBatch( + "upload", + plan.files.length, + plan.totalBytes, + async ({ isCancelled, setCurrentTransfer, report }) => { + await ensureSSHConnection(); + + const dirs = planRemoteDirectories( + plan.files.map((f) => f.relativePath), + plan.emptyDirs, + ); + for (const dir of dirs) { + if (isCancelled()) throw new TransferCancelledError(); + const parent = dir.includes("/") + ? joinRemotePath(remoteDir, dir.slice(0, dir.lastIndexOf("/"))) + : remoteDir; + const name = dir.split("/").pop()!; + try { + await createSSHFolder(sessionId, parent, name, hostId); + } catch { + // directory may already exist + } + } + + const failed: string[] = []; + let bytesDone = 0; + let completed = 0; + for (const file of plan.files) { + if (isCancelled()) break; + const fileName = file.relativePath.split("/").pop()!; + const targetDir = remoteDirForRelativePath( + remoteDir, + file.relativePath, + ); + const transferId = createLocalTransferId("local-upload"); + setCurrentTransfer(transferId); + report(completed, bytesDone, fileName); + try { + await uploadLocalFileToSession({ + sessionId, + remoteDir: targetDir, + localPath: file.localPath, + fileName, + hostId, + transferId, + onProgress: ({ transferred }) => + report(completed, bytesDone + transferred, fileName), + }); + bytesDone += file.size; + } catch (error) { + if (isCancelled()) break; + failed.push(file.relativePath); + bytesDone += file.size; + console.error(`Failed to upload ${file.localPath}:`, error); + } finally { + setCurrentTransfer(null); + } + completed += 1; + report(completed, bytesDone, fileName); + } + return { failed }; + }, + ); + + onRemoteChanged(remoteDir); + }, + [sshSessionId, hostId, ensureSSHConnection, onRemoteChanged, runBatch, t], + ); + + /** Downloads remote files/folders into a local directory. */ + const downloadRemoteItems = useCallback( + async (items: FileItem[], localDir: string) => { + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + if (items.length === 0) return; + const sessionId = sshSessionId; + + const { separator } = await getLocalHome(); + // Every remote name is validated for the local platform and the result + // must stay inside `localDir`; anything else is reported and skipped. + const toLocalPath = (relativePath: string) => + buildLocalDestination(localDir, relativePath, separator); + + // Expand directories into a flat file plan first so the toast can show + // a real total. Listing is the only part that goes through the + // renderer's normal API path. + const plan: RemoteDownloadPlanEntry[] = []; + const emptyDirs: string[] = []; + + const expandTaskId = `local-download-expand-${Date.now()}`; + toast.loading(t("fileManager.localPreparingDownload"), { + id: expandTaskId, + duration: Infinity, + }); + + try { + await ensureSSHConnection(); + + const walkRemote = async (remotePath: string, relDir: string) => { + const { files } = await listSSHFiles(sessionId, remotePath, { + force: true, + }); + if (files.length === 0) { + emptyDirs.push(relDir); + return; + } + for (const child of files) { + const rel = `${relDir}/${child.name}`; + if (child.type === "directory") { + await walkRemote(child.path, rel); + } else if (child.type === "file" || child.type === "link") { + plan.push({ + remotePath: child.path, + relativePath: rel, + size: child.size, + }); + } + } + }; + + for (const item of items) { + const name = item.name || remoteBaseName(item.path); + if (item.type === "directory") { + await walkRemote(item.path, name); + } else { + plan.push({ + remotePath: item.path, + relativePath: name, + size: item.size, + }); + } + } + } catch (error) { + toast.dismiss(expandTaskId); + toast.error( + t("fileManager.localDownloadFailed"), + error instanceof Error ? { description: error.message } : undefined, + ); + return; + } + toast.dismiss(expandTaskId); + + if (plan.length === 0 && emptyDirs.length === 0) { + toast.info(t("fileManager.localNothingToTransfer")); + return; + } + + // Drop anything whose remote name cannot become a safe local path + // (separators, traversal, Windows-invalid characters). These never + // reach the filesystem; the user is told how many were skipped. + const unsafe: string[] = []; + const safeDest = (relativePath: string): string | null => { + try { + return toLocalPath(relativePath); + } catch (error) { + if (error instanceof UnsafeLocalNameError) { + unsafe.push(relativePath); + return null; + } + throw error; + } + }; + const plannedDirs = emptyDirs + .map((dir) => ({ dir, dest: safeDest(dir) })) + .filter((d): d is { dir: string; dest: string } => d.dest !== null); + const plannedFiles = plan + .map((entry) => ({ entry, dest: safeDest(entry.relativePath) })) + .filter( + (p): p is { entry: RemoteDownloadPlanEntry; dest: string } => + p.dest !== null, + ); + if (unsafe.length > 0) { + toast.error( + t("fileManager.localUnsafeNamesSkipped", { count: unsafe.length }), + { description: unsafe.slice(0, 3).join(", ") }, + ); + console.warn("Skipped remote items with unsafe local names:", unsafe); + } + if (plannedFiles.length === 0 && plannedDirs.length === 0) { + return; + } + + // Collision policy: never replace silently. Find destinations that + // already exist and let the user choose Replace or Skip for the batch. + const destinations = plannedFiles.map((p) => p.dest); + let existing: Set; + try { + existing = new Set(await localPathsExist(destinations)); + } catch (error) { + toast.error( + t("fileManager.localDownloadFailed"), + error instanceof Error ? { description: error.message } : undefined, + ); + return; + } + let overwriteExisting = false; + if (existing.size > 0) { + overwriteExisting = await askReplaceExisting(t, existing.size); + } + const skipped = overwriteExisting + ? [] + : plannedFiles.filter(({ dest }) => existing.has(dest)); + const work = overwriteExisting + ? plannedFiles + : plannedFiles.filter(({ dest }) => !existing.has(dest)); + if (skipped.length > 0) { + toast.info( + t("fileManager.localSkippedExisting", { count: skipped.length }), + ); + } + if (work.length === 0 && plannedDirs.length === 0) { + onLocalChanged(localDir); + return; + } + const workBytes = work.reduce((sum, w) => sum + (w.entry.size ?? 0), 0); + + await runBatch( + "download", + work.length, + workBytes, + async ({ isCancelled, setCurrentTransfer, report }) => { + for (const { dest } of plannedDirs) { + if (isCancelled()) throw new TransferCancelledError(); + await ensureLocalDirectory(dest, localDir); + } + + const failed: string[] = []; + let bytesDone = 0; + let completed = 0; + for (const { entry, dest } of work) { + if (isCancelled()) break; + const fileName = entry.relativePath.split("/").pop()!; + const transferId = createLocalTransferId("local-download"); + setCurrentTransfer(transferId); + report(completed, bytesDone, fileName); + try { + await downloadSessionFileToLocal({ + sessionId, + remotePath: entry.remotePath, + destPath: dest, + rootPath: localDir, + expectedSize: entry.size, + overwrite: overwriteExisting && existing.has(dest), + transferId, + onProgress: ({ transferred }) => + report(completed, bytesDone + transferred, fileName), + }); + bytesDone += entry.size ?? 0; + } catch (error) { + if (isCancelled()) break; + failed.push(entry.relativePath); + bytesDone += entry.size ?? 0; + console.error(`Failed to download ${entry.remotePath}:`, error); + } finally { + setCurrentTransfer(null); + } + completed += 1; + report(completed, bytesDone, fileName); + } + return { failed }; + }, + ); + + onLocalChanged(localDir); + }, + [sshSessionId, ensureSSHConnection, onLocalChanged, runBatch, t], + ); + + return { uploadLocalPaths, downloadRemoteItems }; +} diff --git a/src/ui/features/file-manager/hooks/useResizableColumns.ts b/src/ui/features/file-manager/hooks/useResizableColumns.ts new file mode 100644 index 000000000..7256161fd --- /dev/null +++ b/src/ui/features/file-manager/hooks/useResizableColumns.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type React from "react"; + +export interface ResizableColumnSpec { + /** Stable key, also used for persistence. */ + key: string; + defaultWidth: number; + minWidth?: number; + maxWidth?: number; +} + +interface UseResizableColumnsOptions { + /** localStorage key; widths are remembered per pane. */ + storageKey: string; + /** + * Fixed-width columns, in visual order, that follow a leading flexible + * column (the file name). The flexible column absorbs whatever is left. + */ + columns: ResizableColumnSpec[]; +} + +const DEFAULT_MIN = 48; +const DEFAULT_MAX = 600; + +function readStoredWidths( + storageKey: string, + columns: ResizableColumnSpec[], +): Record { + const widths: Record = {}; + for (const column of columns) widths[column.key] = column.defaultWidth; + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return widths; + const parsed = JSON.parse(raw) as Record; + for (const column of columns) { + const value = parsed?.[column.key]; + if (typeof value === "number" && Number.isFinite(value)) { + widths[column.key] = clamp(value, column); + } + } + } catch { + // storage unavailable or corrupt: fall back to defaults + } + return widths; +} + +function clamp(value: number, column: ResizableColumnSpec): number { + const min = column.minWidth ?? DEFAULT_MIN; + const max = column.maxWidth ?? DEFAULT_MAX; + return Math.round(Math.min(max, Math.max(min, value))); +} + +/** + * Column widths for a list view whose first column is flexible and whose + * remaining columns are fixed and user-resizable by dragging the boundary at + * the left edge of each header cell. Double-clicking a handle resets that + * column to its default. Widths persist in localStorage. + */ +export function useResizableColumns({ + storageKey, + columns, +}: UseResizableColumnsOptions) { + const [widths, setWidths] = useState>(() => + readStoredWidths(storageKey, columns), + ); + const [resizingKey, setResizingKey] = useState(null); + const widthsRef = useRef(widths); + widthsRef.current = widths; + + // Re-read if the pane is re-keyed (e.g. a different storage key). + useEffect(() => { + setWidths(readStoredWidths(storageKey, columns)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + const persist = useCallback( + (next: Record) => { + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + } catch { + // storage unavailable + } + }, + [storageKey], + ); + + const gridTemplateColumns = useMemo( + () => + ["minmax(140px, 1fr)", ...columns.map((c) => `${widths[c.key]}px`)].join( + " ", + ), + [columns, widths], + ); + + const startResize = useCallback( + (key: string, event: React.MouseEvent) => { + const column = columns.find((c) => c.key === key); + if (!column) return; + event.preventDefault(); + event.stopPropagation(); + + const startX = event.clientX; + const startWidth = widthsRef.current[key]; + setResizingKey(key); + + const onMove = (e: MouseEvent) => { + // The handle sits on the column's left edge, so dragging right + // narrows the column and dragging left widens it. + const next = clamp(startWidth - (e.clientX - startX), column); + if (next !== widthsRef.current[key]) { + setWidths((prev) => ({ ...prev, [key]: next })); + } + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + setResizingKey(null); + persist(widthsRef.current); + + // The browser dispatches a click to the common ancestor of the + // mousedown/mouseup targets, which here is the sortable header cell. + // Swallow that one click so finishing a drag never toggles the sort. + const swallow = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + document.addEventListener("click", swallow, { capture: true }); + setTimeout( + () => + document.removeEventListener("click", swallow, { capture: true }), + 0, + ); + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [columns, persist], + ); + + const resetColumn = useCallback( + (key: string) => { + const column = columns.find((c) => c.key === key); + if (!column) return; + setWidths((prev) => { + const next = { ...prev, [key]: column.defaultWidth }; + persist(next); + return next; + }); + }, + [columns, persist], + ); + + const getHandleProps = useCallback( + (key: string) => ({ + onMouseDown: (event: React.MouseEvent) => startResize(key, event), + onDoubleClick: (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + resetColumn(key); + }, + onClick: (event: React.MouseEvent) => event.stopPropagation(), + "data-resizing": resizingKey === key ? "true" : undefined, + }), + [startResize, resetColumn, resizingKey], + ); + + return { widths, gridTemplateColumns, getHandleProps, resizingKey }; +} diff --git a/src/ui/features/file-manager/local-transfer-utils.ts b/src/ui/features/file-manager/local-transfer-utils.ts new file mode 100644 index 000000000..6619505e0 --- /dev/null +++ b/src/ui/features/file-manager/local-transfer-utils.ts @@ -0,0 +1,332 @@ +// Pure helpers shared by the dual-pane (local <-> remote) file manager code. +// Kept free of React/DOM so they can be unit tested directly. + +import type { LocalFileEntry } from "@/types/electron"; + +/** Custom MIME type carried by drags that originate in the local pane. */ +export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files"; + +/** + * Custom MIME type the remote grid adds to its internal drags so other panes + * can recognise them during dragenter/dragover (when payloads are unreadable). + */ +export const REMOTE_FILES_DRAG_MIME = "application/x-termix-remote-files"; + +export interface LocalFilesDragPayload { + type: "local_files"; + paths: string[]; +} + +export interface InternalFilesDragPayload { + type: "internal_files"; + files: string[]; +} + +export function serializeLocalFilesDragPayload(paths: string[]): string { + const payload: LocalFilesDragPayload = { type: "local_files", paths }; + return JSON.stringify(payload); +} + +export function parseLocalFilesDragPayload( + raw: string | null | undefined, +): string[] | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed?.type !== "local_files" || !Array.isArray(parsed.paths)) { + return null; + } + const paths = parsed.paths.filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + return paths.length > 0 ? paths : null; + } catch { + return null; + } +} + +/** Parses the payload the remote grid puts on `text/plain` for internal drags. */ +export function parseInternalFilesDragPayload( + raw: string | null | undefined, +): string[] | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed?.type !== "internal_files" || !Array.isArray(parsed.files)) { + return null; + } + const files = parsed.files.filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + return files.length > 0 ? files : null; + } catch { + return null; + } +} + +/** True while a drag that started in the local pane is over the element. */ +export function isLocalFilesDrag( + dataTransfer: Pick | null | undefined, +): boolean { + if (!dataTransfer) return false; + return Array.from(dataTransfer.types ?? []).includes(LOCAL_FILES_DRAG_MIME); +} + +/** True while a drag that started in the remote grid is over the element. */ +export function isRemoteFilesDrag( + dataTransfer: Pick | null | undefined, +): boolean { + if (!dataTransfer) return false; + return Array.from(dataTransfer.types ?? []).includes(REMOTE_FILES_DRAG_MIME); +} + +/** Joins a POSIX remote directory with one or more path segments. */ +export function joinRemotePath(base: string, ...segments: string[]): string { + let out = base || "/"; + for (const segment of segments) { + const clean = segment.replace(/^\/+|\/+$/g, ""); + if (!clean) continue; + out = out.endsWith("/") ? `${out}${clean}` : `${out}/${clean}`; + } + return out; +} + +/** Joins a local directory and a file name using the platform separator. */ +export function joinLocalPath( + base: string, + name: string, + separator: string, +): string { + const sep = separator || "/"; + const trimmedBase = base.endsWith(sep) ? base.slice(0, -sep.length) : base; + // Root on POSIX is "/" which trims to ""; keep the separator in that case. + return `${trimmedBase}${sep}${name}`; +} + +/** Thrown when a remote name cannot be used as a local path component. */ +export class UnsafeLocalNameError extends Error { + readonly code = "EINVAL"; + constructor( + readonly name: string, + readonly reason: string, + ) { + super(`Unsafe file name "${name}": ${reason}`); + } +} + +// Windows refuses these device names in any directory, with any extension. +const WINDOWS_RESERVED_NAMES = + /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.[^.]*)?$/i; + +/** + * Validates one remote path component for use as a single local path + * segment on the destination platform (`separator` "\\" means Windows). + * + * A POSIX file name may legally contain "\\", ":" or end in a dot, all of + * which Windows treats as separators, drive prefixes or strips — so a remote + * name like "..\\outside.txt" would otherwise be normalised out of the + * selected download folder. Anything that is not a plain name is rejected; + * traversal ("..") and separators are rejected on every platform. + */ +export function assertSafeLocalComponent( + name: string, + separator: string, +): string { + if (name === "" || name === "." || name === "..") { + throw new UnsafeLocalNameError(name, "empty or traversal component"); + } + if (name.includes("/") || name.includes("\0")) { + throw new UnsafeLocalNameError(name, "contains a path separator or NUL"); + } + if (separator === "\\") { + if (/[\\:*?"<>|]/.test(name)) { + throw new UnsafeLocalNameError( + name, + "contains a character Windows does not allow in file names", + ); + } + if (/[\u0001-\u001f]/.test(name)) { + throw new UnsafeLocalNameError(name, "contains control characters"); + } + if (/[. ]$/.test(name)) { + throw new UnsafeLocalNameError( + name, + "Windows strips trailing dots and spaces", + ); + } + if (WINDOWS_RESERVED_NAMES.test(name)) { + throw new UnsafeLocalNameError(name, "reserved device name on Windows"); + } + } + return name; +} + +function samePathPrefix(a: string, b: string, separator: string): boolean { + return separator === "\\" ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +/** + * Builds the local destination for a "/"-separated relative remote path + * under `localDir`, validating every component for the destination platform + * and asserting the result stays strictly inside `localDir`. + */ +export function buildLocalDestination( + localDir: string, + relativePath: string, + separator: string, +): string { + const sep = separator || "/"; + const parts = relativePath.split("/").filter((p) => p !== ""); + if (parts.length === 0) { + throw new UnsafeLocalNameError(relativePath, "empty path"); + } + const dest = parts.reduce( + (acc, part) => joinLocalPath(acc, assertSafeLocalComponent(part, sep), sep), + localDir, + ); + const root = localDir.endsWith(sep) ? localDir : `${localDir}${sep}`; + if ( + !samePathPrefix(dest.slice(0, root.length), root, sep) || + dest.length <= root.length + ) { + throw new UnsafeLocalNameError( + relativePath, + "destination escapes the selected folder", + ); + } + return dest; +} + +export function remoteBaseName(remotePath: string): string { + const trimmed = remotePath.replace(/\/+$/, ""); + return trimmed.split("/").pop() || trimmed || "/"; +} + +/** + * Given the "/"-separated relative paths of files being uploaded (each + * including its top-level root name) plus any empty directories, returns the + * set of directories that must exist on the remote, shallowest first, so each + * parent is created before its children. + */ +export function planRemoteDirectories( + fileRelativePaths: string[], + emptyDirs: string[] = [], +): string[] { + const dirs = new Set(); + + for (const relativePath of fileRelativePaths) { + const parts = relativePath.split("/").filter(Boolean); + for (let i = 1; i < parts.length; i++) { + dirs.add(parts.slice(0, i).join("/")); + } + } + for (const dir of emptyDirs) { + const parts = dir.split("/").filter(Boolean); + for (let i = 1; i <= parts.length; i++) { + dirs.add(parts.slice(0, i).join("/")); + } + } + + return Array.from(dirs).sort( + (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), + ); +} + +/** Remote directory a file with the given relative path should land in. */ +export function remoteDirForRelativePath( + base: string, + relativePath: string, +): string { + const idx = relativePath.lastIndexOf("/"); + if (idx <= 0) return base; + return joinRemotePath(base, relativePath.slice(0, idx)); +} + +/** Short "Kind" column label, in the spirit of Finder / Termius. */ +export function describeLocalKind( + entry: Pick, +): string { + if (entry.type === "directory") return "folder"; + if (entry.type === "link") return "link"; + const dot = entry.name.lastIndexOf("."); + if (dot > 0 && dot < entry.name.length - 1) { + return entry.name.slice(dot + 1).toLowerCase(); + } + return "file"; +} + +export type LocalSortField = "name" | "modified" | "size" | "kind"; + +export function sortLocalEntries( + entries: LocalFileEntry[], + field: LocalSortField, + order: "asc" | "desc", +): LocalFileEntry[] { + const dir = order === "asc" ? 1 : -1; + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", + }); + return [...entries].sort((a, b) => { + // Folders always group first, matching the remote grid. + const aDir = a.type === "directory" ? 0 : 1; + const bDir = b.type === "directory" ? 0 : 1; + if (aDir !== bDir) return aDir - bDir; + + let cmp = 0; + switch (field) { + case "modified": + cmp = (a.modifiedTimestamp ?? 0) - (b.modifiedTimestamp ?? 0); + break; + case "size": + cmp = a.size - b.size; + break; + case "kind": + cmp = collator.compare(describeLocalKind(a), describeLocalKind(b)); + break; + default: + cmp = 0; + } + if (cmp === 0) cmp = collator.compare(a.name, b.name); + return cmp * dir; + }); +} + +const MONTH_ABBREVIATIONS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** + * Formats a local mtime the same way the backend formats remote entries + * (`formatMtime` in src/backend/hosts/file-manager/utils.ts, i.e. `ls -l` + * style): `Sep 11 16:25` for the last six months, `Sep 11 2025` before + * that — so both panes read alike. + */ +export function formatLocalModified( + timestamp?: number, + now: Date = new Date(), +): string { + if (!timestamp) return "--"; + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return "--"; + const month = MONTH_ABBREVIATIONS[date.getMonth()]; + const day = date.getDate().toString().padStart(2, " "); + const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000); + if (date > sixMonthsAgo) { + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${month} ${day} ${hours}:${minutes}`; + } + return `${month} ${day} ${date.getFullYear()}`; +} diff --git a/src/ui/lib/local-files.ts b/src/ui/lib/local-files.ts new file mode 100644 index 000000000..8c68bfc2b --- /dev/null +++ b/src/ui/lib/local-files.ts @@ -0,0 +1,105 @@ +// Thin, typed access to the desktop app's local filesystem bridge +// (electron/local-files.cjs via preload.js). Everything here resolves to +// "unavailable" outside Electron so callers can feature-detect cheaply. + +import type { + LocalDirectoryListing, + LocalFsHomeInfo, + LocalFsResult, + LocalTrashResult, + LocalWalkResult, +} from "@/types/electron"; +import { isElectron } from "./electron"; + +export function isLocalFileBrowserAvailable(): boolean { + if (!isElectron()) return false; + const api = window.electronAPI; + return !!api?.localFs && !!api?.localTransfer; +} + +function requireLocalFs() { + const api = window.electronAPI?.localFs; + if (!api) { + throw new Error("Local file access is only available in the desktop app"); + } + return api; +} + +function unwrap(result: LocalFsResult): T { + if (!result || result.success !== true) { + const message = + result && "error" in result && result.error + ? result.error + : "Local file operation failed"; + const error = new Error(message) as Error & { code?: string }; + if (result && "code" in result) error.code = result.code; + throw error; + } + return result; +} + +export async function getLocalHome(): Promise { + return unwrap(await requireLocalFs().home()); +} + +export async function listLocalDirectory( + dirPath: string, +): Promise { + return unwrap(await requireLocalFs().list(dirPath)); +} + +export async function createLocalFolder( + parentPath: string, + name: string, +): Promise { + return unwrap(await requireLocalFs().mkdir(parentPath, name)).path; +} + +export async function createLocalFile( + parentPath: string, + name: string, +): Promise { + return unwrap(await requireLocalFs().createFile(parentPath, name)).path; +} + +export async function renameLocalEntry( + oldPath: string, + newName: string, +): Promise { + return unwrap(await requireLocalFs().rename(oldPath, newName)).path; +} + +/** Moves entries to the OS trash; resolves with per-path failures, if any. */ +export async function trashLocalPaths( + paths: string[], +): Promise { + return unwrap(await requireLocalFs().trash(paths)); +} + +/** Subset of `paths` that already exist on disk. */ +export async function localPathsExist(paths: string[]): Promise { + if (paths.length === 0) return []; + return unwrap(await requireLocalFs().exists(paths)).existing; +} + +/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */ +export async function ensureLocalDirectory( + dirPath: string, + rootPath?: string, +): Promise { + return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path; +} + +export async function walkLocalPaths( + paths: string[], +): Promise { + return unwrap(await requireLocalFs().walk(paths)); +} + +export async function revealLocalPath(targetPath: string): Promise { + unwrap(await requireLocalFs().reveal(targetPath)); +} + +export async function openLocalPath(targetPath: string): Promise { + unwrap(await requireLocalFs().open(targetPath)); +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index b3f4e8154..634c811a0 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2232,6 +2232,52 @@ "sudoOperationFailed": "Sudo operation failed", "sudoAuthFailed": "Sudo authentication failed", "dragFilesToUpload": "Drop files here to upload", + "dropToUploadHere": "Drop to upload here", + "dropToDownloadHere": "Drop to download here", + "localFiles": "Local", + "showLocalFiles": "Show local files (split view)", + "hideLocalFiles": "Hide local files", + "localHome": "Home folder", + "localPathPlaceholder": "Local folder path", + "localShowHidden": "Show hidden files", + "localHideHidden": "Hide hidden files", + "localRevealInFileManager": "Reveal in system file manager", + "localCannotOpenFolder": "Cannot open folder", + "localItemCount": "{{count}} items", + "localSelectedCount": "{{count}} selected", + "back": "Back", + "forward": "Forward", + "up": "Up", + "kind": "Kind", + "localUploadingProgress": "Uploading {{current}} of {{total}}", + "localDownloadingProgress": "Downloading {{current}} of {{total}}", + "localPreparingDownload": "Preparing download...", + "localTransferCancelled": "Transfer cancelled", + "localNothingToTransfer": "Nothing to transfer", + "localUploadComplete": "Uploaded {{count}} file(s)", + "localUploadFailed": "Upload failed", + "localUploadPartial": "Uploaded {{done}} file(s), {{failed}} failed", + "localDownloadComplete": "Downloaded {{count}} file(s)", + "localDownloadFailed": "Download failed", + "localDownloadPartial": "Downloaded {{done}} file(s), {{failed}} failed", + "localReplaceExistingPrompt": "{{count}} item(s) already exist in this folder. Replace them?", + "localReplace": "Replace", + "localSkipExisting": "Skip", + "localSkippedExisting": "Skipped {{count}} existing item(s)", + "localUnsafeNamesSkipped": "Skipped {{count}} item(s) whose names cannot be used on this computer", + "localOpen": "Open", + "localOpenFolder": "Open folder", + "localUploadToRemote": "Upload to server", + "localUploadToRemoteMany": "Upload {{count}} items to server", + "localMoveToTrash": "Move to Trash", + "localMoveToTrashMany": "Move {{count}} items to Trash", + "localTrashConfirmSingle": "Move \"{{name}}\" to the Trash?", + "localTrashConfirmFolder": "Move the folder \"{{name}}\" and all its contents to the Trash?", + "localTrashConfirmMany": "Move {{count}} items to the Trash?", + "localTrashed": "Moved {{count}} item(s) to the Trash", + "localTrashFailed": "Could not move to the Trash", + "localRenameFailed": "Rename failed", + "localCreateFailed": "Could not create item", "emptyFolder": "This folder is empty", "searchFiles": "Search files...", "upload": "Upload", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index c8f799e8f..1757f10a8 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -866,6 +866,11 @@ export function getFileManagerApiForSession(sessionId: string): AxiosInstance { : fileManagerApi; } +/** Which backend currently holds a live SSH session (see setSessionOrigin). */ +export function getSessionOrigin(sessionId: string): "local" | "remote" { + return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local"; +} + export function getTunnelApiForOrigin( origin: "local" | "remote", ): AxiosInstance { @@ -1590,6 +1595,15 @@ export { removeFolderShortcut, } from "@/api/file-manager-data-api"; +// Desktop-only local disk <-> remote transfers (dual-pane file manager). +export { + uploadLocalFileToSession, + downloadSessionFileToLocal, + cancelLocalTransfer, + createLocalTransferId, + type LocalTransferProgressEvent, +} from "@/api/local-transfer-api"; + export { getAllServerStatuses, getServerStatusById, diff --git a/src/ui/tests/features/file-manager/LocalFilePane.test.tsx b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx new file mode 100644 index 000000000..87d0b3c38 --- /dev/null +++ b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx @@ -0,0 +1,432 @@ +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 "@/features/file-manager/LocalFilePane"; +import { + LOCAL_FILES_DRAG_MIME, + REMOTE_FILES_DRAG_MIME, +} from "@/features/file-manager/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 & Record; + 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) => + opts?.count !== undefined ? `${key}:${opts.count}` : key, + }), +})); + +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 = {}, +): DataTransfer { + const store: Record = { ...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(); + + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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(); + 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(); + 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(); + 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("ignores OS file drags (those belong to the remote grid)", async () => { + const onRemoteItemsDropped = vi.fn(); + render(); + 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(); + 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`], + }); + }); +}); diff --git a/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts b/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts new file mode 100644 index 000000000..fda4b3d6a --- /dev/null +++ b/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useResizableColumns } from "@/features/file-manager/hooks/useResizableColumns"; + +const columns = [ + { key: "modified", defaultWidth: 120, minWidth: 70 }, + { key: "size", 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("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); + }); +}); diff --git a/src/ui/tests/features/file-manager/local-transfer-utils.test.ts b/src/ui/tests/features/file-manager/local-transfer-utils.test.ts new file mode 100644 index 000000000..f8c6b4069 --- /dev/null +++ b/src/ui/tests/features/file-manager/local-transfer-utils.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { + LOCAL_FILES_DRAG_MIME, + REMOTE_FILES_DRAG_MIME, + UnsafeLocalNameError, + assertSafeLocalComponent, + buildLocalDestination, + describeLocalKind, + formatLocalModified, + isLocalFilesDrag, + isRemoteFilesDrag, + joinLocalPath, + joinRemotePath, + parseInternalFilesDragPayload, + parseLocalFilesDragPayload, + planRemoteDirectories, + remoteBaseName, + remoteDirForRelativePath, + serializeLocalFilesDragPayload, + sortLocalEntries, +} from "@/features/file-manager/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("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"]); + }); +});