summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 11:17:42 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-14 11:17:42 +0200
commit2fb66c5baecdd8b49be904f6244f66ba04f68061 (patch)
tree3b80940e1819a811f6ec2c04831e3616a74a8eae
parenta294c1d338ba4c20d66873d593d1c101e69c5a40 (diff)
downloadmeshbay-2fb66c5baecdd8b49be904f6244f66ba04f68061.tar.gz
feat(ui): an indexing dock above the music bar, on every page
Adding a large directory left the operator nothing to look at once they left the Settings panel that started it, and nothing at all when it was added from another machine. A band now sits above the music bar on every page: one row per group with indexing under way, naming the root being walked, percent, bytes and files, and the roots waiting their turn; "indexing finished" for a few seconds at the end. A click opens the group's Settings, and × hides the row until that group is idle. Two sources feed it. On the node's own machine the desktop client polls the loopback `GET /api/index-status` for every group, whatever the route. An operator's group page forwards MNP `index_progress` pushes, resolving the root from the roots table it opened; an ordinary member keeps the sidebar dot only, and a page clears its row when it lets go of the group. Where both describe a group, loopback wins. Reconcile passes and watchdog bursts show only past 1 GB or 5 s, so a single dropped file does not flash a bar. The logic lives in index-dock-model.js, which has no imports and is tested under node. The dock publishes `--index-dock-h` and the sidebar stops above it and the music bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js126
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock.js182
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css99
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js8
-rw-r--r--packages/meshbay-hub/tests/test_indexing_dock.py193
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_legal_measured.py43
18 files changed, 797 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 188b389..ee0ab07 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -18,6 +18,7 @@ import {
import { GroupPage } from './group-page.js';
import { SearchPage, ConnectionPool } from './search-page.js';
import { MusicPlayerBar } from './music-player.js';
+import { IndexingDock } from './index-dock.js';
import { SettingsPage } from './settings-page.js';
import { ProfilePage } from './profile-page.js';
import { ExplorePage } from './explore-page.js';
@@ -1094,6 +1095,7 @@ function App() {
${page}
</main>
</div>
+ ${user && html`<${IndexingDock} groups=${groups} />`}
${musicQueue && html`
<${MusicPlayerBar}
getConnection=${getMusicConnection}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 18d3a8f..0f28adf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -15,6 +15,7 @@ import { useStickyBand } from './sticky.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
import { GroupSettingsPanel } from './group-settings.js';
+import { reportIndexPush } from './index-dock.js';
/**
* The group shell: everything a group's "applications" (Chat, Files, and
@@ -96,6 +97,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// unplugged keeps its files listed — they are frozen, not deleted — so this is
// the only thing that lets the UI say which of the two it is.
const [nodeRoots, setNodeRoots] = useState([]);
+ // Read by the index_progress handler, which connect() installs once.
+ const nodeRootsRef = useRef(nodeRoots);
+ nodeRootsRef.current = nodeRoots;
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
// Which applications this group has enabled, from the node. Falls back to
@@ -430,11 +434,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// False transition (daemon.py _progress_pusher), so this always
// settles back to 'online' rather than getting stuck.
transport.onIndexProgress = (status) => {
- if (cancelled || !onPresence) return;
- const pct = status.total_bytes
- ? Math.min(100, Math.round(100 * status.scanned_bytes / status.total_bytes))
- : 0;
- onPresence(groupId, status.scanning ? 'indexing' : 'online', pct);
+ if (cancelled) return;
+ if (onPresence) {
+ const pct = status.total_bytes
+ ? Math.min(100, Math.round(100 * status.scanned_bytes / status.total_bytes))
+ : 0;
+ onPresence(groupId, status.scanning ? 'indexing' : 'online', pct);
+ }
+ // The operator's indexing dock. The push names no root, so the
+ // page that opened the roots table names it.
+ if (transport.memberRole === 'operator') {
+ reportIndexPush(groupId, status, nodeRootsRef.current);
+ }
};
setOperatorPaired(transport.memberRole === 'operator');
@@ -503,6 +514,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onPresence(groupId, 'online');
}
}
+ if (transport.memberRole === 'operator' && ack.indexing) {
+ reportIndexPush(groupId, ack.indexing, indexMsg.roots || nodeRootsRef.current);
+ }
} catch (err) {
if (cancelled) return;
@@ -554,6 +568,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
return () => {
cancelled = true;
+ // Nothing will update this group's dock row once the page lets go of it.
+ reportIndexPush(groupId, null);
if (transportRef.current) {
// Handed over rather than closed: a download running when you leave the
// group keeps its connection, and the last transfer using it closes it.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
new file mode 100644
index 0000000..bd4586b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
@@ -0,0 +1,126 @@
+/**
+ * What the indexing dock shows, worked out from what nodes report. No DOM and no
+ * imports, so tests/test_indexing_dock.py runs this file under node as it is.
+ *
+ * Two sources describe a group's indexing:
+ *
+ * - The loopback API (`GET /api/index-status`, the node's ui/app.py) answers on
+ * the node's own machine, for every group it hosts, whatever page is open,
+ * and names the roots.
+ * - MNP `index_progress` pushes reach an operator anywhere, but only while that
+ * group's page holds a connection, and carry counters only (decision D3): the
+ * root under way is a position in the roots table the page already opened,
+ * and the roots waiting are a count.
+ *
+ * Where both describe a group, the loopback answer wins. It is the fuller one.
+ */
+
+// A reconcile pass or a burst of watchdog events is usually a file or two, done
+// in less time than it takes to read a bar. Shown only past either threshold;
+// a root walked for the first time, or again, is always shown.
+export const NOISE_BYTES = 1024 ** 3;
+export const NOISE_MS = 5000;
+// How long "indexing finished" stays on screen.
+export const DONE_MS = 4000;
+
+export function fromLoopback(g) {
+ return {
+ groupId: g.group_id,
+ groupName: g.group_name || '',
+ scanning: Boolean(g.scanning),
+ kind: g.kind || '',
+ root: g.root || '',
+ scannedBytes: g.scanned_bytes || 0,
+ totalBytes: g.total_bytes || 0,
+ filesDone: g.files_done || 0,
+ filesTotal: g.files_total || 0,
+ queued: Array.isArray(g.queued) ? g.queued : [],
+ };
+}
+
+export function fromPush(groupId, s, roots) {
+ const pos = Number.isInteger(s.root_pos) ? s.root_pos : -1;
+ const known = pos >= 0 && Array.isArray(roots) && roots[pos];
+ return {
+ groupId,
+ groupName: '',
+ scanning: Boolean(s.scanning),
+ kind: s.kind || '',
+ root: known ? (roots[pos].name || '') : '',
+ scannedBytes: s.scanned_bytes || 0,
+ totalBytes: s.total_bytes || 0,
+ filesDone: s.files_done || 0,
+ filesTotal: s.files_total || 0,
+ queued: Number.isInteger(s.queued) ? s.queued : 0,
+ };
+}
+
+export function queuedCount(job) {
+ return Array.isArray(job.queued) ? job.queued.length : (job.queued || 0);
+}
+
+export function percent(job) {
+ return job.totalBytes
+ ? Math.min(100, Math.round(100 * job.scannedBytes / job.totalBytes)) : 0;
+}
+
+/** One job per group, loopback first, named after the hub's group list. */
+export function mergeActivity(local, pushed, groups) {
+ const names = new Map((groups || []).map((g) => [g.id, g.name]));
+ const ids = [...new Set([...Object.keys(local), ...Object.keys(pushed)])];
+ return ids.map((gid) => {
+ const job = local[gid] || pushed[gid];
+ return { ...job, groupName: names.get(gid) || job.groupName || gid.slice(0, 8) };
+ });
+}
+
+function busy(job) {
+ return job.scanning || queuedCount(job) > 0;
+}
+
+function loud(job, since, now) {
+ // '' while scanning is a node older than `kind`: shown, as it always was.
+ return job.kind === 'scan' || job.kind === 'rescan' || job.kind === ''
+ || queuedCount(job) > 0
+ || job.totalBytes >= NOISE_BYTES
+ || now - since >= NOISE_MS;
+}
+
+/**
+ * The rows to draw, and what to remember for the next call.
+ *
+ * `memory` is per group: when the current walk was first seen, whether its row
+ * has been shown (a burst that crossed the threshold stays up until it ends),
+ * whether the operator hid it, and when it finished. Pure: same arguments, same
+ * answer, so a render that runs twice does not flash anything twice.
+ */
+export function dockRows(jobs, memory, now) {
+ const next = {};
+ const rows = [];
+ for (const job of jobs) {
+ const gid = job.groupId;
+ const prev = memory[gid] || {};
+ if (busy(job)) {
+ const sig = `${job.kind}|${job.root}`;
+ const since = prev.busy && prev.sig === sig ? prev.since : now;
+ const shown = Boolean(prev.busy && prev.shown) || loud(job, since, now);
+ const hidden = Boolean(prev.busy && prev.hidden);
+ next[gid] = { busy: true, sig, since, shown, hidden, doneAt: 0 };
+ if (shown && !hidden) rows.push({ ...job, state: 'running' });
+ } else if (prev.busy && prev.shown && !prev.hidden) {
+ next[gid] = { busy: false, doneAt: now };
+ rows.push({ ...job, state: 'done' });
+ } else if (prev.doneAt && now - prev.doneAt < DONE_MS) {
+ next[gid] = prev;
+ rows.push({ ...job, state: 'done' });
+ }
+ }
+ return { rows, memory: next };
+}
+
+/** Hidden until that group has nothing left to index. */
+export function hideRow(memory, groupId) {
+ const prev = memory[groupId];
+ if (!prev || !prev.busy) return memory;
+ return { ...memory, [groupId]: { ...prev, hidden: true } };
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
new file mode 100644
index 0000000..b136578
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
@@ -0,0 +1,182 @@
+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`
+ <div class="index-dock-row ${done ? 'index-dock-done' : ''}">
+ <button type="button" class="index-dock-main" title=${t('indexdock.open')}
+ onClick=${onOpen}>
+ <span class="index-dock-head">
+ <span class="index-dock-title"><strong>${status}</strong> ${where}</span>
+ ${meta && html`<span class="index-dock-meta">${meta}</span>`}
+ </span>
+ <span class="index-progress-bar index-dock-bar">
+ <span class="index-progress-fill ${moving ? 'index-dock-indeterminate' : ''}"
+ style="width:${done ? 100 : moving ? 30 : pct}%"></span>
+ </span>
+ ${next && html`<span class="index-dock-next">${next}</span>`}
+ </button>
+ ${!done && html`
+ <button type="button" class="index-dock-hide" title=${t('indexdock.hide')}
+ aria-label=${t('indexdock.hide')} onClick=${onHide}>
+ <${Icon} name="close" />
+ </button>
+ `}
+ </div>
+ `;
+}
+
+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`
+ <div class="index-dock" ref=${band}>
+ ${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);
+ }} />
+ `)}
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 9d6fc61..8607011 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -722,6 +722,19 @@ export default {
'presence.offline': 'Kein Node für diese Gruppe erreichbar',
'presence.unknown': 'Noch nicht geprüft',
'presence.indexing': 'Indizierung läuft — {pct}%',
+ 'indexdock.kind_scan': "Indizierung",
+ 'indexdock.kind_rescan': "Erneutes Einlesen",
+ 'indexdock.kind_reconcile': "Abgleich",
+ 'indexdock.kind_watch': "Neue Dateien werden indiziert",
+ 'indexdock.waiting': "Wartet auf Indizierung",
+ 'indexdock.done': "Indizierung abgeschlossen",
+ 'indexdock.listing': "Dateien werden aufgelistet…",
+ 'indexdock.percent': "{pct} %",
+ 'indexdock.files': "{done} / {total} Dateien",
+ 'indexdock.next': "Danach: {roots}",
+ 'indexdock.next_count': "Wartend: {count}",
+ 'indexdock.open': "Einstellungen dieser Gruppe öffnen",
+ 'indexdock.hide': "Bis zur nächsten Indizierung ausblenden",
'chat.load_older': '{n} ältere Nachrichten laden',
'chat.start_of_history': 'Anfang des Gesprächs',
'chat.today': 'Heute',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a5dd5d4..fee6cc3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -831,6 +831,19 @@ export default {
'presence.offline': 'No node is reachable for this group',
'presence.unknown': 'Not checked yet',
'presence.indexing': 'Indexing — {pct}% done',
+ 'indexdock.kind_scan': "Indexing",
+ 'indexdock.kind_rescan': "Re-reading",
+ 'indexdock.kind_reconcile': "Catching up",
+ 'indexdock.kind_watch': "Indexing new files",
+ 'indexdock.waiting': "Waiting to index",
+ 'indexdock.done': "Indexing finished",
+ 'indexdock.listing': "Listing files…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} files",
+ 'indexdock.next': "Next: {roots}",
+ 'indexdock.next_count': "{count} more waiting",
+ 'indexdock.open': "Open this group's settings",
+ 'indexdock.hide': "Hide until the next indexing",
'chat.load_older': 'Load {n} older messages',
'chat.start_of_history': 'Beginning of the conversation',
'chat.today': 'Today',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 6e8e191..b958da3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -717,6 +717,19 @@ export default {
'presence.offline': 'Ningún node accesible para este grupo',
'presence.unknown': 'Aún sin comprobar',
'presence.indexing': 'Indexando — {pct}%',
+ 'indexdock.kind_scan': "Indexando",
+ 'indexdock.kind_rescan': "Releyendo",
+ 'indexdock.kind_reconcile': "Poniéndose al día",
+ 'indexdock.kind_watch': "Indexando archivos nuevos",
+ 'indexdock.waiting': "En espera de indexar",
+ 'indexdock.done': "Indexación terminada",
+ 'indexdock.listing': "Listando archivos…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} archivos",
+ 'indexdock.next': "Después: {roots}",
+ 'indexdock.next_count': "En espera: {count}",
+ 'indexdock.open': "Abrir los ajustes de este grupo",
+ 'indexdock.hide': "Ocultar hasta la próxima indexación",
'chat.load_older': 'Cargar {n} mensajes anteriores',
'chat.start_of_history': 'Inicio de la conversación',
'chat.today': 'Hoy',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 47de45f..c993d9c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -720,6 +720,19 @@ export default {
'presence.offline': 'Aucun node joignable pour ce groupe',
'presence.unknown': 'Pas encore vérifié',
'presence.indexing': 'Indexation en cours — {pct} %',
+ 'indexdock.kind_scan': "Indexation",
+ 'indexdock.kind_rescan': "Relecture",
+ 'indexdock.kind_reconcile': "Rattrapage",
+ 'indexdock.kind_watch': "Indexation de nouveaux fichiers",
+ 'indexdock.waiting': "En attente d'indexation",
+ 'indexdock.done': "Indexation terminée",
+ 'indexdock.listing': "Liste des fichiers…",
+ 'indexdock.percent': "{pct} %",
+ 'indexdock.files': "{done} / {total} fichiers",
+ 'indexdock.next': "Ensuite : {roots}",
+ 'indexdock.next_count': "En attente : {count}",
+ 'indexdock.open': "Ouvrir les paramètres de ce groupe",
+ 'indexdock.hide': "Masquer jusqu'à la prochaine indexation",
'chat.load_older': 'Charger {n} messages plus anciens',
'chat.start_of_history': 'Début de la conversation',
'chat.today': 'Aujourd’hui',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 660298e..3d4b502 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -719,6 +719,19 @@ export default {
'presence.offline': 'Nessun node raggiungibile per questo gruppo',
'presence.unknown': 'Non ancora verificato',
'presence.indexing': 'Indicizzazione in corso — {pct}%',
+ 'indexdock.kind_scan': "Indicizzazione",
+ 'indexdock.kind_rescan': "Rilettura",
+ 'indexdock.kind_reconcile': "Allineamento",
+ 'indexdock.kind_watch': "Indicizzazione dei nuovi file",
+ 'indexdock.waiting': "In attesa di indicizzazione",
+ 'indexdock.done': "Indicizzazione completata",
+ 'indexdock.listing': "Elenco dei file…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} file",
+ 'indexdock.next': "Poi: {roots}",
+ 'indexdock.next_count': "In attesa: {count}",
+ 'indexdock.open': "Apri le impostazioni di questo gruppo",
+ 'indexdock.hide': "Nascondi fino alla prossima indicizzazione",
'chat.load_older': 'Carica {n} messaggi precedenti',
'chat.start_of_history': 'Inizio della conversazione',
'chat.today': 'Oggi',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index ff5b693..3140527 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -707,6 +707,19 @@ export default {
'presence.offline': 'このグループに到達できる node がありません',
'presence.unknown': '未確認',
'presence.indexing': 'インデックス中 — {pct}%',
+ 'indexdock.kind_scan': "インデックス中",
+ 'indexdock.kind_rescan': "再読み込み中",
+ 'indexdock.kind_reconcile': "差分を取り込み中",
+ 'indexdock.kind_watch': "新しいファイルをインデックス中",
+ 'indexdock.waiting': "インデックス待ち",
+ 'indexdock.done': "インデックス完了",
+ 'indexdock.listing': "ファイルを一覧中…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} ファイル",
+ 'indexdock.next': "次: {roots}",
+ 'indexdock.next_count': "待機中: {count}",
+ 'indexdock.open': "このグループの設定を開く",
+ 'indexdock.hide': "次のインデックスまで隠す",
'chat.load_older': '以前のメッセージを {n} 件読み込む',
'chat.start_of_history': '会話のはじまり',
'chat.today': '今日',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 3c07b3e..1e55c8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -721,6 +721,19 @@ export default {
'presence.offline': 'Geen node bereikbaar voor deze groep',
'presence.unknown': 'Nog niet gecontroleerd',
'presence.indexing': 'Bezig met indexeren — {pct}%',
+ 'indexdock.kind_scan': "Indexeren",
+ 'indexdock.kind_rescan': "Opnieuw inlezen",
+ 'indexdock.kind_reconcile': "Bijwerken",
+ 'indexdock.kind_watch': "Nieuwe bestanden indexeren",
+ 'indexdock.waiting': "Wacht op indexering",
+ 'indexdock.done': "Indexeren voltooid",
+ 'indexdock.listing': "Bestanden oplijsten…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} bestanden",
+ 'indexdock.next': "Daarna: {roots}",
+ 'indexdock.next_count': "Wachtend: {count}",
+ 'indexdock.open': "Instellingen van deze groep openen",
+ 'indexdock.hide': "Verbergen tot de volgende indexering",
'chat.load_older': '{n} oudere berichten laden',
'chat.start_of_history': 'Begin van het gesprek',
'chat.today': 'Vandaag',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 2ff9b84..8486718 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -739,6 +739,19 @@ export default {
'presence.offline': 'Brak osiągalnego node dla tej grupy',
'presence.unknown': 'Jeszcze nie sprawdzono',
'presence.indexing': 'Indeksowanie — {pct}%',
+ 'indexdock.kind_scan': "Indeksowanie",
+ 'indexdock.kind_rescan': "Ponowny odczyt",
+ 'indexdock.kind_reconcile': "Uzupełnianie",
+ 'indexdock.kind_watch': "Indeksowanie nowych plików",
+ 'indexdock.waiting': "Oczekuje na indeksowanie",
+ 'indexdock.done': "Indeksowanie zakończone",
+ 'indexdock.listing': "Wyliczanie plików…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "Pliki: {done} / {total}",
+ 'indexdock.next': "Następnie: {roots}",
+ 'indexdock.next_count': "W kolejce: {count}",
+ 'indexdock.open': "Otwórz ustawienia tej grupy",
+ 'indexdock.hide': "Ukryj do następnego indeksowania",
'chat.load_older': 'Wczytaj {n} starszych wiadomości',
'chat.start_of_history': 'Początek rozmowy',
'chat.today': 'Dziś',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 33fa2be..e51df81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -718,6 +718,19 @@ export default {
'presence.offline': 'Nenhum node acessível para este grupo',
'presence.unknown': 'Ainda não verificado',
'presence.indexing': 'Indexando — {pct}%',
+ 'indexdock.kind_scan': "Indexando",
+ 'indexdock.kind_rescan': "Relendo",
+ 'indexdock.kind_reconcile': "Atualizando",
+ 'indexdock.kind_watch': "Indexando arquivos novos",
+ 'indexdock.waiting': "Aguardando indexação",
+ 'indexdock.done': "Indexação concluída",
+ 'indexdock.listing': "Listando arquivos…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} arquivos",
+ 'indexdock.next': "Em seguida: {roots}",
+ 'indexdock.next_count': "Na fila: {count}",
+ 'indexdock.open': "Abrir as configurações deste grupo",
+ 'indexdock.hide': "Ocultar até a próxima indexação",
'chat.load_older': 'Carregar {n} mensagens anteriores',
'chat.start_of_history': 'Início da conversa',
'chat.today': 'Hoje',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index b58ba64..cc22a70 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -694,6 +694,19 @@ export default {
'presence.offline': '本群组没有可达的 node',
'presence.unknown': '尚未检测',
'presence.indexing': '正在索引 — {pct}%',
+ 'indexdock.kind_scan': "正在索引",
+ 'indexdock.kind_rescan': "正在重新读取",
+ 'indexdock.kind_reconcile': "正在补录",
+ 'indexdock.kind_watch': "正在索引新文件",
+ 'indexdock.waiting': "等待索引",
+ 'indexdock.done': "索引完成",
+ 'indexdock.listing': "正在列出文件…",
+ 'indexdock.percent': "{pct}%",
+ 'indexdock.files': "{done} / {total} 个文件",
+ 'indexdock.next': "接下来:{roots}",
+ 'indexdock.next_count': "等待中:{count}",
+ 'indexdock.open': "打开此群组的设置",
+ 'indexdock.hide': "隐藏,直到下一次索引",
'chat.load_older': '加载更早的 {n} 条消息',
'chat.start_of_history': '对话开头',
'chat.today': '今天',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 871ca0a..a2ef80e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -73,6 +73,8 @@
--toolbar-h: 0px;
/* Published by the music bar while it is on screen (sticky.js). */
--music-bar-h: 0px;
+ /* Published by the indexing dock, which sits on top of the music bar. */
+ --index-dock-h: 0px;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
@@ -400,7 +402,7 @@ a:hover { text-decoration: underline; }
display: flex;
flex-direction: column;
width: 240px;
- height: calc(100vh - var(--nav-h) - var(--music-bar-h));
+ height: calc(100vh - var(--nav-h) - var(--music-bar-h) - var(--index-dock-h));
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
padding: 16px 0 0;
@@ -2512,7 +2514,7 @@ a.transfer-name {
position: fixed;
left: -240px;
top: 52px;
- height: calc(100vh - 52px - var(--music-bar-h));
+ height: calc(100vh - 52px - var(--music-bar-h) - var(--index-dock-h));
z-index: 50;
transition: left 0.2s;
box-shadow: none;
@@ -4287,6 +4289,99 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
border-radius: 10px;
}
+/* ── Indexing dock (index-dock.js) ────────────────────────────────────────
+ Sticky for the music bar's reason. It is rendered right before that bar, so
+ `bottom: var(--music-bar-h)` stacks it on top when both are pinned, and it
+ publishes its own height as `--index-dock-h` for the sidebar to stop above
+ the two of them. */
+.index-dock {
+ position: sticky;
+ bottom: var(--music-bar-h);
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 16px;
+}
+.index-dock-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+.index-dock-main {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: block;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.index-dock-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 0.85em;
+}
+.index-dock-title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.index-dock-meta {
+ color: var(--text-dim);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+.index-dock-bar { display: block; margin: 6px 0 0; }
+.index-dock-bar .index-progress-fill { display: block; }
+.index-dock-done .index-progress-fill { background: var(--success); }
+/* Walking the tree, or waiting for another root: no percentage to show yet,
+ and a bar parked at 0 % reads as stuck. */
+.index-dock-indeterminate { animation: index-dock-sweep 1.4s ease-in-out infinite; }
+@keyframes index-dock-sweep {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(340%); }
+}
+.index-dock-next {
+ display: block;
+ margin-top: 4px;
+ font-size: 0.78em;
+ color: var(--text-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.index-dock-hide {
+ flex-shrink: 0;
+ display: inline-flex;
+ padding: 4px;
+ border: 0;
+ border-radius: 6px;
+ background: none;
+ color: var(--text-dim);
+ cursor: pointer;
+}
+.index-dock-hide:hover { background: var(--bg-surface); }
+.index-dock-hide .icon { width: 16px; height: 16px; }
+@media (max-width: 640px) {
+ .index-dock-head { flex-direction: column; gap: 2px; }
+ .index-dock-title,
+ .index-dock-meta { white-space: normal; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .index-dock-indeterminate { animation: none; }
+}
+
.music-player-info {
display: flex;
align-items: center;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index a21da19..9de5407 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -3180,10 +3180,18 @@ class MeshBayTransport {
if (msg.type === 'index_progress') {
if (this._onIndexProgress) {
+ // `kind`, `root_pos`, `queued` and the file counts are absent from a
+ // node older than the indexing dock; index-dock-model.js reads them
+ // missing as idle defaults.
this._onIndexProgress({
scanning: Boolean(msg.scanning),
scanned_bytes: msg.scanned_bytes || 0,
total_bytes: msg.total_bytes || 0,
+ files_done: msg.files_done || 0,
+ files_total: msg.files_total || 0,
+ kind: msg.kind || '',
+ root_pos: Number.isInteger(msg.root_pos) ? msg.root_pos : -1,
+ queued: Number.isInteger(msg.queued) ? msg.queued : 0,
});
}
return;
diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py
new file mode 100644
index 0000000..ade4173
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_indexing_dock.py
@@ -0,0 +1,193 @@
+"""
+The indexing dock: what it shows, and who feeds it.
+
+Adding a 900 GB directory left the operator nothing to look at once they left
+the Settings panel — and nothing at all when the directory was added from
+another machine, where that panel's bar never started. The dock sits above the
+music bar on every page. What it shows is worked out in `index-dock-model.js`,
+which has no imports so that it runs here under node as shipped; the wiring
+around it is checked in the source, as the other SPA tests do.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+MODEL = STATIC / "index-dock-model.js"
+DOCK = STATIC / "index-dock.js"
+APP = STATIC / "app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(not MODEL.exists(), reason="SPA sources unavailable")
+
+GB = 1024 ** 3
+
+needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+
+
+def _run(tmp_path, body: str):
+ (tmp_path / "package.json").write_text('{"type":"module"}')
+ (tmp_path / "model.js").write_text(MODEL.read_text(encoding="utf-8"))
+ script = tmp_path / "case.js"
+ script.write_text("import * as m from './model.js';\n" + body)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ cwd=str(tmp_path))
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def _job(**over) -> dict:
+ job = {"groupId": "g1", "groupName": "outputs", "scanning": True, "kind": "scan",
+ "root": "results", "scannedBytes": 0, "totalBytes": 0, "filesDone": 0,
+ "filesTotal": 0, "queued": []}
+ job.update(over)
+ return job
+
+
+def _frames(tmp_path, frames: list[tuple[int, list[dict]]], hide_at: int | None = None):
+ """Feed dockRows a sequence of (time ms, jobs) and return each frame's rows."""
+ return _run(tmp_path, f"""
+ const frames = {json.dumps(frames)};
+ const hideAt = {json.dumps(hide_at)};
+ let memory = {{}};
+ const out = [];
+ for (const [now, jobs] of frames) {{
+ if (hideAt === now) memory = m.hideRow(memory, 'g1');
+ const r = m.dockRows(jobs, memory, now);
+ memory = r.memory;
+ out.push(r.rows.map((row) => [row.groupId, row.state]));
+ }}
+ console.log(JSON.stringify(out));
+ """)
+
+
+# ── What is shown ───────────────────────────────────────────────────────────
+
+@needs_node
+def test_a_root_scan_shows_at_once(tmp_path):
+ assert _frames(tmp_path, [(0, [_job()])]) == [[["g1", "running"]]]
+
+
+@needs_node
+def test_a_small_burst_waits_five_seconds_and_a_large_one_does_not(tmp_path):
+ small = _job(kind="watch", root="", totalBytes=10_000)
+ assert _frames(tmp_path, [(0, [small]), (4000, [small]), (5000, [small])]) == [
+ [], [], [["g1", "running"]]]
+ large = _job(kind="reconcile", totalBytes=2 * GB)
+ assert _frames(tmp_path, [(0, [large])]) == [[["g1", "running"]]]
+
+
+@needs_node
+def test_a_short_burst_never_flashes_a_finished_row(tmp_path):
+ small = _job(kind="watch", root="", totalBytes=10_000)
+ idle = _job(scanning=False, kind="")
+ assert _frames(tmp_path, [(0, [small]), (1000, [idle])]) == [[], []]
+
+
+@needs_node
+def test_a_group_between_two_roots_stays_on_screen(tmp_path):
+ """The gap between one root's walk ending and the next taking the lock."""
+ waiting = _job(scanning=False, kind="", root="", queued=["archive"])
+ assert _frames(tmp_path, [(0, [_job()]), (2000, [waiting])]) == [
+ [["g1", "running"]], [["g1", "running"]]]
+
+
+@needs_node
+def test_finished_says_so_for_four_seconds_then_goes(tmp_path):
+ idle = _job(scanning=False, kind="", root="")
+ assert _frames(tmp_path, [(0, [_job()]), (1000, [idle]), (4999, [idle]),
+ (5000, [idle])]) == [
+ [["g1", "running"]], [["g1", "done"]], [["g1", "done"]], []]
+
+
+@needs_node
+def test_a_hidden_row_stays_hidden_until_the_group_is_idle(tmp_path):
+ second = _job(root="archive")
+ idle = _job(scanning=False, kind="", root="")
+ frames = [(0, [_job()]), (1000, [_job()]), (2000, [second]), (3000, [idle]),
+ (9000, [idle]), (10000, [_job(root="photos")])]
+ assert _frames(tmp_path, frames, hide_at=1000) == [
+ [["g1", "running"]], [], [], [], [], [["g1", "running"]]]
+
+
+# ── Where it comes from ─────────────────────────────────────────────────────
+
+@needs_node
+def test_the_loopback_answer_wins_and_the_hub_names_the_group(tmp_path):
+ out = _run(tmp_path, """
+ const local = { g1: m.fromLoopback({ group_id: 'g1', group_name: 'node name',
+ scanning: true, kind: 'scan', root: 'results', queued: ['archive'] }) };
+ const pushed = {
+ g1: m.fromPush('g1', { scanning: true, kind: 'rescan', root_pos: 0 }, [{ name: 'x' }]),
+ g2: m.fromPush('g2', { scanning: true }, []),
+ };
+ console.log(JSON.stringify(
+ m.mergeActivity(local, pushed, [{ id: 'g1', name: 'outputs' }])));
+ """)
+ by_id = {j["groupId"]: j for j in out}
+ assert (by_id["g1"]["kind"], by_id["g1"]["root"], by_id["g1"]["groupName"]) == (
+ "scan", "results", "outputs")
+ assert by_id["g1"]["queued"] == ["archive"]
+ assert by_id["g2"]["groupName"] == "g2"
+
+
+@needs_node
+def test_a_push_is_named_from_the_roots_table_it_does_not_carry(tmp_path):
+ out = _run(tmp_path, """
+ const roots = [{ name: 'outputs' }, { name: 'results' }];
+ console.log(JSON.stringify([
+ m.fromPush('g', { scanning: true, kind: 'scan', root_pos: 1, queued: 2 }, roots),
+ m.fromPush('g', { scanning: true, root_pos: 7 }, roots),
+ m.fromPush('g', { scanning: true, scanned_bytes: 5, total_bytes: 10 }, null),
+ ]));
+ """)
+ assert (out[0]["root"], out[0]["queued"]) == ("results", 2)
+ assert out[1]["root"] == "", "a position past the table must name nothing"
+ assert (out[2]["kind"], out[2]["root"], out[2]["queued"]) == ("", "", 0), (
+ "a node older than the dock must read as a plain scan")
+
+
+# ── Wiring ──────────────────────────────────────────────────────────────────
+
+def test_the_dock_is_on_every_page_not_in_a_route():
+ app = APP.read_text(encoding="utf-8")
+ mount = app.index("<${IndexingDock}")
+ assert app.index("</main>") < mount < app.index("<${MusicPlayerBar}"), (
+ "the dock must sit outside the routed page, right before the music bar")
+
+
+def test_only_an_operator_connection_feeds_the_dock():
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ calls = [m.start() for m in re.finditer(r"reportIndexPush\(groupId, (?!null)", page)]
+ assert len(calls) == 2, "the push handler and the handshake ack"
+ for at in calls:
+ guard = page[max(0, at - 200):at]
+ assert "transport.memberRole === 'operator'" in guard, (
+ "a member's connection must keep the sidebar dot only")
+
+
+def test_leaving_a_group_clears_its_row():
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ cleanup = page[page.index(" return () => {\n cancelled = true;"):]
+ cleanup = cleanup[:cleanup.index("};")]
+ assert "reportIndexPush(groupId, null)" in cleanup
+
+
+def test_the_transport_passes_the_new_counters_through():
+ source = TRANSPORT.read_text(encoding="utf-8")
+ block = source[source.index("msg.type === 'index_progress'"):]
+ block = block[:block.index("return;")]
+ for field in ("files_done", "files_total", "kind", "root_pos", "queued"):
+ assert f"{field}:" in block, f"{field} is dropped before it reaches the page"
+
+
+def test_the_dock_polls_the_node_wide_route_not_one_group():
+ source = DOCK.read_text(encoding="utf-8")
+ assert "'/api/index-status'" in source
+ assert "/index-status`" not in source
diff --git a/packages/meshbay-hub/tests/test_sidebar_legal_measured.py b/packages/meshbay-hub/tests/test_sidebar_legal_measured.py
index faaaec7..3c44d83 100644
--- a/packages/meshbay-hub/tests/test_sidebar_legal_measured.py
+++ b/packages/meshbay-hub/tests/test_sidebar_legal_measured.py
@@ -29,16 +29,26 @@ pytestmark = pytest.mark.skipif(
# The probe's frames are this tall.
WINDOW_H = 740
MUSIC_BAR_H = 64
+INDEX_DOCK_H = 52
WIDTHS = [390, 1024, 1440]
-def _page(music: bool) -> str:
+def _page(music: bool, dock: bool = False) -> str:
groups = "".join(
f'<a class="sidebar-item sidebar-group" href="#/"><span class="si-head">'
f'<span class="sidebar-item-name">Some group {i}</span></span></a>' for i in range(4))
bar = (f'<div class="music-player-bar" style="height:{MUSIC_BAR_H}px;'
f'box-sizing:border-box">a track</div>') if music else ""
- style = f' style="--music-bar-h:{MUSIC_BAR_H}px"' if music else ""
+ # Rendered right before the music bar, as app.js does.
+ if dock:
+ bar = (f'<div class="index-dock" style="height:{INDEX_DOCK_H}px;'
+ f'box-sizing:border-box">indexing</div>') + bar
+ props = []
+ if music:
+ props.append(f"--music-bar-h:{MUSIC_BAR_H}px")
+ if dock:
+ props.append(f"--index-dock-h:{INDEX_DOCK_H}px")
+ style = f' style="{";".join(props)}"' if props else ""
return textwrap.dedent(f"""
<div id="app"{style}>
<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div>
@@ -55,12 +65,12 @@ def _page(music: bool) -> str:
""")
-def _measure(tmp_path_factory, music: bool) -> dict:
+def _measure(tmp_path_factory, music: bool, dock: bool = False) -> dict:
fragment = tmp_path_factory.mktemp("sidebar") / "fragment.html"
- fragment.write_text(_page(music), encoding="utf-8")
+ fragment.write_text(_page(music, dock), encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
- str(fragment), ".sidebar-legal", ".sidebar", ".music-player-bar"],
+ str(fragment), ".sidebar-legal", ".sidebar", ".music-player-bar", ".index-dock"],
capture_output=True, text=True, timeout=180)
assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
out = json.loads(proc.stdout)
@@ -78,6 +88,11 @@ def playing(tmp_path_factory):
return _measure(tmp_path_factory, music=True)
+@pytest.fixture(scope="module")
+def indexing(tmp_path_factory):
+ return _measure(tmp_path_factory, music=True, dock=True)
+
+
@pytest.mark.parametrize("width", WIDTHS)
def test_the_link_is_at_the_bottom_of_the_window(plain, width):
"""Not at the bottom of a 3000px page, where nobody would find it."""
@@ -99,3 +114,21 @@ def test_the_music_bar_does_not_cover_it(playing, width):
f"at {width} px the link ends at {bottom} and the music bar starts at {bar['top']}")
assert bottom >= bar["top"] - 8, (
f"at {width} px the link floats {bar['top'] - bottom} px above the music bar")
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_indexing_dock_stacks_on_the_music_bar_and_covers_nothing(indexing, width):
+ """The dock (index-dock.js) pins right above the music bar, off
+ `--music-bar-h`, and the sidebar stops above both."""
+ link = indexing[str(width)]["boxes"][".sidebar-legal"]
+ dock = indexing[str(width)]["boxes"][".index-dock"]
+ bar = indexing[str(width)]["boxes"][".music-player-bar"]
+ assert link is not None and dock is not None and bar is not None
+ assert abs(bar["top"] + bar["height"] - WINDOW_H) <= 1, (
+ f"at {width} px the music bar ends at {bar['top'] + bar['height']}")
+ assert abs(dock["top"] + dock["height"] - bar["top"]) <= 1, (
+ f"at {width} px the dock ends at {dock['top'] + dock['height']} "
+ f"and the music bar starts at {bar['top']}")
+ bottom = link["top"] + link["height"]
+ assert dock["top"] - 8 <= bottom <= dock["top"] + 1, (
+ f"at {width} px the link ends at {bottom} and the dock starts at {dock['top']}")