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, Tooltip,
} from '@heroui/react' } from '@heroui/react'
import { platform } from '@tauri-apps/plugin-os' 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 { useCallback, useRef, useState } from 'react'
import { import {
FilePanel, FilePanel,
@@ -83,13 +83,41 @@ export default function PathSelector({
const renderToolbar = useCallback( const renderToolbar = useCallback(
(buttons: ToolbarButtons) => [ (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.SearchInput,
buttons.NewFolderButton,
...(allowMultiple ...(allowMultiple
? [ ? [
<Tooltip
key="select-dropdown-tooltip"
content="Select items"
placement="top"
size="lg"
color="foreground"
>
<div>
<Dropdown <Dropdown
key="select-dropdown"
shadow={platform() === 'windows' ? 'none' : undefined} shadow={platform() === 'windows' ? 'none' : undefined}
> >
<DropdownTrigger> <DropdownTrigger>
@@ -97,10 +125,9 @@ export default function PathSelector({
color="primary" color="primary"
size="sm" size="sm"
radius="full" radius="full"
startContent={<EllipsisVerticalIcon className="size-4" />} isIconOnly={true}
className="gap-0.5 min-w-fit"
> >
SELECT <MousePointerIcon className="size-4" />
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu color="primary"> <DropdownMenu color="primary">
@@ -140,20 +167,11 @@ export default function PathSelector({
Deselect All Deselect All
</DropdownItem> </DropdownItem>
</DropdownMenu> </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 ? ( 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 { import {
forwardRef, forwardRef,
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
@@ -15,6 +17,7 @@ import PathBreadcrumb from './PathBreadcrumb'
import PreviewDrawer from './PreviewDrawer' import PreviewDrawer from './PreviewDrawer'
import RemoteSidebar from './RemoteSidebar' import RemoteSidebar from './RemoteSidebar'
import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types' import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types'
import useCreateFolder from './useCreateFolder'
import useFileNavigation from './useFileNavigation' import useFileNavigation from './useFileNavigation'
import { RE_LEADING_SLASH, dragStateRef, dropTargetsRef, serializeRemotePath } from './utils' import { RE_LEADING_SLASH, dragStateRef, dropTargetsRef, serializeRemotePath } from './utils'
@@ -81,6 +84,36 @@ const FilePanel = forwardRef<
isActive, 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 listRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
const panelIdRef = useRef(`panel-${Math.random().toString(36).slice(2)}`) const panelIdRef = useRef(`panel-${Math.random().toString(36).slice(2)}`)
@@ -348,6 +381,7 @@ const FilePanel = forwardRef<
onSearchChange={nav.setSearchTerm} onSearchChange={nav.setSearchTerm}
renderToolbar={renderToolbar} renderToolbar={renderToolbar}
visible={toolbarVisible && nav.selectedRemote !== 'UI_FAVORITES'} visible={toolbarVisible && nav.selectedRemote !== 'UI_FAVORITES'}
newFolderButton={newFolderButton}
/> />
</div> </div>
</div> </div>
+6 -2
View File
@@ -7,6 +7,7 @@ export type ToolbarButtons = {
BackButton: ReactNode BackButton: ReactNode
RefreshButton: ReactNode RefreshButton: ReactNode
SearchInput: ReactNode SearchInput: ReactNode
NewFolderButton: ReactNode
} }
export default function PanelToolbar({ export default function PanelToolbar({
@@ -18,6 +19,7 @@ export default function PanelToolbar({
onSearchChange, onSearchChange,
renderToolbar, renderToolbar,
visible = true, visible = true,
newFolderButton,
}: { }: {
onBack: () => void onBack: () => void
onRefresh: () => void onRefresh: () => void
@@ -27,6 +29,7 @@ export default function PanelToolbar({
onSearchChange: (term: string) => void onSearchChange: (term: string) => void
renderToolbar?: (buttons: ToolbarButtons) => ReactNode[][] renderToolbar?: (buttons: ToolbarButtons) => ReactNode[][]
visible?: boolean visible?: boolean
newFolderButton?: ReactNode
}) { }) {
const BackButton = ( const BackButton = (
<Tooltip content="Go to parent directory" size="lg" color="foreground"> <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 const groups = renderToolbar
? renderToolbar(buttons) ? renderToolbar(buttons)
: [[BackButton, RefreshButton], [SearchInput]] : [[BackButton, RefreshButton], [SearchInput, NewFolderButton]]
const motionTransition = { const motionTransition = {
enter: { 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 PathBreadcrumb } from './PathBreadcrumb'
export { default as PreviewDrawer } from './PreviewDrawer' export { default as PreviewDrawer } from './PreviewDrawer'
export { default as RemoteSidebar } from './RemoteSidebar' export { default as RemoteSidebar } from './RemoteSidebar'
export { default as useCreateFolder } from './useCreateFolder'
export { default as useFileNavigation } from './useFileNavigation' export { default as useFileNavigation } from './useFileNavigation'
export * from './types' export * from './types'
export * from './utils' 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_TRAILING_SLASH = /\/+$/g
export const RE_LEADING_SLASH = /^\/+/ export const RE_LEADING_SLASH = /^\/+/
export const RE_PATH_SEPARATOR = /[/\\]/ export const RE_PATH_SEPARATOR = /[/\\]/
export const RE_TRAILING_SEPARATORS = /[\\/]+$/
export const VIRTUAL_PADDING_COUNT = 2 export const VIRTUAL_PADDING_COUNT = 2
+1 -182
View File
@@ -27,7 +27,6 @@ import {
ChevronUpIcon, ChevronUpIcon,
CopyIcon, CopyIcon,
ExternalLinkIcon, ExternalLinkIcon,
FolderPlusIcon,
LoaderIcon, LoaderIcon,
MoveIcon, MoveIcon,
SearchCheckIcon, SearchCheckIcon,
@@ -40,14 +39,11 @@ import { getFsInfo } from '../../lib/format'
import { formatBytes } from '../../lib/format.ts' import { formatBytes } from '../../lib/format.ts'
import { startCopy, startMove } from '../../lib/rclone/api' import { startCopy, startMove } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client' import rclone from '../../lib/rclone/client'
import { supportsPersistentEmptyFolders } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window' import { openWindow } from '../../lib/window'
import { FileIcon } from '../components/navigator' 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' import type { Entry, SelectItem } from '../components/navigator/types'
const RE_TRAILING_SEPARATORS = /[\\/]+$/
export default function Browser() { export default function Browser() {
const leftPanelRef = useRef<FilePanelHandle>(null) const leftPanelRef = useRef<FilePanelHandle>(null)
const rightPanelRef = useRef<FilePanelHandle>(null) const rightPanelRef = useRef<FilePanelHandle>(null)
@@ -78,60 +74,6 @@ export default function Browser() {
const remotes = remotesQuery.data ?? [] const remotes = remotesQuery.data ?? []
const firstRemote = remotes[0] ?? null 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( const handleDrop = useCallback(
(items: SelectItem[], destination: string, _sourceSide: 'left' | 'right') => { (items: SelectItem[], destination: string, _sourceSide: 'left' | 'right') => {
setDropOperation({ items, destination }) 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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'r' && (e.metaKey || e.ctrlKey)) { if (e.key === 'r' && (e.metaKey || e.ctrlKey)) {
@@ -428,8 +251,6 @@ export default function Browser() {
onDownload={handleDownload} onDownload={handleDownload}
onRename={handleRename} onRename={handleRename}
onDelete={handleDelete} onDelete={handleDelete}
onNavigate={handleLeftNavigate}
renderToolbar={renderLeftToolbar}
allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']} allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']}
isActive={true} isActive={true}
/> />
@@ -450,8 +271,6 @@ export default function Browser() {
onDownload={handleDownload} onDownload={handleDownload}
onRename={handleRename} onRename={handleRename}
onDelete={handleDelete} onDelete={handleDelete}
onNavigate={handleRightNavigate}
renderToolbar={renderRightToolbar}
allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']} allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']}
isActive={true} isActive={true}
/> />