commander cleanup
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user