diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 159c9d4..3f59fca 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -265,11 +265,44 @@ margin-left: 1.5rem; } +/* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */ +.empty-state--overlay { overflow-y: auto; } +.welcome { max-width: 900px; padding: 1.5rem 0.5rem 2.5rem; } +.welcome__title { font-size: 2.6rem; line-height: 1.1; margin: 0 0 0.6rem; } +.welcome__lede { + font-size: 1.2rem; line-height: 1.55; color: var(--text); + margin: 0 auto 2rem; max-width: 62ch; +} +.welcome__methods { + display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; + margin: 1.75rem 0 0; text-align: left; +} +@media (max-width: 780px) { .welcome__methods { grid-template-columns: 1fr; } } +.method { + border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem 1.15rem; background: var(--bg); +} +.method--primary { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); } +.method__title { font-size: 1.1rem; margin: 0 0 0.5rem; } +.method__tag { + display: inline-block; font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.04em; color: var(--primary); + margin-left: 0.4rem; vertical-align: middle; +} +.method__tag--warn { color: var(--warning); } +.method__what { font-size: 0.95rem; color: var(--text-muted); margin: 0 0 0.7rem; } +.method__steps { margin: 0; padding-left: 1.25rem; font-size: 0.95rem; line-height: 1.6; } +.method__steps li { margin: 0.35rem 0; } +.method__steps code { + background: var(--bg-secondary); padding: 0.05rem 0.35rem; + border-radius: 4px; font-size: 0.85em; +} +.welcome__note { font-size: 0.9rem; color: var(--text-muted); margin-top: 1.5rem; } + /* ── Workspaces (welcome manager) ──────────────────────────────────────── */ -.workspaces { text-align: left; margin: 1rem 0; } +.workspaces { text-align: left; margin: 1.5rem 0 0.5rem; } .ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } -.ws-head h3 { margin: 0; font-size: 1.05rem; } -.ws-intro { font-size: 0.85rem; color: var(--text-muted); margin: 0.4rem 0 0.75rem; } +.ws-head h2 { margin: 0; font-size: 1.4rem; } .ws-list { display: flex; flex-direction: column; gap: 0.4rem; } .ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); } .ws-row { diff --git a/classifier/js/scanner.js b/classifier/js/scanner.js index e011501..7ab7dcd 100644 --- a/classifier/js/scanner.js +++ b/classifier/js/scanner.js @@ -764,6 +764,12 @@ if (n.files && n.files.length) o.f = n.files.map(serFile); var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; }); if (realKids.length) o.c = realKids.map(serNode); + // Record scan progress so an interrupted scan can resume: 'children' + // = direct entries fully read (kids may still be pending); anything + // unfinished (pending/scanning/zip) → 'pending' to re-read. 'done' + // is the default and omitted. + var st = n.scanState; + if (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending'; return o; } return (window.app.folderTree || []).map(serNode); @@ -786,7 +792,7 @@ function deNode(sn, parent) { var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent); node.handle = null; - node.scanState = 'done'; + node.scanState = sn.s || 'done'; // 'pending'/'children' resume on reconnect node.expanded = false; node.files = (sn.f || []).map(deFile); node.children = (sn.c || []).map(function (c) { return deNode(c, node); }); @@ -830,6 +836,46 @@ return h; } + // Resume an interrupted scan: walk the loaded tree for 'pending' folders, + // resolve their handles from the (reconnected) root, and drain only those — + // already-scanned folders are left alone. Returns true if work was done. + async function resumeScan(rootHandle) { + if (!rootHandle) return false; + var pend = []; + (function walk(ns) { + (ns || []).forEach(function (n) { + if (n.scanState === 'pending') pend.push(n); + else walk(n.children); + }); + })(window.app.folderTree || []); + if (!pend.length) return false; + + var myGen = ++scanGen; + zipCache.clear(); + scanStats = { folders: 0, files: 0, current: '', done: false, startedAt: Date.now() }; + var ticker = setInterval(function () { + if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; } + updateScanStatus(); + }, 1000); + + for (var i = 0; i < pend.length; i++) { + try { pend[i].handle = await resolveDirHandle(rootHandle, relFromRoot(pend[i].path)); } + catch (e) { pend[i].scanState = 'done'; reportScanError(pend[i].path, e); } + } + await drainQueue(pend.filter(function (n) { return n.handle; }), myGen, SCAN_CONCURRENCY); + + clearInterval(ticker); + if (myGen !== scanGen) return true; + scanStats.done = true; + scanStats.current = ''; + flushRender(); + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast('Resumed scan complete — ' + scanStats.folders + ' folders, ' + + scanStats.files + ' files added in ' + elapsedStr() + '.', 'success'); + } + return true; + } + // Export module window.app.modules.scanner = { scanDirectory, @@ -839,7 +885,8 @@ snapshotTree, loadSnapshot, resolveFileHandle, - resolveDirHandle + resolveDirHandle, + resumeScan }; })(); diff --git a/classifier/js/workspace.js b/classifier/js/workspace.js index 43fe11a..b088557 100644 --- a/classifier/js/workspace.js +++ b/classifier/js/workspace.js @@ -13,7 +13,8 @@ var els = {}; var initialized = false; var activeId = null; - var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary} + var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary} + var activeStoredHandle = null; // the workspace's persisted source dir handle function P() { return window.app.modules.persist; } function C() { return window.app.modules.classify; } @@ -33,6 +34,7 @@ list: document.getElementById('workspaceList'), newBtn: document.getElementById('newWorkspaceBtn'), wsBtn: document.getElementById('workspacesBtn'), + connectBtn: document.getElementById('connectDirBtn'), }; if (!P() || !P().available) { // No IndexedDB → hide the workspace UI; legacy rename path still works. @@ -42,6 +44,7 @@ } if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace); if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome); + if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); }); if (els.list) els.list.addEventListener('click', onListClick); // Autosave the active workspace whenever the map changes. @@ -139,23 +142,27 @@ try { dir = await window.showDirectoryPicker(); } catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; } - window.app.rootHandle = dir; - window.app.modules.app.enterAppShell(); - // The one slow pass: a full scan (then never again for this workspace). - await window.app.modules.scanner.scanDirectory(dir); - var name = prompt('Name this workspace:', dir.name); if (name === null) name = dir.name; name = name.trim() || dir.name; - activeId = uid(); - activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: summary() }; - await P().putWorkspace(activeMeta, { - id: activeId, rootHandle: dir, - tree: window.app.modules.scanner.snapshotTree(), - classify: C().serialize(), - }); + window.app.rootHandle = dir; + activeStoredHandle = dir; + window.app.modules.app.enterAppShell(); window.app.modules.app.setMode('classify'); + hideWelcome(); + + activeId = uid(); + activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } }; + // Create the record UP FRONT so an interrupted scan survives and resumes. + await saveSnapshotFull(); + updateConnectUI(); + + // Periodically persist the partial snapshot during the (slow) scan, so an + // interruption resumes from where it left off instead of starting over. + var iv = setInterval(saveSnapshotFull, 5000); + try { await window.app.modules.scanner.scanDirectory(dir); } + finally { clearInterval(iv); saveSnapshotFull(); } } async function openWorkspace(id) { @@ -166,12 +173,79 @@ activeId = id; activeMeta = meta; - window.app.rootHandle = rec.rootHandle || null; + activeStoredHandle = rec.rootHandle || null; + window.app.rootHandle = null; // not connected until reconnect window.app.modules.app.enterAppShell(); window.app.modules.scanner.loadSnapshot(rec.tree || []); C().load(rec.classify || {}); window.app.modules.app.setMode('classify'); hideWelcome(); + + // Offer to reconnect the source directory (needed to preview, copy, or + // finish an interrupted scan). Silent if permission is already granted. + await tryReconnect(true); + updateConnectUI(); + } + + // Persist the full workspace (meta + snapshot + map + source handle). + function saveSnapshotFull() { + if (!activeId || !activeMeta) return Promise.resolve(); + activeMeta.updatedAt = now(); + activeMeta.summary = summary(); + return P().putWorkspace(activeMeta, { + id: activeId, + rootHandle: window.app.rootHandle || activeStoredHandle || null, + tree: window.app.modules.scanner.snapshotTree(), + classify: C().serialize(), + }); + } + + // Connect (or reconnect) the source directory. silentOnly=true never shows a + // permission prompt or picker — it only adopts an already-granted handle and + // otherwise nudges the user to click "Connect directory". + async function tryReconnect(silentOnly) { + var h = activeStoredHandle; + if (h && typeof h.queryPermission === 'function') { + var p = 'denied'; + try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ } + if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); } + if (!silentOnly) { + var p2 = 'denied'; + try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ } + if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); } + } + } + if (silentOnly) { + if (!window.app.rootHandle && activeId) { + window.zddc.toast('This workspace’s source directory isn’t connected — click “Connect directory” to preview, copy, or finish scanning.', 'info', { durationMs: 8000 }); + } + return false; + } + // Explicit: no usable stored handle (or permission denied) → let the user pick. + if (!window.showDirectoryPicker) { window.zddc.toast('Connecting a directory needs the File System Access API.', 'error'); return false; } + try { + var picked = await window.showDirectoryPicker(); + window.app.rootHandle = picked; + activeStoredHandle = picked; + return afterConnect(); + } catch (e) { + if (e.name !== 'AbortError') window.zddc.toast('Could not connect directory — ' + (e.message || e), 'error'); + return false; + } + } + + async function afterConnect() { + updateConnectUI(); + // Resume any still-pending folders now that we have the handle. + var did = await window.app.modules.scanner.resumeScan(window.app.rootHandle); + saveSnapshotFull(); // persist refreshed snapshot + the (re-granted) handle + return true; + } + + function updateConnectUI() { + if (!els.connectBtn) return; + var show = !!activeId && !window.app.rootHandle; + els.connectBtn.hidden = !show; } function renameWorkspace(id) { diff --git a/classifier/template.html b/classifier/template.html index c53be4f..290f85c 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -35,6 +35,7 @@ +