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();