diff --git a/lib/hooks.ts b/lib/hooks.ts
index 90ca08e..fa62851 100644
--- a/lib/hooks.ts
+++ b/lib/hooks.ts
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
+import type { RcloneFeatures, RcloneFsInfo } from '../types/rclone'
import { sortByName } from './flags'
import rclone from './rclone/client'
import { SERVE_TYPES } from './rclone/constants'
@@ -37,6 +38,36 @@ export function useRemoteConfig(remote: string | undefined | null) {
return useQuery(remoteConfigQueryOptions(remote))
}
+// Shared query options for a remote's `operations/fsinfo` — the authoritative, per-remote backend
+// capability set (correct even for wrapping backends like crypt/alias/union, which the old static
+// type lists could not express). fsinfo instantiates/connects the remote, so it's cached hard:
+// capabilities are ~immutable per config and only change on a remote edit (explicit invalidation)
+// or an rclone upgrade (self-heals on the 24h staleTime / next cold launch). retry: 1 overrides the
+// app-wide 3-retry default so an unreachable remote costs one attempt, then error-caches.
+export function fsInfoQueryOptions(remote: string | undefined | null) {
+ return {
+ queryKey: ['remote', remote, 'fsinfo'] as const,
+ queryFn: () =>
+ rclone('/operations/fsinfo', { params: { query: { fs: `${remote}:` } } }),
+ enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
+ staleTime: 1000 * 60 * 60 * 24,
+ retry: 1,
+ }
+}
+
+export function useFsInfo(remote: string | undefined | null) {
+ return useQuery(fsInfoQueryOptions(remote))
+}
+
+// undefined while loading/errored → false. Callers that need an optimistic default (e.g. create
+// folder, which historically assumed "supported" until proven otherwise) handle that explicitly.
+export function hasFeature(
+ fsInfo: RcloneFsInfo | undefined,
+ feature: keyof RcloneFeatures
+): boolean {
+ return !!fsInfo?.Features?.[feature]
+}
+
export function useFlags() {
const allFlagsQuery = useQuery({
queryKey: ['options', 'all'],
diff --git a/lib/rclone/constants.ts b/lib/rclone/constants.ts
index 772e56f..c98b7cd 100644
--- a/lib/rclone/constants.ts
+++ b/lib/rclone/constants.ts
@@ -28,142 +28,7 @@ export const RCLONE_RELEASES_SHOWN = 20
export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const
-export const SUPPORTS_CLEANUP = [
- 's3',
- 'b2',
- 'box',
- 'filefabric',
- 'filen',
- 'drive',
- 'internetarchive',
- 'jottacloud',
- 'mailru',
- 'mega',
- 'onedrive',
- 'oos',
- 'pcloud',
- 'pikpak',
- 'putio',
- 'protondrive',
- 'qingstor',
- 'seafile',
- 'yandex',
-] as const
-
-export const SUPPORTS_PURGE = [
- 'netstorage',
- 'box',
- 'sharefile',
- 'dropbox',
- 'filefabric',
- 'filescom',
- 'filen',
- 'gofile',
- 'gcs',
- 'drive',
- 'hdfs',
- 'hifile',
- 'iclouddrive',
- 'imagekit',
- 'jottacloud',
- 'koofr',
- 'mailru',
- 'mega',
- 'azureblob',
- 'onedrive',
- 'opendrive',
- 'swift',
- 'pikpak',
- 'pcloud',
- 'pixeldrain',
- 'premiumizeme',
- 'putio',
- 'protondrive',
- 'quatrix',
- 'seafile',
- 'sugarsync',
- 'storj',
- 'webdav',
- 'yandex',
- 'zoho',
-] as const
-
-export const SUPPORTS_ABOUT = [
- 'box',
- 'dropbox',
- 'gofile',
- 'drive',
- 'filen',
- 'hdfs',
- 'internetarchive',
- 'jottacloud',
- 'koofr',
- 'mailru',
- 'mega',
- 'azurefiles',
- 'onedrive',
- 'opendrive',
- 'swift',
- 'pcloud',
- 'pikpak',
- 'pixeldrain',
- 'premiumizeme',
- 'putio',
- 'protondrive',
- 'quatrix',
- 'seafile',
- 'sftp',
- 'webdav',
- 'yandex',
- 'zoho',
- 'local',
-] as const
-
-export const SUPPORTS_LINK = [
- 'b2',
- 'box',
- 'drive',
- 'dropbox',
- 'fichier',
- 'filescom',
- 'gofile',
- 'imagekit',
- 'internetarchive',
- 'jottacloud',
- 'koofr',
- 'mailru',
- 'mega',
- 'onedrive',
- 'pikpak',
- 'pixeldrain',
- 'premiumizeme',
- 'pcloud',
- 's3',
- 'seafile',
- 'storj',
- 'sugarsync',
- 'yandex',
-] as const
-
-export function supportsPublicLink(backendType?: string | null): boolean {
- if (!backendType) return false
- return SUPPORTS_LINK.includes(backendType.toLowerCase() as any)
-}
-
-export const CANNOT_PERSIST_EMPTY_FOLDERS = [
- 's3',
- 'gcs',
- 'azureblob',
- 'b2',
- 'swift',
- 'oracleobjectstorage',
- 'oos',
- 'qingstor',
- 'storj',
- 'memory',
-]
-
-export function supportsPersistentEmptyFolders(backendType?: string | null) {
- if (!backendType) return true
- return !CANNOT_PERSIST_EMPTY_FOLDERS.includes(backendType.toLowerCase())
-}
+// Backend capabilities are no longer hardcoded here. They are read per-remote from the rclone RC
+// `operations/fsinfo` endpoint (see `fsInfoQueryOptions` / `hasFeature` in lib/hooks.ts), which is
+// authoritative and correct for wrapping backends (crypt/alias/union) that static type lists could
+// not express.
diff --git a/src/components/RemoteEditDrawer.tsx b/src/components/RemoteEditDrawer.tsx
index 6000c53..921e66c 100644
--- a/src/components/RemoteEditDrawer.tsx
+++ b/src/components/RemoteEditDrawer.tsx
@@ -111,6 +111,9 @@ export default function RemoteEditDrawer({
...updatedRemoteConfig,
})
)
+ // Capabilities can change with the config (e.g. s3 provider, webdav vendor, a wrapped
+ // backend's target), so drop the cached fsinfo probe and let consumers re-fetch.
+ queryClient.invalidateQueries({ queryKey: ['remote', remoteName, 'fsinfo'] })
onClose()
},
onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
diff --git a/src/components/ScheduleEditDrawer.tsx b/src/components/ScheduleEditDrawer.tsx
index 878a584..d9d8678 100644
--- a/src/components/ScheduleEditDrawer.tsx
+++ b/src/components/ScheduleEditDrawer.tsx
@@ -341,8 +341,8 @@ export default function ScheduleEditDrawer({
{runMode === 'user'
- ? 'Runs only while you are logged in, inside your session — OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
- : 'Runs even while logged out, but outside your login session — no OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
+ ? 'Runs only while you are logged in, inside your session. OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
+ : 'Runs even while logged out, but outside your login session. No OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
void) {
- const remoteConfigQuery = useRemoteConfig(remote)
-
- 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 fsInfoQuery = useFsInfo(remote)
const canCreateFolder = useMemo(() => {
if (!remote || remote === 'UI_FAVORITES') return false
if (remote === 'UI_LOCAL_FS') return true
- return supportsPersistentEmptyFolders(backendType)
- }, [remote, backendType])
+ // Optimistic while probing: the previous static check defaulted to "supported" for unknown
+ // types, so keep that until fsinfo actually says the backend can't persist empty folders.
+ return fsInfoQuery.data ? hasFeature(fsInfoQuery.data, 'CanHaveEmptyDirectories') : true
+ }, [remote, fsInfoQuery.data])
const createFolder = useCallback(async () => {
if (!remote || remote === 'UI_FAVORITES') return
diff --git a/src/pages/Delete.tsx b/src/pages/Delete.tsx
index 5a04938..f04efed 100644
--- a/src/pages/Delete.tsx
+++ b/src/pages/Delete.tsx
@@ -6,10 +6,10 @@ import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { getRemoteName } from '../../lib/format'
-import { useFlags, useRemoteConfig } from '../../lib/hooks'
+import { hasFeature, useFlags, useFsInfo } from '../../lib/hooks'
import { notify } from '../../lib/notifications'
import { startDelete, startDryRun } from '../../lib/rclone/api'
-import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
+import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
@@ -79,14 +79,12 @@ export default function Delete() {
const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs])
- const sourceRemoteConfigQuery = useRemoteConfig(sourceRemoteName)
+ const sourceFsInfoQuery = useFsInfo(sourceRemoteName)
+ // false while loading — matches the previous default (offer the plain delete until purge is confirmed).
const supportsPurge = useMemo(
- () =>
- sourceRemoteConfigQuery.data
- ? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type)
- : false,
- [sourceRemoteConfigQuery.data]
+ () => hasFeature(sourceFsInfoQuery.data, 'Purge'),
+ [sourceFsInfoQuery.data]
)
const buildArgs = () => ({
diff --git a/src/pages/Settings/RemotesSection.tsx b/src/pages/Settings/RemotesSection.tsx
index db6e854..cc308e6 100644
--- a/src/pages/Settings/RemotesSection.tsx
+++ b/src/pages/Settings/RemotesSection.tsx
@@ -9,7 +9,7 @@ import {
Input,
Spinner,
} from '@heroui/react'
-import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import {
@@ -25,9 +25,8 @@ import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'r
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../../lib/errors'
import { formatBytes } from '../../../lib/format'
-import { remoteConfigQueryOptions } from '../../../lib/hooks'
+import { hasFeature, remoteConfigQueryOptions, useFsInfo } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client'
-import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants'
import { usePersistedStore } from '../../../store/persisted'
import RemoteAutoMountDrawer from '../../components/RemoteAutoMountDrawer'
import RemoteCreateDrawer from '../../components/RemoteCreateDrawer'
@@ -53,33 +52,10 @@ export default function RemotesSection() {
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
- const remoteConfigQueries = useQueries({
- queries: remotes.map((remote) => ({
- ...remoteConfigQueryOptions(remote),
- staleTime: 1000 * 60,
- })),
- })
-
- const sortedRemotes = useMemo(() => {
- // useQueries preserves input order, so remoteConfigQueries[i] corresponds to remotes[i].
- const typeByRemote = new Map()
- remotes.forEach((remote, i) => {
- typeByRemote.set(remote, remoteConfigQueries[i]?.data?.type ?? null)
- })
-
- return [...remotes].sort((a, b) => {
- const aType = typeByRemote.get(a)
- const bType = typeByRemote.get(b)
-
- const aSupportsAbout = aType ? SUPPORTS_ABOUT.includes(aType) : false
- const bSupportsAbout = bType ? SUPPORTS_ABOUT.includes(bType) : false
-
- if (aSupportsAbout && !bSupportsAbout) return -1
- if (!aSupportsAbout && bSupportsAbout) return 1
-
- return a.localeCompare(b)
- })
- }, [remotes, remoteConfigQueries])
+ const sortedRemotes = useMemo(
+ () => [...remotes].sort((a, b) => a.localeCompare(b)),
+ [remotes]
+ )
const [searchQuery, setSearchQuery] = useState('')
@@ -333,7 +309,9 @@ function RemoteCard({
const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type])
const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider])
- const supportsAbout = useMemo(() => !!type && SUPPORTS_ABOUT.includes(type), [type])
+
+ const fsInfoQuery = useFsInfo(remote)
+ const supportsAbout = hasFeature(fsInfoQuery.data, 'About')
const { data: remoteAboutData } = useQuery({
queryKey: ['remotes', remote, 'about'],
diff --git a/src/pages/Toolbar.tsx b/src/pages/Toolbar.tsx
index ce91be3..7ef7d95 100644
--- a/src/pages/Toolbar.tsx
+++ b/src/pages/Toolbar.tsx
@@ -1,14 +1,16 @@
import { Divider, Kbd, ScrollShadow, cn } from '@heroui/react'
-import { useQuery } from '@tanstack/react-query'
+import { useQueries, useQuery } from '@tanstack/react-query'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { platform } from '@tauri-apps/plugin-os'
import type { KeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce'
+import { fsInfoQueryOptions } from '../../lib/hooks'
import { fetchMountList, fetchServeList } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import { openWindow } from '../../lib/window'
import { type ResolvedToolbarResult, runToolbarEngine } from '../../toolbar/engine'
+import type { RcloneFeatures } from '../../types/rclone'
const toolbarWindow = getCurrentWebviewWindow()
const isWindows = platform() === 'windows'
@@ -111,6 +113,19 @@ export default function Toolbar() {
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
const remoteTypes = useMemo(() => remoteTypesQuery.data ?? {}, [remoteTypesQuery.data])
+ // Eager per-remote capability probes (operations/fsinfo), cached hard. Feeds the synchronous
+ // engine so cleanup/purge can gate on the authoritative feature set. Unresolved/unreachable
+ // remotes are simply absent from the map → those actions fall to their generic item.
+ const fsInfoQueries = useQueries({ queries: remotes.map((remote) => fsInfoQueryOptions(remote)) })
+ const capabilitiesByRemote = useMemo(() => {
+ const map: Record = {}
+ remotes.forEach((remote, i) => {
+ const features = fsInfoQueries[i]?.data?.Features
+ if (features) map[remote] = features
+ })
+ return map
+ }, [remotes, fsInfoQueries])
+
const [searchString, setSearchString] = useState('')
const [searchStringDebounced] = useDebounce(searchString, 40)
@@ -127,11 +142,24 @@ export default function Toolbar() {
console.log(`${serveList?.length} serves`)
console.log(`${vfsList?.length} vfses`)
- const { results } = runToolbarEngine(searchStringDebounced, remotes, remoteTypes)
+ const { results } = runToolbarEngine(
+ searchStringDebounced,
+ remotes,
+ remoteTypes,
+ capabilitiesByRemote
+ )
startTransition(() => {
setEngineResults(results)
})
- }, [mountList, serveList, vfsList, searchStringDebounced, remotes, remoteTypes])
+ }, [
+ mountList,
+ serveList,
+ vfsList,
+ searchStringDebounced,
+ remotes,
+ remoteTypes,
+ capabilitiesByRemote,
+ ])
useEffect(() => {
let unlisten: (() => void) | undefined
diff --git a/toolbar/actions.ts b/toolbar/actions.ts
index fc6d11f..cd2f84d 100644
--- a/toolbar/actions.ts
+++ b/toolbar/actions.ts
@@ -8,7 +8,7 @@ import { notify } from '../lib/notifications'
import queryClient from '../lib/query'
import type { fetchMountList, fetchServeList } from '../lib/rclone/api'
import rclone from '../lib/rclone/client'
-import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants'
+import { SERVE_TYPES } from '../lib/rclone/constants'
import { openFullWindow } from '../lib/window'
import { selectCurrentHost, usePersistedStore } from '../store/persisted'
import { COMMAND_CONFIG, COMMAND_DESCRIPTIONS, COMMAND_KEYWORDS } from './constants'
@@ -656,9 +656,7 @@ const actions: ToolbarActionDefinition[] = [
return []
}
- const supportedRemotePaths = paths.filter(
- (path) => path.remoteType && SUPPORTS_CLEANUP.includes(path.remoteType)
- )
+ const supportedRemotePaths = paths.filter((path) => !!path.features?.CleanUp)
if (supportedRemotePaths.length === 0) {
return [createBaseResult('Cleanup', COMMAND_DESCRIPTIONS.cleanup, {}, 36)]
@@ -878,9 +876,7 @@ const actions: ToolbarActionDefinition[] = [
return []
}
- const supportedPaths = paths.filter(
- (path) => path.remoteType && SUPPORTS_PURGE.includes(path.remoteType)
- )
+ const supportedPaths = paths.filter((path) => !!path.features?.Purge)
if (supportedPaths.length === 0) {
return [createBaseResult('Purge', COMMAND_DESCRIPTIONS.purge, {}, 36)]
diff --git a/toolbar/engine.ts b/toolbar/engine.ts
index e0a4623..6e4936b 100644
--- a/toolbar/engine.ts
+++ b/toolbar/engine.ts
@@ -1,4 +1,5 @@
import { sep } from '@tauri-apps/api/path'
+import type { RcloneFeatures } from '../types/rclone'
import { getToolbarAction, getToolbarActions } from './actions'
import type {
ToolbarActionArgs,
@@ -22,13 +23,14 @@ export interface ResolvedToolbarResult {
export function runToolbarEngine(
query: string,
remotes: string[],
- remoteTypes?: Record
+ remoteTypes?: Record,
+ capabilitiesByRemote?: Record
) {
const actions = getToolbarActions()
const trimmed = query.trim()
- const parsedPaths = extractPaths(trimmed, remotes, remoteTypes)
+ const parsedPaths = extractPaths(trimmed, remotes, remoteTypes, capabilitiesByRemote)
const cleanedQuery = trimmed.replace(parsedPaths.map((path) => path.full).join(' '), '').trim()
@@ -120,7 +122,8 @@ const NON_WHITESPACE_TOKEN_REGEX = /^(\S+)/
function extractPaths(
input: string,
remotes: string[],
- remoteTypes?: Record
+ remoteTypes?: Record,
+ capabilitiesByRemote?: Record
): ToolbarActionPath[] {
const seen = new Set()
const results: ToolbarActionPath[] = []
@@ -178,6 +181,7 @@ function extractPaths(
isLocal,
remoteName,
remoteType,
+ features: remoteName ? capabilitiesByRemote?.[remoteName] : undefined,
})
}
}
diff --git a/toolbar/types.ts b/toolbar/types.ts
index 4e2273c..9337763 100644
--- a/toolbar/types.ts
+++ b/toolbar/types.ts
@@ -1,3 +1,5 @@
+import type { RcloneFeatures } from '../types/rclone'
+
export type ToolbarCommandId =
| 'copy'
| 'move'
@@ -43,6 +45,9 @@ export interface ToolbarActionPath {
isLocal: boolean
remoteName?: string
remoteType?: string
+ // Authoritative backend capability set from `operations/fsinfo`; undefined until the probe
+ // resolves (or for local paths), in which case capability-gated actions fall to their generic item.
+ features?: RcloneFeatures
}
export interface ToolbarActionContext {
diff --git a/types/rclone.d.ts b/types/rclone.d.ts
index 8626e38..4a2d45e 100644
--- a/types/rclone.d.ts
+++ b/types/rclone.d.ts
@@ -26,3 +26,24 @@ export interface Backend {
}
export type FlagValue = string | number | boolean | string[] | null
+
+// Response shape of the rclone RC `operations/fsinfo` endpoint. The SDK types `Features` only as
+// an open `{ [k: string]: boolean }` map; this names the keys we actually gate on. Keys are the
+// PascalCase names rclone emits from `Features.Enabled()` (fs/features.go).
+export interface RcloneFeatures {
+ About?: boolean
+ Purge?: boolean
+ CleanUp?: boolean
+ PublicLink?: boolean
+ CanHaveEmptyDirectories?: boolean
+ [key: string]: boolean | undefined
+}
+
+export interface RcloneFsInfo {
+ Name?: string
+ Root?: string
+ String?: string
+ Precision?: number
+ Hashes?: string[]
+ Features?: RcloneFeatures
+}