@@ -250,8 +221,8 @@
Start Background Scan
const provList = document.getElementById('prov-list');
const provCrumb = document.getElementById('prov-crumb');
const provPanel = document.getElementById('prov-panel-content');
- const provScanForm = document.getElementById('prov-scan-form');
- const provScanMsg = document.getElementById('prov-scan-msg');
+ const provScanBtn = document.getElementById('prov-scan-btn');
+ const provCurrentPath = document.getElementById('prov-current-path');
const btnROC = document.getElementById('open-rocrate');
const btnROCClose = document.getElementById('close-rocrate');
const rocFrame = document.getElementById('rocrate-frame');
@@ -352,57 +323,31 @@
Start Background Scan
const size = e.type === 'folder' ? '' : fmtBytes(e.size||0);
const mod = fmtTime(e.mtime||0);
const prov = providerId;
- const chkId = `sel-${btoa((e.id||'')+':'+(e.type||''))}`;
- return `
-
- ${e.name} ${type} ${size} ${mod} ${prov} `;
+ return `
${e.name} ${type} ${size} ${mod} ${prov} `;
}).join('');
provList.innerHTML = rows || '
Empty folder. ';
attachProvHandlers();
- } catch(e){ provList.innerHTML = '
Browse failed. '; }
- }
- // Selection store (rules)
- const selectionRules = [];
- function upsertRule(action, path, recursive, node_type){
- // Remove opposite rule if exists, then add/replace
- const idx = selectionRules.findIndex(r => r.path===path && r.recursive===!!recursive && r.node_type===node_type);
- if (idx >= 0) selectionRules.splice(idx,1);
- selectionRules.push({ action, path, recursive: !!recursive, node_type });
- renderSelSummary();
- }
- function renderSelSummary(){
- const s = document.getElementById('sel-summary');
- if (!s) return;
- const inc = selectionRules.filter(r=>r.action==='include').length;
- const exc = selectionRules.filter(r=>r.action==='exclude').length;
- s.textContent = `Selection: ${inc} include, ${exc} exclude`;
+ // Update current path display and enable scan button
+ if (provCurrentPath) {
+ provCurrentPath.textContent = fullPath || '(root)';
+ }
+ if (provScanBtn) {
+ provScanBtn.disabled = false;
+ }
+ } catch(e){
+ provList.innerHTML = '
Browse failed. ';
+ if (provScanBtn) provScanBtn.disabled = true;
+ }
}
function attachProvHandlers(){
- // Select/Deselect all on page
- const selAll = document.getElementById('prov-sel-all');
- if (selAll){
- selAll.addEventListener('change', () => {
- const boxes = document.querySelectorAll('input.prov-sel');
- boxes.forEach(chk => {
- const was = chk.checked;
- chk.checked = selAll.checked;
- if (was !== selAll.checked){
- // trigger change handler to update rules
- chk.dispatchEvent(new Event('change'));
- }
- });
- });
- }
- // Row click navigates into folders; file shows details
document.querySelectorAll('tr.prov-item')?.forEach(tr => {
- tr.addEventListener('click', (ev) => {
- // Ignore clicks originating from checkbox
- if (ev.target && ev.target.closest && ev.target.closest('input.prov-sel')) return;
+ tr.addEventListener('click', () => {
const id = tr.getAttribute('data-id');
const type = tr.getAttribute('data-type');
if (type === 'folder'){
+ // For rclone, set currentPath to relative portion (strip remote prefix)
if (currentProv === 'rclone'){
const i = id.indexOf(':');
let rel = i >= 0 ? id.slice(i+1) : id;
@@ -414,23 +359,10 @@
Start Background Scan
if (provPathInput) provPathInput.value = currentPath;
browse(currentProv, currentRoot, currentPath);
} else {
- provPanel.innerHTML = `
${tr.children[1]?.textContent||id}
Path: ${id}
`;
+ provPanel.innerHTML = `
${tr.firstChild?.textContent||id}
Path: ${id}
`;
}
});
});
- // Checkbox selection → translate to include/exclude rules
- document.querySelectorAll('input.prov-sel')?.forEach(chk => {
- chk.addEventListener('change', () => {
- const id = chk.getAttribute('data-id');
- const type = chk.getAttribute('data-type');
- const isFolder = (type === 'folder');
- const recursive = isFolder;
- const node_type = isFolder ? 'folder' : 'file';
- const action = chk.checked ? 'include' : 'exclude';
- upsertRule(action, id, recursive, node_type);
- });
- });
- renderSelSummary();
}
if (provSelect){
@@ -497,80 +429,68 @@
Start Background Scan
if (chkFast) chkFast.addEventListener('change', reBrowse);
if (inpDepth) inpDepth.addEventListener('change', reBrowse);
}
- if (provScanForm){
- provScanForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- // Derive values from UI at submit-time to avoid race with async init
+
+ // Unified scan button handler - uses background tasks
+ if (provScanBtn){
+ provScanBtn.addEventListener('click', async () => {
+ // Get current browse context
const provId = (provSelect && provSelect.value) || currentProv || 'local_fs';
const rootId = (rootSelect && rootSelect.value) || currentRoot || '/';
const inputPath = (provPathInput && provPathInput.value && provPathInput.value.trim()) || '';
const relOrAbs = inputPath || currentPath || '';
- // Compose full scan target for Rclone; keep other providers untouched
+
+ // Compose full scan path
let scanPath = relOrAbs || rootId || '/';
if (provId === 'rclone') {
- scanPath = composePath(provId, rootId, relOrAbs); // e.g., "dropbox:AIPT"
+ scanPath = composePath(provId, rootId, relOrAbs);
+ }
+
+ if (!provId || !scanPath){
+ alert('Please select a provider and folder first.');
+ return;
}
- currentProv = provId;
- currentRoot = rootId;
- currentPath = relOrAbs;
- if (!provId || !scanPath){ provScanMsg.textContent = 'Select a provider and folder first.'; return; }
- const recursive = document.getElementById('prov-scan-recursive').checked;
- const chkFast = document.getElementById('prov-browse-fast-list');
- const fastList = !!(chkFast && chkFast.checked);
- const btn = provScanForm.querySelector('button[type="submit"]');
- if (btn) { btn.disabled = true; btn.textContent = 'Scanning…'; }
- provScanMsg.textContent = `Starting scan for ${scanPath}…`;
- // Add a local pseudo-task so status appears with other progress bars
- const localId = 'provscan-' + Date.now();
- const localTask = { id: localId, type: 'scan', status: 'running', path: scanPath, processed: 0, total: null, progress: 0 };
- try { (window.scidkLocalTasks||[]).push(localTask); } catch(_) { /* ignore */ }
- fetchTasks(); // trigger re-render with local task
+
+ // Populate background scan form and trigger it
+ const bgPathInput = document.getElementById('scan-path');
+ const bgRecursive = document.getElementById('scan-recursive');
+ if (bgPathInput) bgPathInput.value = scanPath;
+ if (bgRecursive) bgRecursive.checked = true;
+
+ // Trigger background scan via tasks API (same as "Start Background Scan" button)
+ const recursive = true;
+ provScanBtn.disabled = true;
+ provScanBtn.textContent = 'Starting scan...';
+
try {
- const overrideIg = !!(document.getElementById('sel-override-ignore') && document.getElementById('sel-override-ignore').checked);
- const selection = { rules: selectionRules.slice(), use_ignore: true, allow_override_ignores: overrideIg };
- const r = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ provider_id: provId, root_id: rootId||'/', path: scanPath, recursive, fast_list: fastList, selection }) });
- const ctype = (r.headers && r.headers.get('content-type')) || '';
- let j = null;
- if (ctype.includes('application/json')){
- try { j = await r.json(); } catch(_) { j = null; }
- } else {
- try { const txt = await r.text(); throw new Error(`HTTP ${r.status}: ${txt}`); } catch(e){ throw e; }
- }
- if (r.ok && j){
- const files = j.scanned || 0;
- const folders = (j.folder_count !== undefined) ? j.folder_count : undefined;
- const dur = j.duration_sec ? (Math.round(j.duration_sec*10)/10+'s') : '';
- let msg = `Scan complete: ${j.scan_id} — files: ${files}`;
- if (folders !== undefined) msg += `, folders: ${folders}`;
- if (files === 0 && folders > 0 && !recursive){ msg += ' — Only folders found. Enable Recursive to include files in subfolders.'; }
- if (dur) msg += ` (${dur})`;
- provScanMsg.textContent = msg;
- localTask.status = 'completed';
- localTask.processed = files;
- localTask.total = files || localTask.processed;
- localTask.progress = 1;
- localTask.scan_id = j.scan_id;
+ const payload = {
+ type: 'scan',
+ path: scanPath,
+ recursive,
+ provider_id: provId,
+ root_id: rootId
+ };
+ const r = await fetch('/api/tasks', {
+ method: 'POST',
+ headers: { 'Content-Type':'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (r.status === 202){
+ startPolling();
+ fetchTasks();
+ alert(`Background scan started for: ${scanPath}\nCheck progress in "Scans Summary" section below.`);
} else {
- const err = (j && (j.error||j.message)) || `HTTP ${r.status}`;
- provScanMsg.textContent = `Scan error: ${err}`;
- localTask.status = 'error';
- localTask.error = err;
- localTask.progress = 1;
+ const j = await r.json();
+ alert('Scan error: ' + (j.error || r.status));
}
} catch(err){
- provScanMsg.textContent = 'Scan error: ' + err;
- localTask.status = 'error';
- localTask.error = (err && err.message) ? err.message : String(err);
- localTask.progress = 1;
- }
- finally {
- if (btn) { btn.disabled = false; btn.textContent = 'Scan'; }
- fetchTasks();
+ alert('Scan error: ' + err);
+ } finally {
+ provScanBtn.disabled = false;
+ provScanBtn.textContent = '🔍 Scan This Folder';
}
});
}
- const btnScanWithSel = document.getElementById('btn-scan-with-selection');
- if (btnScanWithSel){ btnScanWithSel.addEventListener('click', (ev) => { ev.preventDefault(); if (provScanForm) provScanForm.dispatchEvent(new Event('submit')); }); }
// Snapshot browse logic (index-backed)
const snapScanSel = document.getElementById('snapshot-scan');
@@ -583,8 +503,6 @@
Start Background Scan
const snapNext = document.getElementById('snap-next');
const snapUseLive = document.getElementById('snap-use-live');
const snapCommit = document.getElementById('snap-commit');
- const snapReint = document.getElementById('snap-reinterpret');
- const snapRescan = document.getElementById('snap-rescan');
const snapStatus = document.getElementById('snap-status');
const snapSearchQ = document.getElementById('snap-search-q');
const snapSearchExt = document.getElementById('snap-search-ext');
@@ -595,22 +513,8 @@
Start Background Scan
const snapCrumb = document.getElementById('snap-crumb');
let snapToken = null;
let snapHistory = [];
- // cache rclone interpret settings
- let rcSettings = { suggest_mount_threshold: 400, max_files_per_batch: 1000 };
- async function loadRcSettings(){
- try{ const r = await fetch('/api/settings/rclone-interpret'); const j = await r.json(); if (r.ok){ rcSettings = { suggest_mount_threshold: Number(j.suggest_mount_threshold||400), max_files_per_batch: Number(j.max_files_per_batch||1000) }; } }
- catch(_) { /* ignore */ }
- }
- loadRcSettings();
async function loadScansForSnapshot(){
try{ const r = await fetch('/api/scans'); const scans = await r.json(); if (!snapScanSel) return; const current = snapScanSel.value; snapScanSel.innerHTML = '
-- select a recent scan below -- ' + scans.map(s => `
${s.path} — ${s.file_count} files `).join(''); if (current) snapScanSel.value = current; } catch(e) { /* ignore */ }
- // show/hide rclone banner if applicable
- try {
- const id = snapScanSel && snapScanSel.value;
- const banner = document.getElementById('rclone-banner');
- if (id){ const rs = await fetch(`/api/scans/${encodeURIComponent(id)}`); const scan = await rs.json(); const isRclone = (scan && scan.provider_id) === 'rclone'; const big = (scan && (scan.file_count||0)) >= (rcSettings.suggest_mount_threshold||400); if (banner) banner.style.display = (isRclone && big) ? '' : 'none'; }
- else { if (banner) banner.style.display = 'none'; }
- } catch(_) { /* ignore */ }
}
async function browseSnapshot(direction){
if (!snapScanSel || !snapScanSel.value){ snapList.innerHTML = '
Select a scan first. '; return; }
@@ -638,8 +542,6 @@
Start Background Scan
// Update crumb with clickable ancestors
const p = j.path || '';
let parts = [];
- const panel = document.getElementById('snap-panel');
- if (panel) { panel.style.display = 'none'; panel.innerHTML=''; }
if (p){
if (p.includes(':')){ const i = p.indexOf(':'); const rem = p.slice(0,i+1); const suff = p.slice(i+1).replace(/^\/+/, ''); const segs = suff? suff.split('/'): []; let acc = rem; parts.push({name: rem.replace(/:$/,''), path: rem}); for (let s of segs){ acc = acc + (acc.endsWith(':')? '':'/') + s; parts.push({name: s, path: acc}); }
} else { const segs = p.replace(/^\/+/, '').split('/'); let acc = ''; for (let i=0;i
Start Background Scan
// Render
const rows = entries.map(e => {
const mod = (e.modified && e.modified > 0 && e.type==='file') ? fmtTime(e.modified) : '';
- const interp = e.interpreted_as ? `${e.interpreted_as} ` : '';
- const esc = (s) => (s||'').replaceAll('&','&').replaceAll('"','"').replaceAll('<','<').replaceAll('>','>');
- const preview = e.interpretation_json ? esc((e.interpretation_json||'').slice(0,800)) : '';
- return `${e.name} ${interp} ${e.type} ${e.type==='file'?(fmtBytes(e.size||0)):''} ${mod} `;
+ return `${e.name} ${e.type} ${e.type==='file'?(fmtBytes(e.size||0)):''} ${mod} `;
}).join('');
snapList.innerHTML = rows || 'No entries. ';
snapNext.disabled = !snapToken;
snapPrev.disabled = (snapHistory.length <= 1);
- // Click to drill when folder; show details when file
+ // Click to drill when folder
document.querySelectorAll('#snap-list tr')?.forEach(tr => {
tr.addEventListener('click', () => {
const t = tr.getAttribute('data-type'); const p = tr.getAttribute('data-path');
- const panel = document.getElementById('snap-panel');
- if (t === 'folder' && snapPathInput){ snapPathInput.value = p; snapToken = null; snapHistory = []; browseSnapshot(); return; }
- if (t === 'file' && panel){
- const ias = tr.getAttribute('data-interp-as') || '';
- const ij = tr.getAttribute('data-interp-json') || '';
- let body = '';
- if (ias){ body += `Interpreted as: ${ias}
`; }
- if (ij){
- try {
- const parsed = JSON.parse(ij);
- body += `Interpretation ${JSON.stringify(parsed, null, 2)} `;
- } catch(_){ body += `Interpretation ${ij} `; }
- } else {
- body += 'No interpretation stored for this file.
';
- }
- panel.innerHTML = `Path: ${p}
${body}`;
- panel.style.display = 'block';
- }
+ if (t === 'folder' && snapPathInput){ snapPathInput.value = p; snapToken = null; snapHistory = []; browseSnapshot(); }
});
});
} catch(e){
@@ -710,7 +592,7 @@ Start Background Scan
snapSearchResults.innerHTML = rows || 'No matches.
';
} catch(e){ snapSearchResults.textContent = 'Search error: ' + e; }
}); }
- if (snapScanSel){ snapScanSel.addEventListener('change', () => { snapToken = null; snapHistory = []; browseSnapshot(); loadScansForSnapshot(); }); }
+ if (snapScanSel){ snapScanSel.addEventListener('change', () => { snapToken = null; snapHistory = []; browseSnapshot(); }); }
if (snapUseLive){ snapUseLive.addEventListener('click', () => {
const provId = (provSelect && provSelect.value) || currentProv || 'local_fs';
const rootId = (rootSelect && rootSelect.value) || currentRoot || '/';
@@ -725,71 +607,6 @@ Start Background Scan
catch(e){ snapStatus.textContent = 'Commit failed: ' + e; }
finally { snapCommit.disabled = false; snapCommit.textContent = prev; }
}); }
- if (snapRescan){ snapRescan.addEventListener('click', async () => {
- if (!snapScanSel || !snapScanSel.value){ snapStatus.textContent = 'Select a scan first.'; return; }
- const id = snapScanSel.value; snapRescan.disabled = true; const prev = snapRescan.textContent; snapRescan.textContent = 'Rescanning…'; snapStatus.textContent = '';
- try{
- // Load original scan details and stored selection
- const sd = await fetch(`/api/scans/${encodeURIComponent(id)}`);
- const scan = await sd.json();
- if (!sd.ok){ snapStatus.textContent = 'Rescan failed: ' + (scan.error || sd.status); return; }
- const cfgResp = await fetch(`/api/scans/${encodeURIComponent(id)}/config`);
- const sel = cfgResp.ok ? (await cfgResp.json()) : {};
- const payload = {
- type: 'scan',
- path: scan.path,
- recursive: !!scan.recursive,
- provider_id: scan.provider_id || 'local_fs',
- root_id: scan.root_id || '/',
- selection: sel || {}
- };
- const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
- if (r.status === 202){ snapStatus.textContent = 'Rescan started in background. Watch progress above.'; }
- else { const j = await r.json(); snapStatus.textContent = 'Rescan failed: ' + (j.error || r.status); }
- } catch(e){ snapStatus.textContent = 'Rescan failed: ' + e; }
- finally { snapRescan.disabled = false; snapRescan.textContent = prev; }
- }); }
-
- if (snapReint){ snapReint.addEventListener('click', async () => {
- if (!snapScanSel || !snapScanSel.value){ snapStatus.textContent = 'Select a scan first.'; return; }
- const id = snapScanSel.value; snapReint.disabled = true; const prev = snapReint.textContent; snapReint.textContent = 'Re-interpreting…'; snapStatus.textContent = '';
- try{
- // Fetch scan details to decide which endpoint to call
- const sd = await fetch(`/api/scans/${encodeURIComponent(id)}`);
- const scan = await sd.json();
- const providerId = (scan && scan.provider_id) || null;
- if (providerId === 'rclone'){
- // Chunked streaming interpretation for rclone scans
- const batch = rcSettings.max_files_per_batch || 1000;
- let cursor = null;
- let totalProcessed = 0, totalErrors = 0, batches = 0;
- const common = { include: ["*.txt","*.csv","*.md","*.json","*.yaml","*.yml","*.py","*.ipynb"], max_files: batch, max_size_bytes: 1048576, timeout_sec: 60, overwrite: true };
- while (true){
- const payload = cursor ? { ...common, after_rowid: cursor } : { ...common };
- const r = await fetch(`/api/interpret/scan/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
- const j = await r.json();
- if (!r.ok){ snapStatus.textContent = 'Interpret (remote) failed: ' + (j.error || r.status); break; }
- totalProcessed += (j.processed_count||0); totalErrors += (j.error_count||0); batches += 1;
- snapStatus.textContent = `Interpret (remote): batches=${batches}, processed=${totalProcessed}, errors=${totalErrors}`;
- if (!j.next_cursor){ break; }
- cursor = j.next_cursor;
- }
- browseSnapshot();
- } else {
- // Legacy local reinterpret
- const r = await fetch(`/api/scans/${encodeURIComponent(id)}/reinterpret`, { method: 'POST' });
- const j = await r.json();
- if (!r.ok){ snapStatus.textContent = 'Re-interpret failed: ' + (j.error || r.status); }
- else {
- const fs = (j.files_seen!==undefined)?`, files_seen=${j.files_seen}`:''; const fm = (j.files_matched!==undefined)?`, files_matched=${j.files_matched}`:'';
- snapStatus.textContent = `Re-interpret: updated=${j.updated}, skipped_remote=${j.skipped_remote}, not_found=${j.not_found}, errors=${j.errors}${fs}${fm}`;
- browseSnapshot();
- }
- }
- }
- catch(e){ snapStatus.textContent = 'Re-interpret failed: ' + e; }
- finally { snapReint.disabled = false; snapReint.textContent = prev; }
- }); }
// Scans dropdown and background tasks
// Recent scans dropdown helpers
@@ -813,19 +630,7 @@ Start Background Scan
}
if (refreshBtn) refreshBtn.addEventListener('click', loadScansIntoDropdown);
- const scanForm = document.getElementById('bg-scan-form');
const tasksDiv = document.getElementById('tasks-list');
- const bgUseCurrent = document.getElementById('bg-use-current');
- if (bgUseCurrent) {
- bgUseCurrent.addEventListener('click', () => {
- const provId = (provSelect && provSelect.value) || currentProv || 'local_fs';
- const rootId = (rootSelect && rootSelect.value) || currentRoot || '/';
- const relOrAbs = (provPathInput && provPathInput.value && provPathInput.value.trim()) || currentPath || '';
- const full = provId === 'rclone' ? composePath(provId, rootId, relOrAbs) : (relOrAbs || rootId || '/');
- const bgInput = document.getElementById('scan-path');
- if (bgInput) bgInput.value = full;
- });
- }
// Keep a small client-side list for local (synchronous) scans so they appear alongside server tasks
window.scidkLocalTasks = window.scidkLocalTasks || [];
const fmtPct = (x) => Math.round((x || 0) * 100);
@@ -892,8 +697,8 @@ Start Background Scan
const fmtTs = (t) => { try { return t ? new Date(Math.round(t)*1000).toLocaleString() : ''; } catch(_) { return t || ''; } };
if (!scans || scans.length === 0){ tbody.innerHTML = 'No scans yet. '; return; }
tbody.innerHTML = scans.map(s => `
- ${s.id}${s.rescan_of?` rescan `:''}
- ${s.path}${s.rescan_of?` (of ${s.rescan_of}) `:''}
+ ${s.id}
+ ${s.path}
${s.file_count||0}
${s.recursive?'yes':'no'}
${fmtTs(s.started)}
@@ -917,39 +722,20 @@ Start Background Scan
}, 1000);
}
function stopPolling(){ if (poller){ clearInterval(poller); poller = null; } }
- if (scanForm){
- scanForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const path = document.getElementById('scan-path').value.trim();
- const recursive = document.getElementById('scan-recursive').checked;
- try {
- const provId = (provSelect && provSelect.value) || currentProv || 'local_fs';
- const rootId = (rootSelect && rootSelect.value) || currentRoot || '/';
- const overrideIgSel = !!(document.getElementById('sel-override-ignore') && document.getElementById('sel-override-ignore').checked);
- const selection = { rules: selectionRules.slice(), use_ignore: true, allow_override_ignores: overrideIgSel };
- const payload = { type: 'scan', path, recursive, provider_id: provId, root_id: rootId, selection };
- const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
- if (r.status === 202){ startPolling(); fetchTasks(); }
- else { const j = await r.json(); alert('Task error: ' + (j.error || r.status)); }
- } catch(err){ alert('Task error: ' + err); }
- });
- // initial render
- startPolling(); fetchTasks(); loadScansIntoDropdown(); fetchNeoStatus();
- }
+
+ // initial render
+ startPolling(); fetchTasks(); loadScansIntoDropdown(); fetchNeoStatus();
// Scans management panel
const scansDiv = document.getElementById('scans-panel');
function scansRow(s){
const ts = Math.round(s.ended||s.started||0);
- const tag = s.rescan_of ? `rescan ` : '';
- const of = s.rescan_of ? ` (of ${s.rescan_of})` : '';
- return `
-
${s.id} ${tag} — ${s.path}${of} — files: ${s.file_count} — ${s.recursive?'recursive':'shallow'} — ${ts}
-
-
Open
-
Commit to Graph
-
Rescan
-
Delete
+ return `
+
${s.id} — ${s.path} — files: ${s.file_count} — ${s.recursive?'recursive':'shallow'} — ${ts}
+
+
Open
+
Commit to Graph
+
Delete
`;
}
@@ -975,11 +761,6 @@
Start Background Scan
} else if (action === 'delete'){
const r = await fetch('/api/scans/' + encodeURIComponent(id), { method: 'DELETE' });
if (!r.ok){ const j = await r.json(); alert('Delete failed: ' + (j.error || r.status)); }
- } else if (action === 'rescan'){
- const r = await fetch('/api/scans/' + encodeURIComponent(id) + '/rescan', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({}) });
- const j = await r.json();
- if (!r.ok){ alert('Rescan failed: ' + (j.error || r.status)); }
- else { alert('Rescan started: ' + (j.scan_id || '(ok)')); }
}
} catch(err){ alert('Action failed: ' + err); }
renderScansPanel();
diff --git a/tests/test_files_page_e2e.py b/tests/test_files_page_e2e.py
new file mode 100644
index 0000000..93db2ea
--- /dev/null
+++ b/tests/test_files_page_e2e.py
@@ -0,0 +1,307 @@
+"""
+End-to-end tests for Files page UX workflows.
+
+Validates the consolidated scan functionality and browser-to-scan integration.
+"""
+import os
+import time
+from pathlib import Path
+import pytest
+from bs4 import BeautifulSoup
+
+
+def test_files_page_loads_successfully():
+ """Test that the Files page loads without errors."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ resp = client.get('/datasets')
+ assert resp.status_code == 200
+ assert b'Files' in resp.data
+ assert b'Provider' in resp.data
+
+
+def test_scan_button_uses_background_tasks_only():
+ """Verify that the scan button uses /api/tasks, not /api/scan."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ resp = client.get('/datasets')
+ assert resp.status_code == 200
+
+ # Check that the template has the new unified scan button
+ html = resp.data.decode('utf-8')
+ assert 'prov-scan-btn' in html
+ assert '🔍 Scan This Folder' in html
+
+ # Check that the old sync scan form is removed
+ assert 'prov-scan-form' not in html
+ assert 'prov-scan-recursive' not in html # old checkbox removed
+
+
+def test_browse_and_scan_integration(tmp_path: Path):
+ """Test the full workflow: browse folder → scan it via background task."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ # Create test directory
+ test_dir = tmp_path / 'test_project'
+ test_dir.mkdir()
+ (test_dir / 'file1.txt').write_text('content1', encoding='utf-8')
+ (test_dir / 'file2.txt').write_text('content2', encoding='utf-8')
+ (test_dir / 'subdir').mkdir()
+ (test_dir / 'subdir' / 'file3.txt').write_text('content3', encoding='utf-8')
+
+ with app.test_client() as client:
+ # Browse the directory
+ browse_resp = client.get(f'/api/browse?provider_id=local_fs&root_id=/&path={str(test_dir)}')
+ assert browse_resp.status_code == 200
+ browse_data = browse_resp.get_json()
+ assert 'entries' in browse_data
+ assert len(browse_data['entries']) >= 3 # 2 files + 1 subdir
+
+ # Trigger scan via background task (unified mechanism)
+ scan_resp = client.post('/api/tasks', json={
+ 'type': 'scan',
+ 'path': str(test_dir),
+ 'recursive': True,
+ 'provider_id': 'local_fs',
+ 'root_id': '/'
+ })
+ assert scan_resp.status_code == 202 # Accepted
+ scan_data = scan_resp.get_json()
+ assert 'task_id' in scan_data
+ task_id = scan_data['task_id']
+
+ # Poll for task completion (max 10 seconds)
+ max_wait = 10
+ start_time = time.time()
+ task_completed = False
+
+ while time.time() - start_time < max_wait:
+ task_resp = client.get(f'/api/tasks/{task_id}')
+ assert task_resp.status_code == 200
+ task_data = task_resp.get_json()
+
+ if task_data['status'] == 'completed':
+ task_completed = True
+ assert task_data['processed'] >= 3
+ break
+
+ time.sleep(0.5)
+
+ assert task_completed, "Scan task did not complete in time"
+
+
+def test_scan_history_unified_display(tmp_path: Path):
+ """Test that all scans appear in unified history."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ test_dir = tmp_path / 'scan_test'
+ test_dir.mkdir()
+ (test_dir / 'test.txt').write_text('test', encoding='utf-8')
+
+ with app.test_client() as client:
+ # Create first scan
+ resp1 = client.post('/api/tasks', json={
+ 'type': 'scan',
+ 'path': str(test_dir),
+ 'recursive': True,
+ 'provider_id': 'local_fs',
+ 'root_id': '/'
+ })
+ assert resp1.status_code == 202
+
+ time.sleep(1) # Allow scan to process
+
+ # Get all scans
+ scans_resp = client.get('/api/scans')
+ assert scans_resp.status_code == 200
+ scans = scans_resp.get_json()
+ assert isinstance(scans, list)
+ assert len(scans) >= 1
+
+ # Verify scan appears in summary
+ found = any(s.get('path') == str(test_dir) for s in scans)
+ assert found, "Scan not found in unified history"
+
+
+def test_rclone_scan_with_options():
+ """Test that rclone-specific options are handled correctly."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ # Mock rclone scan with fast-list option
+ # Note: This will fail in test without actual rclone, but validates API contract
+ resp = client.post('/api/tasks', json={
+ 'type': 'scan',
+ 'path': 'dropbox:test',
+ 'recursive': True,
+ 'provider_id': 'rclone',
+ 'root_id': 'dropbox:',
+ 'fast_list': True
+ })
+
+ # Should accept the request format (will fail on execution without rclone)
+ assert resp.status_code in (202, 400, 500) # 202 if rclone available, error otherwise
+
+
+def test_snapshot_browser_after_scan(tmp_path: Path):
+ """Test viewing scan snapshot after completion."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ test_dir = tmp_path / 'snapshot_test'
+ test_dir.mkdir()
+ (test_dir / 'data.csv').write_text('col1,col2\n1,2\n', encoding='utf-8')
+
+ with app.test_client() as client:
+ # Perform scan
+ scan_resp = client.post('/api/tasks', json={
+ 'type': 'scan',
+ 'path': str(test_dir),
+ 'recursive': True,
+ 'provider_id': 'local_fs',
+ 'root_id': '/'
+ })
+ assert scan_resp.status_code == 202
+ task_id = scan_resp.get_json()['task_id']
+
+ # Wait for completion
+ max_wait = 10
+ start_time = time.time()
+ scan_id = None
+
+ while time.time() - start_time < max_wait:
+ task_resp = client.get(f'/api/tasks/{task_id}')
+ task_data = task_resp.get_json()
+
+ if task_data['status'] == 'completed':
+ scan_id = task_data.get('scan_id')
+ break
+
+ time.sleep(0.5)
+
+ assert scan_id is not None, "Scan did not complete"
+
+ # Browse snapshot
+ snapshot_resp = client.get(f'/api/scans/{scan_id}/browse')
+ assert snapshot_resp.status_code == 200
+ snapshot_data = snapshot_resp.get_json()
+
+ assert 'entries' in snapshot_data
+ # Should find the data.csv file or parent folder
+ assert len(snapshot_data['entries']) >= 1
+
+
+def test_no_synchronous_scan_in_ui():
+ """Verify that synchronous /api/scan is NOT used by the Files page UI."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ resp = client.get('/datasets')
+ html = resp.data.decode('utf-8')
+
+ # Check that the JavaScript does NOT call /api/scan from provider panel
+ # (it should only use /api/tasks)
+ assert "'/api/scan'" not in html or html.count("'/api/scan'") <= 1
+ # Allow one mention in comments/strings, but not active code
+
+ # Verify /api/tasks is used instead
+ assert "'/api/tasks'" in html
+
+
+def test_current_location_display_updates():
+ """Test that the 'Current Location' panel updates when browsing."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ resp = client.get('/datasets')
+ html = resp.data.decode('utf-8')
+
+ # Check that current location display exists
+ assert 'prov-current-path' in html
+ assert 'Current Location:' in html
+
+ # Verify scan button is present and starts disabled
+ assert 'prov-scan-btn' in html
+ assert 'disabled' in html # Button should start disabled
+
+
+def test_scan_button_integration_with_background_form():
+ """Test that clicking scan button populates background scan form."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ resp = client.get('/datasets')
+ html = resp.data.decode('utf-8')
+
+ # Verify the scan button handler references background scan form elements
+ assert 'scan-path' in html # Background scan path input
+ assert 'scan-recursive' in html # Background scan recursive checkbox
+
+ # The JavaScript should populate these when scan button is clicked
+ # (Verified by manual testing and code inspection)
+
+
+def test_files_page_structure_consolidated():
+ """Verify that redundant sections have been removed/consolidated."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ resp = client.get('/datasets')
+ html = resp.data.decode('utf-8')
+ soup = BeautifulSoup(html, 'html.parser')
+
+ # Count h2 headings (main sections)
+ sections = soup.find_all('h2')
+ section_titles = [s.get_text() for s in sections]
+
+ # Should have core sections: Files, Snapshot browse, Scans Summary
+ assert 'Files' in section_titles
+ assert 'Snapshot (scanned) browse' in section_titles or 'Snapshot browse' in section_titles
+ assert 'Scans Summary' in section_titles
+
+ # Verify old sync scan form is gone
+ old_form = soup.find('form', id='prov-scan-form')
+ assert old_form is None, "Old synchronous scan form still present"
+
+
+def test_provider_selector_and_roots_load():
+ """Test that providers and roots load correctly."""
+ from scidk.app import create_app
+ app = create_app()
+ app.config['TESTING'] = True
+
+ with app.test_client() as client:
+ # Get providers
+ prov_resp = client.get('/api/providers')
+ assert prov_resp.status_code == 200
+ providers = prov_resp.get_json()
+ assert isinstance(providers, list)
+ assert len(providers) > 0
+
+ # Get roots for first provider
+ first_prov = providers[0]['id']
+ roots_resp = client.get(f'/api/provider_roots?provider_id={first_prov}')
+ assert roots_resp.status_code == 200
+ roots = roots_resp.get_json()
+ assert isinstance(roots, list)