fix: scan button visual consistency and responsive cancel
All checks were successful
CI / test (push) Successful in 23s

- Unified toast variant (info) for scan start/cancel
- Added minWidth to scan buttons to prevent layout shift
- Simplified button text (Stop Scan vs Stop Scan (X Found))
- Optimistic cancel via clearScan() for instant button response
- Shows Starting… state instead of disabled during scan init
This commit is contained in:
jlightner 2026-04-08 07:10:05 +00:00
parent 3e21acfe2b
commit b633a23cfb
3 changed files with 28 additions and 10 deletions

View file

@ -19,7 +19,16 @@
"Bash(gh repo:*)", "Bash(gh repo:*)",
"Bash(git remote:*)", "Bash(git remote:*)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"Bash(npm list:*)",
"Bash(npm show:*)",
"Bash(gsd --version)",
"Bash(gsd version:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('version', 'no version field'\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")",
"Bash(gsd)",
"Read(//home/aux/.config/**)",
"Read(//home/aux/**)"
] ]
} }
} }

View file

@ -161,6 +161,8 @@ interface DownloadProgressContextValue {
scanStoreSubscribe: (listener: () => void) => () => void; scanStoreSubscribe: (listener: () => void) => () => void;
/** Get scan store snapshot */ /** Get scan store snapshot */
scanStoreGetSnapshot: () => Map<number, ScanProgress>; scanStoreGetSnapshot: () => Map<number, ScanProgress>;
/** Clear scan state for a channel (optimistic update) */
clearScan: (channelId: number) => void;
} }
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null); const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
@ -252,6 +254,7 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
isConnected, isConnected,
scanStoreSubscribe: scanStore.subscribe, scanStoreSubscribe: scanStore.subscribe,
scanStoreGetSnapshot: scanStore.getSnapshot, scanStoreGetSnapshot: scanStore.getSnapshot,
clearScan: scanStore.clearScan.bind(scanStore),
}} }}
> >
{children} {children}
@ -288,7 +291,7 @@ export function useDownloadProgressConnection(): boolean {
* Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore. * Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore.
* Only re-renders components that use this hook when the scan store changes. * Only re-renders components that use this hook when the scan store changes.
*/ */
export function useScanProgress(channelId: number): ScanProgress { export function useScanProgress(channelId: number): ScanProgress & { clearScan: () => void } {
const context = useContext(DownloadProgressContext); const context = useContext(DownloadProgressContext);
if (!context) { if (!context) {
throw new Error('useScanProgress must be used within a DownloadProgressProvider'); throw new Error('useScanProgress must be used within a DownloadProgressProvider');
@ -297,7 +300,11 @@ export function useScanProgress(channelId: number): ScanProgress {
context.scanStoreSubscribe, context.scanStoreSubscribe,
context.scanStoreGetSnapshot, context.scanStoreGetSnapshot,
); );
return scanMap.get(channelId) ?? { scanning: false, newItemCount: 0 }; return {
scanning: scanMap.get(channelId)?.scanning ?? false,
newItemCount: scanMap.get(channelId)?.newItemCount ?? 0,
clearScan: () => context.clearScan(channelId),
};
} }
// ── Cache Injection Helper ── // ── Cache Injection Helper ──

View file

@ -162,7 +162,7 @@ export function ChannelDetail() {
const updateContentRating = useUpdateContentRating(channelId); const updateContentRating = useUpdateContentRating(channelId);
// ── Scan state (WebSocket-driven) ── // ── Scan state (WebSocket-driven) ──
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId); const { scanning: scanInProgress, newItemCount: scanNewItemCount, clearScan } = useScanProgress(channelId);
// ── Local state ── // ── Local state ──
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@ -299,7 +299,7 @@ export function ChannelDetail() {
if (result.status === 'already_running') { if (result.status === 'already_running') {
toast('Scan already in progress', 'info'); toast('Scan already in progress', 'info');
} else if (result.status === 'started') { } else if (result.status === 'started') {
toast('Scan started — items will appear as they\'re found', 'success'); toast('Scan started', 'info');
} }
}, },
onError: (err) => { onError: (err) => {
@ -309,6 +309,8 @@ export function ChannelDetail() {
}, [scanChannel, toast]); }, [scanChannel, toast]);
const handleCancelScan = useCallback(() => { const handleCancelScan = useCallback(() => {
// Optimistic update: clear scan state immediately
clearScan();
cancelScan.mutate(undefined, { cancelScan.mutate(undefined, {
onSuccess: (result) => { onSuccess: (result) => {
if (result.cancelled) { if (result.cancelled) {
@ -321,7 +323,7 @@ export function ChannelDetail() {
toast(err instanceof Error ? err.message : 'Failed to cancel scan', 'error'); toast(err instanceof Error ? err.message : 'Failed to cancel scan', 'error');
}, },
}); });
}, [cancelScan, toast]); }, [cancelScan, toast, clearScan]);
const handleCollect = useCallback(() => { const handleCollect = useCallback(() => {
collectMonitored.mutate(undefined, { collectMonitored.mutate(undefined, {
@ -1034,7 +1036,7 @@ export function ChannelDetail() {
disabled={scanChannel.isPending || cancelScan.isPending} disabled={scanChannel.isPending || cancelScan.isPending}
title={scanInProgress ? 'Cancel scan' : 'Refresh & Scan'} title={scanInProgress ? 'Cancel scan' : 'Refresh & Scan'}
className={scanInProgress ? 'btn btn-danger' : 'btn btn-ghost'} className={scanInProgress ? 'btn btn-danger' : 'btn btn-ghost'}
style={{ padding: '4px 10px', fontSize: 'var(--font-size-xs)', opacity: (scanChannel.isPending || cancelScan.isPending) ? 0.6 : 1 }} style={{ padding: '4px 10px', fontSize: 'var(--font-size-xs)', minWidth: 120, opacity: (scanChannel.isPending || cancelScan.isPending) ? 0.6 : 1 }}
> >
{scanInProgress ? ( {scanInProgress ? (
<Square size={12} /> <Square size={12} />
@ -1043,7 +1045,7 @@ export function ChannelDetail() {
) : ( ) : (
<RefreshCw size={12} /> <RefreshCw size={12} />
)} )}
{scanInProgress ? `Stop (${scanNewItemCount})` : 'Scan'} {scanInProgress ? 'Stop Scan' : scanChannel.isPending ? 'Starting…' : 'Scan'}
</button> </button>
<button <button
@ -1359,7 +1361,7 @@ export function ChannelDetail() {
disabled={scanChannel.isPending || cancelScan.isPending} disabled={scanChannel.isPending || cancelScan.isPending}
title={scanInProgress ? 'Cancel scan' : 'Refresh & Scan'} title={scanInProgress ? 'Cancel scan' : 'Refresh & Scan'}
className={scanInProgress ? 'btn btn-danger' : 'btn btn-ghost'} className={scanInProgress ? 'btn btn-danger' : 'btn btn-ghost'}
style={{ opacity: (scanChannel.isPending || cancelScan.isPending) ? 0.6 : 1 }} style={{ opacity: (scanChannel.isPending || cancelScan.isPending) ? 0.6 : 1, minWidth: 140 }}
> >
{scanInProgress ? ( {scanInProgress ? (
<Square size={14} /> <Square size={14} />
@ -1368,7 +1370,7 @@ export function ChannelDetail() {
) : ( ) : (
<RefreshCw size={14} /> <RefreshCw size={14} />
)} )}
{scanInProgress ? `Stop Scan (${scanNewItemCount} found)` : 'Scan'} {scanInProgress ? 'Stop Scan' : scanChannel.isPending ? 'Starting…' : 'Scan'}
</button> </button>
<button <button
onClick={handleCollect} onClick={handleCollect}