allow folder creation everywhere
This commit is contained in:
@@ -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,
|
||||
<Tooltip
|
||||
key="dismiss-tooltip"
|
||||
content="Close this window (Esc)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
radius="full"
|
||||
isIconOnly={true}
|
||||
onPress={onClose}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
],
|
||||
[
|
||||
buttons.SearchInput,
|
||||
buttons.NewFolderButton,
|
||||
...(allowMultiple
|
||||
? [
|
||||
<Dropdown
|
||||
key="select-dropdown"
|
||||
shadow={platform() === 'windows' ? 'none' : undefined}
|
||||
<Tooltip
|
||||
key="select-dropdown-tooltip"
|
||||
content="Select items"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
radius="full"
|
||||
startContent={<EllipsisVerticalIcon className="size-4" />}
|
||||
className="gap-0.5 min-w-fit"
|
||||
<div>
|
||||
<Dropdown
|
||||
shadow={platform() === 'windows' ? 'none' : undefined}
|
||||
>
|
||||
SELECT
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
radius="full"
|
||||
isIconOnly={true}
|
||||
>
|
||||
<MousePointerIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu color="primary">
|
||||
<DropdownItem
|
||||
key="select-current"
|
||||
@@ -140,20 +167,11 @@ export default function PathSelector({
|
||||
Deselect All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>,
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>,
|
||||
]
|
||||
: []),
|
||||
<Tooltip
|
||||
key="dismiss-tooltip"
|
||||
content="Close this window (Esc)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button color="danger" size="sm" radius="full" onPress={onClose}>
|
||||
DISMISS
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
],
|
||||
[
|
||||
allowMultiple ? (
|
||||
|
||||
@@ -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 ? (
|
||||
<Tooltip
|
||||
key="new-folder-tooltip"
|
||||
content="Create a new folder in this directory"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
radius="full"
|
||||
startContent={<FolderPlusIcon className="size-4" />}
|
||||
className="gap-1 min-w-fit"
|
||||
onPress={createFolder}
|
||||
>
|
||||
NEW
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
[canCreateFolder, createFolder]
|
||||
)
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 = (
|
||||
<Tooltip content="Go to parent directory" size="lg" color="foreground">
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string | null>('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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-182
@@ -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<FilePanelHandle>(null)
|
||||
const rightPanelRef = useRef<FilePanelHandle>(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<string, string> = {}
|
||||
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<string | null>('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)
|
||||
? [
|
||||
<Tooltip
|
||||
key="left-new-folder-tooltip"
|
||||
content="Create a new folder in this directory"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
radius="full"
|
||||
startContent={<FolderPlusIcon className="size-4" />}
|
||||
className="gap-1 min-w-fit"
|
||||
onPress={() => handleCreateFolder('left')}
|
||||
>
|
||||
NEW FOLDER
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
],
|
||||
[canCreateFolderAtRemote, leftPanelLocation.remote, handleCreateFolder]
|
||||
)
|
||||
|
||||
const renderRightToolbar = useCallback(
|
||||
(buttons: ToolbarButtons) => [
|
||||
[buttons.BackButton, buttons.RefreshButton],
|
||||
[
|
||||
buttons.SearchInput,
|
||||
...(canCreateFolderAtRemote(rightPanelLocation.remote)
|
||||
? [
|
||||
<Tooltip
|
||||
key="right-new-folder-tooltip"
|
||||
content="Create a new folder in this directory"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
radius="full"
|
||||
startContent={<FolderPlusIcon className="size-4" />}
|
||||
className="gap-1 min-w-fit"
|
||||
onPress={() => handleCreateFolder('right')}
|
||||
>
|
||||
NEW FOLDER
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
],
|
||||
[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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user