diff --git a/public/index.html b/public/index.html index e520c0d..2741869 100644 --- a/public/index.html +++ b/public/index.html @@ -3815,7 +3815,10 @@

Portable session

*/ function beginScanChrome({ quiet = false } = {}) { state.scanning = true; - $('scanBtn').disabled = true; + $('scanBtn').disabled = false; + $('scanBtn').classList.remove('btn-primary'); + $('scanBtn').classList.add('btn-danger'); + $('scanBtn').innerHTML = icon('x', 16) + 'Stop'; $('headerSpin').classList.add('on'); $('headerSpin').innerHTML = icon('loader', 15, REDUCED ? '' : 'spin'); $('progressTrack').classList.add('active'); @@ -4096,6 +4099,9 @@

Portable session

function endScanChrome() { state.scanning = false; $('scanBtn').disabled = false; + $('scanBtn').classList.remove('btn-danger'); + $('scanBtn').classList.add('btn-primary'); + $('scanBtn').innerHTML = icon('play', 16) + 'Scan'; $('headerSpin').classList.remove('on'); $('progressTrack').classList.remove('active'); $('scanMeta').textContent = ''; @@ -4332,6 +4338,14 @@

Portable session

} $('scanBtn').addEventListener('click', () => { + if (state.scanning) { + if (state.scanId) { + api(`/api/scan/${state.scanId}/cancel`, { method: 'POST' }).catch(() => {}); + closeEventSource(); + failScan('Scan stopped by user'); + } + return; + } const p = $('pathInput').value.trim(); if (!p) { toast('Enter a folder path first', 'error'); return; } startScan(p, { incremental: !$('fastRescanWrap').hidden && $('fastRescan').checked }); diff --git a/src/api/openapi.ts b/src/api/openapi.ts index fecb6fb..60e7b2c 100644 --- a/src/api/openapi.ts +++ b/src/api/openapi.ts @@ -671,6 +671,18 @@ export const ENDPOINTS: EndpointDescriptor[] = [ '404': errorResponse('Path does not exist'), }, }, + { + method: 'post', + path: '/api/scan/{scanId}/cancel', + summary: 'Cancel a running scan', + tag: 'scan', + destructive: false, + parameters: [pathParam('scanId', 'Scan id')], + responses: { + '200': jsonResponse('Scan cancellation requested', obj({ scanId: str(), cancelled: bool() }, ['scanId', 'cancelled'])), + '404': errorResponse('Unknown scanId'), + }, + }, { method: 'get', path: '/api/scan/{scanId}/progress', diff --git a/src/api/scanRoutes.ts b/src/api/scanRoutes.ts index 590d790..454c74b 100644 --- a/src/api/scanRoutes.ts +++ b/src/api/scanRoutes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { startScan, getScan, collectLargestFiles, collectFileTypes } from '../services/diskScanner'; +import { startScan, getScan, cancelScan, collectLargestFiles, collectFileTypes } from '../services/diskScanner'; import { buildTreemapFromStore } from '../utils/treemap'; import { pruneTree, PruneResult } from '../utils/pruneTree'; import { isInside } from '../utils/pathSanitizer'; @@ -163,6 +163,12 @@ scanRouter.post('/scan', guardBodyPath, async (req: Request, res: Response) => { res.status(202).json({ scanId: scan.scanId, incremental: scan.incremental === true }); }); +/** POST /api/scan/:scanId/cancel — stop a running scan */ +scanRouter.post('/scan/:scanId/cancel', (req: Request, res: Response) => { + const ok = cancelScan(String(req.params.scanId)); + res.json({ scanId: req.params.scanId, cancelled: ok }); +}); + /** GET /api/scan/:scanId/progress — Server-Sent Events stream. */ scanRouter.get('/scan/:scanId/progress', (req: Request, res: Response) => { const scan = requireScan(req, req.params.scanId); diff --git a/src/services/diskScanner.ts b/src/services/diskScanner.ts index 661d1b8..5cd21af 100644 --- a/src/services/diskScanner.ts +++ b/src/services/diskScanner.ts @@ -117,6 +117,18 @@ export function cancelAllScans(): void { for (const scan of scans.values()) scan.cancelled = true; } +export function cancelScan(scanId: string): boolean { + const scan = scans.get(scanId); + if (scan && scan.status === 'running') { + scan.cancelled = true; + scan.status = 'error'; + scan.error = 'Scan cancelled by user'; + scan.finishedAt = Date.now(); + return true; + } + return false; +} + /** * Compatibility accessor: every production scan's tree lives in `scan.store`, * and no production code path reads or writes `scan.root` (the bounded @@ -425,7 +437,12 @@ async function walk(scan: ScanResult, rootIsDir: boolean, ignore: CompiledIgnore // before their cached listing is trusted — membership is per-scan. await drainQueue(scan, store, [{ id: store.rootId, path: scan.rootPath, cached: null, revalidate: false }], ignore, cache, new Set()); } - if (scan.cancelled) return; + if (scan.cancelled) { + scan.status = 'error'; + scan.error = scan.error ?? 'Scan cancelled by user'; + scan.finishedAt = Date.now(); + return; + } store.finalize(); store.sumSizes();