commander cleanup

This commit is contained in:
FTCHD
2026-08-30 20:21:49 +03:00
parent 756e20e6ca
commit 2498c72f1a
5 changed files with 464 additions and 198 deletions
+5 -5
View File
@@ -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 (
<Listbox
items={items}
@@ -218,9 +217,10 @@ export default function FileList({
>
<div
className={cn(
`grid ${gridCols} items-center hover:bg-content2 py-2 border-b border-divider group transition-colors w-full h-full`,
'grid items-center hover:bg-content2 py-2 border-b border-divider group transition-colors w-full h-full',
isSelected ? 'bg-primary-50 hover:bg-primary-100' : ''
)}
style={{ gridTemplateColumns: columnTemplate }}
draggable={draggable}
onDragStart={(e) => handleDragStart(entry, e)}
onDragEnd={handleDragEnd}
+191 -75
View File
@@ -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<number | null>(null)
const nameCellRef = useRef<HTMLDivElement>(null)
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<Entry | null>(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 (
<div
ref={panelRef}
@@ -351,82 +423,126 @@ const FilePanel = forwardRef<
<Divider />
<div className="relative flex flex-col w-full h-full overflow-hidden">
<div
className={`sticky top-0 z-10 grid ${showPreviewColumn ? 'grid-cols-[2.5rem_1fr_6rem_9rem_11rem]' : 'grid-cols-[2.5rem_1fr_6rem_9rem_2.5rem]'} items-stretch bg-default-100`}
>
<div />
{(
[
{ 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 (
<div key={column}>
<button
type="button"
className={`flex items-center w-full h-full gap-1 py-2 font-semibold text-left rounded-small text-small hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${className}`}
aria-pressed={isSorted}
aria-label={`${label}${sortStatus}. Activate to sort ${nextDirection}.`}
onClick={() => nav.handleSort(column)}
{/* Header and list share the column template; a widened Name
column pushes both past the panel, and they scroll together. */}
<div className="flex flex-col flex-1 min-h-0 overflow-x-auto overflow-y-hidden">
<div
className="sticky top-0 z-10 grid items-stretch bg-default-100"
style={{
gridTemplateColumns: columns.columnTemplate,
minWidth: columns.rowMinWidth,
}}
>
<div className="flex items-center justify-end">
{showSelectAll && (
<Checkbox
isSelected={allSelected}
isIndeterminate={someSelected}
isDisabled={selectableCount === 0}
onValueChange={() =>
allSelected
? nav.clearSelection()
: nav.selectAll('all')
}
aria-label="Select all items"
/>
)}
</div>
{(
[
{ 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 (
<div
key={column}
ref={column === 'name' ? columns.nameCellRef : undefined}
className={column === 'name' ? 'relative' : undefined}
>
<span>{label}</span>
{isSorted &&
(nav.sortDescriptor.direction === 'ascending' ? (
<ChevronUpIcon
className="size-3.5"
aria-hidden="true"
/>
) : (
<ChevronDownIcon
className="size-3.5"
aria-hidden="true"
/>
))}
</button>
</div>
)
})}
<div />
</div>
<button
type="button"
className={`flex items-center w-full h-full gap-1 py-2 font-semibold text-left rounded-small text-small hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${className}`}
aria-pressed={isSorted}
aria-label={`${label}${sortStatus}. Activate to sort ${nextDirection}.`}
onClick={() => nav.handleSort(column)}
>
<span>{label}</span>
{isSorted &&
(nav.sortDescriptor.direction === 'ascending' ? (
<ChevronUpIcon
className="size-3.5"
aria-hidden="true"
/>
) : (
<ChevronDownIcon
className="size-3.5"
aria-hidden="true"
/>
))}
</button>
{column === 'name' && (
// The column's right edge: drag to widen Name,
// double-click to let it fill again.
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Name column"
tabIndex={0}
data-column-resize="name"
className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize touch-none hover:bg-primary-200 active:bg-primary-300"
{...columns.handleProps}
/>
)}
</div>
)
})}
<div />
</div>
<div ref={listRef} className="relative flex-1 w-full overflow-hidden">
<FileList
items={nav.virtualizedItems}
isLoading={nav.isLoading || nav.isSearching}
error={nav.recursiveSearchActive ? nav.searchError : nav.error}
selectedKeys={nav.selectedPaths}
onToggleSelect={nav.handleToggleSelect}
onNavigate={nav.handleNavigate}
selectionMode={selectionMode}
allowMultiple={allowMultiple}
showPreviewColumn={showPreviewColumn}
onPreviewClick={handlePreviewClick}
onDownload={onDownload}
onShare={canShare ? onShare : undefined}
onRename={onRename}
onDelete={onDelete}
draggable={selectionMode === 'drag' || selectionMode === 'both'}
onDragStart={handleDragStartInternal}
// handled at the Browser level
onContextMenu={contextMenuItems ? () => {} : undefined}
favoritedKeys={nav.favoritedKeys}
onToggleFavorite={handleToggleFavorite}
listHeight={listHeight}
/>
<div
ref={listRef}
className="relative flex-1 w-full overflow-hidden"
style={{ minWidth: columns.rowMinWidth }}
>
<FileList
columnTemplate={columns.columnTemplate}
items={nav.virtualizedItems}
isLoading={nav.isLoading || nav.isSearching}
error={nav.recursiveSearchActive ? nav.searchError : nav.error}
selectedKeys={nav.selectedPaths}
onToggleSelect={nav.handleToggleSelect}
onNavigate={nav.handleNavigate}
selectionMode={selectionMode}
allowMultiple={allowMultiple}
showPreviewColumn={showPreviewColumn}
onPreviewClick={handlePreviewClick}
onDownload={onDownload}
onShare={canShare ? onShare : undefined}
onRename={onRename}
onDelete={onDelete}
draggable={selectionMode === 'drag' || selectionMode === 'both'}
onDragStart={handleDragStartInternal}
// handled at the Browser level
onContextMenu={contextMenuItems ? () => {} : undefined}
favoritedKeys={nav.favoritedKeys}
onToggleFavorite={handleToggleFavorite}
listHeight={listHeight}
/>
</div>
</div>
<PanelToolbar
+2
View File
@@ -1,3 +1,5 @@
export { default as BatchRenameDrawer } from './BatchRenameDrawer'
export { default as CompareDrawer, type PanelLocation } from './CompareDrawer'
export { default as FilePanel, type FilePanelHandle } from './FilePanel'
export { default as FileIcon, getFileType, isPreviewable } from './FileIcon'
export { default as FileList } from './FileList'
+63 -24
View File
@@ -158,21 +158,25 @@ export async function listRemotePath(
return { list: deduped, baseDir: base }
}
export async function searchPath(
// Resolves a navigator location to the `fs` + relative dir pair the /operations/* endpoints take:
// local paths go through getFsInfo to the `:local:` backend rooted at `/`, remotes to `remote:`.
function resolveFs(remote: string | 'UI_LOCAL_FS', dir: string) {
if (remote !== 'UI_LOCAL_FS') return { fs: `${remote}:`, base: normalizeRemoteDir(dir) }
const info = getFsInfo(dir)
return { fs: info.root === ':local:' ? ':local:/' : info.root, base: info.filePath }
}
// Lists `dir` through /operations/list with the given `opt` (and optional `_filter`) objects. Some
// backends want a trailing slash on the directory and others reject it, so a failed bare call is
// retried slashed. Entries are deduped by path since a few backends report the same object twice.
export async function listPath(
remote: string | 'UI_LOCAL_FS',
dir: string,
term: string,
signal: AbortSignal
) {
const isLocal = remote === 'UI_LOCAL_FS'
const localInfo = isLocal ? getFsInfo(dir) : null
const fs = localInfo
? localInfo.root === ':local:'
? ':local:/'
: localInfo.root
: `${remote}:`
const base = localInfo ? localInfo.filePath : normalizeRemoteDir(dir)
const escapedTerm = term.replace(RE_RCLONE_GLOB_META, '\\$&')
opt: Record<string, unknown>,
signal: AbortSignal,
filter?: Record<string, unknown>
): Promise<any[]> {
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<string>()
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)
+203 -94
View File
@@ -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<PanelLocation | null>(null)
const [rightLoc, setRightLoc] = useState<PanelLocation | null>(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<SelectItem[]>([])
const [rightSel, setRightSel] = useState<SelectItem[]>([])
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<SelectItem[]>([])
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<string | null>('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<string | null>('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() {
</Panel>
</Group>
<TransfersBar trackedJobIds={trackedJobIds} />
<TransfersBar
trackedJobIds={trackedJobIds}
onOpenCompare={() => setCompareOpen(true)}
compareDisabledReason={compareDisabledReason}
onOpenBatchRename={handleOpenBatchRename}
renameDisabledReason={renameDisabledReason}
/>
<CompareDrawer
isOpen={compareOpen}
left={leftLoc}
right={rightLoc}
onClose={() => setCompareOpen(false)}
/>
<BatchRenameDrawer
isOpen={batchRenameOpen}
items={batchRenameItems}
onClose={() => setBatchRenameOpen(false)}
onDone={handleBatchRenameDone}
/>
<OperationDialog
items={dropOperation?.items ?? null}
destination={dropOperation?.destination ?? null}
onClose={() => setDropOperation(null)}
onComplete={handleOperationComplete}
onComplete={refreshPanels}
onJobStarted={handleJobStarted}
/>
@@ -362,7 +413,50 @@ export default function Browser() {
)
}
function TransfersBar({ trackedJobIds }: { trackedJobIds: Set<number> }) {
// 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 (
<Tooltip content={disabledReason ?? label} size="sm">
<div onClick={(e) => e.stopPropagation()}>
<Button
isIconOnly={true}
size="sm"
variant="light"
aria-label={label}
isDisabled={!!disabledReason}
onPress={onPress}
>
{children}
</Button>
</div>
</Tooltip>
)
}
function TransfersBar({
trackedJobIds,
onOpenCompare,
compareDisabledReason,
onOpenBatchRename,
renameDisabledReason,
}: {
trackedJobIds: Set<number>
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<number> }) {
className="flex items-center justify-between h-10 px-4 cursor-pointer select-none hover:bg-content2"
onClick={toggleExpanded}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Transfers</span>
{inProgressCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700">
{inProgressCount} active
</span>
)}
{!hasTransfers && (
<span className="text-sm text-default-400">No active transfers</span>
)}
<div className="flex items-center gap-2">
<BarAction
label="Batch Rename"
disabledReason={renameDisabledReason}
onPress={onOpenBatchRename}
>
<PencilIcon className="size-4" />
</BarAction>
<BarAction
label="Compare"
disabledReason={compareDisabledReason}
onPress={onOpenCompare}
>
<ArrowLeftRightIcon className="size-4" />
</BarAction>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
{inProgressCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700">
{inProgressCount} active
</span>
)}
{!hasTransfers && (
<span className="text-sm text-default-400">No active transfers</span>
)}
</div>
<Tooltip content="Open Transfers page" size="sm">
<Button
isIconOnly={true}