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(git remote:*)",
"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;
/** Get scan store snapshot */
scanStoreGetSnapshot: () => Map<number, ScanProgress>;
/** Clear scan state for a channel (optimistic update) */
clearScan: (channelId: number) => void;
}
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
@ -252,6 +254,7 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
isConnected,
scanStoreSubscribe: scanStore.subscribe,
scanStoreGetSnapshot: scanStore.getSnapshot,
clearScan: scanStore.clearScan.bind(scanStore),
}}
>
{children}
@ -288,7 +291,7 @@ export function useDownloadProgressConnection(): boolean {
* Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore.
* 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);
if (!context) {
throw new Error('useScanProgress must be used within a DownloadProgressProvider');
@ -297,7 +300,11 @@ export function useScanProgress(channelId: number): ScanProgress {
context.scanStoreSubscribe,
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 ──

View file

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