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 +}