ext: popup, options, and the panel bridge (build order step 6)
Popup and Options are separate documents from the background page and can't reach its live VeloxTransport directly, so background/bridge.ts relays it over one browser.runtime.connect port per document (call/subscribe/getStatus/reconnect/pair/unpair/setOverride in; result/event/status/pairError out). shared/panel-client.ts is the client side both surfaces use. Popup (src/popup/): status dot + text, active-downloads list driven by event.task.progress/added/state/removed (repaints ride the event's own <=4 Hz cap rather than adding a second timer), pause/resume buttons, "Start it" wired to a reconnect request. State lives in a DOM-free store.ts for unit testing; rendering uses createElement, not innerHTML (web-ext lint flags the latter). Options (src/options/): transport override select, pairing (code entry + pair/unpair, backed by two new WebSocketTransport methods, pairWithCode/unpair), and the daemon's capture policy mirrored via capture.getRules. The capture-policy form is editable only when the active transport is native messaging (uds) -- settings.set and rules.upsert are privileged, uds-only methods per shared/protocol METHODS, so WebSocket can't write them no matter what the page shows; CLAUDE.md section 2 rules out working around that locally. The bridge's status payload adds a kind field (which transport is live) for this to key off. Default category is the one piece of state that's genuinely the extension's own, not the daemon's, and lives in browser.storage.local via options/prefs.ts. Pure decisions (statusLine, pairingAvailable, captureRulesEditable) are split into view.ts for unit testing without a DOM. manifest.json registers the popup action and options_ui page (and, in the same edit, the content_scripts entry the next commit's media detection needs -- split by file, not by manifest line). build.mjs gains popup/options as further esbuild entry points, plus copying their static HTML/CSS into dist/. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// Toolbar popup: daemon status dot, active downloads with live progress, pause/resume.
|
||||
// docs/05 §3. No download logic — this only renders state and forwards pause/resume
|
||||
// intent over the panel bridge (background/bridge.ts) to the transport the background
|
||||
// page owns.
|
||||
|
||||
import { PanelClient } from '../shared/panel-client.js';
|
||||
import type { PanelStatus } from '../background/bridge.js';
|
||||
import { formatEta, formatSpeed, PopupStore, progressPercent, type TaskRow } from './store.js';
|
||||
|
||||
const client = new PanelClient();
|
||||
const store = new PopupStore();
|
||||
|
||||
const $status = document.querySelector<HTMLElement>('#status-dot')!;
|
||||
const $statusText = document.querySelector<HTMLElement>('#status-text')!;
|
||||
const $list = document.querySelector<HTMLElement>('#task-list')!;
|
||||
const $empty = document.querySelector<HTMLElement>('#empty')!;
|
||||
const $startButton = document.querySelector<HTMLButtonElement>('#start-daemon')!;
|
||||
|
||||
function renderStatus(status: PanelStatus): void {
|
||||
$status.className = `dot dot-${status.state}`;
|
||||
if (status.fatal) {
|
||||
$statusText.textContent = status.fatal;
|
||||
} else if (status.needsPairing) {
|
||||
$statusText.textContent = status.retryAfterSec
|
||||
? `Pairing locked — retry in ${status.retryAfterSec}s`
|
||||
: 'Velox needs pairing — open Options';
|
||||
} else if (status.state === 'connected') {
|
||||
$statusText.textContent = status.daemonVersion ? `Connected · v${status.daemonVersion}` : 'Connected';
|
||||
} else if (status.state === 'connecting') {
|
||||
$statusText.textContent = 'Connecting…';
|
||||
} else {
|
||||
$statusText.textContent = "Velox isn't running.";
|
||||
}
|
||||
$startButton.hidden = status.state === 'connected';
|
||||
}
|
||||
|
||||
// Built with createElement rather than innerHTML: AMO's linter flags dynamic innerHTML
|
||||
// on sight, and building nodes directly means a filename can never be parsed as markup.
|
||||
function buildRow(row: TaskRow): HTMLLIElement {
|
||||
const pct = progressPercent(row);
|
||||
const canPause = row.state === 'downloading' || row.state === 'connecting' || row.state === 'queued';
|
||||
const canResume = row.state === 'paused' || row.state === 'retry_wait' || row.state === 'failed';
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className = 'task';
|
||||
li.dataset['taskId'] = row.taskId;
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'task-name';
|
||||
name.title = row.filename;
|
||||
name.textContent = row.filename;
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'task-bar';
|
||||
const barFill = document.createElement('div');
|
||||
barFill.className = 'task-bar-fill';
|
||||
barFill.style.width = `${pct ?? 0}%`;
|
||||
bar.appendChild(barFill);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'task-meta';
|
||||
const state = document.createElement('span');
|
||||
state.className = 'task-state';
|
||||
state.textContent = row.state;
|
||||
const speed = document.createElement('span');
|
||||
speed.className = 'task-speed';
|
||||
speed.textContent = formatSpeed(row.speedBps);
|
||||
const eta = document.createElement('span');
|
||||
eta.className = 'task-eta';
|
||||
eta.textContent = formatEta(row.etaSeconds);
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'task-actions';
|
||||
if (canPause) actions.appendChild(makeActionButton('pause', row.taskId, 'Pause'));
|
||||
if (canResume) actions.appendChild(makeActionButton('resume', row.taskId, 'Resume'));
|
||||
meta.append(state, speed, eta, actions);
|
||||
|
||||
li.append(name, bar, meta);
|
||||
return li;
|
||||
}
|
||||
|
||||
function makeActionButton(action: 'pause' | 'resume', taskId: string, label: string): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.dataset['action'] = action;
|
||||
button.dataset['taskId'] = taskId;
|
||||
button.textContent = label;
|
||||
return button;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const rows = store.rows();
|
||||
$empty.hidden = rows.length > 0;
|
||||
$list.replaceChildren(...rows.map(buildRow));
|
||||
}
|
||||
|
||||
$list.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const action = target.dataset['action'];
|
||||
const taskId = target.dataset['taskId'];
|
||||
if (!action || !taskId) return;
|
||||
if (action === 'pause') void client.call('download.pause', { taskIds: [taskId] });
|
||||
else if (action === 'resume') void client.call('download.resume', { taskIds: [taskId] });
|
||||
});
|
||||
|
||||
$startButton.addEventListener('click', () => {
|
||||
// The transport reconnects on its own with backoff; this just gives the user
|
||||
// something to click rather than staring at a red dot (docs/05 §5).
|
||||
client.reconnect();
|
||||
});
|
||||
|
||||
async function start(): Promise<void> {
|
||||
client.onStatus(renderStatus);
|
||||
client.on('event.task.added', (evt) => {
|
||||
store.onAdded(evt);
|
||||
render();
|
||||
});
|
||||
// event.task.progress arrives at up to 4 Hz (EVENTS contract) — this repaint rides
|
||||
// that rate directly rather than adding a second timer, so the popup never exceeds it.
|
||||
client.on('event.task.progress', (evt) => {
|
||||
store.onProgress(evt);
|
||||
render();
|
||||
});
|
||||
client.on('event.task.state', (evt) => {
|
||||
store.onState(evt);
|
||||
render();
|
||||
});
|
||||
client.on('event.task.removed', (evt) => {
|
||||
store.onRemoved(evt);
|
||||
render();
|
||||
});
|
||||
|
||||
try {
|
||||
const list = await client.call('download.list', {
|
||||
filter: { states: ['queued', 'connecting', 'downloading', 'paused', 'retry_wait', 'assembling', 'verifying'] },
|
||||
limit: 100,
|
||||
});
|
||||
store.setInitial(list.items);
|
||||
} catch {
|
||||
// Daemon unreachable — status dot already shows red; the list just stays empty.
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
void start();
|
||||
Reference in New Issue
Block a user