feat(device-sync): manifest, cancel, font picker + sync status fix

- Write psysonic-sync.json to device after sync/deletions; auto-import
  on folder select when localStorage is empty (cross-platform handoff)
- Add cancel button during active sync: AtomicBool flag per job,
  tasks bail after acquiring semaphore, cancelled state in UI
- Fix sync status staying "pending": normalize template path separators
  to OS separator (Windows: '/' → '\') so compute_sync_paths and
  list_device_dir_files produce matching strings for Set comparison
- Add Geist and JetBrains Mono as variable fonts; font picker collapsible
- Fix device mount detection on Windows: removable drive letters (E:\)
  were incorrectly skipped alongside system roots; strip \?\ prefix
  from canonicalized paths before mount-point comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-15 22:59:27 +02:00
parent e23280c013
commit 442ed9d191
16 changed files with 255 additions and 46 deletions
+59 -12
View File
@@ -251,20 +251,27 @@ export default function DeviceSync() {
});
const unlistenComplete = listen<{
jobId: string; done: number; skipped: number; failed: number; total: number;
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) {
useDeviceSyncJobStore.getState().complete(
payload.done, payload.skipped, payload.failed
);
showToast(
t('deviceSync.syncResult', {
done: payload.done, skipped: payload.skipped, total: payload.total
}),
5000, 'info'
);
// Re-scan the device after sync completes
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(() => {});
}
// Re-scan the device after sync completes (cancelled or not)
scanDevice();
}
});
@@ -344,7 +351,21 @@ export default function DeviceSync() {
const handleChooseFolder = async () => {
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
if (sel) {
setTargetDir(sel as string);
const dir = sel as string;
setTargetDir(dir);
// If the device has a psysonic-sync.json and localStorage has no sources yet,
// auto-import so the list is populated when switching machines.
if (useDeviceSyncStore.getState().sources.length === 0) {
try {
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
'read_device_manifest', { destDir: dir }
);
if (manifest?.sources?.length) {
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);
}
@@ -401,6 +422,9 @@ export default function DeviceSync() {
await invoke<number>('delete_device_files', { paths: allPaths });
removeSources(deletionSources.map(s => s.id));
// Update manifest so it stays in sync after deletions
const remainingSources = useDeviceSyncStore.getState().sources;
if (targetDir) invoke('write_device_manifest', { destDir: targetDir, sources: remainingSources }).catch(() => {});
showToast(
t('deviceSync.deleteComplete', { count: deletionSources.length }),
3000, 'info'
@@ -815,6 +839,29 @@ export default function DeviceSync() {
{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>
)}