From 2498c72f1a96d7580087d864f17163fc7d6b2fe6 Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:21:49 +0300 Subject: [PATCH] commander cleanup --- src/components/navigator/FileList.tsx | 10 +- src/components/navigator/FilePanel.tsx | 266 +++++++++++++++------- src/components/navigator/index.ts | 2 + src/components/navigator/utils.ts | 87 ++++++-- src/pages/Commander.tsx | 297 +++++++++++++++++-------- 5 files changed, 464 insertions(+), 198 deletions(-) diff --git a/src/components/navigator/FileList.tsx b/src/components/navigator/FileList.tsx index 29f149f..467b782 100644 --- a/src/components/navigator/FileList.tsx +++ b/src/components/navigator/FileList.tsx @@ -28,6 +28,7 @@ export default function FileList({ onRename, onDelete, listHeight, + columnTemplate, }: { items: (VirtualizedEntry | PaddingItem)[] isLoading: boolean @@ -49,6 +50,8 @@ export default function FileList({ onRename?: (entry: Entry) => void onDelete?: (entry: Entry) => void listHeight: number + /** The panel header's grid template — rows must line up with it (see useNameColumnResize). */ + columnTemplate: string }) { const showCheckbox = selectionMode === 'checkbox' || selectionMode === 'both' @@ -163,10 +166,6 @@ export default function FileList({ ) } - const gridCols = showPreviewColumn - ? 'grid-cols-[2.5rem_1fr_6rem_9rem_11rem]' - : 'grid-cols-[2.5rem_1fr_6rem_9rem_2.5rem]' - return (
handleDragStart(entry, e)} onDragEnd={handleDragEnd} diff --git a/src/components/navigator/FilePanel.tsx b/src/components/navigator/FilePanel.tsx index 6c5fd10..d52f4a9 100644 --- a/src/components/navigator/FilePanel.tsx +++ b/src/components/navigator/FilePanel.tsx @@ -1,7 +1,9 @@ -import { Button, Divider, Tooltip } from '@heroui/react' +import { Button, Checkbox, Divider, Tooltip } from '@heroui/react' import { useQuery } from '@tanstack/react-query' import { ChevronDownIcon, ChevronUpIcon, FolderPlusIcon } from 'lucide-react' import { + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, forwardRef, startTransition, useCallback, @@ -25,6 +27,66 @@ import { RE_LEADING_SLASH, dragStateRef, dropTargetsRef, serializeRemotePath } f export type { FilePanelHandle } from './types' +const MIN_NAME_WIDTH = 96 +const MAX_NAME_WIDTH = 1200 +const clampNameWidth = (width: number) => Math.min(MAX_NAME_WIDTH, Math.max(MIN_NAME_WIDTH, width)) +const measuredWidth = (cell: HTMLDivElement | null) => + cell?.getBoundingClientRect().width ?? MIN_NAME_WIDTH + +// The columns either side of Name: checkbox 2.5rem, Size 6rem, Modified 9rem, and the actions +// column (11rem with the preview/actions column, 2.5rem without). +const fixedRem = (showPreviewColumn: boolean) => 2.5 + 6 + 9 + (showPreviewColumn ? 11 : 2.5) + +// Finder-style width for the Name column: the header's right edge is dragged; the other columns +// keep their size and shift right (the list then scrolls horizontally), so a long name can be +// read without giving up Size/Modified. Double-clicking the edge restores the automatic (fill) +// width; with the edge focused, the arrow keys nudge it. Local to the panel — not persisted. +function useNameColumnResize(showPreviewColumn: boolean) { + const [nameWidth, setNameWidth] = useState(null) + const nameCellRef = useRef(null) + + const onPointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const startX = event.clientX + const startWidth = measuredWidth(nameCellRef.current) + // Moves keep coming while the pointer is outside the handle (and the window); listen on + // the window for the whole drag. + event.currentTarget.setPointerCapture?.(event.pointerId) + const move = (e: PointerEvent) => + setNameWidth(clampNameWidth(startWidth + e.clientX - startX)) + const stop = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', stop) + window.removeEventListener('pointercancel', stop) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', stop) + window.addEventListener('pointercancel', stop) + }, []) + + const onKeyDown = useCallback((event: ReactKeyboardEvent) => { + const step = event.key === 'ArrowRight' ? 16 : event.key === 'ArrowLeft' ? -16 : 0 + if (step === 0) return + event.preventDefault() + setNameWidth(clampNameWidth(measuredWidth(nameCellRef.current) + step)) + }, []) + + const reset = useCallback(() => setNameWidth(null), []) + + const name = nameWidth === null ? '1fr' : `${nameWidth}px` + return { + nameCellRef, + columnTemplate: `2.5rem ${name} 6rem 9rem ${showPreviewColumn ? '11rem' : '2.5rem'}`, + // Rows and header grow past the panel once Name is wider than the fill width. + rowMinWidth: + nameWidth === null + ? undefined + : `calc(${nameWidth}px + ${fixedRem(showPreviewColumn)}rem)`, + handleProps: { onPointerDown, onDoubleClick: reset, onKeyDown }, + } +} + const FilePanel = forwardRef< FilePanelHandle, { @@ -78,6 +140,7 @@ const FilePanel = forwardRef< ) { const favoritePaths = useHostStore((state) => state.favoritePaths) const [previewItem, setPreviewItem] = useState(null) + const columns = useNameColumnResize(showPreviewColumn) const nav = useFileNavigation({ initialRemote, @@ -318,6 +381,15 @@ const FilePanel = forwardRef< const showSidebar = sidebarPosition !== 'none' + const showSelectAll = + (selectionMode === 'checkbox' || selectionMode === 'both') && allowMultiple + const selectableCount = nav.visibleItems.length + const selectedVisibleCount = nav.visibleItems.filter((item) => + nav.selectedPaths.has(item.key) + ).length + const allSelected = selectableCount > 0 && selectedVisibleCount === selectableCount + const someSelected = selectedVisibleCount > 0 && !allSelected + return (
-
-
- {( - [ - { column: 'name', label: 'Name', className: 'pl-2' }, - { column: 'size', label: 'Size', className: '' }, - { - column: 'modTime', - label: 'Last Modified', - className: '', - }, - ] as const - ).map(({ column, label, className }) => { - const isSorted = nav.sortDescriptor.column === column - const nextDirection = - isSorted && nav.sortDescriptor.direction === 'ascending' - ? 'descending' - : 'ascending' - const sortStatus = isSorted - ? `, sorted ${nav.sortDescriptor.direction}` - : '' - return ( -
- -
- ) - })} -
-
+ + {column === 'name' && ( + // The column's right edge: drag to widen Name, + // double-click to let it fill again. +
+ )} +
+ ) + })} +
+
-
- {} : undefined} - favoritedKeys={nav.favoritedKeys} - onToggleFavorite={handleToggleFavorite} - listHeight={listHeight} - /> +
+ {} : undefined} + favoritedKeys={nav.favoritedKeys} + onToggleFavorite={handleToggleFavorite} + listHeight={listHeight} + /> +
, + signal: AbortSignal, + filter?: Record +): Promise { + const { fs, base } = resolveFs(remote, dir) const run = async (target: string) => { const result = await rclone('/operations/list', { @@ -180,15 +184,8 @@ export async function searchPath( query: { fs, remote: target, - opt: JSON.stringify({ - recurse: true, - noModTime: false, - noMimeType: true, - }), - _filter: JSON.stringify({ - IncludeRule: [`*${escapedTerm}*`], - IgnoreCase: true, - }), + opt: JSON.stringify(opt), + ...(filter ? { _filter: JSON.stringify(filter) } : {}), } as any, }, signal, @@ -203,8 +200,7 @@ export async function searchPath( if (signal.aborted || !base) throw error list = await run(`${base}/`) } - - if (!Array.isArray(list)) throw new Error('Invalid search response') + if (!Array.isArray(list)) throw new Error('Invalid list response') const seen = new Set() return (list as any[]).filter((item) => { @@ -215,6 +211,49 @@ export async function searchPath( }) } +export function searchPath( + remote: string | 'UI_LOCAL_FS', + dir: string, + term: string, + signal: AbortSignal +) { + const escapedTerm = term.replace(RE_RCLONE_GLOB_META, '\\$&') + return listPath(remote, dir, { recurse: true, noModTime: false, noMimeType: true }, signal, { + IncludeRule: [`*${escapedTerm}*`], + IgnoreCase: true, + }) +} + +// Renames a file or folder in place. Folders go through sync/move (the RC API has no directory +// rename). Both endpoints silently overwrite — or merge into — an existing target, so the +// destination is stat'ed first and the rename refused when something is already there. +export async function renamePath(fullPath: string, isDir: boolean, newName: string) { + const { root, filePath } = getFsInfo(fullPath) + const fs = root === ':local:' ? ':local:/' : root + const dstRemote = [...filePath.split('/').slice(0, -1), newName].join('/') + + const existing = await rclone('/operations/stat', { + params: { query: { fs, remote: dstRemote } }, + }) + if (existing?.item) throw new Error(`"${newName}" already exists`) + + if (isDir) { + await rclone('/sync/move' as any, { + params: { + query: { + srcFs: `${fs}${filePath}/`, + dstFs: `${fs}${dstRemote}/`, + deleteEmptySrcDirs: true, + }, + }, + }) + } else { + await rclone('/operations/movefile' as any, { + params: { query: { srcFs: fs, srcRemote: filePath, dstFs: fs, dstRemote } }, + }) + } +} + export async function listLocalPath(dir: string) { log('listLocalPath', { dir }) const entries = await readDir(dir) diff --git a/src/pages/Commander.tsx b/src/pages/Commander.tsx index fe5925a..ca78438 100644 --- a/src/pages/Commander.tsx +++ b/src/pages/Commander.tsx @@ -23,6 +23,7 @@ import { ask, save } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import { AnimatePresence, motion } from 'framer-motion' import { + ArrowLeftRightIcon, CheckCircle2Icon, ChevronDownIcon, ChevronUpIcon, @@ -30,22 +31,30 @@ import { ExternalLinkIcon, LoaderIcon, MoveIcon, + PencilIcon, SearchCheckIcon, XCircleIcon, } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Group, Panel, Separator } from 'react-resizable-panels' import { onErrorDialog, reportError } from '../../lib/errors' import { getFsInfo } from '../../lib/format' -import { useIsPreview } from '../../lib/preview' // import { Document, Page, pdfjs } from 'react-pdf' import { formatBytes } from '../../lib/format.ts' import { notify } from '../../lib/notifications' +import { useIsPreview } from '../../lib/preview' import { startCopy, startMove } from '../../lib/rclone/api' import rclone from '../../lib/rclone/client' import { openWindow } from '../../lib/window' -import { FileIcon } from '../components/navigator' -import { FilePanel, type FilePanelHandle } from '../components/navigator' +import { + BatchRenameDrawer, + CompareDrawer, + FileIcon, + FilePanel, + type FilePanelHandle, + type PanelLocation, + renamePath, +} from '../components/navigator' import type { Entry, SelectItem } from '../components/navigator/types' export default function Browser() { @@ -70,6 +79,54 @@ export default function Browser() { setTrackedJobIds((prev) => new Set([...prev, jobId])) }, []) + const refreshPanels = useCallback(() => { + leftPanelRef.current?.refresh() + rightPanelRef.current?.refresh() + }, []) + + // Where each panel is and what it has selected, for the Compare / Batch Rename actions. + const [leftLoc, setLeftLoc] = useState(null) + const [rightLoc, setRightLoc] = useState(null) + const handleLeftNavigate = useCallback((remote: string, path: string) => { + setLeftLoc({ remote, path }) + }, []) + const handleRightNavigate = useCallback((remote: string, path: string) => { + setRightLoc({ remote, path }) + }, []) + const [leftSel, setLeftSel] = useState([]) + const [rightSel, setRightSel] = useState([]) + + const [compareOpen, setCompareOpen] = useState(false) + const compareDisabledReason = [leftLoc, rightLoc].every( + (loc) => loc?.remote && loc.remote !== 'UI_FAVORITES' + ) + ? undefined + : 'Open a folder in both panels to compare.' + + // Batch Rename needs a selection of at least 2 items in exactly one panel. The items are + // snapshotted on open so the drawer keeps them through its closing animation. + const renameSel = leftSel.length > 0 ? leftSel : rightSel + const renameDisabledReason = + leftSel.length > 0 && rightSel.length > 0 + ? 'Select items in only one panel to batch rename.' + : renameSel.length < 2 + ? 'Select at least 2 items in one panel to batch rename.' + : undefined + const [batchRenameOpen, setBatchRenameOpen] = useState(false) + const [batchRenameItems, setBatchRenameItems] = useState([]) + const handleOpenBatchRename = useCallback(() => { + setBatchRenameItems(renameSel) + setBatchRenameOpen(true) + }, [renameSel]) + + // Renamed entries have new keys, so the old selection is stale either way. + const handleBatchRenameDone = useCallback(() => { + for (const ref of [leftPanelRef, rightPanelRef]) { + ref.current?.refresh() + ref.current?.clearSelection() + } + }, []) + const remotesQuery = useQuery({ queryKey: ['remotes', 'list', 'all'], queryFn: async () => await rclone('/config/listremotes').then((r) => r?.remotes), @@ -95,11 +152,6 @@ export default function Browser() { [] ) - const handleOperationComplete = useCallback(() => { - leftPanelRef.current?.refresh() - rightPanelRef.current?.refresh() - }, []) - const handleDownload = useCallback( async (entry: Entry) => { const defaultName = entry.name @@ -149,87 +201,63 @@ export default function Browser() { setContextMenu(null) }, []) - const handleDelete = useCallback(async (entry: Entry) => { - const confirmed = await ask(`Are you sure you want to delete "${entry.name}"?`, { - title: 'Confirm Delete', - kind: 'warning', - }) - if (!confirmed) return - - try { - const source = entry.fullPath + (entry.isDir ? '/' : '') - const info = getFsInfo(source) - const endpoint = entry.isDir ? '/operations/purge' : '/operations/deletefile' - - await rclone(endpoint as any, { - params: { - query: { - fs: info.root, - remote: info.filePath, - }, - }, + const handleDelete = useCallback( + async (entry: Entry) => { + const confirmed = await ask(`Are you sure you want to delete "${entry.name}"?`, { + title: 'Confirm Delete', + kind: 'warning', }) + if (!confirmed) return - leftPanelRef.current?.refresh() - rightPanelRef.current?.refresh() - } catch (error) { - await reportError(error, { - title: 'Error', - fallback: 'Delete failed', - capture: false, - }) - } - }, []) + try { + const source = entry.fullPath + (entry.isDir ? '/' : '') + const info = getFsInfo(source) + const endpoint = entry.isDir ? '/operations/purge' : '/operations/deletefile' - const handleRename = useCallback(async (entry: Entry) => { - const newName = await invoke('prompt', { - title: 'Rename', - message: `Enter a new name for "${entry.name}"`, - default: entry.name, - sensitive: false, - }) - if (!newName || newName === entry.name) return - - try { - const info = getFsInfo(entry.fullPath) - const parentDir = info.filePath.includes('/') - ? info.filePath.slice(0, info.filePath.lastIndexOf('/') + 1) - : '' - const dstRemote = `${parentDir}${newName}` - - if (entry.isDir) { - await rclone('/sync/move' as any, { + await rclone(endpoint as any, { params: { query: { - srcFs: `${info.root}${info.filePath}/`, - dstFs: `${info.root}${dstRemote}/`, - deleteEmptySrcDirs: true, + fs: info.root, + remote: info.filePath, }, }, }) - } else { - await rclone('/operations/movefile' as any, { - params: { - query: { - srcFs: info.root, - srcRemote: info.filePath, - dstFs: info.root, - dstRemote, - }, - }, + + refreshPanels() + } catch (error) { + await reportError(error, { + title: 'Error', + fallback: 'Delete failed', + capture: false, }) } + }, + [refreshPanels] + ) - leftPanelRef.current?.refresh() - rightPanelRef.current?.refresh() - } catch (error) { - await reportError(error, { - title: 'Error', - fallback: 'Rename failed', - capture: false, + const handleRename = useCallback( + async (entry: Entry) => { + const newName = await invoke('prompt', { + title: 'Rename', + message: `Enter a new name for "${entry.name}"`, + default: entry.name, + sensitive: false, }) - } - }, []) + if (!newName || newName === entry.name) return + + try { + await renamePath(entry.fullPath, entry.isDir, newName) + refreshPanels() + } catch (error) { + await reportError(error, { + title: 'Error', + fallback: 'Rename failed', + capture: false, + }) + } + }, + [refreshPanels] + ) const handleShare = useCallback(async (entry: Entry) => { try { @@ -263,14 +291,13 @@ export default function Browser() { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'r' && (e.metaKey || e.ctrlKey)) { e.preventDefault() - leftPanelRef.current?.refresh() - rightPanelRef.current?.refresh() + refreshPanels() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, []) + }, [refreshPanels]) useEffect(() => { if (contextMenu) { @@ -292,6 +319,8 @@ export default function Browser() { allowFiles={true} allowMultiple={true} showPreviewColumn={true} + onSelectionChange={setLeftSel} + onNavigate={handleLeftNavigate} onDrop={(items, dest) => handleDrop(items, dest, 'left')} onDownload={handleDownload} onShare={handleShare} @@ -314,6 +343,8 @@ export default function Browser() { allowFiles={true} allowMultiple={true} showPreviewColumn={true} + onSelectionChange={setRightSel} + onNavigate={handleRightNavigate} onDrop={(items, dest) => handleDrop(items, dest, 'right')} onDownload={handleDownload} onShare={handleShare} @@ -325,13 +356,33 @@ export default function Browser() { - + setCompareOpen(true)} + compareDisabledReason={compareDisabledReason} + onOpenBatchRename={handleOpenBatchRename} + renameDisabledReason={renameDisabledReason} + /> + + setCompareOpen(false)} + /> + + setBatchRenameOpen(false)} + onDone={handleBatchRenameDone} + /> setDropOperation(null)} - onComplete={handleOperationComplete} + onComplete={refreshPanels} onJobStarted={handleJobStarted} /> @@ -362,7 +413,50 @@ export default function Browser() { ) } -function TransfersBar({ trackedJobIds }: { trackedJobIds: Set }) { +// An icon action in the bar's header row. The wrapping div keeps the tooltip working (and +// explaining) when the button is disabled, and stops the click from toggling the bar. +function BarAction({ + label, + disabledReason, + onPress, + children, +}: { + label: string + disabledReason?: string + onPress: () => void + children: ReactNode +}) { + return ( + +
e.stopPropagation()}> + +
+
+ ) +} + +function TransfersBar({ + trackedJobIds, + onOpenCompare, + compareDisabledReason, + onOpenBatchRename, + renameDisabledReason, +}: { + trackedJobIds: Set + onOpenCompare: () => void + compareDisabledReason?: string + onOpenBatchRename: () => void + renameDisabledReason?: string +}) { const [isExpanded, setIsExpanded] = useState(false) const jobIds = Array.from(trackedJobIds) @@ -425,18 +519,33 @@ function TransfersBar({ trackedJobIds }: { trackedJobIds: Set }) { className="flex items-center justify-between h-10 px-4 cursor-pointer select-none hover:bg-content2" onClick={toggleExpanded} > -
- Transfers - {inProgressCount > 0 && ( - - {inProgressCount} active - - )} - {!hasTransfers && ( - No active transfers - )} +
+ + + + + +
+
+ {inProgressCount > 0 && ( + + {inProgressCount} active + + )} + {!hasTransfers && ( + No active transfers + )} +