allow folder creation everywhere

This commit is contained in:
FTCHD
2026-05-11 12:24:56 +03:00
parent 9e0e522352
commit 84a1759e56
7 changed files with 174 additions and 212 deletions
+36 -18
View File
@@ -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,13 +83,41 @@ 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
? [
<Tooltip
key="select-dropdown-tooltip"
content="Select items"
placement="top"
size="lg"
color="foreground"
>
<div>
<Dropdown
key="select-dropdown"
shadow={platform() === 'windows' ? 'none' : undefined}
>
<DropdownTrigger>
@@ -97,10 +125,9 @@ export default function PathSelector({
color="primary"
size="sm"
radius="full"
startContent={<EllipsisVerticalIcon className="size-4" />}
className="gap-0.5 min-w-fit"
isIconOnly={true}
>
SELECT
<MousePointerIcon className="size-4" />
</Button>
</DropdownTrigger>
<DropdownMenu color="primary">
@@ -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 ? (
+35 -1
View File
@@ -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>
+6 -2
View File
@@ -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: {
+1
View File
@@ -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 }
}
+1
View File
@@ -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
View File
@@ -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}
/>