From 6fcf2259f6d8a8a3cd1cc6116636d457ba75f84f Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 15:17:19 +0200 Subject: [PATCH] =?UTF-8?q?refactor(device-sync):=20G.76=20=E2=80=94=20ext?= =?UTF-8?q?ract=20browser=20+=20device-scan=20+=20job-events=20hooks=20+?= =?UTF-8?q?=20choose-folder=20util=20(cluster)=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-cut cluster pulling the remaining lifecycle code out of DeviceSync.tsx. 928 → 638 LOC (−290). useDeviceSyncBrowser — playlists / randomAlbums / artists state + their three loaders + the tab-switch useEffect that lazy-loads on first visit + the 300 ms debounced album-search useEffect + the expandedArtistIds / artistAlbumsMap / loadingArtistIds state with toggleArtistExpand. Takes activeTab + search + a resetSearch callback (so the tab-switch effect can clear the search input the page still owns). useDeviceSyncDeviceScan — scanDevice useCallback + the on-mount useEffect + the auto-import-manifest useEffect (with the manifestImportedRef gate so it only fires once per drive plug-in) + the clean-on-unplug useEffect that clears deviceFilePaths and resets the import flag. useDeviceSyncJobEvents — the device:sync:progress and device:sync:complete event listeners. Complete handler dispatches the toast, writes the manifest, generates per-playlist m3u8 files (through fetchTracksForSource + trackToSyncInfo), and triggers scanDevice. Cancelled state is preserved by re-calling useDeviceSyncJobStore.cancel() after complete(). runDeviceSyncChooseFolder — the openDialog → setTargetDir → optional manifest auto-import → scanDevice timer flow. DeviceSync drops every direct import that those hooks now own (getPlaylists, getArtists, getArtist, getAlbumList, searchSubsonic, listen, openDialog, useEffect, useRef, SubsonicPlaylist / SubsonicArtist / SubsonicAlbum type imports). Pure code move otherwise — no behaviour change. --- src/hooks/useDeviceSyncBrowser.ts | 106 ++++++++++++ src/hooks/useDeviceSyncDeviceScan.ts | 64 +++++++ src/hooks/useDeviceSyncJobEvents.ts | 75 +++++++++ src/pages/DeviceSync.tsx | 225 ++----------------------- src/utils/runDeviceSyncChooseFolder.ts | 34 ++++ 5 files changed, 297 insertions(+), 207 deletions(-) create mode 100644 src/hooks/useDeviceSyncBrowser.ts create mode 100644 src/hooks/useDeviceSyncDeviceScan.ts create mode 100644 src/hooks/useDeviceSyncJobEvents.ts create mode 100644 src/utils/runDeviceSyncChooseFolder.ts diff --git a/src/hooks/useDeviceSyncBrowser.ts b/src/hooks/useDeviceSyncBrowser.ts new file mode 100644 index 00000000..4435efe0 --- /dev/null +++ b/src/hooks/useDeviceSyncBrowser.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getPlaylists } from '../api/subsonicPlaylists'; +import { getArtists, getArtist } from '../api/subsonicArtists'; +import { getAlbumList } from '../api/subsonicLibrary'; +import { search as searchSubsonic } from '../api/subsonicSearch'; +import type { + SubsonicAlbum, SubsonicArtist, SubsonicPlaylist, +} from '../api/subsonicTypes'; +import type { SourceTab } from '../utils/deviceSyncHelpers'; + +export interface DeviceSyncBrowserResult { + playlists: SubsonicPlaylist[]; + randomAlbums: SubsonicAlbum[]; + albumSearchResults: SubsonicAlbum[]; + albumSearchLoading: boolean; + artists: SubsonicArtist[]; + loadingBrowser: boolean; + expandedArtistIds: Set; + artistAlbumsMap: Map; + loadingArtistIds: Set; + toggleArtistExpand: (artistId: string) => Promise; +} + +export function useDeviceSyncBrowser( + activeTab: SourceTab, + search: string, + resetSearch: () => void, +): DeviceSyncBrowserResult { + const [playlists, setPlaylists] = useState([]); + const [randomAlbums, setRandomAlbums] = useState([]); + const [albumSearchResults, setAlbumSearchResults] = useState([]); + const [albumSearchLoading, setAlbumSearchLoading] = useState(false); + const [artists, setArtists] = useState([]); + const [loadingBrowser, setLoadingBrowser] = useState(false); + const [expandedArtistIds, setExpandedArtistIds] = useState>(new Set()); + const [artistAlbumsMap, setArtistAlbumsMap] = useState>(new Map()); + const [loadingArtistIds, setLoadingArtistIds] = useState>(new Set()); + + const loadPlaylists = useCallback(async () => { + setLoadingBrowser(true); + try { setPlaylists(await getPlaylists()); } catch { /* ignore */ } + finally { setLoadingBrowser(false); } + }, []); + const loadRandomAlbums = useCallback(async () => { + setLoadingBrowser(true); + try { setRandomAlbums(await getAlbumList('random', 10)); } catch { /* ignore */ } + finally { setLoadingBrowser(false); } + }, []); + const loadArtists = useCallback(async () => { + setLoadingBrowser(true); + try { setArtists(await getArtists()); } catch { /* ignore */ } + finally { setLoadingBrowser(false); } + }, []); + + useEffect(() => { + resetSearch(); + if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists(); + if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums(); + if (activeTab === 'artists' && artists.length === 0) loadArtists(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab]); + + // Live album search with 300ms debounce + useEffect(() => { + if (activeTab !== 'albums') return; + const q = search.trim(); + if (!q) { setAlbumSearchResults([]); return; } + setAlbumSearchLoading(true); + const timer = setTimeout(async () => { + try { + const { albums } = await searchSubsonic(q, { albumCount: 20, artistCount: 0, songCount: 0 }); + setAlbumSearchResults(albums); + } catch { + setAlbumSearchResults([]); + } finally { + setAlbumSearchLoading(false); + } + }, 300); + return () => { clearTimeout(timer); setAlbumSearchLoading(false); }; + }, [search, activeTab]); + + const toggleArtistExpand = useCallback(async (artistId: string) => { + setExpandedArtistIds(prev => { + const next = new Set(prev); + if (next.has(artistId)) { next.delete(artistId); return next; } + next.add(artistId); + return next; + }); + if (!artistAlbumsMap.has(artistId)) { + setLoadingArtistIds(prev => new Set(prev).add(artistId)); + try { + const { albums } = await getArtist(artistId); + setArtistAlbumsMap(prev => new Map(prev).set(artistId, albums)); + } finally { + setLoadingArtistIds(prev => { const n = new Set(prev); n.delete(artistId); return n; }); + } + } + }, [artistAlbumsMap]); + + return { + playlists, randomAlbums, albumSearchResults, albumSearchLoading, + artists, loadingBrowser, + expandedArtistIds, artistAlbumsMap, loadingArtistIds, + toggleArtistExpand, + }; +} diff --git a/src/hooks/useDeviceSyncDeviceScan.ts b/src/hooks/useDeviceSyncDeviceScan.ts new file mode 100644 index 00000000..c987d42a --- /dev/null +++ b/src/hooks/useDeviceSyncDeviceScan.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { TFunction } from 'i18next'; +import { useDeviceSyncStore, type DeviceSyncSource } from '../store/deviceSyncStore'; +import { showToast } from '../utils/toast'; + +export interface DeviceSyncDeviceScanResult { + scanDevice: () => Promise; +} + +export function useDeviceSyncDeviceScan( + targetDir: string | null, + sourcesLength: number, + driveDetected: boolean, + t: TFunction, +): DeviceSyncDeviceScanResult { + const setDeviceFilePaths = useDeviceSyncStore.getState().setDeviceFilePaths; + const setScanning = useDeviceSyncStore.getState().setScanning; + + const scanDevice = useCallback(async () => { + if (!targetDir || sourcesLength === 0) { + setDeviceFilePaths([]); + return; + } + setScanning(true); + try { + const files = await invoke('list_device_dir_files', { dir: targetDir }); + setDeviceFilePaths(files); + } catch { + setDeviceFilePaths([]); + } finally { + setScanning(false); + } + }, [targetDir, sourcesLength, setDeviceFilePaths, setScanning]); + + // Scan device on mount and when targetDir changes + useEffect(() => { scanDevice(); }, [scanDevice]); + + // Auto-import manifest when page loads and drive is already connected + const manifestImportedRef = useRef(false); + useEffect(() => { + if (!targetDir || !driveDetected || manifestImportedRef.current) return; + manifestImportedRef.current = true; + invoke<{ version: number; sources: DeviceSyncSource[] } | null>( + 'read_device_manifest', { destDir: targetDir } + ).then(manifest => { + if (manifest?.sources?.length) { + useDeviceSyncStore.getState().clearSources(); + manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); + showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); + } + }).catch(() => {}); + }, [targetDir, driveDetected, t]); + + // Clear device file list and reset import flag when stick is unplugged + useEffect(() => { + if (!driveDetected) { + setDeviceFilePaths([]); + manifestImportedRef.current = false; + } + }, [driveDetected, setDeviceFilePaths]); + + return { scanDevice }; +} diff --git a/src/hooks/useDeviceSyncJobEvents.ts b/src/hooks/useDeviceSyncJobEvents.ts new file mode 100644 index 00000000..4d23b154 --- /dev/null +++ b/src/hooks/useDeviceSyncJobEvents.ts @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import type { TFunction } from 'i18next'; +import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; +import { useDeviceSyncStore } from '../store/deviceSyncStore'; +import { showToast } from '../utils/toast'; +import { trackToSyncInfo } from '../utils/deviceSyncHelpers'; +import { fetchTracksForSource } from '../utils/fetchTracksForSource'; + +export function useDeviceSyncJobEvents( + t: TFunction, + scanDevice: () => Promise, +): void { + useEffect(() => { + const jobStore = useDeviceSyncJobStore.getState; + const unlistenProgress = listen<{ + jobId: string; done: number; skipped: number; failed: number; total: number; + }>('device:sync:progress', ({ payload }) => { + const current = jobStore(); + if (current.jobId && payload.jobId === current.jobId) { + useDeviceSyncJobStore.getState().updateProgress( + payload.done, payload.skipped, payload.failed + ); + } + }); + + const unlistenComplete = listen<{ + jobId: string; done: number; skipped: number; failed: number; total: number; cancelled?: boolean; + }>('device:sync:complete', ({ payload }) => { + const current = jobStore(); + if (current.jobId && payload.jobId === current.jobId) { + if (payload.cancelled) { + useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); + // status is already 'cancelled' from the button click; complete() would overwrite it — restore it + useDeviceSyncJobStore.getState().cancel(); + } else { + useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); + showToast( + t('deviceSync.syncResult', { + done: payload.done, skipped: payload.skipped, total: payload.total + }), + 5000, 'info' + ); + // Write manifest so another machine can read the synced sources from the stick + const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); + if (dir) { + invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); + // For every playlist source, write an Extended-M3U next to the + // playlist-folder tracks. Context carries the playlist name + + // per-track index so the filenames match the files we just synced. + const playlistSources = srcs.filter(s => s.type === 'playlist'); + playlistSources.forEach(async playlist => { + try { + const tracks = await fetchTracksForSource(playlist); + await invoke('write_playlist_m3u8', { + destDir: dir, + playlistName: playlist.name, + tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })), + }); + } catch { /* m3u8 failure is non-fatal — skip silently */ } + }); + } + } + // Re-scan the device after sync completes (cancelled or not) + scanDevice(); + } + }); + + return () => { + unlistenProgress.then(f => f()); + unlistenComplete.then(f => f()); + }; + }, [t, scanDevice]); +} diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index 65bd8790..860fc39c 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -1,12 +1,7 @@ -import { getPlaylists } from '../api/subsonicPlaylists'; import { buildDownloadUrl } from '../api/subsonicStreamUrl'; -import { getArtists, getArtist } from '../api/subsonicArtists'; -import { getAlbumList } from '../api/subsonicLibrary'; -import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist } from '../api/subsonicTypes'; -import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import React, { useState, useCallback, useMemo } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { HardDriveUpload, FolderOpen, Loader2, ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock, @@ -16,18 +11,19 @@ import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; -import { search as searchSubsonic } from '../api/subsonicSearch'; import { showToast } from '../utils/toast'; import { IS_WINDOWS } from '../utils/platform'; import { - formatBytes, trackToSyncInfo, + formatBytes, type SourceTab, } from '../utils/deviceSyncHelpers'; -import { fetchTracksForSource } from '../utils/fetchTracksForSource'; import BrowserRow from '../components/deviceSync/BrowserRow'; import { useDeviceSyncDrives } from '../hooks/useDeviceSyncDrives'; import { useDeviceSyncSourceStatuses } from '../hooks/useDeviceSyncSourceStatuses'; +import { useDeviceSyncBrowser } from '../hooks/useDeviceSyncBrowser'; +import { useDeviceSyncDeviceScan } from '../hooks/useDeviceSyncDeviceScan'; +import { useDeviceSyncJobEvents } from '../hooks/useDeviceSyncJobEvents'; import { runDeviceSyncMigrationPreview, runDeviceSyncMigrationExecute, @@ -38,6 +34,7 @@ import { runDeviceSyncExecute, type SyncDelta, } from '../utils/runDeviceSyncExecution'; +import { runDeviceSyncChooseFolder } from '../utils/runDeviceSyncChooseFolder'; // ─── component ─────────────────────────────────────────────────────────────── @@ -64,16 +61,6 @@ export default function DeviceSync() { const [activeTab, setActiveTab] = useState('albums'); const [search, setSearch] = useState(''); - const [playlists, setPlaylists] = useState([]); - const [randomAlbums, setRandomAlbums] = useState([]); - const [albumSearchResults, setAlbumSearchResults] = useState([]); - const [albumSearchLoading, setAlbumSearchLoading] = useState(false); - const [artists, setArtists] = useState([]); - const [loadingBrowser, setLoadingBrowser] = useState(false); - const [expandedArtistIds, setExpandedArtistIds] = useState>(new Set()); - const [artistAlbumsMap, setArtistAlbumsMap] = useState>(new Map()); - const [loadingArtistIds, setLoadingArtistIds] = useState>(new Set()); - // ─── Removable drive detection ────────────────────────────────────────── const { drives, drivesLoading, activeDrive, driveDetected, refreshDrives } = useDeviceSyncDrives(targetDir); @@ -92,50 +79,8 @@ export default function DeviceSync() { const isRunning = jobStatus === 'running'; - // ─── Device scan on mount ─────────────────────────────────────────────── - - const scanDevice = useCallback(async () => { - if (!targetDir || sources.length === 0) { - setDeviceFilePaths([]); - return; - } - setScanning(true); - try { - const files = await invoke('list_device_dir_files', { dir: targetDir }); - setDeviceFilePaths(files); - } catch { - setDeviceFilePaths([]); - } finally { - setScanning(false); - } - }, [targetDir, sources.length]); - - // Scan device on mount and when targetDir changes - useEffect(() => { scanDevice(); }, [scanDevice]); - - // Auto-import manifest when page loads and drive is already connected - const manifestImportedRef = useRef(false); - useEffect(() => { - if (!targetDir || !driveDetected || manifestImportedRef.current) return; - manifestImportedRef.current = true; - invoke<{ version: number; sources: DeviceSyncSource[] } | null>( - 'read_device_manifest', { destDir: targetDir } - ).then(manifest => { - if (manifest?.sources?.length) { - useDeviceSyncStore.getState().clearSources(); - manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); - showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); - } - }).catch(() => {}); - }, [targetDir, driveDetected, t]); - - // Clear device file list and reset import flag when stick is unplugged - useEffect(() => { - if (!driveDetected) { - setDeviceFilePaths([]); - manifestImportedRef.current = false; - } - }, [driveDetected]); + // ─── Device scan + manifest auto-import ───────────────────────────────── + const { scanDevice } = useDeviceSyncDeviceScan(targetDir, sources.length, driveDetected, t); // Source status (path map + derived synced/pending/deletion) const { sourcePathsMap, sourceStatuses } = useDeviceSyncSourceStatuses( @@ -172,129 +117,15 @@ export default function DeviceSync() { }, [sources, pendingDeletion, sourceStatuses, sourcePathsMap, deviceFilePaths, markForDeletion, removeSource, unmarkDeletion, addSource]); // ─── Listen for background sync events ────────────────────────────────── + useDeviceSyncJobEvents(t, scanDevice); - useEffect(() => { - const jobStore = useDeviceSyncJobStore.getState; - const unlistenProgress = listen<{ - jobId: string; done: number; skipped: number; failed: number; total: number; - }>('device:sync:progress', ({ payload }) => { - const current = jobStore(); - if (current.jobId && payload.jobId === current.jobId) { - useDeviceSyncJobStore.getState().updateProgress( - payload.done, payload.skipped, payload.failed - ); - } - }); - - const unlistenComplete = listen<{ - jobId: string; done: number; skipped: number; failed: number; total: number; cancelled?: boolean; - }>('device:sync:complete', ({ payload }) => { - const current = jobStore(); - if (current.jobId && payload.jobId === current.jobId) { - if (payload.cancelled) { - useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); - // status is already 'cancelled' from the button click; complete() would overwrite it — restore it - useDeviceSyncJobStore.getState().cancel(); - } else { - useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); - showToast( - t('deviceSync.syncResult', { - done: payload.done, skipped: payload.skipped, total: payload.total - }), - 5000, 'info' - ); - // Write manifest so another machine can read the synced sources from the stick - const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); - if (dir) { - invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); - // For every playlist source, write an Extended-M3U next to the - // playlist-folder tracks. Context carries the playlist name + - // per-track index so the filenames match the files we just synced. - const playlistSources = srcs.filter(s => s.type === 'playlist'); - playlistSources.forEach(async playlist => { - try { - const tracks = await fetchTracksForSource(playlist); - await invoke('write_playlist_m3u8', { - destDir: dir, - playlistName: playlist.name, - tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })), - }); - } catch { /* m3u8 failure is non-fatal — skip silently */ } - }); - } - } - // Re-scan the device after sync completes (cancelled or not) - scanDevice(); - } - }); - - return () => { - unlistenProgress.then(f => f()); - unlistenComplete.then(f => f()); - }; - }, [t, scanDevice]); - - // Load browser data when tab switches - useEffect(() => { - setSearch(''); - if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists(); - if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums(); - if (activeTab === 'artists' && artists.length === 0) loadArtists(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab]); - - // Live album search with 300ms debounce - useEffect(() => { - if (activeTab !== 'albums') return; - const q = search.trim(); - if (!q) { setAlbumSearchResults([]); return; } - setAlbumSearchLoading(true); - const timer = setTimeout(async () => { - try { - const { albums } = await searchSubsonic(q, { albumCount: 20, artistCount: 0, songCount: 0 }); - setAlbumSearchResults(albums); - } catch { - setAlbumSearchResults([]); - } finally { - setAlbumSearchLoading(false); - } - }, 300); - return () => { clearTimeout(timer); setAlbumSearchLoading(false); }; - }, [search, activeTab]); - - const loadPlaylists = useCallback(async () => { - setLoadingBrowser(true); - try { setPlaylists(await getPlaylists()); } catch { /* ignore */ } - finally { setLoadingBrowser(false); } - }, []); - const loadRandomAlbums = useCallback(async () => { - setLoadingBrowser(true); - try { setRandomAlbums(await getAlbumList('random', 10)); } catch { /* ignore */ } - finally { setLoadingBrowser(false); } - }, []); - const loadArtists = useCallback(async () => { - setLoadingBrowser(true); - try { setArtists(await getArtists()); } catch { /* ignore */ } - finally { setLoadingBrowser(false); } - }, []); - - const toggleArtistExpand = useCallback(async (artistId: string) => { - setExpandedArtistIds(prev => { - const next = new Set(prev); - if (next.has(artistId)) { next.delete(artistId); return next; } - next.add(artistId); - return next; - }); - if (!artistAlbumsMap.has(artistId)) { - setLoadingArtistIds(prev => new Set(prev).add(artistId)); - try { - const { albums } = await getArtist(artistId); - setArtistAlbumsMap(prev => new Map(prev).set(artistId, albums)); - } finally { - setLoadingArtistIds(prev => { const n = new Set(prev); n.delete(artistId); return n; }); - } - } - }, [artistAlbumsMap]); + // Browser (playlists / albums / artists tabs + their loaders + debounced search) + const { + playlists, randomAlbums, albumSearchResults, albumSearchLoading, + artists, loadingBrowser, + expandedArtistIds, artistAlbumsMap, loadingArtistIds, + toggleArtistExpand, + } = useDeviceSyncBrowser(activeTab, search, () => setSearch('')); const q = search.toLowerCase(); const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]); @@ -321,27 +152,7 @@ export default function DeviceSync() { setMigrationOldTemplate(''); }; - const handleChooseFolder = async () => { - const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') }); - if (sel) { - const dir = sel as string; - setTargetDir(dir); - // If the device has a psysonic-sync.json, always import it — replacing any - // sources from a previous device so switching sticks works correctly. - try { - const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>( - 'read_device_manifest', { destDir: dir } - ); - if (manifest?.sources?.length) { - useDeviceSyncStore.getState().clearSources(); - manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); - showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); - } - } catch { /* no manifest, that's fine */ } - // Trigger a device scan after folder change - setTimeout(() => scanDevice(), 100); - } - }; + const handleChooseFolder = () => runDeviceSyncChooseFolder({ t, setTargetDir, scanDevice }); // ─── Sync (non-blocking) ──────────────────────────────────────────────── diff --git a/src/utils/runDeviceSyncChooseFolder.ts b/src/utils/runDeviceSyncChooseFolder.ts new file mode 100644 index 00000000..f327c37a --- /dev/null +++ b/src/utils/runDeviceSyncChooseFolder.ts @@ -0,0 +1,34 @@ +import { invoke } from '@tauri-apps/api/core'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import type { TFunction } from 'i18next'; +import { useDeviceSyncStore, type DeviceSyncSource } from '../store/deviceSyncStore'; +import { showToast } from './toast'; + +export interface RunDeviceSyncChooseFolderDeps { + t: TFunction; + setTargetDir: (dir: string) => void; + scanDevice: () => Promise; +} + +export async function runDeviceSyncChooseFolder(deps: RunDeviceSyncChooseFolderDeps): Promise { + const { t, setTargetDir, scanDevice } = deps; + const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') }); + if (!sel) return; + + const dir = sel as string; + setTargetDir(dir); + // If the device has a psysonic-sync.json, always import it — replacing any + // sources from a previous device so switching sticks works correctly. + try { + const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>( + 'read_device_manifest', { destDir: dir } + ); + if (manifest?.sources?.length) { + useDeviceSyncStore.getState().clearSources(); + manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); + showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); + } + } catch { /* no manifest, that's fine */ } + // Trigger a device scan after folder change + setTimeout(() => scanDevice(), 100); +}