From 9e06d2b4fa336f8150fb40545615579fd600e2d7 Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:10:13 +0300 Subject: [PATCH] determine if user cancelled an operation --- lib/errors.ts | 9 +++++++++ lib/query.ts | 3 +++ lib/rclone/api.ts | 45 +++++++++++++++----------------------------- lib/rclone/client.ts | 17 +++++++++++------ 4 files changed, 38 insertions(+), 36 deletions(-) diff --git a/lib/errors.ts b/lib/errors.ts index d8d62f8..d6138c9 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -1,6 +1,15 @@ import * as Sentry from '@sentry/browser' import { message } from '@tauri-apps/plugin-dialog' +// Signals that the user explicitly stopped a call (e.g. dismissed the reconnect prompt), so retry +// layers should abort instead of re-running and re-prompting. +export class UserCancelledError extends Error { + constructor(message: string) { + super(message) + this.name = 'UserCancelledError' + } +} + // Coerce an unknown thrown value into a user-facing string. Mirrors the // `error instanceof Error ? error.message : ` idiom hand-written across the app. // Pass `String(error)` as the fallback to preserve sites that surfaced the raw value. diff --git a/lib/query.ts b/lib/query.ts index dd30921..28ba074 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -1,12 +1,15 @@ import { persistQueryClient } from '@tanstack/query-persist-client-core' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' import { QueryClient } from '@tanstack/react-query' +import { UserCancelledError } from './errors' const queryClient = new QueryClient({ defaultOptions: { queries: { // staleTime: 60_000, // gcTime: 3_600_000, + retry: (failureCount, error) => + !(error instanceof UserCancelledError) && failureCount < 3, }, }, }) diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 10e6a4c..3d71d67 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -6,6 +6,7 @@ import { selectActiveConfigFile, useHostStore } from '../../store/host' import { useStore } from '../../store/memory' import type { JobItem } from '../../types/jobs' import type { FlagValue } from '../../types/rclone' +import { UserCancelledError, formatErrorMessage } from '../errors' import { getFsInfo } from '../format' import { restartActiveRclone, runRcloneCli } from './cli' import rclone, { rcloneAsync } from './client' @@ -17,6 +18,10 @@ const RE_WINDOWS_EXTENDED_PATH = /(\/\/\?\/|\\\\\?\\)/ const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/ const RE_WINDOWS_DRIVE_LETTER = /^[a-zA-Z]:$/ +const RETRY_OPTIONS = { + retries: 3, + shouldRetry: ({ error }: { error: unknown }) => !(error instanceof UserCancelledError), +} export async function startDryRun(operation: () => Promise): Promise { await rclone('/options/set', { body: { @@ -652,9 +657,7 @@ export async function startMount({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) return response?.mountPoint } @@ -681,9 +684,7 @@ export async function startMount({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) if (!r || !r.item) { directoryExists = false @@ -713,9 +714,7 @@ export async function startMount({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) isEmpty = !list || list.length === 0 } catch (err) { @@ -738,9 +737,7 @@ export async function startMount({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) } catch (err) { console.error('[Mount] Error removing directory:', err) @@ -758,9 +755,7 @@ export async function startMount({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) } catch (error) { console.error('[Mount] Error creating directory:', error) @@ -794,9 +789,7 @@ export async function startMount({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) } @@ -862,9 +855,7 @@ export async function startBisync({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ) if (!r?.jobid) { @@ -883,9 +874,7 @@ export async function startBisync({ }, }, }), - { - retries: 3, - } + RETRY_OPTIONS ).catch(() => null) console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) @@ -1201,9 +1190,7 @@ export async function startBatch(inputs: ({ _path: string } & Record null) console.log('[startBatch] jobStatus', { diff --git a/lib/rclone/client.ts b/lib/rclone/client.ts index ee16615..20b7ed6 100644 --- a/lib/rclone/client.ts +++ b/lib/rclone/client.ts @@ -11,12 +11,15 @@ import createRCDClient, { type RCDClient, } from 'rclone-sdk' import { selectCurrentHost, usePersistedStore } from '../../store/persisted' +import { UserCancelledError } from '../errors' const RE_RECONNECT = /rclone config reconnect (\S+?):/ +// Returns true when the user declined to reconnect (dismissed the prompt or the reconnect attempt +// failed) so callers can abort retries instead of re-running and re-prompting. async function handleReconnectIfNeeded(errorMessage: string) { const match = errorMessage.match(RE_RECONNECT) - if (!match) return + if (!match) return false const remoteName = match[1] const confirmed = await ask( `Remote "${remoteName}" needs to be reconnected. This usually means the authentication token has expired.\n\nWould you like to reconnect now?`, @@ -27,7 +30,7 @@ async function handleReconnectIfNeeded(errorMessage: string) { cancelLabel: 'Dismiss', } ) - if (!confirmed) return + if (!confirmed) return true try { const { reconnectRemote } = await import('./api') await reconnectRemote(remoteName) @@ -35,11 +38,13 @@ async function handleReconnectIfNeeded(errorMessage: string) { title: 'Reconnected', kind: 'info', }) + return false } catch (err) { await message(err instanceof Error ? err.message : 'Reconnection failed', { title: 'Reconnect Error', kind: 'error', }) + return true } } @@ -123,8 +128,8 @@ async function request(mode: 'sync' | 'async', path: string, init: any[]): Promi const errMsg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error) - await handleReconnectIfNeeded(errMsg) - throw new Error(errMsg) + const cancelled = await handleReconnectIfNeeded(errMsg) + throw cancelled ? new UserCancelledError(errMsg) : new Error(errMsg) } const data = result.data as { error?: unknown } | undefined @@ -132,8 +137,8 @@ async function request(mode: 'sync' | 'async', path: string, init: any[]): Promi console.error('[rclone] DATA ERROR', path, { error: data.error }) const errMsg = typeof data.error === 'string' ? data.error : JSON.stringify(data.error) - await handleReconnectIfNeeded(errMsg) - throw new Error(errMsg) + const cancelled = await handleReconnectIfNeeded(errMsg) + throw cancelled ? new UserCancelledError(errMsg) : new Error(errMsg) } if (!result.response.ok) {