From 89bdac56c704965fd830d05c9bcc78411094f87a Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 8 Sep 2026 06:04:20 +0000 Subject: [PATCH 1/8] feat(file-manager): Termius-style Local | Remote dual pane with drag-and-drop transfers (desktop) Renderer side of the dual-pane file manager, built on the local filesystem bridge added in the previous PR. Desktop app only; the web build is unchanged (the toggle is hidden when `window.electronAPI.localFs` is absent). - New Local pane (LocalFilePane) next to the remote grid, toggled from the toolbar (Laptop icon); path, visibility and width are remembered in localStorage (`termix:file-manager:local-pane:*`). Grid and list views, hidden files toggle, breadcrumb navigation, New Folder. - Drag files/folders from the Local pane onto the remote grid to upload, and from the remote grid onto the Local pane to download. Both directions stream through the main process (`useLocalTransfers`) with a single progress toast per batch (speed, ETA, cancel). Finder drops onto the remote grid keep working as before. - Collision policy for downloads: destinations are checked first; if any exist the user is asked Replace / Skip for the batch. Skip, dismiss and timeout all mean skip - nothing is ever replaced without an explicit click, and the main process enforces the same rule (`EEXIST` unless `overwrite` is set). - Drag MIME contract: `application/x-termix-local-files` for local drags, `application/x-termix-remote-files` marker on the remote grid's internal drags, so each pane can tell the two apart from Finder drops. - Transfer targets are described as `{ origin, route, deviceId }` (`getSessionOrigin` in main-axios) - the renderer never hands the main process a URL. - i18n: new `fileManager.local*` keys in en.json only (other locales via Crowdin). - Tests: LocalFilePane rendering/navigation, local-transfer-utils (relative-path planning, size formatting). - Modified column uses the same `Mon DD HH:MM` / `Mon DD YYYY` (ls -l style) format as the remote grid, so both panes read alike. --- src/ui/api/local-transfer-api.ts | 161 ++++ src/ui/features/file-manager/FileManager.tsx | 155 +++- .../features/file-manager/FileManagerGrid.tsx | 102 ++- .../file-manager/FileManagerToolbar.tsx | 25 + .../features/file-manager/LocalFilePane.tsx | 761 ++++++++++++++++++ .../components/LocalTransferProgressToast.tsx | 94 +++ .../file-manager/hooks/useLocalTransfers.ts | 487 +++++++++++ .../file-manager/local-transfer-utils.ts | 238 ++++++ src/ui/lib/local-files.ts | 79 ++ src/ui/locales/en.json | 32 + src/ui/main-axios.ts | 14 + .../file-manager/LocalFilePane.test.tsx | 269 +++++++ .../file-manager/local-transfer-utils.test.ts | 185 +++++ 13 files changed, 2590 insertions(+), 12 deletions(-) create mode 100644 src/ui/api/local-transfer-api.ts create mode 100644 src/ui/features/file-manager/LocalFilePane.tsx create mode 100644 src/ui/features/file-manager/components/LocalTransferProgressToast.tsx create mode 100644 src/ui/features/file-manager/hooks/useLocalTransfers.ts create mode 100644 src/ui/features/file-manager/local-transfer-utils.ts create mode 100644 src/ui/lib/local-files.ts create mode 100644 src/ui/tests/features/file-manager/LocalFilePane.test.tsx create mode 100644 src/ui/tests/features/file-manager/local-transfer-utils.test.ts diff --git a/src/ui/api/local-transfer-api.ts b/src/ui/api/local-transfer-api.ts new file mode 100644 index 000000000..aaaef9417 --- /dev/null +++ b/src/ui/api/local-transfer-api.ts @@ -0,0 +1,161 @@ +// 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; + 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, + 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..ddd42e1dd 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,36 @@ 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..25147c372 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -32,9 +32,19 @@ 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"; 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 +60,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 +192,8 @@ export function FileManagerGrid({ onSelectionChange, onRefresh, onUpload, + onUploadItems, + onLocalFilesDrop, onDownload, onContextMenu, viewMode = "grid", @@ -350,6 +366,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 +381,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 +411,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 +479,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 +499,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 +782,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 +1026,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 +1041,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")}

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 && ( + + )} +
+ + + +
+ + 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); + }} + > + {creatingFolder && ( +
+
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submitNewFolder(); + if (e.key === "Escape") { + setCreatingFolder(false); + setNewFolderName(""); + } + }} + onBlur={() => void submitNewFolder()} + onClick={(e) => e.stopPropagation()} + placeholder={t("fileManager.newFolder")} + 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)} + onDragStart={(e) => handleRowDragStart(e, entry)} + onDragOver={(e) => handleFolderDragOver(e, entry)} + onDragLeave={(e) => handleFolderDragLeave(e, entry)} + onDrop={(e) => handleFolderDrop(e, entry)} + title={entry.path} + > +
+ + + + + {entry.name} + {entry.type === "link" && entry.linkTarget && ( + + → {entry.linkTarget} + + )} + +
+ + {formatLocalModified(entry.modifiedTimestamp)} + + + {entry.type === "directory" + ? "--" + : formatFileSize(entry.size)} + + + {describeLocalKind(entry)} + +
+
+ ); + })} +
+ )} + + {showPaneOverlay && ( +
+
+ +

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

+
+
+ )} +
+ + {/* 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/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..37130dc66 --- /dev/null +++ b/src/ui/features/file-manager/hooks/useLocalTransfers.ts @@ -0,0 +1,487 @@ +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 { + joinLocalPath, + 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(); + const toLocalPath = (relativePath: string) => + relativePath + .split("/") + .filter(Boolean) + .reduce((acc, part) => joinLocalPath(acc, part, separator), localDir); + + // 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; + } + + // Collision policy: never replace silently. Find destinations that + // already exist and let the user choose Replace or Skip for the batch. + const destinations = plan.map((entry) => toLocalPath(entry.relativePath)); + 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 + ? [] + : plan.filter((_, i) => existing.has(destinations[i])); + const work = overwriteExisting + ? plan.map((entry, i) => ({ entry, dest: destinations[i] })) + : plan + .map((entry, i) => ({ entry, dest: destinations[i] })) + .filter(({ dest }) => !existing.has(dest)); + if (skipped.length > 0) { + toast.info( + t("fileManager.localSkippedExisting", { count: skipped.length }), + ); + } + if (work.length === 0 && emptyDirs.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 dir of emptyDirs) { + if (isCancelled()) throw new TransferCancelledError(); + await ensureLocalDirectory(toLocalPath(dir)); + } + + 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, + 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/local-transfer-utils.ts b/src/ui/features/file-manager/local-transfer-utils.ts new file mode 100644 index 000000000..4cef09887 --- /dev/null +++ b/src/ui/features/file-manager/local-transfer-utils.ts @@ -0,0 +1,238 @@ +// 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}`; +} + +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..e6171a2c7 --- /dev/null +++ b/src/ui/lib/local-files.ts @@ -0,0 +1,79 @@ +// 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, + 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; +} + +/** 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; +} + +export async function ensureLocalDirectory(dirPath: string): Promise { + return unwrap(await requireLocalFs().ensureDir(dirPath)).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 b34905867..e6e3d573d 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2220,6 +2220,38 @@ "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)", "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 860708de8..0f6984863 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -865,6 +865,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 { @@ -1589,6 +1594,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..920a12068 --- /dev/null +++ b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx @@ -0,0 +1,269 @@ +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: () => {}, + }), +})); + +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(), + 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("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/local-transfer-utils.test.ts b/src/ui/tests/features/file-manager/local-transfer-utils.test.ts new file mode 100644 index 000000000..49f5bca8c --- /dev/null +++ b/src/ui/tests/features/file-manager/local-transfer-utils.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + LOCAL_FILES_DRAG_MIME, + REMOTE_FILES_DRAG_MIME, + 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("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"]); + }); +}); From 6c1ed04870c62c06af3178155df6a950ea5986d6 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 16 Sep 2026 04:19:05 +0000 Subject: [PATCH 2/8] fix(file-manager): keep downloaded files inside the selected local folder Remote file names were turned into local destinations by splitting the relative remote path on "/" and concatenating each name with the platform separator. A POSIX file name may contain "\", ":" or end in a dot, so on Windows a remote "..\outside.txt" downloaded into C:\Downloads\selected was normalised to C:\Downloads\outside.txt - outside the folder the user picked, even with overwrite disabled. The main process only normalised destPath and never checked it against the selected root. Two independent layers now enforce containment: Renderer (local-transfer-utils.ts) - `assertSafeLocalComponent(name, separator)` validates each remote path component for the destination platform: never empty, "." or "..", never "/" or NUL; on Windows (separator "\") additionally no "\ : * ? " < > |", no control characters, no trailing dot/space, no reserved device names (CON, NUL, COM1...). Backslashes stay legal on macOS/Linux, where they are ordinary file-name characters and the result remains inside the folder. - `buildLocalDestination(localDir, relativePath, separator)` joins the validated components and asserts the result is strictly under localDir (case-insensitive on Windows). - useLocalTransfers builds every file and directory destination through it. Items that fail are skipped before any filesystem call and reported ("Skipped N item(s) whose names cannot be used on this computer"). Main process (electron/local-files.cjs) - `assertWithinRoot(rootPath, candidate, pathImpl)` resolves + normalises both paths and refuses anything that is the root itself, escapes it (".." after normalisation), is absolute relative to it (another drive, UNC) or contains empty/".." segments. `pathImpl` is injectable so the Windows rules run in tests on any OS. - downloadToLocal requires `rootPath` (the selected folder) and checks the destination before the request is made or any file is created; the ensure-dir handler applies the same check when a root is passed (used for the directory skeleton of downloaded trees). - LocalDownloadRequest / downloadSessionFileToLocal / ensureLocalDirectory carry the root. Tests - Renderer: ordinary nested folders on Windows; backslash traversal in a POSIX name; absolute, drive-qualified and UNC names; reserved names, trailing dots/spaces, control chars; POSIX traversal rejected while a POSIX-legal backslash name is kept inside the folder. - Main process (path.win32): nested destinations accepted incl. case differences; "selected\..\outside.txt" refused; other drive / UNC / sibling folder refused; missing root refused; end-to-end handler test that a POSIX "../outside.txt" download and an escaping ensure-dir are refused before any network traffic or disk write. --- electron/local-files.cjs | 53 ++++++- electron/preload.js | 3 +- .../tests/electron/local-files.test.ts | 139 ++++++++++++++++++ src/types/electron.d.ts | 7 +- src/ui/api/local-transfer-api.ts | 3 + .../file-manager/hooks/useLocalTransfers.ts | 62 ++++++-- .../file-manager/local-transfer-utils.ts | 94 ++++++++++++ src/ui/lib/local-files.ts | 8 +- src/ui/locales/en.json | 1 + .../file-manager/local-transfer-utils.test.ts | 85 +++++++++++ 10 files changed, 432 insertions(+), 23 deletions(-) diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 5ee09bfde..651139362 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -71,6 +71,36 @@ 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; +} + async function pathExists(target) { try { await fsp.lstat(target); @@ -610,8 +640,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 +660,9 @@ 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 absDest = assertWithinRoot(rootPath, normalizeLocalPath(destPath)); if (activeDestinations.has(absDest)) { throw new LocalFileError( "EBUSY", @@ -838,8 +877,11 @@ function createLocalFileHandlers({ return { trashed: targetPaths.length - failed.length, failed }; }), - [IPC.ENSURE_DIR]: wrap(async (_event, dirPath) => { - const target = normalizeLocalPath(dirPath); + [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 = assertWithinRoot(rootPath, target); await fsp.mkdir(target, { recursive: true }); return { path: target }; }), @@ -915,6 +957,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..fc54762e9 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,6 +262,7 @@ describe("local-files download boundary", () => { origin: "local", body: { sessionId: "1", path: "/remote/file.bin" }, destPath: dest, + rootPath: root, ...extra, }); @@ -312,6 +318,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"); @@ -339,6 +346,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 +354,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 +396,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 +444,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 +695,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 index aaaef9417..2a6d44e01 100644 --- a/src/ui/api/local-transfer-api.ts +++ b/src/ui/api/local-transfer-api.ts @@ -119,6 +119,8 @@ 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; @@ -140,6 +142,7 @@ export async function downloadSessionFileToLocal(options: { deviceId: getDeviceId() ?? undefined, body: { sessionId: options.sessionId, path: options.remotePath }, destPath: options.destPath, + rootPath: options.rootPath, expectedSize: options.expectedSize, overwrite: options.overwrite === true, }); diff --git a/src/ui/features/file-manager/hooks/useLocalTransfers.ts b/src/ui/features/file-manager/hooks/useLocalTransfers.ts index 37130dc66..034ab4eb5 100644 --- a/src/ui/features/file-manager/hooks/useLocalTransfers.ts +++ b/src/ui/features/file-manager/hooks/useLocalTransfers.ts @@ -18,7 +18,8 @@ import { walkLocalPaths, } from "@/lib/local-files.ts"; import { - joinLocalPath, + UnsafeLocalNameError, + buildLocalDestination, joinRemotePath, planRemoteDirectories, remoteBaseName, @@ -326,11 +327,10 @@ export function useLocalTransfers({ 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) => - relativePath - .split("/") - .filter(Boolean) - .reduce((acc, part) => joinLocalPath(acc, part, separator), localDir); + 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 @@ -396,9 +396,44 @@ export function useLocalTransfers({ 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 = plan.map((entry) => toLocalPath(entry.relativePath)); + const destinations = plannedFiles.map((p) => p.dest); let existing: Set; try { existing = new Set(await localPathsExist(destinations)); @@ -415,18 +450,16 @@ export function useLocalTransfers({ } const skipped = overwriteExisting ? [] - : plan.filter((_, i) => existing.has(destinations[i])); + : plannedFiles.filter(({ dest }) => existing.has(dest)); const work = overwriteExisting - ? plan.map((entry, i) => ({ entry, dest: destinations[i] })) - : plan - .map((entry, i) => ({ entry, dest: destinations[i] })) - .filter(({ dest }) => !existing.has(dest)); + ? plannedFiles + : plannedFiles.filter(({ dest }) => !existing.has(dest)); if (skipped.length > 0) { toast.info( t("fileManager.localSkippedExisting", { count: skipped.length }), ); } - if (work.length === 0 && emptyDirs.length === 0) { + if (work.length === 0 && plannedDirs.length === 0) { onLocalChanged(localDir); return; } @@ -437,9 +470,9 @@ export function useLocalTransfers({ work.length, workBytes, async ({ isCancelled, setCurrentTransfer, report }) => { - for (const dir of emptyDirs) { + for (const { dest } of plannedDirs) { if (isCancelled()) throw new TransferCancelledError(); - await ensureLocalDirectory(toLocalPath(dir)); + await ensureLocalDirectory(dest, localDir); } const failed: string[] = []; @@ -456,6 +489,7 @@ export function useLocalTransfers({ sessionId, remotePath: entry.remotePath, destPath: dest, + rootPath: localDir, expectedSize: entry.size, overwrite: overwriteExisting && existing.has(dest), transferId, diff --git a/src/ui/features/file-manager/local-transfer-utils.ts b/src/ui/features/file-manager/local-transfer-utils.ts index 4cef09887..6619505e0 100644 --- a/src/ui/features/file-manager/local-transfer-utils.ts +++ b/src/ui/features/file-manager/local-transfer-utils.ts @@ -103,6 +103,100 @@ export function joinLocalPath( 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 || "/"; diff --git a/src/ui/lib/local-files.ts b/src/ui/lib/local-files.ts index e6171a2c7..03173892d 100644 --- a/src/ui/lib/local-files.ts +++ b/src/ui/lib/local-files.ts @@ -60,8 +60,12 @@ export async function localPathsExist(paths: string[]): Promise { return unwrap(await requireLocalFs().exists(paths)).existing; } -export async function ensureLocalDirectory(dirPath: string): Promise { - return unwrap(await requireLocalFs().ensureDir(dirPath)).path; +/** 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( diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index e6e3d573d..8a07afc37 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2252,6 +2252,7 @@ "localReplace": "Replace", "localSkipExisting": "Skip", "localSkippedExisting": "Skipped {{count}} existing item(s)", + "localUnsafeNamesSkipped": "Skipped {{count}} item(s) whose names cannot be used on this computer", "emptyFolder": "This folder is empty", "searchFiles": "Search files...", "upload": "Upload", 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 index 49f5bca8c..f8c6b4069 100644 --- a/src/ui/tests/features/file-manager/local-transfer-utils.test.ts +++ b/src/ui/tests/features/file-manager/local-transfer-utils.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { LOCAL_FILES_DRAG_MIME, REMOTE_FILES_DRAG_MIME, + UnsafeLocalNameError, + assertSafeLocalComponent, + buildLocalDestination, describeLocalKind, formatLocalModified, isLocalFilesDrag, @@ -103,6 +106,88 @@ describe("path helpers", () => { }); }); +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( From 61d9dd5aaf6b4ff32c16400d1a4d3522510362f4 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 05:39:59 +0000 Subject: [PATCH 3/8] feat(file-manager): right-click context menu for the local pane Mirrors the remote grid's menu for the user's own disk. On an entry (or the current multi-selection): Open / Open folder, Upload to server, Reveal in Finder/Explorer, Rename (inline, F2), Copy Path, Move to Trash (confirmation toast, Del). On the background: New Folder, New File, Reveal, Show/Hide hidden files, Refresh (F5). Enter opens, Cmd/Ctrl+A selects all, Escape clears the selection. Deletion goes through shell.trashItem so it lands in the OS Trash and is recoverable; rename and create refuse names containing path separators and never overwrite an existing entry. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Bn6K6xNAihgWZ5fMVWt1W --- src/ui/features/file-manager/FileManager.tsx | 1 + .../file-manager/LocalFileContextMenu.tsx | 322 ++++++++++++++++++ .../features/file-manager/LocalFilePane.tsx | 320 ++++++++++++++--- src/ui/lib/local-files.ts | 22 ++ src/ui/locales/en.json | 13 + .../file-manager/LocalFilePane.test.tsx | 163 +++++++++ 6 files changed, 803 insertions(+), 38 deletions(-) create mode 100644 src/ui/features/file-manager/LocalFileContextMenu.tsx diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index ddd42e1dd..5957ab7f9 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -3442,6 +3442,7 @@ function FileManagerContent({ refreshToken={localPaneRefreshToken} onClose={toggleLocalPane} onRemoteItemsDropped={handleRemoteItemsDroppedToLocal} + onUploadToRemote={handleLocalFilesDrop} />
{/* Drag handle between the local and remote panes */} diff --git a/src/ui/features/file-manager/LocalFileContextMenu.tsx b/src/ui/features/file-manager/LocalFileContextMenu.tsx new file mode 100644 index 000000000..87f36ab60 --- /dev/null +++ b/src/ui/features/file-manager/LocalFileContextMenu.tsx @@ -0,0 +1,322 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils.ts"; +import { + Clipboard, + Edit3, + Eye, + EyeOff, + ExternalLink, + FilePlus, + FolderOpen, + FolderPlus, + RefreshCw, + Trash2, + Upload, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx"; +import type { LocalFileEntry } from "@/types/electron"; + +const VIEWPORT_PADDING = 16; + +export interface LocalFileContextMenuProps { + x: number; + y: number; + /** Entries the menu acts on; empty means the pane background was clicked. */ + entries: LocalFileEntry[]; + isVisible: boolean; + showHidden: boolean; + canUpload: boolean; + onClose: () => void; + onOpen: (entry: LocalFileEntry) => void; + onUploadToRemote: (entries: LocalFileEntry[]) => void; + onReveal: (entry?: LocalFileEntry) => void; + onRename: (entry: LocalFileEntry) => void; + onCopyPath: (entries: LocalFileEntry[]) => void; + onNewFolder: () => void; + onNewFile: () => void; + onRefresh: () => void; + onToggleHidden: () => void; + onDelete: (entries: LocalFileEntry[]) => void; +} + +interface MenuItem { + icon?: React.ReactNode; + label?: string; + action?: () => void; + shortcut?: string; + separator?: boolean; + disabled?: boolean; + danger?: boolean; +} + +/** + * Right-click menu for the local pane. Same chrome and dismissal rules as + * FileManagerContextMenu, with the subset of actions that make sense for the + * user's own disk. + */ +export function LocalFileContextMenu({ + x, + y, + entries, + isVisible, + showHidden, + canUpload, + onClose, + onOpen, + onUploadToRemote, + onReveal, + onRename, + onCopyPath, + onNewFolder, + onNewFile, + onRefresh, + onToggleHidden, + onDelete, +}: LocalFileContextMenuProps) { + const { t } = useTranslation(); + const menuRef = useRef(null); + const [menuPosition, setMenuPosition] = useState({ x, y }); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + if (!isVisible) { + setIsMounted(false); + return; + } + setIsMounted(true); + + let cleanupFn: (() => void) | null = null; + const timeoutId = setTimeout(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!menuRef.current?.contains(event.target as Node)) onClose(); + }; + const handleRightClick = (event: MouseEvent) => { + event.preventDefault(); + onClose(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }; + const handleBlur = () => onClose(); + const handleScroll = () => onClose(); + + document.addEventListener("mousedown", handleClickOutside, true); + document.addEventListener("contextmenu", handleRightClick); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", handleBlur); + window.addEventListener("scroll", handleScroll, true); + + cleanupFn = () => { + document.removeEventListener("mousedown", handleClickOutside, true); + document.removeEventListener("contextmenu", handleRightClick); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", handleBlur); + window.removeEventListener("scroll", handleScroll, true); + }; + }, 50); + + return () => { + clearTimeout(timeoutId); + cleanupFn?.(); + }; + }, [isVisible, x, y, onClose]); + + useLayoutEffect(() => { + if (!isVisible || !menuRef.current) return; + const menuWidth = menuRef.current.offsetWidth; + const menuHeight = menuRef.current.offsetHeight; + let adjustedX = x; + let adjustedY = y; + if (x + menuWidth > window.innerWidth) { + adjustedX = window.innerWidth - menuWidth - 10; + } + if (y + menuHeight > window.innerHeight) { + adjustedY = Math.max(10, window.innerHeight - menuHeight - 10); + } + setMenuPosition({ x: Math.max(8, adjustedX), y: Math.max(8, adjustedY) }); + }, [isVisible, x, y, entries.length]); + + const isEntryContext = entries.length > 0; + const isSingle = entries.length === 1; + const single = isSingle ? entries[0] : null; + + const menuItems: MenuItem[] = []; + + if (isEntryContext) { + if (single) { + menuItems.push({ + icon: , + label: + single.type === "directory" + ? t("fileManager.localOpenFolder") + : t("fileManager.localOpen"), + action: () => onOpen(single), + shortcut: "Enter", + }); + } + + menuItems.push({ + icon: , + label: isSingle + ? t("fileManager.localUploadToRemote") + : t("fileManager.localUploadToRemoteMany", { count: entries.length }), + action: () => onUploadToRemote(entries), + disabled: !canUpload, + }); + + menuItems.push({ + icon: , + label: t("fileManager.localRevealInFileManager"), + action: () => onReveal(entries[0]), + }); + + menuItems.push({ separator: true }); + + if (single) { + menuItems.push({ + icon: , + label: t("fileManager.rename"), + action: () => onRename(single), + shortcut: "F2", + }); + } + + menuItems.push({ + icon: , + label: isSingle ? t("fileManager.copyPath") : t("fileManager.copyPaths"), + action: () => onCopyPath(entries), + }); + + menuItems.push({ separator: true }); + + menuItems.push({ + icon: , + label: isSingle + ? t("fileManager.localMoveToTrash") + : t("fileManager.localMoveToTrashMany", { count: entries.length }), + action: () => onDelete(entries), + shortcut: "Del", + danger: true, + }); + } else { + menuItems.push({ + icon: , + label: t("fileManager.newFolder"), + action: onNewFolder, + }); + menuItems.push({ + icon: , + label: t("fileManager.newFile"), + action: onNewFile, + }); + menuItems.push({ separator: true }); + menuItems.push({ + icon: , + label: t("fileManager.localRevealInFileManager"), + action: () => onReveal(), + }); + menuItems.push({ + icon: showHidden ? ( + + ) : ( + + ), + label: showHidden + ? t("fileManager.localHideHidden") + : t("fileManager.localShowHidden"), + action: onToggleHidden, + }); + menuItems.push({ + icon: , + label: t("fileManager.refresh"), + action: onRefresh, + shortcut: "F5", + }); + } + + const renderShortcut = (shortcut: string) => { + const keys = shortcut.split("+"); + if (keys.length === 1) return {keys[0]}; + return ( + + {keys.map((key, index) => ( + + {key} + {index < keys.length - 1 && } + + ))} + + ); + }; + + if (!isVisible && !isMounted) return null; + + return ( + <> +
+
+ {menuItems.map((item, index) => { + if (item.separator) { + return ( +
+ ); + } + return ( + + ); + })} +
+ + ); +} diff --git a/src/ui/features/file-manager/LocalFilePane.tsx b/src/ui/features/file-manager/LocalFilePane.tsx index bf8021c14..8a0d1726e 100644 --- a/src/ui/features/file-manager/LocalFilePane.tsx +++ b/src/ui/features/file-manager/LocalFilePane.tsx @@ -7,6 +7,7 @@ import React, { } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { ArrowDown, ArrowUp, @@ -29,12 +30,18 @@ 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 { formatFileSize } from "./file-manager-utils.ts"; import { LOCAL_FILES_DRAG_MIME, @@ -60,6 +67,11 @@ export interface LocalFilePaneProps { * `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; } @@ -93,9 +105,11 @@ 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); @@ -111,14 +125,25 @@ export function LocalFilePane({ const historyRef = useRef([]); const historyIndexRef = useRef(-1); const [nav, setNav] = useState({ canBack: false, canForward: false }); - const [creatingFolder, setCreatingFolder] = useState(false); - const [newFolderName, setNewFolderName] = useState(""); + 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 newFolderInputRef = useRef(null); + const newEntryInputRef = useRef(null); + const renameInputRef = useRef(null); const currentPathRef = useRef(null); currentPathRef.current = currentPath; @@ -231,8 +256,19 @@ export function LocalFilePane({ }, [showHidden]); useEffect(() => { - if (creatingFolder) newFolderInputRef.current?.focus(); - }, [creatingFolder]); + 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; @@ -282,13 +318,7 @@ export function LocalFilePane({ const handleRowClick = (entry: LocalFileEntry, event: React.MouseEvent) => { event.stopPropagation(); if (event.detail === 2) { - if (entry.type === "directory") { - void navigateTo(entry.path); - } else { - void openLocalPath(entry.path).catch(() => { - void revealLocalPath(entry.path); - }); - } + openEntry(entry); return; } @@ -415,16 +445,169 @@ export function LocalFilePane({ finishDrop(event, entry.path); }; - const submitNewFolder = async () => { - const name = newFolderName.trim(); - setCreatingFolder(false); - setNewFolderName(""); - if (!name || !currentPath) return; + 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 { - await createLocalFolder(currentPath, name); + if (kind === "folder") await createLocalFolder(currentPath, name); + else await createLocalFile(currentPath, name); await load(currentPath); } catch (err) { - setError(err instanceof Error ? err.message : String(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))); } }; @@ -524,7 +707,7 @@ export function LocalFilePane({ variant="ghost" size="icon" className="size-7 rounded-none border-l border-border" - onClick={() => setCreatingFolder(true)} + onClick={() => setCreating("folder")} disabled={!currentPath} title={t("fileManager.newFolder")} > @@ -615,29 +798,40 @@ export function LocalFilePane({ "flex-1 min-h-0 overflow-y-auto thin-scrollbar relative", showPaneOverlay && "bg-muted/20", )} + tabIndex={0} + onKeyDown={handleKeyDown} onClick={() => { setSelected(new Set()); setAnchorPath(null); }} + onContextMenu={(e) => openContextMenu(e)} > - {creatingFolder && ( + {creating && (
- + {creating === "folder" ? ( + + ) : ( + + )} setNewFolderName(e.target.value)} + ref={newEntryInputRef} + value={newEntryName} + onChange={(e) => setNewEntryName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") void submitNewFolder(); + if (e.key === "Enter") void submitNewEntry(); if (e.key === "Escape") { - setCreatingFolder(false); - setNewFolderName(""); + setCreating(null); + setNewEntryName(""); } }} - onBlur={() => void submitNewFolder()} + onBlur={() => void submitNewEntry()} onClick={(e) => e.stopPropagation()} - placeholder={t("fileManager.newFolder")} + 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" />
@@ -696,6 +890,7 @@ export function LocalFilePane({ entry.hidden && "opacity-60", )} onClick={(e) => handleRowClick(entry, e)} + onContextMenu={(e) => openContextMenu(e, entry)} onDragStart={(e) => handleRowDragStart(e, entry)} onDragOver={(e) => handleFolderDragOver(e, entry)} onDragLeave={(e) => handleFolderDragLeave(e, entry)} @@ -706,14 +901,38 @@ export function LocalFilePane({ - - {entry.name} - {entry.type === "link" && entry.linkTarget && ( - - → {entry.linkTarget} - - )} - + {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)} @@ -745,6 +964,31 @@ export function LocalFilePane({ )}
+ + 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 */}
diff --git a/src/ui/lib/local-files.ts b/src/ui/lib/local-files.ts index 03173892d..8c68bfc2b 100644 --- a/src/ui/lib/local-files.ts +++ b/src/ui/lib/local-files.ts @@ -6,6 +6,7 @@ import type { LocalDirectoryListing, LocalFsHomeInfo, LocalFsResult, + LocalTrashResult, LocalWalkResult, } from "@/types/electron"; import { isElectron } from "./electron"; @@ -54,6 +55,27 @@ export async function createLocalFolder( 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 []; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 8a07afc37..c33d61006 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2253,6 +2253,19 @@ "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/tests/features/file-manager/LocalFilePane.test.tsx b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx index 920a12068..87d0b3c38 100644 --- a/src/ui/tests/features/file-manager/LocalFilePane.test.tsx +++ b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx @@ -23,6 +23,18 @@ vi.mock("@tanstack/react-virtual", () => ({ }), })); +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) => @@ -78,6 +90,13 @@ function installElectronApi() { })), 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(), @@ -232,6 +251,150 @@ describe("LocalFilePane", () => { ).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(); From cd548c6048e99027139a23935fb74e4b71a04e4d Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 06:33:46 +0000 Subject: [PATCH 4/8] feat(file-manager): resizable list-view columns in both panes Drag the boundary at the left edge of a column header (Modified, Owner, Size, Permissions on the remote grid; Modified, Size, Kind in the local pane) to change its width; the Name column takes whatever is left. Double-click a handle to reset that column. Widths are remembered per pane in localStorage. Finishing a drag over a sortable header no longer toggles the sort. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Bn6K6xNAihgWZ5fMVWt1W --- .../features/file-manager/FileManagerGrid.tsx | 70 +++++-- .../features/file-manager/LocalFilePane.tsx | 91 ++++++--- .../components/ColumnResizeHandle.tsx | 43 +++++ .../file-manager/hooks/useResizableColumns.ts | 173 ++++++++++++++++++ .../hooks/useResizableColumns.test.ts | 76 ++++++++ 5 files changed, 412 insertions(+), 41 deletions(-) create mode 100644 src/ui/features/file-manager/components/ColumnResizeHandle.tsx create mode 100644 src/ui/features/file-manager/hooks/useResizableColumns.ts create mode 100644 src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts diff --git a/src/ui/features/file-manager/FileManagerGrid.tsx b/src/ui/features/file-manager/FileManagerGrid.tsx index 25147c372..2f8b03a55 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -37,6 +37,20 @@ import { 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 { /** @@ -273,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, @@ -1200,12 +1219,13 @@ export function FileManagerGrid({
onSortChange?.("name")} > {t("fileManager.name")} @@ -1217,10 +1237,13 @@ export function FileManagerGrid({ ))}
onSortChange?.("modified")} > - {t("fileManager.modified")} + + {t("fileManager.modified")} {sortBy === "modified" && (sortOrder === "asc" ? ( @@ -1228,12 +1251,18 @@ export function FileManagerGrid({ ))}
-
+
+ + {t("fileManager.owner")} +
onSortChange?.("size")} > - {t("fileManager.size")} + + {t("fileManager.size")} {sortBy === "size" && (sortOrder === "asc" ? ( @@ -1241,13 +1270,21 @@ export function FileManagerGrid({ ))}
-
{t("fileManager.permissions")}
+
+ + + {t("fileManager.permissions")} + +
{createIntent && ( )}
- + {file.modified || "—"} @@ -1350,7 +1390,7 @@ export function FileManagerGrid({ : "—"} - + {file.permissions || "—"}
@@ -1536,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); @@ -1583,7 +1625,11 @@ function CreateIntentListItem({ return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > diff --git a/src/ui/features/file-manager/LocalFilePane.tsx b/src/ui/features/file-manager/LocalFilePane.tsx index 8a0d1726e..c73570313 100644 --- a/src/ui/features/file-manager/LocalFilePane.tsx +++ b/src/ui/features/file-manager/LocalFilePane.tsx @@ -42,6 +42,11 @@ import { 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, @@ -57,6 +62,12 @@ import { 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. */ @@ -154,6 +165,12 @@ export function LocalFilePane({ 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, @@ -756,39 +773,51 @@ export function LocalFilePane({
{/* Column headers */} -
+
- - - +
+ + +
+
+ + +
+
+ + +
{/* Body */} @@ -807,7 +836,10 @@ export function LocalFilePane({ onContextMenu={(e) => openContextMenu(e)} > {creating && ( -
+
{creating === "folder" ? ( @@ -882,8 +914,9 @@ export function LocalFilePane({
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/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/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); + }); +}); From 632cc93e7501d48ed28a352928d5f0e7674b2e1b Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 06:50:55 +0000 Subject: [PATCH 5/8] feat(file-manager): pinned ".." parent entry in both panes Both the remote grid (list, grid and empty-folder states) and the local pane show a Termius-style ".." row pinned above the entries whenever the current folder has a parent. Double-clicking it goes up one level. It is also a drop target: local files dropped on it upload into the parent folder, remote rows dragged onto it move there, and remote items dropped on the local pane's ".." download into the parent folder. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Bn6K6xNAihgWZ5fMVWt1W --- src/ui/features/file-manager/FileManager.tsx | 7 ++ .../features/file-manager/FileManagerGrid.tsx | 113 ++++++++++++++++-- .../features/file-manager/LocalFilePane.tsx | 52 +++++++- src/ui/locales/en.json | 1 + .../file-manager/LocalFilePane.test.tsx | 37 ++++++ 5 files changed, 198 insertions(+), 12 deletions(-) diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 5957ab7f9..28cb66db1 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -3472,6 +3472,13 @@ function FileManagerContent({ onLocalFilesDrop={ localPaneAvailable ? handleLocalFilesDrop : undefined } + parentPath={ + currentPath === "/" + ? null + : currentPath.substring(0, currentPath.lastIndexOf("/")) || + "/" + } + onNavigateUp={goUp} sortBy={sortBy} sortOrder={sortOrder} onSortChange={(field) => { diff --git a/src/ui/features/file-manager/FileManagerGrid.tsx b/src/ui/features/file-manager/FileManagerGrid.tsx index 2f8b03a55..e0825b878 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -23,6 +23,7 @@ import { Download, Upload, ArrowUp, + CornerLeftUp, ArrowDown, FileSymlink, Move, @@ -104,6 +105,13 @@ interface FileManagerGridProps { sortBy?: "name" | "modified" | "size"; sortOrder?: "asc" | "desc"; onSortChange?: (field: "name" | "modified" | "size") => void; + /** + * Parent of the listed directory. When set (together with onNavigateUp) a + * pinned ".." entry is shown first; double-click goes up and drops on it + * target the parent folder. + */ + parentPath?: string | null; + onNavigateUp?: () => void; } const getFileTypeColor = (file: FileItem): string => { @@ -233,6 +241,8 @@ export function FileManagerGrid({ sortBy, sortOrder, onSortChange, + parentPath, + onNavigateUp, }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); @@ -1037,6 +1047,80 @@ export function FileManagerGrid({ onUndo, ]); + // Pinned ".." entry (Termius-style). Behaves as a directory drop target + // for internal moves and local-pane uploads, and navigates up on open. + const parentEntry: FileItem | null = + parentPath && onNavigateUp + ? { name: "..", path: parentPath, type: "directory" } + : null; + const isParentTarget = + !!parentEntry && dragState.target?.path === parentEntry.path; + + const parentEntryHandlers = parentEntry + ? { + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + if (e.detail === 2) onNavigateUp?.(); + }, + onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(), + onContextMenu: (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, + onDragOver: (e: React.DragEvent) => handleFileDragOver(e, parentEntry), + onDragLeave: (e: React.DragEvent) => + handleFileDragLeave(e, parentEntry), + onDrop: (e: React.DragEvent) => handleFileDrop(e, parentEntry), + } + : null; + + const parentListRow = + parentEntry && parentEntryHandlers ? ( +
+
+
+ +
+ + .. + +
+
+ ) : null; + + const parentGridTile = + parentEntry && parentEntryHandlers ? ( +
+
+ +
+

+ .. +

+
+ ) : null; + return (
@@ -1075,26 +1159,32 @@ export function FileManagerGrid({ )} {files.length === 0 && !createIntent ? ( -
- - - {t("fileManager.emptyFolder")} - +
+ {parentListRow} +
+ + + {t("fileManager.emptyFolder")} + +
) : viewMode === "grid" ? (
- {createIntent && ( + {(createIntent || parentGridTile) && (
- + {parentGridTile} + {createIntent && ( + + )}
)}
+ {parentListRow} {createIntent && ( sortBy === field ? ( sortOrder === "asc" ? ( @@ -835,6 +852,39 @@ export function LocalFilePane({ }} onContextMenu={(e) => openContextMenu(e)} > + {parentEntry && ( +
{ + e.stopPropagation(); + if (e.detail === 2) goUp(); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onDragOver={(e) => handleFolderDragOver(e, parentEntry)} + onDragLeave={(e) => handleFolderDragLeave(e, parentEntry)} + onDrop={(e) => handleFolderDrop(e, parentEntry)} + > +
+ + + + + .. + +
+
+ )} + {creating && (
) : visibleEntries.length === 0 && !loading ? ( -
+
{t("fileManager.emptyFolder")} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index c33d61006..4bde85ebd 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2266,6 +2266,7 @@ "localTrashFailed": "Could not move to the Trash", "localRenameFailed": "Rename failed", "localCreateFailed": "Could not create item", + "goToParentFolder": "Go to parent folder", "emptyFolder": "This folder is empty", "searchFiles": "Search files...", "upload": "Upload", diff --git a/src/ui/tests/features/file-manager/LocalFilePane.test.tsx b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx index 87d0b3c38..dc48c3e99 100644 --- a/src/ui/tests/features/file-manager/LocalFilePane.test.tsx +++ b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx @@ -395,6 +395,43 @@ describe("LocalFilePane", () => { ); }); + it("shows a pinned .. entry that goes up on double click and accepts drops", async () => { + const api = installElectronApi(); + const onRemoteItemsDropped = vi.fn(); + render(); + await waitFor(() => + expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), + ); + + const parentRow = document.querySelector("[data-parent-entry]"); + expect(parentRow).not.toBeNull(); + // It is the first row, above the real entries. + const firstRow = screen + .getByTestId("local-file-pane") + .querySelector(".thin-scrollbar > *"); + expect(firstRow).toBe(parentRow); + + const payload = JSON.stringify({ + type: "internal_files", + files: ["/srv/app/a.log"], + }); + const dataTransfer = makeDataTransfer( + [REMOTE_FILES_DRAG_MIME, "text/plain"], + { "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" }, + ); + fireEvent.dragOver(parentRow!, { dataTransfer }); + fireEvent.drop(parentRow!, { dataTransfer }); + expect(onRemoteItemsDropped).toHaveBeenCalledWith( + ["/srv/app/a.log"], + "/Users", + ); + + fireEvent.click(parentRow!, { detail: 2 }); + await waitFor(() => + expect(api.localFs.list).toHaveBeenCalledWith("/Users"), + ); + }); + it("ignores OS file drags (those belong to the remote grid)", async () => { const onRemoteItemsDropped = vi.fn(); render(); From 6330e651eaeaf8be77e0beca68b9cb4c365cd9c2 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 06:56:57 +0000 Subject: [PATCH 6/8] feat(file-manager): show/hide list-view columns from the header Right-click a list-view header (remote grid or local pane) to open a Columns menu and tick/untick Modified, Owner, Size, Permissions (remote) or Modified, Size, Kind (local). Hidden columns leave the grid template entirely so the Name column gets the space back; at least one optional column always stays on. The choice is remembered per pane alongside the column widths. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Bn6K6xNAihgWZ5fMVWt1W --- .../features/file-manager/FileManagerGrid.tsx | 192 ++++++++++++------ .../features/file-manager/LocalFilePane.tsx | 135 +++++++----- .../components/ColumnVisibilityMenu.tsx | 105 ++++++++++ .../file-manager/hooks/useResizableColumns.ts | 76 ++++++- src/ui/locales/en.json | 2 + .../file-manager/LocalFilePane.test.tsx | 33 +++ .../hooks/useResizableColumns.test.ts | 29 ++- 7 files changed, 451 insertions(+), 121 deletions(-) create mode 100644 src/ui/features/file-manager/components/ColumnVisibilityMenu.tsx diff --git a/src/ui/features/file-manager/FileManagerGrid.tsx b/src/ui/features/file-manager/FileManagerGrid.tsx index e0825b878..c5f420c0f 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -43,13 +43,29 @@ import { type ResizableColumnSpec, } from "./hooks/useResizableColumns.ts"; import { ColumnResizeHandle } from "./components/ColumnResizeHandle.tsx"; +import { ColumnVisibilityMenu } from "./components/ColumnVisibilityMenu.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 }, + { + key: "modified", + labelKey: "fileManager.modified", + defaultWidth: 120, + minWidth: 70, + }, + { + key: "owner", + labelKey: "fileManager.owner", + defaultWidth: 150, + minWidth: 60, + }, + { key: "size", labelKey: "fileManager.size", defaultWidth: 80, minWidth: 56 }, + { + key: "permissions", + labelKey: "fileManager.permissions", + defaultWidth: 90, + minWidth: 70, + }, ]; const LIST_COLUMNS_STORAGE_KEY = "termix:file-manager:columns:remote"; @@ -301,6 +317,15 @@ export function FileManagerGrid({ storageKey: LIST_COLUMNS_STORAGE_KEY, columns: LIST_COLUMNS, }); + const [columnMenu, setColumnMenu] = useState<{ + x: number; + y: number; + visible: boolean; + }>({ x: 0, y: 0, visible: false }); + const closeColumnMenu = useCallback( + () => setColumnMenu((prev) => ({ ...prev, visible: false })), + [], + ); const gridVirtualizer = useVirtualizer({ count: gridRowCount, @@ -1313,6 +1338,12 @@ export function FileManagerGrid({ compact ? "px-2 py-1" : "px-4 py-2", )} style={{ gridTemplateColumns: listColumns.gridTemplateColumns }} + title={t("fileManager.columnsHint")} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setColumnMenu({ x: e.clientX, y: e.clientY, visible: true }); + }} >
))}
-
onSortChange?.("modified")} - > - - {t("fileManager.modified")} - {sortBy === "modified" && - (sortOrder === "asc" ? ( - - ) : ( - - ))} -
-
- - {t("fileManager.owner")} -
-
onSortChange?.("size")} - > - - {t("fileManager.size")} - {sortBy === "size" && - (sortOrder === "asc" ? ( - - ) : ( - - ))} -
-
- - - {t("fileManager.permissions")} - -
+ {listColumns.isVisible("modified") && ( +
onSortChange?.("modified")} + > + + + {t("fileManager.modified")} + + {sortBy === "modified" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
+ )} + {listColumns.isVisible("owner") && ( +
+ + {t("fileManager.owner")} +
+ )} + {listColumns.isVisible("size") && ( +
onSortChange?.("size")} + > + + {t("fileManager.size")} + {sortBy === "size" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
+ )} + {listColumns.isVisible("permissions") && ( +
+ + + {t("fileManager.permissions")} + +
+ )}
+ {parentListRow} {createIntent && ( - - {file.modified || "—"} - - - - {file.owner - ? `${file.owner}${file.group ? `:${file.group}` : ""}` - : "—"} - - - - {file.type === "file" && - file.size !== undefined && - file.size !== null - ? formatFileSize(file.size) - : "—"} - - - - {file.permissions || "—"} - + {listColumns.isVisible("modified") && ( + + {file.modified || "—"} + + )} + + {listColumns.isVisible("owner") && ( + + {file.owner + ? `${file.owner}${file.group ? `:${file.group}` : ""}` + : "—"} + + )} + + {listColumns.isVisible("size") && ( + + {file.type === "file" && + file.size !== undefined && + file.size !== null + ? formatFileSize(file.size) + : "—"} + + )} + + {listColumns.isVisible("permissions") && ( + + {file.permissions || "—"} + + )}
); diff --git a/src/ui/features/file-manager/LocalFilePane.tsx b/src/ui/features/file-manager/LocalFilePane.tsx index 2f3d0fe71..82bc885d0 100644 --- a/src/ui/features/file-manager/LocalFilePane.tsx +++ b/src/ui/features/file-manager/LocalFilePane.tsx @@ -48,6 +48,7 @@ import { type ResizableColumnSpec, } from "./hooks/useResizableColumns.ts"; import { ColumnResizeHandle } from "./components/ColumnResizeHandle.tsx"; +import { ColumnVisibilityMenu } from "./components/ColumnVisibilityMenu.tsx"; import { formatFileSize } from "./file-manager-utils.ts"; import { LOCAL_FILES_DRAG_MIME, @@ -65,9 +66,14 @@ 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 }, + { + key: "modified", + labelKey: "fileManager.modified", + defaultWidth: 130, + minWidth: 70, + }, + { key: "size", labelKey: "fileManager.size", defaultWidth: 72, minWidth: 56 }, + { key: "kind", labelKey: "fileManager.kind", defaultWidth: 64, minWidth: 48 }, ]; export interface LocalFilePaneProps { @@ -171,6 +177,15 @@ export function LocalFilePane({ columns: LOCAL_COLUMNS, }); const rowStyle = { gridTemplateColumns: columns.gridTemplateColumns }; + const [columnMenu, setColumnMenu] = useState<{ + x: number; + y: number; + visible: boolean; + }>({ x: 0, y: 0, visible: false }); + const closeColumnMenu = useCallback( + () => setColumnMenu((prev) => ({ ...prev, visible: false })), + [], + ); const virtualizer = useVirtualizer({ count: visibleEntries.length, @@ -793,6 +808,12 @@ export function LocalFilePane({
{ + e.preventDefault(); + e.stopPropagation(); + setColumnMenu({ x: e.clientX, y: e.clientY, visible: true }); + }} > -
- - -
-
- - -
-
- - -
+ {columns.isVisible("modified") && ( +
+ + +
+ )} + {columns.isVisible("size") && ( +
+ + +
+ )} + {columns.isVisible("kind") && ( +
+ + +
+ )}
+ + {/* Body */}
)}
- - {formatLocalModified(entry.modifiedTimestamp)} - - - {entry.type === "directory" - ? "--" - : formatFileSize(entry.size)} - - - {describeLocalKind(entry)} - + {columns.isVisible("modified") && ( + + {formatLocalModified(entry.modifiedTimestamp)} + + )} + {columns.isVisible("size") && ( + + {entry.type === "directory" + ? "--" + : formatFileSize(entry.size)} + + )} + {columns.isVisible("kind") && ( + + {describeLocalKind(entry)} + + )}
); diff --git a/src/ui/features/file-manager/components/ColumnVisibilityMenu.tsx b/src/ui/features/file-manager/components/ColumnVisibilityMenu.tsx new file mode 100644 index 000000000..2936e8981 --- /dev/null +++ b/src/ui/features/file-manager/components/ColumnVisibilityMenu.tsx @@ -0,0 +1,105 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Check } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils.ts"; +import type { ResizableColumns } from "../hooks/useResizableColumns.ts"; + +interface ColumnVisibilityMenuProps { + x: number; + y: number; + isVisible: boolean; + columns: ResizableColumns; + onClose: () => void; +} + +/** + * Right-click menu for a list-view header: tick/untick which optional + * columns are shown. The name column is always on and not listed. + */ +export function ColumnVisibilityMenu({ + x, + y, + isVisible, + columns, + onClose, +}: ColumnVisibilityMenuProps) { + const { t } = useTranslation(); + const menuRef = useRef(null); + const [position, setPosition] = useState({ x, y }); + + useEffect(() => { + if (!isVisible) return; + const handleMouseDown = (event: MouseEvent) => { + if (!menuRef.current?.contains(event.target as Node)) onClose(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + const timeout = setTimeout(() => { + document.addEventListener("mousedown", handleMouseDown, true); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", onClose); + }, 50); + return () => { + clearTimeout(timeout); + document.removeEventListener("mousedown", handleMouseDown, true); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", onClose); + }; + }, [isVisible, onClose]); + + useLayoutEffect(() => { + if (!isVisible || !menuRef.current) return; + const { offsetWidth, offsetHeight } = menuRef.current; + setPosition({ + x: Math.max(8, Math.min(x, window.innerWidth - offsetWidth - 10)), + y: Math.max(8, Math.min(y, window.innerHeight - offsetHeight - 10)), + }); + }, [isVisible, x, y]); + + if (!isVisible) return null; + + const onlyOneVisible = columns.visibleColumns.length <= 1; + + return ( +
+
+ {t("fileManager.columns")} +
+ {columns.columns.map((column) => { + const visible = columns.isVisible(column.key); + // Keep at least one optional column so the header stays meaningful. + const disabled = visible && onlyOneVisible; + return ( + + ); + })} +
+ ); +} diff --git a/src/ui/features/file-manager/hooks/useResizableColumns.ts b/src/ui/features/file-manager/hooks/useResizableColumns.ts index 7256161fd..1b7fe9ac1 100644 --- a/src/ui/features/file-manager/hooks/useResizableColumns.ts +++ b/src/ui/features/file-manager/hooks/useResizableColumns.ts @@ -4,6 +4,8 @@ import type React from "react"; export interface ResizableColumnSpec { /** Stable key, also used for persistence. */ key: string; + /** i18n key for the column's label (used by the show/hide menu). */ + labelKey: string; defaultWidth: number; minWidth?: number; maxWidth?: number; @@ -44,6 +46,24 @@ function readStoredWidths( return widths; } +function readHiddenKeys( + storageKey: string, + columns: ResizableColumnSpec[], +): Set { + try { + const raw = localStorage.getItem(`${storageKey}:hidden`); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + const known = new Set(columns.map((c) => c.key)); + return new Set( + parsed.filter((k): k is string => typeof k === "string" && known.has(k)), + ); + } catch { + return new Set(); + } +} + function clamp(value: number, column: ResizableColumnSpec): number { const min = column.minWidth ?? DEFAULT_MIN; const max = column.maxWidth ?? DEFAULT_MAX; @@ -63,6 +83,9 @@ export function useResizableColumns({ const [widths, setWidths] = useState>(() => readStoredWidths(storageKey, columns), ); + const [hiddenKeys, setHiddenKeys] = useState>(() => + readHiddenKeys(storageKey, columns), + ); const [resizingKey, setResizingKey] = useState(null); const widthsRef = useRef(widths); widthsRef.current = widths; @@ -70,9 +93,40 @@ export function useResizableColumns({ // Re-read if the pane is re-keyed (e.g. a different storage key). useEffect(() => { setWidths(readStoredWidths(storageKey, columns)); + setHiddenKeys(readHiddenKeys(storageKey, columns)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [storageKey]); + const isVisible = useCallback( + (key: string) => !hiddenKeys.has(key), + [hiddenKeys], + ); + + const visibleColumns = useMemo( + () => columns.filter((c) => !hiddenKeys.has(c.key)), + [columns, hiddenKeys], + ); + + const toggleColumn = useCallback( + (key: string) => { + setHiddenKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + try { + localStorage.setItem( + `${storageKey}:hidden`, + JSON.stringify(Array.from(next)), + ); + } catch { + // storage unavailable + } + return next; + }); + }, + [storageKey], + ); + const persist = useCallback( (next: Record) => { try { @@ -86,10 +140,11 @@ export function useResizableColumns({ const gridTemplateColumns = useMemo( () => - ["minmax(140px, 1fr)", ...columns.map((c) => `${widths[c.key]}px`)].join( - " ", - ), - [columns, widths], + [ + "minmax(140px, 1fr)", + ...visibleColumns.map((c) => `${widths[c.key]}px`), + ].join(" "), + [visibleColumns, widths], ); const startResize = useCallback( @@ -169,5 +224,16 @@ export function useResizableColumns({ [startResize, resetColumn, resizingKey], ); - return { widths, gridTemplateColumns, getHandleProps, resizingKey }; + return { + widths, + gridTemplateColumns, + getHandleProps, + resizingKey, + columns, + visibleColumns, + isVisible, + toggleColumn, + }; } + +export type ResizableColumns = ReturnType; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 4bde85ebd..00b401670 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2267,6 +2267,8 @@ "localRenameFailed": "Rename failed", "localCreateFailed": "Could not create item", "goToParentFolder": "Go to parent folder", + "columns": "Columns", + "columnsHint": "Right-click to choose columns", "emptyFolder": "This folder is empty", "searchFiles": "Search files...", "upload": "Upload", diff --git a/src/ui/tests/features/file-manager/LocalFilePane.test.tsx b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx index dc48c3e99..349adf145 100644 --- a/src/ui/tests/features/file-manager/LocalFilePane.test.tsx +++ b/src/ui/tests/features/file-manager/LocalFilePane.test.tsx @@ -432,6 +432,39 @@ describe("LocalFilePane", () => { ); }); + it("lets the header context menu hide and show columns", async () => { + render(); + await waitFor(() => + expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(), + ); + + const kindHeader = screen.getByText("fileManager.kind"); + const kindCellsBefore = document.querySelectorAll( + `[data-local-path="${HOME}/notes.txt"] > span`, + ).length; + + fireEvent.contextMenu(kindHeader, { clientX: 300, clientY: 60 }); + const menu = screen.getByTestId("column-visibility-menu"); + expect(menu).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "fileManager.kind" }), + ); + + // The sortable header button is gone; only the menu's checkbox remains. + expect( + screen.queryByRole("button", { name: "fileManager.kind" }), + ).toBeNull(); + expect( + document.querySelectorAll(`[data-local-path="${HOME}/notes.txt"] > span`) + .length, + ).toBe(kindCellsBefore - 1); + expect( + JSON.parse( + localStorage.getItem("termix:file-manager:columns:local:hidden")!, + ), + ).toEqual(["kind"]); + }); + it("ignores OS file drags (those belong to the remote grid)", async () => { const onRemoteItemsDropped = vi.fn(); render(); diff --git a/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts b/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts index fda4b3d6a..273937ce8 100644 --- a/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts +++ b/src/ui/tests/features/file-manager/hooks/useResizableColumns.test.ts @@ -3,8 +3,8 @@ 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 }, + { key: "modified", labelKey: "m", defaultWidth: 120, minWidth: 70 }, + { key: "size", labelKey: "s", defaultWidth: 80, minWidth: 56, maxWidth: 200 }, ]; const STORAGE_KEY = "test:columns"; @@ -58,6 +58,31 @@ describe("useResizableColumns", () => { expect(result.current.widths).toEqual({ modified: 70, size: 200 }); }); + it("hides and shows columns, dropping them from the template", () => { + const { result } = renderHook(() => + useResizableColumns({ storageKey: STORAGE_KEY, columns }), + ); + act(() => result.current.toggleColumn("size")); + expect(result.current.isVisible("size")).toBe(false); + expect(result.current.gridTemplateColumns).toBe("minmax(140px, 1fr) 120px"); + expect(result.current.visibleColumns.map((c) => c.key)).toEqual([ + "modified", + ]); + expect(JSON.parse(localStorage.getItem(`${STORAGE_KEY}:hidden`)!)).toEqual([ + "size", + ]); + + // A fresh mount restores the hidden set; toggling again shows it. + const remount = renderHook(() => + useResizableColumns({ storageKey: STORAGE_KEY, columns }), + ); + expect(remount.result.current.isVisible("size")).toBe(false); + act(() => remount.result.current.toggleColumn("size")); + expect(remount.result.current.gridTemplateColumns).toBe( + "minmax(140px, 1fr) 120px 80px", + ); + }); + it("resets a column to its default on double click", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ modified: 150 })); const { result } = renderHook(() => From 5bd8f1445e69facd43101df4fbb5016d9a866ae8 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 07:52:30 +0000 Subject: [PATCH 7/8] feat(file-manager): sidebar toggle works on desktop too The toolbar's sidebar button now shows/hides the directories panel on desktop (persisted), while below md it still opens the mobile overlay. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Bn6K6xNAihgWZ5fMVWt1W --- src/ui/features/file-manager/FileManager.tsx | 27 +++++++++++++++++-- .../file-manager/FileManagerToolbar.tsx | 18 +++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 28cb66db1..e85674bf0 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -111,6 +111,7 @@ import { } from "./optimistic-file-list"; const LOCAL_PANE_OPEN_STORAGE_KEY = "termix:file-manager:local-pane:open"; +const SIDEBAR_OPEN_STORAGE_KEY = "termix:file-manager:sidebar: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; @@ -213,6 +214,25 @@ function FileManagerContent({ } }); const [localPaneRefreshToken, setLocalPaneRefreshToken] = useState(0); + // Desktop directories sidebar visibility (mobile uses the overlay state). + const [sidebarOpen, setSidebarOpen] = useState(() => { + try { + return localStorage.getItem(SIDEBAR_OPEN_STORAGE_KEY) !== "false"; + } catch { + return true; + } + }); + const toggleSidebar = useCallback(() => { + setSidebarOpen((prev) => { + const next = !prev; + try { + localStorage.setItem(SIDEBAR_OPEN_STORAGE_KEY, String(next)); + } catch { + // storage unavailable + } + return next; + }); + }, []); const [localPaneWidth, setLocalPaneWidth] = useState(() => { try { const saved = Number(localStorage.getItem(LOCAL_PANE_WIDTH_STORAGE_KEY)); @@ -3388,6 +3408,8 @@ function FileManagerContent({ showLocalPaneToggle={localPaneAvailable} localPaneOpen={localPaneAvailable && localPaneOpen} onToggleLocalPane={toggleLocalPane} + sidebarOpen={sidebarOpen} + onToggleSidebar={toggleSidebar} />
diff --git a/src/ui/features/file-manager/FileManagerToolbar.tsx b/src/ui/features/file-manager/FileManagerToolbar.tsx index 705d0ab92..b09f644c5 100644 --- a/src/ui/features/file-manager/FileManagerToolbar.tsx +++ b/src/ui/features/file-manager/FileManagerToolbar.tsx @@ -70,6 +70,9 @@ type FileManagerToolbarProps = { showLocalPaneToggle?: boolean; localPaneOpen?: boolean; onToggleLocalPane?: () => void; + /** Desktop directories sidebar (mobile uses the overlay instead). */ + sidebarOpen?: boolean; + onToggleSidebar?: () => void; }; function Breadcrumb({ @@ -226,6 +229,8 @@ export function FileManagerToolbar({ showLocalPaneToggle = false, localPaneOpen = false, onToggleLocalPane, + sidebarOpen = true, + onToggleSidebar, }: FileManagerToolbarProps) { return (
@@ -234,9 +239,18 @@ export function FileManagerToolbar({ From 052fe01a11e2655830dc49e2a6a85242a80d63e6 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 07:40:09 +0000 Subject: [PATCH 8/8] feat(file-manager): resizable directories sidebar The Trash/Directories sidebar can now be resized by dragging its right edge (160px minimum, up to 40% of the row); double-click the handle to restore the default 224px. The width is remembered. The same handle and persisted-width hook now also drive the local pane divider, replacing the inline implementation. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Bn6K6xNAihgWZ5fMVWt1W --- src/ui/features/file-manager/FileManager.tsx | 98 +++++++--------- .../components/PaneResizeHandle.tsx | 43 +++++++ .../file-manager/hooks/usePaneWidth.ts | 106 ++++++++++++++++++ src/ui/locales/en.json | 2 + .../file-manager/hooks/usePaneWidth.test.ts | 50 +++++++++ 5 files changed, 241 insertions(+), 58 deletions(-) create mode 100644 src/ui/features/file-manager/components/PaneResizeHandle.tsx create mode 100644 src/ui/features/file-manager/hooks/usePaneWidth.ts create mode 100644 src/ui/tests/features/file-manager/hooks/usePaneWidth.test.ts diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index e85674bf0..7feaa548d 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -31,6 +31,8 @@ import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { FileManagerToolbar } from "./FileManagerToolbar.tsx"; import { LocalFilePane } from "./LocalFilePane.tsx"; import { useLocalTransfers } from "./hooks/useLocalTransfers.ts"; +import { usePaneWidth } from "./hooks/usePaneWidth.ts"; +import { PaneResizeHandle } from "./components/PaneResizeHandle.tsx"; import { isLocalFileBrowserAvailable } from "@/lib/local-files.ts"; import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx"; import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx"; @@ -115,6 +117,9 @@ const SIDEBAR_OPEN_STORAGE_KEY = "termix:file-manager:sidebar: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 SIDEBAR_WIDTH_STORAGE_KEY = "termix:file-manager:sidebar:width"; +const SIDEBAR_MIN_WIDTH = 160; +const SIDEBAR_DEFAULT_WIDTH = 224; // matches the previous fixed w-56 const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024; @@ -233,53 +238,21 @@ function FileManagerContent({ return next; }); }, []); - 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 localPaneSize = usePaneWidth({ + storageKey: LOCAL_PANE_WIDTH_STORAGE_KEY, + defaultWidth: LOCAL_PANE_DEFAULT_WIDTH, + minWidth: LOCAL_PANE_MIN_WIDTH, + maxFraction: 0.65, + containerRef: panesRowRef, + }); + const sidebarSize = usePaneWidth({ + storageKey: SIDEBAR_WIDTH_STORAGE_KEY, + defaultWidth: SIDEBAR_DEFAULT_WIDTH, + minWidth: SIDEBAR_MIN_WIDTH, + maxFraction: 0.4, + containerRef: panesRowRef, + }); const toggleLocalPane = useCallback(() => { setLocalPaneOpen((prev) => { const next = !prev; @@ -3428,13 +3401,18 @@ function FileManagerContent({ {/* Sidebar — fixed overlay on mobile, static on desktop */}
+ {sidebarOpen && !mobileSidebarOpen && ( + + )} {localPaneAvailable && localPaneOpen && ( <>
- {/* Drag handle between the local and remote panes */} -
-
-
+ )} diff --git a/src/ui/features/file-manager/components/PaneResizeHandle.tsx b/src/ui/features/file-manager/components/PaneResizeHandle.tsx new file mode 100644 index 000000000..76f080486 --- /dev/null +++ b/src/ui/features/file-manager/components/PaneResizeHandle.tsx @@ -0,0 +1,43 @@ +import type React from "react"; +import { cn } from "@/lib/utils.ts"; + +interface PaneResizeHandleProps { + label: string; + onMouseDown: (event: React.MouseEvent) => void; + onDoubleClick?: () => void; + active?: boolean; +} + +/** + * Vertical drag handle placed between two panes in a `gap-3` flex row. It + * overlaps the gap (negative margins) so the panes' spacing is unchanged, and + * shows a hairline on hover / while dragging. Double-click resets the width. + */ +export function PaneResizeHandle({ + label, + onMouseDown, + onDoubleClick, + active = false, +}: PaneResizeHandleProps) { + return ( +
+
+
+ ); +} diff --git a/src/ui/features/file-manager/hooks/usePaneWidth.ts b/src/ui/features/file-manager/hooks/usePaneWidth.ts new file mode 100644 index 000000000..cf614a5bd --- /dev/null +++ b/src/ui/features/file-manager/hooks/usePaneWidth.ts @@ -0,0 +1,106 @@ +import { useCallback, useRef, useState } from "react"; +import type React from "react"; + +interface UsePaneWidthOptions { + /** localStorage key the width is remembered under. */ + storageKey: string; + defaultWidth: number; + minWidth: number; + /** + * Upper bound as a fraction of the containing row's width (measured at + * drag start from `containerRef`), so a pane can never swallow the layout. + */ + maxFraction?: number; + containerRef?: React.RefObject; +} + +function readStoredWidth( + storageKey: string, + defaultWidth: number, + minWidth: number, +): number { + try { + const saved = Number(localStorage.getItem(storageKey)); + return Number.isFinite(saved) && saved >= minWidth ? saved : defaultWidth; + } catch { + return defaultWidth; + } +} + +/** + * A persisted, drag-resizable pane width. `startResize` goes on the + * mousedown of a vertical drag handle sitting on the pane's right edge; + * `resetWidth` (e.g. on double-click) restores the default. + */ +export function usePaneWidth({ + storageKey, + defaultWidth, + minWidth, + maxFraction = 0.65, + containerRef, +}: UsePaneWidthOptions) { + const [width, setWidth] = useState(() => + readStoredWidth(storageKey, defaultWidth, minWidth), + ); + const [isResizing, setIsResizing] = useState(false); + const widthRef = useRef(width); + widthRef.current = width; + + const persist = useCallback( + (value: number) => { + try { + localStorage.setItem(storageKey, String(value)); + } catch { + // storage unavailable + } + }, + [storageKey], + ); + + const startResize = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startWidth = widthRef.current; + const rowWidth = + containerRef?.current?.getBoundingClientRect().width ?? 0; + const maxWidth = Math.max( + minWidth, + rowWidth ? rowWidth * maxFraction : Number.POSITIVE_INFINITY, + ); + let latest = startWidth; + setIsResizing(true); + + const onMove = (e: MouseEvent) => { + latest = Math.round( + Math.min( + maxWidth, + Math.max(minWidth, startWidth + (e.clientX - startX)), + ), + ); + if (latest !== widthRef.current) setWidth(latest); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + setIsResizing(false); + persist(latest); + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [containerRef, maxFraction, minWidth, persist], + ); + + const resetWidth = useCallback(() => { + setWidth(defaultWidth); + persist(defaultWidth); + }, [defaultWidth, persist]); + + return { width, isResizing, startResize, resetWidth }; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 00b401670..c9cc0431b 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2267,6 +2267,8 @@ "localRenameFailed": "Rename failed", "localCreateFailed": "Could not create item", "goToParentFolder": "Go to parent folder", + "resizeSidebar": "Drag to resize the sidebar (double-click to reset)", + "resizeLocalPane": "Drag to resize the local pane (double-click to reset)", "columns": "Columns", "columnsHint": "Right-click to choose columns", "emptyFolder": "This folder is empty", diff --git a/src/ui/tests/features/file-manager/hooks/usePaneWidth.test.ts b/src/ui/tests/features/file-manager/hooks/usePaneWidth.test.ts new file mode 100644 index 000000000..ffb1e0637 --- /dev/null +++ b/src/ui/tests/features/file-manager/hooks/usePaneWidth.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { usePaneWidth } from "@/features/file-manager/hooks/usePaneWidth"; + +const KEY = "test:pane-width"; + +function mouse(type: string, clientX: number) { + document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true })); +} + +const opts = { storageKey: KEY, defaultWidth: 224, minWidth: 160 }; + +describe("usePaneWidth", () => { + beforeEach(() => localStorage.clear()); + + it("starts at the default and restores a stored width", () => { + expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224); + localStorage.setItem(KEY, "300"); + expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(300); + localStorage.setItem(KEY, "10"); // below min → ignored + expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224); + }); + + it("follows the drag, clamps to the minimum, and persists on release", () => { + const { result } = renderHook(() => usePaneWidth(opts)); + act(() => { + result.current.startResize({ + clientX: 400, + preventDefault() {}, + stopPropagation() {}, + } as unknown as React.MouseEvent); + }); + expect(result.current.isResizing).toBe(true); + act(() => mouse("mousemove", 460)); + expect(result.current.width).toBe(284); + act(() => mouse("mousemove", 100)); + expect(result.current.width).toBe(160); + act(() => mouse("mouseup", 100)); + expect(result.current.isResizing).toBe(false); + expect(localStorage.getItem(KEY)).toBe("160"); + }); + + it("resets to the default", () => { + localStorage.setItem(KEY, "300"); + const { result } = renderHook(() => usePaneWidth(opts)); + act(() => result.current.resetWidth()); + expect(result.current.width).toBe(224); + expect(localStorage.getItem(KEY)).toBe("224"); + }); +});