Skip to content

Commit 44d306e

Browse files
axpnetaeroftp[bot]claude
committed
feat(transfer): honor real provider capabilities in the Sync GUI
The Sync panel hardcoded parallel streams (1/3/6/8/8) regardless of provider, advertising 8 streams on single-lease backends where the transfer engine runs one file at a time. The GUI now consumes the get_transfer_capabilities command through a new useTransferCapabilities hook: speed presets and the parallel-stream selector are clamped to max(max_file_slots, max_chunk_slots), the pill tooltip shows the real effective stream count, the advanced selector offers only honourable options (a single-lease provider lists Sequential only), and an honest note discloses that SFTP checksum compare runs single-lease without the session pool. When capabilities are unknown the legacy ceiling of 8 is kept, so pre-connect config and saved profiles are unchanged. Consumer-side only: no new engine, pool or lease. One new i18n key across all 47 locales. Co-Authored-By: aeroftp[bot] <aeroftp[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 890239f commit 44d306e

52 files changed

Lines changed: 261 additions & 56 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/components/Sync/SyncAdvancedConfig.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ interface SyncAdvancedConfigProps {
3838
onSpeedLimitChange: (dl: number, ul: number) => void;
3939
parallelStreams: number;
4040
onParallelStreamsChange: (n: number) => void;
41+
/** Real backend stream ceiling for the active provider (capability-driven). */
42+
streamCap: number;
43+
/** True when the active SFTP session has no pooled strategy (single-lease). */
44+
sftpPoolUnavailable: boolean;
4145
compressionMode: CompressionMode;
4246
onCompressionModeChange: (m: CompressionMode) => void;
4347
deltaSyncEnabled: boolean;
@@ -116,6 +120,8 @@ export const SyncAdvancedConfig: React.FC<SyncAdvancedConfigProps> = React.memo(
116120
onSpeedLimitChange,
117121
parallelStreams,
118122
onParallelStreamsChange,
123+
streamCap,
124+
sftpPoolUnavailable,
119125
compressionMode,
120126
onCompressionModeChange,
121127
deltaSyncEnabled,
@@ -281,6 +287,12 @@ export const SyncAdvancedConfig: React.FC<SyncAdvancedConfigProps> = React.memo(
281287
</div>
282288
</div>
283289

290+
{sftpPoolUnavailable && options.compare_checksum && (
291+
<div className="mt-2 text-[11px] leading-relaxed text-amber-300/90">
292+
{t('syncPanel.checksumPoolNote')}
293+
</div>
294+
)}
295+
284296
<div className="sync-adv-divider" />
285297

286298
<div className="sync-compare-options">
@@ -485,19 +497,24 @@ export const SyncAdvancedConfig: React.FC<SyncAdvancedConfigProps> = React.memo(
485497
<span className="text-xs text-gray-400">{t('syncPanel.parallelStreams')}:</span>
486498
<select
487499
className="sync-adv-select"
488-
value={parallelStreams}
500+
value={Math.max(1, Math.min(parallelStreams, streamCap))}
489501
onChange={e => onParallelStreamsChange(Number(e.target.value))}
490502
disabled={disabled}
491503
>
492-
<option value="1">{t('syncPanel.parallelSequential')}</option>
493-
<option value="2">2 {t('syncPanel.parallelStreamLabel')}</option>
494-
<option value="3">3 {t('syncPanel.parallelStreamLabel')}</option>
495-
<option value="4">4 {t('syncPanel.parallelStreamLabel')}</option>
496-
<option value="6">6 {t('syncPanel.parallelStreamLabel')}</option>
497-
<option value="8">8 {t('syncPanel.parallelStreamLabel')}</option>
504+
{/* Only offer stream counts the backend can honor:
505+
a single-lease provider lists Sequential only. */}
506+
{[1, 2, 3, 4, 6, 8]
507+
.filter(v => v <= streamCap)
508+
.map(v => (
509+
<option key={v} value={v}>
510+
{v === 1
511+
? t('syncPanel.parallelSequential')
512+
: `${v} ${t('syncPanel.parallelStreamLabel')}`}
513+
</option>
514+
))}
498515
</select>
499516
</label>
500-
{parallelStreams > 1 && (
517+
{parallelStreams > 1 && streamCap > 1 && (
501518
<span className="text-[10px] text-yellow-400/70 flex items-center gap-0.5">
502519
<Zap size={10} /> {t('syncPanel.parallelTurboLabel')}
503520
</span>

src/components/Sync/SyncQuickMode.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface SyncQuickModeProps {
2424
maniacConfirmed: boolean;
2525
onManiacConfirm: () => void;
2626
disabled: boolean;
27+
/** Real backend stream ceiling for the active provider (capability-driven). */
28+
streamCap: number;
2729
}
2830

2931
// Preset card configuration
@@ -53,6 +55,7 @@ export const SyncQuickMode: React.FC<SyncQuickModeProps> = React.memo(({
5355
maniacConfirmed,
5456
onManiacConfirm,
5557
disabled,
58+
streamCap,
5659
}) => {
5760
const t = useTranslation();
5861

@@ -132,13 +135,20 @@ export const SyncQuickMode: React.FC<SyncQuickModeProps> = React.memo(({
132135
{speedModes.map(mode => {
133136
const isActive = speedMode === mode;
134137
const preset = SPEED_PRESETS[mode];
138+
// Honest tooltip: show the streams the backend will
139+
// actually run for this provider, not the nominal
140+
// preset value (clamped to the real capability).
141+
const effectiveStreams = Math.max(
142+
1,
143+
Math.min(preset.parallelStreams, streamCap),
144+
);
135145
return (
136146
<button
137147
key={mode}
138148
className={`sync-speed-pill ${mode} ${isActive ? 'active' : ''}`}
139149
onClick={() => handleSpeedChange(mode)}
140150
disabled={disabled}
141-
title={t('syncPanel.speedStreams', { count: preset.parallelStreams })}
151+
title={t('syncPanel.speedStreams', { count: effectiveStreams })}
142152
>
143153
{mode === 'maniac' && <Skull size={12} className="mr-1" />}
144154
{t(SPEED_MODE_KEYS[mode])}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (c) 2024-2026 axpnet: AI-assisted (see AI-TRANSPARENCY.md)
3+
4+
/**
5+
* Hook for fetching per-provider transfer capabilities.
6+
*
7+
* Backed by the `get_transfer_capabilities` Tauri command (single source of
8+
* truth: TransferCapabilities::from_provider_hints). The GUI consumes this so
9+
* speed presets and parallel-stream controls reflect what the backend can
10+
* actually honor instead of advertising parallelism it will never apply.
11+
*/
12+
13+
import { useState, useEffect } from "react";
14+
import { invoke } from "@tauri-apps/api/core";
15+
import { TransferCapabilities, Capability, ProviderType } from "../../types";
16+
17+
/** Mirrors Rust Capability::is_available. */
18+
export function isCapabilityAvailable(c?: Capability | null): boolean {
19+
return (
20+
c === "supported" || c === "supported_after_probe" || c === "experimental"
21+
);
22+
}
23+
24+
export function useTransferCapabilities(
25+
protocol?: ProviderType,
26+
refreshKey?: unknown,
27+
): {
28+
caps: TransferCapabilities | null;
29+
loading: boolean;
30+
} {
31+
const [caps, setCaps] = useState<TransferCapabilities | null>(null);
32+
const [loading, setLoading] = useState(false);
33+
34+
useEffect(() => {
35+
if (!protocol) {
36+
setCaps(null);
37+
return;
38+
}
39+
40+
let cancelled = false;
41+
setLoading(true);
42+
43+
invoke<TransferCapabilities>("get_transfer_capabilities", {
44+
providerType: protocol,
45+
})
46+
.then((c) => {
47+
if (!cancelled) setCaps(c);
48+
})
49+
.catch(() => {
50+
if (!cancelled) setCaps(null);
51+
})
52+
.finally(() => {
53+
if (!cancelled) setLoading(false);
54+
});
55+
56+
return () => {
57+
cancelled = true;
58+
};
59+
}, [protocol, refreshKey]);
60+
61+
return { caps, loading };
62+
}

src/components/SyncPanel.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
// SPDX-License-Identifier: GPL-3.0-or-later
22
// Copyright (c) 2024-2026 axpnet: AI-assisted (see AI-TRANSPARENCY.md)
33

4-
import React, { useState, useEffect, useRef, useCallback } from "react";
4+
import React, {
5+
useState,
6+
useEffect,
7+
useRef,
8+
useCallback,
9+
useMemo,
10+
} from "react";
511
import { invoke } from "@tauri-apps/api/core";
612
import { save } from "@tauri-apps/plugin-dialog";
713
import { writeTextFile } from "@tauri-apps/plugin-fs";
@@ -88,6 +94,10 @@ import { SyncQuickMode } from "./Sync/SyncQuickMode";
8894
import { SyncAdvancedConfig } from "./Sync/SyncAdvancedConfig";
8995
import { useSyncProfiles } from "./Sync/useSyncProfiles";
9096
import { useSyncOptimization } from "./Sync/useSyncOptimization";
97+
import {
98+
useTransferCapabilities,
99+
isCapabilityAvailable,
100+
} from "./Sync/useTransferCapabilities";
91101
import {
92102
SpeedMode,
93103
SPEED_PRESETS,
@@ -323,8 +333,13 @@ export const SyncPanel: React.FC<SyncPanelProps> = ({
323333

324334
// Phase 3A+: Parallel streams (1 = sequential/legacy, 2-8 = turbo)
325335
const [parallelStreams, setParallelStreamsRaw] = useState(1);
336+
// Upper bound for parallel streams: the real backend capability for the
337+
// active provider (FTP/FTPS pool = 8, multipart providers = chunk slots,
338+
// everything else = single-stream). Defaults to 8 until caps resolve so the
339+
// pre-connect config UX is not artificially constrained.
340+
const capStreamsRef = useRef(8);
326341
const setParallelStreams = (n: number) =>
327-
setParallelStreamsRaw(Math.max(1, Math.min(8, n)));
342+
setParallelStreamsRaw(Math.max(1, Math.min(capStreamsRef.current, n)));
328343
const [compressionMode, setCompressionMode] =
329344
useState<CompressionMode>("off");
330345
const [deltaSyncEnabled, setDeltaSyncEnabled] = useState(false);
@@ -342,6 +357,29 @@ export const SyncPanel: React.FC<SyncPanelProps> = ({
342357
protocol,
343358
isConnected,
344359
);
360+
361+
// Real per-provider transfer capabilities (single source of truth on the
362+
// Rust side). The speed presets and the parallel-streams selector are
363+
// clamped to what the backend can actually honor: advertising 8 streams on
364+
// a single-lease provider is a silent no-op, not an optimization.
365+
const { caps: transferCaps } = useTransferCapabilities(protocol, isConnected);
366+
const capabilityMaxStreams = useMemo(() => {
367+
if (!transferCaps) return 8;
368+
return Math.max(
369+
1,
370+
transferCaps.max_file_slots ?? 1,
371+
transferCaps.max_chunk_slots ?? 1,
372+
);
373+
}, [transferCaps]);
374+
const sftpPoolUnavailable =
375+
protocol === "sftp" &&
376+
!!transferCaps &&
377+
!isCapabilityAvailable(transferCaps.session_pool);
378+
useEffect(() => {
379+
capStreamsRef.current = capabilityMaxStreams;
380+
setParallelStreamsRaw((p) => Math.max(1, Math.min(p, capabilityMaxStreams)));
381+
}, [capabilityMaxStreams]);
382+
345383
const deltaEligibilityResolverRef = useRef<
346384
((proceed: boolean) => void) | null
347385
>(null);
@@ -3040,6 +3078,7 @@ export const SyncPanel: React.FC<SyncPanelProps> = ({
30403078
onSpeedModeChange={handleSpeedModeChange}
30413079
showManiac={isCyberTheme()}
30423080
maniacConfirmed={maniacConfirmed}
3081+
streamCap={capabilityMaxStreams}
30433082
onManiacConfirm={() => {
30443083
setManiacConfirmed(true);
30453084
// Apply maniac overrides ONLY after user confirmation
@@ -3077,6 +3116,8 @@ export const SyncPanel: React.FC<SyncPanelProps> = ({
30773116
onSpeedLimitChange={handleSpeedLimitChange}
30783117
parallelStreams={parallelStreams}
30793118
onParallelStreamsChange={setParallelStreams}
3119+
streamCap={capabilityMaxStreams}
3120+
sftpPoolUnavailable={sftpPoolUnavailable}
30803121
compressionMode={compressionMode}
30813122
onCompressionModeChange={setCompressionMode}
30823123
deltaSyncEnabled={deltaSyncEnabled}

src/i18n/locales/bg.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@
21632163
"templateFormatPwsh": "PowerShell скрипт (.ps1)",
21642164
"templateScriptExportedToast": "Скриптът е експортиран",
21652165
"templateScriptImportedToast": "Метаданните на скрипта са заредени",
2166-
"templateScriptInvalidToast": "Скриптът не съдържа метаданни на AeroFTP"
2166+
"templateScriptInvalidToast": "Скриптът не съдържа метаданни на AeroFTP",
2167+
"checksumPoolNote": "Сравнението на checksum в тази SFTP сесия използва една връзка (без пул от сесии): очаквайте по-бавно сканиране на големи дървета."
21672168
},
21682169
"ui": {
21692170
"syncing": "Синхронизиране...",

src/i18n/locales/bn.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@
21632163
"templateFormatPwsh": "PowerShell স্ক্রিপ্ট (.ps1)",
21642164
"templateScriptExportedToast": "স্ক্রিপ্ট রপ্তানি করা হয়েছে",
21652165
"templateScriptImportedToast": "স্ক্রিপ্ট মেটাডেটা লোড করা হয়েছে",
2166-
"templateScriptInvalidToast": "স্ক্রিপ্টে AeroFTP মেটাডেটা নেই"
2166+
"templateScriptInvalidToast": "স্ক্রিপ্টে AeroFTP মেটাডেটা নেই",
2167+
"checksumPoolNote": "এই SFTP সেশনে checksum তুলনা একটি একক সংযোগ ব্যবহার করে (কোনো সেশন pool নেই): বড় ট্রিতে ধীর স্ক্যানের আশা করুন।"
21672168
},
21682169
"ui": {
21692170
"syncing": "সিঙ্ক হচ্ছে...",

src/i18n/locales/ca.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@
21632163
"templateFormatPwsh": "Script PowerShell (.ps1)",
21642164
"templateScriptExportedToast": "Script exportat",
21652165
"templateScriptImportedToast": "Metadades del script carregades",
2166-
"templateScriptInvalidToast": "L'script no conté metadades d'AeroFTP"
2166+
"templateScriptInvalidToast": "L'script no conté metadades d'AeroFTP",
2167+
"checksumPoolNote": "La comparació de checksum en aquesta sessió SFTP utilitza una sola connexió (sense pool de sessions): espereu escaneigs més lents en arbres grans."
21672168
},
21682169
"ui": {
21692170
"syncing": "Sincronitzant...",

src/i18n/locales/cs.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@
21632163
"templateFormatPwsh": "Skript PowerShell (.ps1)",
21642164
"templateScriptExportedToast": "Skript exportován",
21652165
"templateScriptImportedToast": "Metadata skriptu načtena",
2166-
"templateScriptInvalidToast": "Skript neobsahuje metadata AeroFTP"
2166+
"templateScriptInvalidToast": "Skript neobsahuje metadata AeroFTP",
2167+
"checksumPoolNote": "Porovnání kontrolního součtu v této relaci SFTP používá jediné připojení (bez poolu relací): u velkých stromů očekávejte pomalejší skenování."
21672168
},
21682169
"ui": {
21692170
"syncing": "Synchronizace...",

src/i18n/locales/cy.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@
21632163
"templateFormatPwsh": "Sgript PowerShell (.ps1)",
21642164
"templateScriptExportedToast": "Sgript wedi ei allforio",
21652165
"templateScriptImportedToast": "Metadata sgript wedi ei lwytho",
2166-
"templateScriptInvalidToast": "Nid yw'r sgript yn cynnwys metadata AeroFTP"
2166+
"templateScriptInvalidToast": "Nid yw'r sgript yn cynnwys metadata AeroFTP",
2167+
"checksumPoolNote": "Mae cymharu checksum yn y sesiwn SFTP hwn yn defnyddio un cysylltiad (dim pwll sesiynau): disgwyliwch sganiau arafach ar goed mawr."
21672168
},
21682169
"ui": {
21692170
"syncing": "Yn cysoni...",

src/i18n/locales/da.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,8 @@
21632163
"templateFormatPwsh": "PowerShell-script (.ps1)",
21642164
"templateScriptExportedToast": "Script eksporteret",
21652165
"templateScriptImportedToast": "Scriptmetadata indlæst",
2166-
"templateScriptInvalidToast": "Scriptet indeholder ikke AeroFTP-metadata"
2166+
"templateScriptInvalidToast": "Scriptet indeholder ikke AeroFTP-metadata",
2167+
"checksumPoolNote": "Checksum-sammenligning i denne SFTP-session bruger en enkelt forbindelse (ingen sessionspulje): forvent langsommere scanninger på store træer."
21672168
},
21682169
"ui": {
21692170
"syncing": "Synkroniserer...",

0 commit comments

Comments
 (0)