diff --git a/src/components/PathSelector.tsx b/src/components/PathSelector.tsx index 3f1985d..e7bb364 100644 --- a/src/components/PathSelector.tsx +++ b/src/components/PathSelector.tsx @@ -10,7 +10,7 @@ import { Tooltip, } from '@heroui/react' import { platform } from '@tauri-apps/plugin-os' -import { EllipsisVerticalIcon } from 'lucide-react' +import { MousePointerIcon, XIcon } from 'lucide-react' import { useCallback, useRef, useState } from 'react' import { FilePanel, @@ -83,26 +83,53 @@ export default function PathSelector({ const renderToolbar = useCallback( (buttons: ToolbarButtons) => [ - [buttons.BackButton, buttons.RefreshButton], + [ + buttons.BackButton, + buttons.RefreshButton, + + + , + ], [ buttons.SearchInput, + buttons.NewFolderButton, ...(allowMultiple ? [ - - - - + + + - , + + + , ] : []), - - - , ], [ allowMultiple ? ( diff --git a/src/components/navigator/FilePanel.tsx b/src/components/navigator/FilePanel.tsx index 06941a8..1b05ae2 100644 --- a/src/components/navigator/FilePanel.tsx +++ b/src/components/navigator/FilePanel.tsx @@ -1,10 +1,12 @@ -import { Divider } from '@heroui/react' +import { Button, Divider, Tooltip } from '@heroui/react' +import { FolderPlusIcon } from 'lucide-react' import { forwardRef, startTransition, useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from 'react' @@ -15,6 +17,7 @@ import PathBreadcrumb from './PathBreadcrumb' import PreviewDrawer from './PreviewDrawer' import RemoteSidebar from './RemoteSidebar' import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types' +import useCreateFolder from './useCreateFolder' import useFileNavigation from './useFileNavigation' import { RE_LEADING_SLASH, dragStateRef, dropTargetsRef, serializeRemotePath } from './utils' @@ -81,6 +84,36 @@ const FilePanel = forwardRef< isActive, }) + const { canCreateFolder, createFolder } = useCreateFolder( + nav.selectedRemote, + nav.cwd, + nav.refresh + ) + + const newFolderButton = useMemo( + () => + canCreateFolder ? ( + + + + ) : null, + [canCreateFolder, createFolder] + ) + const listRef = useRef(null) const panelRef = useRef(null) const panelIdRef = useRef(`panel-${Math.random().toString(36).slice(2)}`) @@ -348,6 +381,7 @@ const FilePanel = forwardRef< onSearchChange={nav.setSearchTerm} renderToolbar={renderToolbar} visible={toolbarVisible && nav.selectedRemote !== 'UI_FAVORITES'} + newFolderButton={newFolderButton} /> diff --git a/src/components/navigator/PanelToolbar.tsx b/src/components/navigator/PanelToolbar.tsx index d082947..ac608e9 100644 --- a/src/components/navigator/PanelToolbar.tsx +++ b/src/components/navigator/PanelToolbar.tsx @@ -7,6 +7,7 @@ export type ToolbarButtons = { BackButton: ReactNode RefreshButton: ReactNode SearchInput: ReactNode + NewFolderButton: ReactNode } export default function PanelToolbar({ @@ -18,6 +19,7 @@ export default function PanelToolbar({ onSearchChange, renderToolbar, visible = true, + newFolderButton, }: { onBack: () => void onRefresh: () => void @@ -27,6 +29,7 @@ export default function PanelToolbar({ onSearchChange: (term: string) => void renderToolbar?: (buttons: ToolbarButtons) => ReactNode[][] visible?: boolean + newFolderButton?: ReactNode }) { const BackButton = ( @@ -78,10 +81,11 @@ export default function PanelToolbar({ /> ) - const buttons: ToolbarButtons = { BackButton, RefreshButton, SearchInput } + const NewFolderButton = newFolderButton ?? null + const buttons: ToolbarButtons = { BackButton, RefreshButton, SearchInput, NewFolderButton } const groups = renderToolbar ? renderToolbar(buttons) - : [[BackButton, RefreshButton], [SearchInput]] + : [[BackButton, RefreshButton], [SearchInput, NewFolderButton]] const motionTransition = { enter: { diff --git a/src/components/navigator/index.ts b/src/components/navigator/index.ts index c7decbc..622b25a 100644 --- a/src/components/navigator/index.ts +++ b/src/components/navigator/index.ts @@ -5,6 +5,7 @@ export { default as PanelToolbar, type ToolbarButtons } from './PanelToolbar' export { default as PathBreadcrumb } from './PathBreadcrumb' export { default as PreviewDrawer } from './PreviewDrawer' export { default as RemoteSidebar } from './RemoteSidebar' +export { default as useCreateFolder } from './useCreateFolder' export { default as useFileNavigation } from './useFileNavigation' export * from './types' export * from './utils' diff --git a/src/components/navigator/useCreateFolder.ts b/src/components/navigator/useCreateFolder.ts new file mode 100644 index 0000000..157eb37 --- /dev/null +++ b/src/components/navigator/useCreateFolder.ts @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-query' +import { invoke } from '@tauri-apps/api/core' +import { message } from '@tauri-apps/plugin-dialog' +import { useCallback, useMemo } from 'react' +import { getFsInfo } from '../../../lib/format' +import rclone from '../../../lib/rclone/client' +import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants' +import type { RemoteString } from './types' +import { RE_TRAILING_SEPARATORS } from './utils' + +export default function useCreateFolder( + remote: RemoteString, + cwd: string, + refresh: () => void +) { + const remoteConfigQuery = useQuery({ + queryKey: ['remote', remote, 'config'], + queryFn: async () => { + return await rclone('/config/get', { + params: { query: { name: remote } }, + }) + }, + enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES', + }) + + const backendType = useMemo(() => { + if (!remote || remote === 'UI_FAVORITES') return null + if (remote === 'UI_LOCAL_FS') return 'local' + return remoteConfigQuery.data?.type ?? null + }, [remote, remoteConfigQuery.data]) + + const canCreateFolder = useMemo(() => { + if (!remote || remote === 'UI_FAVORITES') return false + if (remote === 'UI_LOCAL_FS') return true + return supportsPersistentEmptyFolders(backendType) + }, [remote, backendType]) + + const createFolder = useCallback(async () => { + if (!remote || remote === 'UI_FAVORITES') return + + if (!canCreateFolder) { + await message( + 'This backend does not support persistent empty folders. Create a folder by uploading a file into it.', + { title: 'Unsupported Backend', kind: 'warning' } + ) + return + } + + const folderName = await invoke('prompt', { + title: 'New Folder', + message: 'Enter a name for the new folder', + default: 'New Folder', + sensitive: false, + }) + const normalizedFolderName = folderName?.trim() + if (!normalizedFolderName) return + + try { + const normalizedPath = cwd.replace(RE_TRAILING_SEPARATORS, '') + const fullTargetPath = + remote === 'UI_LOCAL_FS' + ? `${normalizedPath}${normalizedPath ? '/' : ''}${normalizedFolderName}` + : `${remote}:/${normalizedPath}${normalizedPath ? '/' : ''}${normalizedFolderName}` + const info = getFsInfo(fullTargetPath) + + await rclone('/operations/mkdir' as any, { + params: { + query: { + fs: info.root === ':local:' ? ':local:/' : info.root, + remote: info.filePath, + }, + }, + }) + + refresh() + } catch (error) { + await message(error instanceof Error ? error.message : 'Create folder failed', { + title: 'Error', + kind: 'error', + }) + } + }, [remote, cwd, refresh, canCreateFolder]) + + return { canCreateFolder, createFolder } +} diff --git a/src/components/navigator/utils.ts b/src/components/navigator/utils.ts index a49b1fa..dc80e72 100644 --- a/src/components/navigator/utils.ts +++ b/src/components/navigator/utils.ts @@ -32,6 +32,7 @@ export const RE_BACKSLASH = /\\/g export const RE_TRAILING_SLASH = /\/+$/g export const RE_LEADING_SLASH = /^\/+/ export const RE_PATH_SEPARATOR = /[/\\]/ +export const RE_TRAILING_SEPARATORS = /[\\/]+$/ export const VIRTUAL_PADDING_COUNT = 2 diff --git a/src/pages/Commander.tsx b/src/pages/Commander.tsx index 7dec765..863ed34 100644 --- a/src/pages/Commander.tsx +++ b/src/pages/Commander.tsx @@ -27,7 +27,6 @@ import { ChevronUpIcon, CopyIcon, ExternalLinkIcon, - FolderPlusIcon, LoaderIcon, MoveIcon, SearchCheckIcon, @@ -40,14 +39,11 @@ import { getFsInfo } from '../../lib/format' import { formatBytes } from '../../lib/format.ts' import { startCopy, startMove } from '../../lib/rclone/api' import rclone from '../../lib/rclone/client' -import { supportsPersistentEmptyFolders } from '../../lib/rclone/constants' import { openWindow } from '../../lib/window' import { FileIcon } from '../components/navigator' -import { FilePanel, type FilePanelHandle, type ToolbarButtons } from '../components/navigator' +import { FilePanel, type FilePanelHandle } from '../components/navigator' import type { Entry, SelectItem } from '../components/navigator/types' -const RE_TRAILING_SEPARATORS = /[\\/]+$/ - export default function Browser() { const leftPanelRef = useRef(null) const rightPanelRef = useRef(null) @@ -78,60 +74,6 @@ export default function Browser() { const remotes = remotesQuery.data ?? [] const firstRemote = remotes[0] ?? null - const [leftPanelLocation, setLeftPanelLocation] = useState<{ - remote: string | null - path: string - }>({ remote: 'UI_LOCAL_FS', path: '' }) - const [rightPanelLocation, setRightPanelLocation] = useState<{ - remote: string | null - path: string - }>({ remote: null, path: '' }) - - const remoteTypesQuery = useQuery({ - queryKey: ['remotes', 'types'], - queryFn: async () => { - const dump = await rclone('/config/dump') - const types: Record = {} - if (dump && typeof dump === 'object') { - for (const [name, config] of Object.entries(dump)) { - if (config && typeof config === 'object' && 'type' in config) { - types[name] = (config as { type: string }).type - } - } - } - return types - }, - staleTime: 1000 * 60, - }) - - const remoteTypes = remoteTypesQuery.data ?? {} - - const getBackendTypeForRemote = useCallback( - (remote: string | null) => { - if (!remote || remote === 'UI_FAVORITES') return null - if (remote === 'UI_LOCAL_FS') return 'local' - return remoteTypes[remote] ?? null - }, - [remoteTypes] - ) - - const canCreateFolderAtRemote = useCallback( - (remote: string | null) => { - if (!remote || remote === 'UI_FAVORITES') return false - if (remote === 'UI_LOCAL_FS') return true - return supportsPersistentEmptyFolders(getBackendTypeForRemote(remote)) - }, - [getBackendTypeForRemote] - ) - - const handleLeftNavigate = useCallback((remote: string, path: string) => { - setLeftPanelLocation({ remote, path }) - }, []) - - const handleRightNavigate = useCallback((remote: string, path: string) => { - setRightPanelLocation({ remote, path }) - }, []) - const handleDrop = useCallback( (items: SelectItem[], destination: string, _sourceSide: 'left' | 'right') => { setDropOperation({ items, destination }) @@ -272,125 +214,6 @@ export default function Browser() { } }, []) - const handleCreateFolder = useCallback( - async (panelSide: 'left' | 'right') => { - const panelRef = panelSide === 'left' ? leftPanelRef : rightPanelRef - const panel = panelRef.current - if (!panel) return - - const currentPath = panel.getCurrentPath() - if (!currentPath.remote || currentPath.remote === 'UI_FAVORITES') return - - if (!canCreateFolderAtRemote(currentPath.remote)) { - await message( - 'This backend does not support persistent empty folders. Create a folder by uploading a file into it.', - { - title: 'Unsupported Backend', - kind: 'warning', - } - ) - return - } - - const folderName = await invoke('prompt', { - title: 'New Folder', - message: 'Enter a name for the new folder', - default: 'New Folder', - sensitive: false, - }) - const normalizedFolderName = folderName?.trim() - if (!normalizedFolderName) return - - try { - const normalizedPath = currentPath.path.replace(RE_TRAILING_SEPARATORS, '') - const fullTargetPath = - currentPath.remote === 'UI_LOCAL_FS' - ? `${normalizedPath}${normalizedPath ? '/' : ''}${normalizedFolderName}` - : `${currentPath.remote}:/${normalizedPath}${normalizedPath ? '/' : ''}${normalizedFolderName}` - const info = getFsInfo(fullTargetPath) - - await rclone('/operations/mkdir' as any, { - params: { - query: { - fs: info.root === ':local:' ? ':local:/' : info.root, - remote: info.filePath, - }, - }, - }) - - panel.refresh() - } catch (error) { - await message(error instanceof Error ? error.message : 'Create folder failed', { - title: 'Error', - kind: 'error', - }) - } - }, - [canCreateFolderAtRemote] - ) - - const renderLeftToolbar = useCallback( - (buttons: ToolbarButtons) => [ - [buttons.BackButton, buttons.RefreshButton], - [ - buttons.SearchInput, - ...(canCreateFolderAtRemote(leftPanelLocation.remote) - ? [ - - - , - ] - : []), - ], - ], - [canCreateFolderAtRemote, leftPanelLocation.remote, handleCreateFolder] - ) - - const renderRightToolbar = useCallback( - (buttons: ToolbarButtons) => [ - [buttons.BackButton, buttons.RefreshButton], - [ - buttons.SearchInput, - ...(canCreateFolderAtRemote(rightPanelLocation.remote) - ? [ - - - , - ] - : []), - ], - ], - [canCreateFolderAtRemote, rightPanelLocation.remote, handleCreateFolder] - ) - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'r' && (e.metaKey || e.ctrlKey)) { @@ -428,8 +251,6 @@ export default function Browser() { onDownload={handleDownload} onRename={handleRename} onDelete={handleDelete} - onNavigate={handleLeftNavigate} - renderToolbar={renderLeftToolbar} allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']} isActive={true} /> @@ -450,8 +271,6 @@ export default function Browser() { onDownload={handleDownload} onRename={handleRename} onDelete={handleDelete} - onNavigate={handleRightNavigate} - renderToolbar={renderRightToolbar} allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']} isActive={true} />