refactor(device-sync): G.78 — extract BrowserPanel + DevicePanel (cluster) (#645)

Two-cut cluster pulling the main layout columns out of
DeviceSync.tsx. 511 → 233 LOC (−278). DeviceSync is now mostly
glue: state hooks, hook calls, action wrappers, and a flat tree of
five layout components.

DeviceSyncBrowserPanel — left column. Owns the tabs row
(playlists / albums / artists with icons), the search input with
the "Live search" badge on the albums tab, and the result list:
loading spinner, "Random albums" section label, playlist /
album / artist rows with their BrowserRow leaf component, and the
expand-an-artist tree (loading state, chevron, child album rows
with indent). filteredPlaylists / filteredArtists memos move into
the panel since only the row mapping consumes them.

DeviceSyncDevicePanel — right column. Owns the header (title +
scanning spinner + sync action button with three label variants
+ "Delete from device" button), the status badges row
(synced / pending / deletion), the source list with checkbox /
type / status icon / per-row action (mark-for-deletion /
remove-source / undo-deletion), and the bottom progress strip
(running / cancelled / done) with their dismiss / cancel
buttons. invoke('cancel_device_sync') stays in the panel since
it's a panel-local action.

DeviceSync drops the now-unused invoke / BrowserRow / useMemo
imports (filteredPlaylists/Artists moved into the panel). Pure
code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 15:41:08 +02:00
committed by GitHub
parent c13ee5003f
commit 84c682aeb8
3 changed files with 412 additions and 267 deletions
+47 -267
View File
@@ -1,7 +1,6 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useState, useCallback, useMemo } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
HardDriveUpload, Loader2,
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock,
@@ -18,7 +17,6 @@ import {
formatBytes,
type SourceTab,
} from '../utils/deviceSyncHelpers';
import BrowserRow from '../components/deviceSync/BrowserRow';
import { useDeviceSyncDrives } from '../hooks/useDeviceSyncDrives';
import { useDeviceSyncSourceStatuses } from '../hooks/useDeviceSyncSourceStatuses';
import { useDeviceSyncBrowser } from '../hooks/useDeviceSyncBrowser';
@@ -38,6 +36,8 @@ import { runDeviceSyncChooseFolder } from '../utils/runDeviceSyncChooseFolder';
import DeviceSyncHeader from '../components/deviceSync/DeviceSyncHeader';
import DeviceSyncPreSyncModal from '../components/deviceSync/DeviceSyncPreSyncModal';
import DeviceSyncMigrationModal from '../components/deviceSync/DeviceSyncMigrationModal';
import DeviceSyncBrowserPanel from '../components/deviceSync/DeviceSyncBrowserPanel';
import DeviceSyncDevicePanel from '../components/deviceSync/DeviceSyncDevicePanel';
// ─── component ───────────────────────────────────────────────────────────────
@@ -130,10 +130,6 @@ export default function DeviceSync() {
toggleArtistExpand,
} = useDeviceSyncBrowser(activeTab, search, () => setSearch(''));
const q = search.toLowerCase();
const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]);
const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]);
// ─── Migration handlers ─────────────────────────────────────────────────
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
@@ -223,268 +219,52 @@ export default function DeviceSync() {
{/* ── Main ── */}
<div className="device-sync-main">
{/* ── Browser (left) ── */}
<div className="device-sync-browser">
<div className="device-sync-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`device-sync-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon}{tab.label}
</button>
))}
</div>
<div className="device-sync-search-wrap">
<input
className="input"
placeholder={t('deviceSync.searchPlaceholder')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
{activeTab === 'albums' && (
<span className="device-sync-live-badge">
<Zap size={10} />{t('deviceSync.liveSearch')}
</span>
)}
</div>
<div className="device-sync-list">
{(loadingBrowser || albumSearchLoading) && (
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
)}
{activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && (
<div className="device-sync-section-label">
<Shuffle size={11} />{t('deviceSync.randomAlbumsLabel')}
</div>
)}
{activeTab === 'playlists' && filteredPlaylists.map(pl => (
<BrowserRow key={pl.id} name={pl.name} meta={`${pl.songCount} tracks`}
selected={sources.some(s => s.id === pl.id) && !pendingDeletion.includes(pl.id)}
onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} />
))}
{activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.artist}
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist })} />
))}
{activeTab === 'artists' && filteredArtists.map(ar => (
<React.Fragment key={ar.id}>
<div className="device-sync-artist-row">
<button
className="device-sync-expand-btn"
onClick={() => toggleArtistExpand(ar.id)}
>
{loadingArtistIds.has(ar.id)
? <Loader2 size={13} className="spin" />
: expandedArtistIds.has(ar.id)
? <ChevronDown size={13} />
: <ChevronRight size={13} />}
</button>
<span className="device-sync-row-name">{ar.name}</span>
{ar.albumCount != null &&
<span className="device-sync-row-meta">{ar.albumCount} Albums</span>}
</div>
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) &&
artistAlbumsMap.get(ar.id)!.map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
indent
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} />
))
}
</React.Fragment>
))}
</div>
</div>
<DeviceSyncBrowserPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
search={search}
setSearch={setSearch}
playlists={playlists}
randomAlbums={randomAlbums}
albumSearchResults={albumSearchResults}
albumSearchLoading={albumSearchLoading}
artists={artists}
loadingBrowser={loadingBrowser}
expandedArtistIds={expandedArtistIds}
artistAlbumsMap={artistAlbumsMap}
loadingArtistIds={loadingArtistIds}
toggleArtistExpand={toggleArtistExpand}
sources={sources}
pendingDeletion={pendingDeletion}
handleToggleSource={handleToggleSource}
/>
{/* ── Device Manager (right) ── */}
<div className="device-sync-device-panel">
<div className="device-sync-panel-header">
<span className="device-sync-panel-title">
{t('deviceSync.onDevice')}
{scanning && <Loader2 size={12} className="spin" style={{ marginLeft: 6 }} />}
</span>
<div className="device-sync-panel-actions">
{/* Sync button */}
<button
className="btn btn-surface"
onClick={promptSyncSummary}
disabled={actionButtonDisabled}
>
{isRunning
? <><Loader2 size={13} className="spin" /> {jobDone + jobSkip + jobFail}/{jobTotal}</>
: <>
{deletionCount > 0 && pendingCount === 0
? <Trash2 size={13} />
: <HardDriveUpload size={13} />}
{actionButtonLabel}
</>
}
</button>
{/* Mark for deletion */}
{checkedIds.length > 0 && !isRunning && (
<button
className="btn btn-danger"
onClick={handleMarkCheckedForDeletion}
>
<Trash2 size={13} />
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
</button>
)}
</div>
</div>
{/* Status summary badges */}
{sources.length > 0 && driveDetected && (
<div className="device-sync-status-summary">
{syncedCount > 0 && (
<span className="device-sync-badge synced">
<CheckCircle2 size={11} /> {syncedCount} {t('deviceSync.statusSynced')}
</span>
)}
{pendingCount > 0 && (
<span className="device-sync-badge pending">
<Clock size={11} /> {pendingCount} {t('deviceSync.statusPending')}
</span>
)}
{deletionCount > 0 && (
<span className="device-sync-badge deletion">
<Trash2 size={11} /> {deletionCount} {t('deviceSync.statusDeletion')}
</span>
)}
</div>
)}
{sources.length === 0 || !driveDetected ? (
<p className="device-sync-empty">{t('deviceSync.noSourcesSelected')}</p>
) : (
<>
<div className="device-sync-list-header">
<label className="device-sync-check-label">
<input type="checkbox" checked={allChecked} onChange={toggleAll} />
</label>
<span className="device-sync-list-col-name">{t('deviceSync.colName')}</span>
<span className="device-sync-list-col-type">{t('deviceSync.colType')}</span>
<span className="device-sync-list-col-status">{t('deviceSync.colStatus')}</span>
<span className="device-sync-list-col-actions" />
</div>
<div className="device-sync-device-list">
{sources.map(s => {
const status = sourceStatuses.get(s.id) ?? 'pending';
return (
<label
key={s.id}
className={`device-sync-device-row ${status}${checkedIds.includes(s.id) ? ' checked' : ''}`}
>
<input
type="checkbox"
checked={checkedIds.includes(s.id)}
onChange={() => toggleChecked(s.id)}
disabled={status === 'deletion'}
/>
<span className="device-sync-row-name">
{s.name}
{s.artist && <span className="device-sync-row-artist"> · {s.artist}</span>}
</span>
<span className="device-sync-source-type">{s.type}</span>
<span className={`device-sync-status-icon ${status}`}>
{status === 'synced' && <CheckCircle2 size={13} />}
{status === 'pending' && <Clock size={13} />}
{status === 'deletion' && <Trash2 size={13} />}
</span>
<span className="device-sync-row-actions">
{status === 'synced' && (
<button
className="device-sync-action-btn danger"
onClick={e => { e.preventDefault(); markForDeletion([s.id]); }}
data-tooltip={t('deviceSync.markForDeletion')}
>
<Trash2 size={12} />
</button>
)}
{status === 'pending' && (
<button
className="device-sync-action-btn muted"
onClick={e => { e.preventDefault(); handleToggleSource(s); }}
data-tooltip={t('deviceSync.removeSource')}
>
<Trash2 size={12} />
</button>
)}
{status === 'deletion' && (
<button
className="device-sync-action-btn undo"
onClick={e => { e.preventDefault(); unmarkDeletion(s.id); }}
data-tooltip={t('deviceSync.undoDeletion')}
>
<Undo2 size={12} />
</button>
)}
</span>
</label>
);
})}
</div>
</>
)}
{/* Background sync progress (non-blocking) */}
{jobStatus === 'running' && (
<div className="device-sync-bg-progress">
<div className="device-sync-bg-progress-bar-wrap">
<div
className="device-sync-bg-progress-bar"
style={{ width: jobTotal > 0
? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%`
: '0%' }}
/>
</div>
<span className="device-sync-bg-progress-text">
<Loader2 size={12} className="spin" />
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
</span>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '2px 10px' }}
onClick={() => {
const jobId = useDeviceSyncJobStore.getState().jobId;
if (jobId) invoke('cancel_device_sync', { jobId });
useDeviceSyncJobStore.getState().cancel();
}}
>
{t('deviceSync.cancelSync')}
</button>
</div>
)}
{jobStatus === 'cancelled' && (
<div className="device-sync-bg-progress done">
<span className="device-sync-bg-progress-text">
<AlertCircle size={12} style={{ color: 'var(--text-muted)' }} />
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
</span>
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
{t('deviceSync.dismiss')}
</button>
</div>
)}
{jobStatus === 'done' && (
<div className="device-sync-bg-progress done">
<span className="device-sync-bg-progress-text">
<CheckCircle2 size={12} className="color-success" />
{t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })}
</span>
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
{t('deviceSync.dismiss')}
</button>
</div>
)}
</div>
<DeviceSyncDevicePanel
sources={sources}
sourceStatuses={sourceStatuses}
driveDetected={driveDetected}
scanning={scanning}
checkedIds={checkedIds}
toggleChecked={toggleChecked}
allChecked={allChecked}
toggleAll={toggleAll}
syncedCount={syncedCount}
pendingCount={pendingCount}
deletionCount={deletionCount}
isRunning={isRunning}
actionButtonLabel={actionButtonLabel}
actionButtonDisabled={actionButtonDisabled}
promptSyncSummary={promptSyncSummary}
handleMarkCheckedForDeletion={handleMarkCheckedForDeletion}
handleToggleSource={handleToggleSource}
markForDeletion={markForDeletion}
unmarkDeletion={unmarkDeletion}
jobStatus={jobStatus}
jobDone={jobDone}
jobSkip={jobSkip}
jobFail={jobFail}
jobTotal={jobTotal}
/>
</div>