import { html, useState, useEffect, useRef } from './vendor/htm-preact.js'; import { t, getLocale } from './i18n.js'; import * as platform from './platform.js'; import { formatSize } from './file-utils.js'; import { navigate, session } from './hub-client.js'; import { useStickyBand } from './sticky.js'; import { Icon } from './icon.js'; import { fromLoopback, fromPush, mergeActivity, dockRows, hideRow, percent, queuedCount, } from './index-dock-model.js'; // ── Indexing dock ─────────────────────────────────────────────────────────── // // A node asked to index a large directory works for hours, and the only sign of // it used to be a progress bar in the one Settings panel that had started it — // gone the moment the operator went anywhere else, and never shown at all when // the directory was added from another machine. This band sits above the music // bar on every page, one row per group with indexing under way; the roots of a // group are walked one after another, so a group's row names the one being // walked and the ones waiting (index-dock-model.js). // Loopback is cheap and local; a node with nothing to do is asked less often, // and one that is not there at all, rarely. const BUSY_POLL_MS = 2000; const IDLE_POLL_MS = 5000; const ABSENT_POLL_MS = 30000; // MNP pushes, reported by the group page holding the connection. Module state // rather than a prop through App: the page and the dock are far apart, and a // push every two seconds must not re-render the whole application. const pushed = new Map(); const listeners = new Set(); /** * Called by group-page.js for an operator's connection only — an ordinary * member keeps the sidebar dot. `status` is the push (or the handshake ack's * `indexing`); `roots` the roots table the page opened, which names `root_pos`. * `null` when the page lets go of the group: nothing will update the entry any * more, and a stale "scanning" would stay on screen for ever. */ export function reportIndexPush(groupId, status, roots) { if (status) pushed.set(groupId, fromPush(groupId, status, roots)); else pushed.delete(groupId); for (const fn of listeners) fn(); } function useLoopbackActivity() { const [local, setLocal] = useState({}); useEffect(() => { if (!platform.node.available) return undefined; let stopped = false; let timer = null; let last = ''; const poll = async () => { let delay = IDLE_POLL_MS; try { const data = await platform.node.call('GET', '/api/index-status'); const next = {}; for (const g of (data && data.groups) || []) next[g.group_id] = fromLoopback(g); if (Object.values(next).some((j) => j.scanning || j.queued.length)) { delay = BUSY_POLL_MS; } const text = JSON.stringify(next); if (!stopped && text !== last) { last = text; setLocal(next); } } catch { // No node on this machine, or one older than the route. if (!stopped && last !== '{}') { last = '{}'; setLocal({}); } delay = ABSENT_POLL_MS; } if (!stopped) timer = setTimeout(poll, delay); }; poll(); return () => { stopped = true; clearTimeout(timer); }; }, []); return local; } function usePushedActivity() { const [snapshot, setSnapshot] = useState(() => Object.fromEntries(pushed)); useEffect(() => { const update = () => setSnapshot(Object.fromEntries(pushed)); listeners.add(update); update(); return () => { listeners.delete(update); }; }, []); return snapshot; } function DockRow({ row, onOpen, onHide }) { const done = row.state === 'done'; const waiting = !done && !row.scanning; const listing = !done && row.scanning && !row.totalBytes; const pct = percent(row); const number = (v) => v.toLocaleString(getLocale()); const status = done ? t('indexdock.done') : waiting ? t('indexdock.waiting') : t(`indexdock.kind_${row.kind || 'scan'}`); const where = row.root ? `${row.groupName} › ${row.root}` : row.groupName; let meta = ''; if (listing) { meta = t('indexdock.listing'); } else if (!done && !waiting) { meta = [ t('indexdock.percent', { pct }), `${formatSize(row.scannedBytes)} / ${formatSize(row.totalBytes)}`, row.filesTotal ? t('indexdock.files', { done: number(row.filesDone), total: number(row.filesTotal) }) : '', ].filter(Boolean).join(' · '); } const count = queuedCount(row); const next = done || !count ? '' : Array.isArray(row.queued) ? t('indexdock.next', { roots: row.queued.join(', ') }) : t('indexdock.next_count', { count: number(count) }); const moving = listing || waiting; return html`
${!done && html` `}
`; } export function IndexingDock({ groups }) { const local = useLoopbackActivity(); const remote = usePushedActivity(); const memory = useRef({}); const [, setTick] = useState(0); // Publishes `--index-dock-h` for the sidebar, as the music bar publishes its // own; withdrawn when the dock renders nothing. const band = useStickyBand('--index-dock-h'); const jobs = mergeActivity(local, remote, groups); const { rows, memory: nextMemory } = dockRows(jobs, memory.current, Date.now()); memory.current = nextMemory; // Time alone changes what is shown — a burst crossing five seconds, a // "finished" row expiring — so while anything is on or pending, look again. const pending = Object.values(nextMemory).length > 0; useEffect(() => { if (!pending) return undefined; const id = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(id); }, [pending]); if (!rows.length) return null; return html`
${rows.map((row) => html` <${DockRow} key=${row.groupId} row=${row} onOpen=${() => { session.openGroupTab = { groupId: row.groupId, tab: 'settings' }; navigate('/group/' + row.groupId); }} onHide=${() => { memory.current = hideRow(memory.current, row.groupId); setTick((n) => n + 1); }} /> `)}
`; }