refactor(device-sync): G.75 — extract drives hook + source-statuses hook + migration + execution orchestrators (cluster) (#642)

Four-cut cluster pulling the orchestrators out of DeviceSync.tsx.
1179 → 905 LOC (−274).

useDeviceSyncDrives — drives state + drivesLoading + refreshDrives
callback + the 5 s polling useEffect + the activeDrive memo that
matches targetDir against any detected drive's mount_point.
Returns { drives, drivesLoading, activeDrive, driveDetected,
refreshDrives }.

useDeviceSyncSourceStatuses — owns sourcePathsMap state + the
useEffect that computes per-source paths through compute_sync_paths
(parallel for all sources, with the cancellation guard), and the
derived sourceStatuses Map keyed on 'synced' / 'pending' /
'deletion'.

runDeviceSyncMigration — runDeviceSyncMigrationPreview (read v1
manifest's filenameTemplate, fetch album-source tracks, compute
new paths via Rust + old paths via JS legacy template, diff into
pairs + collisions + unchanged) and runDeviceSyncMigrationExecute
(invoke rename_device_files, bump manifest to v2, rescan device).
Exports MigrationPhase / MigrationPair / MigrationResult types so
the page state stays typed.

runDeviceSyncExecution — runDeviceSyncSummaryPrompt (input
validation + invoke calculate_sync_payload through the subsonic
client) and runDeviceSyncExecute (delete pending sources, re-write
playlist m3u8 even when nothing to download, fire sync_batch_to_device
with the right toast variants on space / mount / generic errors).
SyncDelta type moves with it.

DeviceSync drops the inline definitions; closeMigration stays
inline (six lines, no real win extracting it). Pure code move
otherwise — no behaviour change.
This commit is contained in:
Frank Stellmacher
2026-05-13 15:06:49 +02:00
committed by GitHub
parent 31542c9923
commit c7946f26b6
5 changed files with 446 additions and 292 deletions
+45
View File
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { RemovableDrive } from '../utils/deviceSyncHelpers';
export interface DeviceSyncDrivesResult {
drives: RemovableDrive[];
drivesLoading: boolean;
activeDrive: RemovableDrive | null;
driveDetected: boolean;
refreshDrives: () => Promise<void>;
}
export function useDeviceSyncDrives(targetDir: string | null): DeviceSyncDrivesResult {
const [drives, setDrives] = useState<RemovableDrive[]>([]);
const [drivesLoading, setDrivesLoading] = useState(false);
const refreshDrives = useCallback(async () => {
setDrivesLoading(true);
try {
const result = await invoke<RemovableDrive[]>('get_removable_drives');
setDrives(result);
} catch {
setDrives([]);
} finally {
setDrivesLoading(false);
}
}, []);
// Fetch drives on mount, then poll every 5 seconds
useEffect(() => {
refreshDrives();
const interval = setInterval(refreshDrives, 5000);
return () => clearInterval(interval);
}, [refreshDrives]);
// Detect if the current targetDir is on a detected removable drive
const activeDrive = useMemo(() => {
if (!targetDir) return null;
return drives.find(d => targetDir.startsWith(d.mount_point)) ?? null;
}, [targetDir, drives]);
const driveDetected = activeDrive !== null;
return { drives, drivesLoading, activeDrive, driveDetected, refreshDrives };
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { fetchTracksForSource } from '../utils/fetchTracksForSource';
import { trackToSyncInfo, type SyncStatus } from '../utils/deviceSyncHelpers';
import type { DeviceSyncSource } from '../store/deviceSyncStore';
export interface DeviceSyncSourceStatusesResult {
sourcePathsMap: Map<string, string[]>;
sourceStatuses: Map<string, SyncStatus>;
}
export function useDeviceSyncSourceStatuses(
targetDir: string | null,
sources: DeviceSyncSource[],
pendingDeletion: string[],
deviceFilePaths: string[],
): DeviceSyncSourceStatusesResult {
// Map source IDs → computed device paths (for status derivation)
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
// Compute expected paths for each source (for status comparison)
useEffect(() => {
if (!targetDir || sources.length === 0) {
setSourcePathsMap(new Map());
return;
}
// Path schema is fixed in the Rust backend now — no template parameter.
let cancelled = false;
(async () => {
const map = new Map<string, string[]>();
await Promise.all(sources.map(async source => {
if (cancelled) return;
try {
const tracks = await fetchTracksForSource(source);
const paths = await invoke<string[]>('compute_sync_paths', {
tracks: tracks.map((tr, idx) => trackToSyncInfo(
tr, '',
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
)),
destDir: targetDir,
});
map.set(source.id, paths);
} catch {
map.set(source.id, []);
}
}));
if (!cancelled) setSourcePathsMap(map);
})();
return () => { cancelled = true; };
}, [targetDir, sources]);
// Derive sync status per source
const sourceStatuses = useMemo(() => {
const deviceSet = new Set(deviceFilePaths);
const statuses = new Map<string, SyncStatus>();
for (const source of sources) {
if (pendingDeletion.includes(source.id)) {
statuses.set(source.id, 'deletion');
} else {
const paths = sourcePathsMap.get(source.id) ?? [];
const allSynced = paths.length > 0 && paths.every(p => deviceSet.has(p));
statuses.set(source.id, allSynced ? 'synced' : 'pending');
}
}
return statuses;
}, [sources, pendingDeletion, sourcePathsMap, deviceFilePaths]);
return { sourcePathsMap, sourceStatuses };
}