Files
SubMiner/src/syncui/syncui.ts
T

479 lines
16 KiB
TypeScript

import type {
SyncDirection,
SyncHostEntry,
SyncUiAPI,
SyncUiCheckResult,
SyncUiProgressPayload,
SyncUiSnapshot,
SyncUiStartResult,
} from '../types/sync-ui';
import {
formatBytes,
formatMergeTargetHeading,
formatRelativeTime,
summarizeMergeCounts,
} from './syncui-format';
declare global {
interface Window {
syncUiAPI: SyncUiAPI;
}
}
const api = window.syncUiAPI;
function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element #${id}`);
return node as T;
}
const runPill = el<HTMLDivElement>('runPill');
const dbPathEl = el<HTMLDivElement>('dbPath');
const hostList = el<HTMLDivElement>('hostList');
const hostListEmpty = el<HTMLDivElement>('hostListEmpty');
const autoIntervalInput = el<HTMLInputElement>('autoIntervalInput');
const newHostInput = el<HTMLInputElement>('newHostInput');
const newLabelInput = el<HTMLInputElement>('newLabelInput');
const testHostButton = el<HTMLButtonElement>('testHostButton');
const addHostButton = el<HTMLButtonElement>('addHostButton');
const checkResultEl = el<HTMLDivElement>('checkResult');
const activityPanel = el<HTMLElement>('activityPanel');
const cancelButton = el<HTMLButtonElement>('cancelButton');
const stageList = el<HTMLDivElement>('stageList');
const remoteOutput = el<HTMLPreElement>('remoteOutput');
const resultCard = el<HTMLDivElement>('resultCard');
const snapshotsDirEl = el<HTMLSpanElement>('snapshotsDir');
const snapshotList = el<HTMLDivElement>('snapshotList');
const snapshotListEmpty = el<HTMLDivElement>('snapshotListEmpty');
const createSnapshotButton = el<HTMLButtonElement>('createSnapshotButton');
const mergeFileButton = el<HTMLButtonElement>('mergeFileButton');
let snapshot: SyncUiSnapshot | null = null;
let activeRunId: number | null = null;
let retryWithForce: (() => void) | null = null;
const DIRECTIONS: Array<{ id: SyncDirection; label: string; title: string }> = [
{ id: 'both', label: '⇄ Two-way', title: 'Merge both databases into each other' },
{ id: 'push', label: '⇡ Push', title: 'Only merge this machine into the device' },
{ id: 'pull', label: '⇣ Pull', title: 'Only merge the device into this machine' },
];
function describeRun(kind: string, host: string | null): string {
if (kind === 'host-sync') return `Syncing with ${host ?? 'device'}`;
if (kind === 'merge') return 'Merging snapshot';
if (kind === 'snapshot') return 'Creating snapshot';
return 'Working';
}
function setRunPill(): void {
const running = snapshot?.run.running ?? false;
runPill.classList.toggle('running', running);
runPill.classList.toggle('idle', !running);
runPill.textContent = running
? describeRun(snapshot!.run.kind ?? '', snapshot!.run.host)
: 'Idle';
cancelButton.classList.toggle('hidden', !running);
}
function clearActivity(): void {
stageList.replaceChildren();
remoteOutput.textContent = '';
remoteOutput.classList.add('hidden');
resultCard.replaceChildren();
resultCard.classList.add('hidden');
}
function markStageStates(state: 'done' | 'failed'): void {
for (const item of stageList.querySelectorAll('.stage-item.active')) {
item.classList.remove('active');
item.classList.add(state);
}
}
function addStage(message: string): void {
markStageStates('done');
const item = document.createElement('div');
item.className = 'stage-item active';
const icon = document.createElement('span');
icon.className = 'stage-icon';
const text = document.createElement('span');
text.textContent = message;
item.append(icon, text);
stageList.appendChild(item);
}
function showResult(ok: boolean, error: string | null): void {
markStageStates(ok ? 'done' : 'failed');
resultCard.classList.remove('hidden');
resultCard.classList.toggle('error', !ok);
const title = document.createElement('div');
title.className = 'result-title';
title.textContent = ok ? 'Sync complete' : 'Sync failed';
resultCard.prepend(title);
if (!ok && error) {
const detail = document.createElement('div');
detail.className = 'result-error-detail';
detail.textContent = error;
resultCard.appendChild(detail);
if (retryWithForce && /--force/.test(error)) {
const retry = document.createElement('button');
retry.className = 'ghost-button';
retry.style.marginTop = '10px';
retry.type = 'button';
retry.textContent = 'Retry with --force (skip the running-app check)';
retry.addEventListener('click', () => {
const action = retryWithForce;
retryWithForce = null;
action?.();
});
resultCard.appendChild(retry);
}
}
}
function appendMergeSummary(
target: 'local' | 'remote',
lines: ReturnType<typeof summarizeMergeCounts>,
): void {
resultCard.classList.remove('hidden');
const heading = document.createElement('div');
heading.className = 'result-title';
heading.textContent = formatMergeTargetHeading(target);
const grid = document.createElement('div');
grid.className = 'count-grid';
for (const line of lines) {
const cell = document.createElement('div');
cell.className = 'count-cell';
const label = document.createElement('span');
label.textContent = line.label;
const value = document.createElement('b');
value.textContent = String(line.value);
cell.append(label, value);
grid.appendChild(cell);
}
resultCard.append(heading, grid);
}
function handleProgress(payload: SyncUiProgressPayload): void {
if (payload.runId !== activeRunId) {
activeRunId = payload.runId;
clearActivity();
activityPanel.classList.remove('hidden');
}
const event = payload.event;
if (event.type === 'stage') {
addStage(event.message);
} else if (event.type === 'snapshot-created') {
addStage(`Snapshot written to ${event.path}`);
} else if (event.type === 'remote-output') {
remoteOutput.classList.remove('hidden');
remoteOutput.textContent += event.text;
} else if (event.type === 'merge-summary') {
appendMergeSummary(event.target, summarizeMergeCounts(event.summary));
} else if (event.type === 'result') {
// The run's completion handler broadcasts state-changed, which refreshes.
showResult(event.ok, event.error);
}
}
async function startRun(action: () => Promise<SyncUiStartResult>): Promise<void> {
const result = await action();
if (!result.started) {
activityPanel.classList.remove('hidden');
clearActivity();
activeRunId = null;
showResult(false, result.reason ?? 'Could not start the sync.');
return;
}
await refresh();
}
function runHostSync(entry: SyncHostEntry): Promise<SyncUiStartResult> {
retryWithForce = () => {
void startRun(() => api.runSync({ host: entry.host, direction: entry.direction, force: true }));
};
return api.runSync({ host: entry.host, direction: entry.direction });
}
function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
const running = snapshot?.run.running ?? false;
const syncingThis = running && snapshot?.run.host === entry.host;
const card = document.createElement('div');
card.className = `host-card${syncingThis ? ' syncing' : ''}`;
const top = document.createElement('div');
top.className = 'host-top';
const id = document.createElement('div');
id.className = 'host-id';
const name = document.createElement('span');
name.className = 'host-name';
name.textContent = entry.host;
id.appendChild(name);
if (entry.label) {
const label = document.createElement('span');
label.className = 'host-label';
label.textContent = entry.label;
id.appendChild(label);
}
const status = document.createElement('div');
status.className = 'host-status';
const dot = document.createElement('span');
dot.className = `status-dot${entry.lastSyncStatus ? ` ${entry.lastSyncStatus}` : ''}`;
const statusText = document.createElement('span');
statusText.className = 'detail';
const when = formatRelativeTime(entry.lastSyncAtMs, Date.now());
statusText.textContent =
entry.lastSyncStatus === null
? 'never synced'
: `${when}${entry.lastSyncDetail ? ` · ${entry.lastSyncDetail}` : ''}`;
statusText.title = entry.lastSyncDetail ?? '';
status.append(dot, statusText);
top.append(id, status);
const controls = document.createElement('div');
controls.className = 'host-controls';
const toggle = document.createElement('div');
toggle.className = 'direction-toggle';
toggle.setAttribute('role', 'group');
toggle.setAttribute('aria-label', `Sync direction for ${entry.host}`);
for (const direction of DIRECTIONS) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = direction.label;
button.title = direction.title;
button.classList.toggle('active', entry.direction === direction.id);
button.addEventListener('click', () => {
// Every mutation triggers a state-changed broadcast, which refreshes.
void api.saveHost({ host: entry.host, direction: direction.id });
});
toggle.appendChild(button);
}
const auto = document.createElement('label');
auto.className = 'auto-toggle';
const autoBox = document.createElement('input');
autoBox.type = 'checkbox';
autoBox.checked = entry.autoSync;
autoBox.addEventListener('change', () => {
void api.saveHost({ host: entry.host, autoSync: autoBox.checked });
});
const autoText = document.createElement('span');
autoText.textContent = 'Auto-sync';
auto.append(autoBox, autoText);
const actions = document.createElement('div');
actions.className = 'host-actions';
const syncButton = document.createElement('button');
syncButton.type = 'button';
syncButton.className = 'primary-button';
syncButton.textContent = syncingThis ? 'Syncing…' : 'Sync now';
syncButton.disabled = running;
syncButton.addEventListener('click', () => {
void startRun(() => runHostSync(entry));
});
const testButton = document.createElement('button');
testButton.type = 'button';
testButton.className = 'mini-button';
testButton.textContent = 'Test';
testButton.addEventListener('click', () => {
testButton.disabled = true;
testButton.textContent = 'Testing…';
void api
.checkHost(entry.host)
.then((result) => renderCheckResult(result))
.finally(() => {
testButton.disabled = false;
testButton.textContent = 'Test';
});
});
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'mini-button danger';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return;
void api.removeHost(entry.host);
});
actions.append(syncButton, testButton, removeButton);
controls.append(toggle, auto, actions);
card.append(top, controls);
return card;
}
function renderCheckResult(result: SyncUiCheckResult): void {
checkResultEl.classList.remove('hidden', 'ok', 'fail');
checkResultEl.classList.add(result.ok ? 'ok' : 'fail');
checkResultEl.replaceChildren();
const headline = document.createElement('div');
headline.textContent = result.ok
? `✓ ${result.host} is ready to sync`
: `✕ ${result.host} is not ready`;
checkResultEl.appendChild(headline);
const sub = document.createElement('div');
sub.className = 'sub';
if (result.ok) {
sub.textContent = `SSH ok · remote launcher: ${result.remoteCommand ?? '?'}${
result.remoteVersion ? ` (${result.remoteVersion})` : ''
}`;
} else {
sub.textContent = result.error ?? (result.sshOk ? 'Remote launcher missing.' : 'SSH failed.');
}
checkResultEl.appendChild(sub);
}
function renderHosts(): void {
const hosts = snapshot?.hosts.hosts ?? [];
hostList.replaceChildren(...hosts.map(renderHostCard));
hostListEmpty.classList.toggle('hidden', hosts.length > 0);
}
function renderSnapshots(): void {
const files = snapshot?.snapshots ?? [];
snapshotsDirEl.textContent = snapshot?.snapshotsDir ?? '';
snapshotList.replaceChildren(
...files.map((file) => {
const row = document.createElement('div');
row.className = 'snapshot-row';
const name = document.createElement('span');
name.className = 'snapshot-name';
name.textContent = file.name;
name.title = file.path;
const meta = document.createElement('span');
meta.className = 'snapshot-meta';
meta.textContent = `${formatBytes(file.sizeBytes)} · ${new Date(
file.modifiedAtMs,
).toLocaleString()}`;
const actions = document.createElement('div');
actions.className = 'snapshot-actions';
const merge = document.createElement('button');
merge.type = 'button';
merge.className = 'mini-button';
merge.textContent = 'Merge';
merge.title = 'Merge this snapshot into the local database';
merge.disabled = snapshot?.run.running ?? false;
merge.addEventListener('click', () => {
retryWithForce = () => {
void startRun(() => api.mergeSnapshotFile(file.path, true));
};
void startRun(() => api.mergeSnapshotFile(file.path));
});
const reveal = document.createElement('button');
reveal.type = 'button';
reveal.className = 'mini-button';
reveal.textContent = 'Reveal';
reveal.addEventListener('click', () => {
void api.revealSnapshot(file.path);
});
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'mini-button danger';
remove.textContent = 'Delete';
remove.addEventListener('click', () => {
if (!window.confirm(`Delete snapshot ${file.name}?`)) return;
void api.deleteSnapshot(file.path);
});
actions.append(merge, reveal, remove);
row.append(name, meta, actions);
return row;
}),
);
snapshotListEmpty.classList.toggle('hidden', files.length > 0);
}
async function refresh(): Promise<void> {
snapshot = await api.getSnapshot();
dbPathEl.textContent = snapshot.dbPath;
dbPathEl.title = snapshot.dbPath;
if (document.activeElement !== autoIntervalInput) {
autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes);
}
setRunPill();
renderHosts();
renderSnapshots();
createSnapshotButton.disabled = snapshot.run.running;
mergeFileButton.disabled = snapshot.run.running;
}
function refreshFromState(): void {
void refresh();
}
addHostButton.addEventListener('click', () => {
const host = newHostInput.value.trim();
if (!host) {
newHostInput.focus();
return;
}
const label = newLabelInput.value.trim();
void api
.saveHost({ host, label: label || null })
.then(() => {
newHostInput.value = '';
newLabelInput.value = '';
checkResultEl.classList.add('hidden');
})
.catch((error: unknown) => {
renderCheckResult({
host,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
});
});
newHostInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') addHostButton.click();
});
testHostButton.addEventListener('click', () => {
const host = newHostInput.value.trim();
if (!host) {
newHostInput.focus();
return;
}
testHostButton.disabled = true;
testHostButton.textContent = 'Testing…';
void api
.checkHost(host)
.then((result) => renderCheckResult(result))
.finally(() => {
testHostButton.disabled = false;
testHostButton.textContent = 'Test connection';
});
});
autoIntervalInput.addEventListener('change', () => {
const minutes = Number(autoIntervalInput.value);
if (!Number.isFinite(minutes) || minutes < 1) return;
void api.setAutoSyncInterval(Math.floor(minutes));
});
cancelButton.addEventListener('click', () => {
void api.cancelRun();
});
createSnapshotButton.addEventListener('click', () => {
retryWithForce = null;
void startRun(() => api.createSnapshot());
});
mergeFileButton.addEventListener('click', () => {
void api.pickSnapshotFile().then((path) => {
if (!path) return;
retryWithForce = () => {
void startRun(() => api.mergeSnapshotFile(path, true));
};
void startRun(() => api.mergeSnapshotFile(path));
});
});
api.onProgress(handleProgress);
api.onStateChanged(refreshFromState);
void refresh();