- }
- className="gap-0.5 min-w-fit"
+
+
- SELECT
-
-
+
+
+
- ,
+
+
+ ,
]
: []),
-
-
- ,
],
[
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 ? (
+
+ }
+ className="gap-1 min-w-fit"
+ onPress={createFolder}
+ >
+ NEW
+
+
+ ) : 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)
- ? [
-
- }
- className="gap-1 min-w-fit"
- onPress={() => handleCreateFolder('left')}
- >
- NEW FOLDER
-
- ,
- ]
- : []),
- ],
- ],
- [canCreateFolderAtRemote, leftPanelLocation.remote, handleCreateFolder]
- )
-
- const renderRightToolbar = useCallback(
- (buttons: ToolbarButtons) => [
- [buttons.BackButton, buttons.RefreshButton],
- [
- buttons.SearchInput,
- ...(canCreateFolderAtRemote(rightPanelLocation.remote)
- ? [
-
- }
- className="gap-1 min-w-fit"
- onPress={() => handleCreateFolder('right')}
- >
- NEW FOLDER
-
- ,
- ]
- : []),
- ],
- ],
- [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}
/>