Download
1597 lines 54.9 KiBTypeScriptModified 2026-10-16 raw
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597
export {};

declare global {
  interface Window {
    __TAURI__: {
      core: {
        invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
      };
    };
    toggleJob: (idx: number, enabled: boolean) => Promise<void>;
    editJob: (idx: number) => Promise<void>;
    togglePause: () => Promise<void>;
    cancelBackup: () => Promise<void>;
    showConfigFromBackup: () => Promise<void>;
    ejectDrive: () => Promise<void>;
    goToRestore: () => Promise<void>;
  }
}

const { invoke } = window.__TAURI__.core;

// ── Types ──────────────────────────────────────────────────────────────────────

interface DriveInfo {
  device: string;
  display_name: string;
  size?: string;
  tran?: string;
  fstype?: string;
  is_encrypted: boolean;
  is_mounted: boolean;
  dev_type: string;
  luks_parent?: string;
}

interface BackupJob {
  name: string;
  source: string;
  destination: string;
  excludes: string[];
  mode: string;
  enabled: boolean;
  exclude_caches: boolean;
}

interface BackupConfig {
  jobs: BackupJob[];
  last_backup?: string;
}

interface AppStatus {
  config?: BackupConfig;
  config_dirty: boolean;
  mount_point?: string;
}

interface OpenDriveResult {
  error?: string;
  needs_password?: boolean;
  mounted?: {
    mount_point: string;
    config: BackupConfig;
  };
}

interface UnlockDriveResult {
  mount_point: string;
  config: BackupConfig;
}

interface BackupProgress {
  running: boolean;
  finished: boolean;
  finished_msg?: string;
  error?: string;
  paused: boolean;
  cancelled: boolean;
  job_name: string;
  current_file: string;
  overall_fraction: number;
  elapsed?: string;
  eta: string;
  log_lines: string[];
}

interface PreviewCommand {
  name: string;
  cmd: string;
}

interface RemoteDirEntry {
  name: string;
  is_dir: boolean;
  size: string;
}

interface TreeNode {
  name: string;
  path: string;
  isDir: boolean;
  size: string;
  children: TreeNode[] | null;
  loading: boolean;
  expanded: boolean;
}

interface DriveProbeResult {
  finished: boolean;
  lsblk_text?: string;
  note?: string;
  df_text?: string;
  ls_text?: string;
}

interface FormatProgress {
  step: number;
  total_steps: number;
  step_name: string;
  finished: boolean;
  error?: string;
  log: string[];
}

interface WipeProgress {
  bytes_written: number;
  total_bytes: number;
  finished: boolean;
  error?: string;
  cancelled: boolean;
}

// ── State ─────────────────────────────────────────────────────────────────────

let drives: DriveInfo[] = [];
let selectedDevice: string | null = null;
let editingJobIdx: number | null = null;
let treeSource = '';
let treeRoots: TreeNode[] = [];
let treeLoadError = '';
let treeLoadGen = 0;
let treeLoaded = false;

function getExcludeTextarea(): HTMLTextAreaElement {
  return document.getElementById('job-excludes-manual') as HTMLTextAreaElement;
}

function getTextareaLines(): string[] {
  return getExcludeTextarea()
    .value.split('\n')
    .map((s) => s.trim())
    .filter(Boolean);
}

function getTextareaExcludedPaths(): Set<string> {
  const presetPatternSet = new Set(PRESETS.flatMap((p) => p.patterns));
  const paths = new Set<string>();
  for (const line of getTextareaLines()) {
    if (!presetPatternSet.has(line) && line.startsWith('/') && !/[*?[{]/.test(line)) {
      paths.add(line.replace(/^\//, '').replace(/\/$/, ''));
    }
  }
  return paths;
}

const PRESETS: Array<{ id: string; label: string; title: string; patterns: string[] }> = [
  {
    id: 'cache',
    label: '~/.cache',
    title: 'Browser caches, thumbnails, app data caches (/.cache/)',
    patterns: ['/.cache/'],
  },
  {
    id: 'trash',
    label: 'Trash',
    title: 'Deleted files in ~/.local/share/Trash',
    patterns: ['/.local/share/Trash/'],
  },
  {
    id: 'node',
    label: 'node_modules',
    title: 'npm/yarn/pnpm dependency directories (node_modules/)',
    patterns: ['node_modules/'],
  },
  {
    id: 'python',
    label: 'Python',
    title: 'Bytecode and virtual environments (__pycache__/, *.pyc, .venv/)',
    patterns: ['__pycache__/', '*.pyc', '*.pyo', '.venv/', 'venv/'],
  },
  {
    id: 'rust',
    label: 'Rust build',
    title: 'Cargo compiler output (target/)',
    patterns: ['target/'],
  },
  {
    id: 'temp',
    label: 'Temp files',
    title: 'Temporary files and editor backups (*.tmp, *~, *.swp)',
    patterns: ['*.tmp', '*.temp', '*~', '*.swp', '*.swo'],
  },
  {
    id: 'steam',
    label: 'Steam',
    title: 'Game files — large, re-downloadable (/.local/share/Steam/)',
    patterns: ['/.local/share/Steam/'],
  },
  {
    id: 'osjunk',
    label: 'OS junk',
    title: 'macOS/Windows metadata files (.DS_Store, Thumbs.db)',
    patterns: ['.DS_Store', 'Thumbs.db'],
  },
];
let backupPollId: ReturnType<typeof setInterval> | null = null;
let formatPollId: ReturnType<typeof setInterval> | null = null;
let wipePollId: ReturnType<typeof setInterval> | null = null;
let probePollId: ReturnType<typeof setInterval> | null = null;
let formatDevice: string | null = null;
let formatIsDisk = false;
let operationIsRestore = false;
let pendingRestoreSnapshot: string | null = null;
let pendingRestoreJobIndices: number[] = [];
let pendingRestoreSubpaths: string[] = [];
let pendingRestoreDeleteExtra = false;

// ── Screen routing ────────────────────────────────────────────────────────────

function showScreen(name: string): void {
  document.querySelectorAll('.screen').forEach((s) => s.classList.remove('active'));
  document.getElementById('screen-' + name)!.classList.add('active');
  document.querySelector('main')!.scrollTop = 0;
}

function setStatusBar(msg: string, loading = false): void {
  const bar = document.getElementById('status-bar')!;
  if (loading) {
    bar.innerHTML = `<span class="spinner"></span> ${escHtml(msg)}`;
  } else {
    bar.textContent = msg || '';
  }
}

// ── Drive Select screen ───────────────────────────────────────────────────────

async function refreshDrives(): Promise<void> {
  try {
    drives = await invoke<DriveInfo[]>('list_drives');
  } catch (e) {
    drives = [];
    setStatusBar('Error listing drives: ' + e);
  }
  renderDriveList();
}

function renderDriveList(): void {
  const el = document.getElementById('drive-list')!;
  if (drives.length === 0) {
    el.innerHTML = '<div class="empty-state">No removable drives detected.</div>';
  } else {
    el.innerHTML = drives
      .map((d, i) => {
        const badges: string[] = [];
        if (d.tran)
          badges.push(`<span class="badge badge-usb">${escHtml(d.tran.toUpperCase())}</span>`);
        if (d.fstype && d.fstype !== 'crypto_LUKS')
          badges.push(`<span class="badge badge-fs">${escHtml(d.fstype)}</span>`);
        if (d.is_encrypted) badges.push('<span class="badge badge-luks">LUKS</span>');
        if (d.is_mounted) badges.push('<span class="badge badge-mounted">mounted</span>');
        const selected = d.device === selectedDevice ? ' selected' : '';
        return `
        <div class="drive-item${selected}" data-device="${d.device}" data-idx="${i}">
          <div>
            <div class="drive-name">${escHtml(d.display_name)}</div>
            <div class="drive-detail">${escHtml(d.device)}</div>
            <div class="badges">${badges.join('')}</div>
          </div>
          <div class="drive-size">${escHtml(d.size || '')}</div>
        </div>`;
      })
      .join('');

    el.querySelectorAll('.drive-item').forEach((item) => {
      const htmlItem = item as HTMLElement;
      htmlItem.addEventListener('click', () => {
        selectedDevice = htmlItem.dataset.device ?? null;
        renderDriveList();
        updateDriveSelectButtons();
      });
      htmlItem.addEventListener('dblclick', () => openSelectedDrive());
    });
  }
  updateDriveSelectButtons();
}

function updateDriveSelectButtons(): void {
  const drive = drives.find((d) => d.device === selectedDevice);
  (document.getElementById('btn-open-drive') as HTMLButtonElement).disabled = !drive;
  const canFormat = drive && (drive.dev_type === 'disk' || drive.dev_type === 'part');
  (document.getElementById('btn-format-drive') as HTMLButtonElement).disabled = !canFormat;
}

async function openSelectedDrive(): Promise<void> {
  if (!selectedDevice) return;
  setError('drive-select-error', '');
  try {
    const result = await invoke<OpenDriveResult>('open_drive', { device: selectedDevice });
    if (result.error) {
      setError('drive-select-error', result.error);
    } else if (result.needs_password) {
      const drive = drives.find((d) => d.device === selectedDevice);
      document.getElementById('password-heading')!.textContent =
        'Unlock: ' + (drive ? drive.display_name : selectedDevice);
      (document.getElementById('password-input') as HTMLInputElement).value = '';
      setError('password-error', '');
      showScreen('password');
      (document.getElementById('password-input') as HTMLInputElement).focus();
    } else if (result.mounted) {
      enterConfig(result.mounted.mount_point, result.mounted.config);
    }
  } catch (e) {
    setError('drive-select-error', String(e));
  }
}

// ── Password screen ───────────────────────────────────────────────────────────

async function unlockDrive(): Promise<void> {
  const pw = (document.getElementById('password-input') as HTMLInputElement).value;
  setError('password-error', '');
  (document.getElementById('btn-password-unlock') as HTMLButtonElement).disabled = true;
  try {
    const result = await invoke<UnlockDriveResult>('unlock_drive', {
      device: selectedDevice,
      password: pw,
    });
    enterConfig(result.mount_point, result.config);
  } catch (e) {
    setError('password-error', String(e));
  } finally {
    (document.getElementById('btn-password-unlock') as HTMLButtonElement).disabled = false;
  }
}

// ── Config Editor screen ──────────────────────────────────────────────────────

function enterConfig(mountPoint: string, config: BackupConfig): void {
  renderConfig(mountPoint, config);
  showScreen('config');
}

function renderConfig(mountPoint: string, config: BackupConfig): void {
  document.getElementById('config-mount-point')!.textContent = 'Drive: ' + mountPoint;
  if (config.last_backup) {
    const d = new Date(config.last_backup);
    document.getElementById('config-last-backup')!.textContent =
      'Last backup: ' + d.toLocaleString();
  } else {
    document.getElementById('config-last-backup')!.textContent = '';
  }
  renderJobsTable(config.jobs);
}

function renderJobsTable(jobs: BackupJob[]): void {
  const tbody = document.getElementById('jobs-tbody')!;
  if (jobs.length === 0) {
    tbody.innerHTML =
      '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:16px">No jobs configured.</td></tr>';
    return;
  }
  tbody.innerHTML = jobs
    .map(
      (j, i) => `
    <tr>
      <td>${escHtml(j.name)}</td>
      <td class="mono direction-cell">${escHtml(j.source)}<span class="dir-arrow">→</span>${escHtml(j.destination)}</td>
      <td>${j.mode === 'Backup' ? '📸 Snapshot' : j.mode === 'Media' ? '📚 Hoard' : escHtml(j.mode)}</td>
      <td>
        <input type="checkbox" ${j.enabled ? 'checked' : ''}
          onchange="toggleJob(${i}, this.checked)">
      </td>
      <td class="actions">
        <button onclick="editJob(${i})">Edit</button>
      </td>
    </tr>
  `
    )
    .join('');
}

async function toggleJob(idx: number, enabled: boolean): Promise<void> {
  const status = await invoke<AppStatus>('get_status');
  if (!status.config) return;
  const config = status.config;
  config.jobs[idx].enabled = enabled;
  await invoke('update_config', { config });
  await refreshConfigView();
}

async function refreshConfigView(): Promise<void> {
  const status = await invoke<AppStatus>('get_status');
  if (status.config) {
    renderJobsTable(status.config.jobs);
    const dirty = status.config_dirty;
    (document.getElementById('btn-save-config') as HTMLElement).style.display = dirty ? '' : 'none';
    (document.getElementById('unsaved-indicator') as HTMLElement).style.display = dirty
      ? ''
      : 'none';
  }
}

async function addJob(): Promise<void> {
  try {
    const config = await invoke<BackupConfig>('add_job');
    await editJob(config.jobs.length - 1);
  } catch (e) {
    setError('config-error', String(e));
  }
}

// ── Excludes file tree ────────────────────────────────────────────────────────

function isEffectivelyExcluded(path: string, excludedPaths: Set<string>): boolean {
  if (excludedPaths.has(path)) return true;
  const parts = path.split('/');
  for (let i = 1; i < parts.length; i++) {
    if (excludedPaths.has(parts.slice(0, i).join('/'))) return true;
  }
  return false;
}

function hasExcludedDescendant(node: TreeNode, excludedPaths: Set<string>): boolean {
  if (!node.children) return false;
  return node.children.some(
    (c) => excludedPaths.has(c.path) || hasExcludedDescendant(c, excludedPaths)
  );
}

function toggleTreeNode(path: string): void {
  const ta = getExcludeTextarea();
  const pattern = '/' + path;
  const excludedPaths = getTextareaExcludedPaths();
  let lines = getTextareaLines();
  if (excludedPaths.has(path)) {
    lines = lines.filter((l) => l !== pattern && l !== pattern + '/');
  } else if (!isEffectivelyExcluded(path, excludedPaths)) {
    lines.push(pattern);
    lines = lines.filter((l) => l === pattern || !l.startsWith(pattern + '/'));
  }
  ta.value = lines.join('\n');
  renderExcludesTree();
}

function findTreeNode(nodes: TreeNode[], path: string): TreeNode | null {
  for (const node of nodes) {
    if (node.path === path) return node;
    if (node.children && path.startsWith(node.path + '/')) {
      const found = findTreeNode(node.children, path);
      if (found) return found;
    }
  }
  return null;
}

async function toggleTreeExpand(path: string): Promise<void> {
  const node = findTreeNode(treeRoots, path);
  if (!node || !node.isDir || node.loading) return;
  node.expanded = !node.expanded;
  if (node.expanded && node.children === null) {
    node.loading = true;
    renderExcludesTree();
    try {
      const fullPath = treeSource.replace(/\/$/, '') + '/' + path;
      const entries = await invoke<RemoteDirEntry[]>('list_dir', { path: fullPath });
      node.children = entries.map((e) => ({
        name: e.name,
        path: path + '/' + e.name,
        isDir: e.is_dir,
        size: e.size,
        children: null,
        loading: false,
        expanded: false,
      }));
    } catch (_) {
      node.children = null;
      node.expanded = false;
    }
    node.loading = false;
  }
  renderExcludesTree();
}

async function loadExcludesTree(source: string, excludes: string[]): Promise<void> {
  const gen = ++treeLoadGen;
  treeSource = source.trim();
  treeRoots = [];
  treeLoaded = false;
  treeLoadError = '';
  getExcludeTextarea().value = excludes.join('\n');
  renderPresetChips();
  renderExcludesTree();
  if (!treeSource) return;

  try {
    const entries = await invoke<RemoteDirEntry[]>('list_dir', { path: treeSource });
    if (gen !== treeLoadGen) return;
    treeRoots = entries.map((e) => ({
      name: e.name,
      path: e.name,
      isDir: e.is_dir,
      size: e.size,
      children: null,
      loading: false,
      expanded: false,
    }));
  } catch (err) {
    if (gen !== treeLoadGen) return;
    treeLoadError = String(err);
  }
  treeLoaded = true;
  renderExcludesTree();
}

async function reloadTreeSource(source: string): Promise<void> {
  const gen = ++treeLoadGen;
  treeSource = source.trim();
  treeRoots = [];
  treeLoaded = false;
  treeLoadError = '';
  renderExcludesTree();
  if (!treeSource) return;
  try {
    const entries = await invoke<RemoteDirEntry[]>('list_dir', { path: treeSource });
    if (gen !== treeLoadGen) return;
    treeRoots = entries.map((e) => ({
      name: e.name,
      path: e.name,
      isDir: e.is_dir,
      size: e.size,
      children: null,
      loading: false,
      expanded: false,
    }));
  } catch (err) {
    if (gen !== treeLoadGen) return;
    treeLoadError = String(err);
  }
  treeLoaded = true;
  renderExcludesTree();
}

function renderExcludesTree(): void {
  const el = document.getElementById('excludes-tree')!;
  const scrollTop = el.scrollTop;

  if (!treeSource) {
    el.innerHTML = '<div class="tree-msg">Enter a source folder above to browse files</div>';
    return;
  }
  if (treeLoadError) {
    el.innerHTML = `<div class="tree-msg tree-error">${escHtml(treeLoadError)}</div>`;
    return;
  }
  if (!treeLoaded) {
    el.innerHTML = '<div class="tree-msg"><span class="spinner"></span> Loading directory…</div>';
    return;
  }
  if (treeRoots.length === 0) {
    el.innerHTML = '<div class="tree-msg">Source folder is empty</div>';
    return;
  }

  const excludedPaths = getTextareaExcludedPaths();
  el.innerHTML = renderTreeNodes(treeRoots, 0, excludedPaths);
  el.scrollTop = scrollTop;

  el.querySelectorAll<HTMLInputElement>('.tree-check[data-ind]').forEach((cb) => {
    cb.indeterminate = true;
  });
  el.querySelectorAll<HTMLInputElement>('.tree-check:not([disabled])').forEach((cb) => {
    cb.addEventListener('change', () => toggleTreeNode(cb.dataset.path!));
  });
  el.querySelectorAll<HTMLButtonElement>('.tree-toggle').forEach((btn) => {
    btn.addEventListener('click', () => toggleTreeExpand(btn.dataset.path!));
  });
}

function renderTreeNodes(nodes: TreeNode[], depth: number, excludedPaths: Set<string>): string {
  return nodes.map((n) => renderTreeNode(n, depth, excludedPaths)).join('');
}

function renderTreeNode(node: TreeNode, depth: number, excludedPaths: Set<string>): string {
  const effExcluded = isEffectivelyExcluded(node.path, excludedPaths);
  const directExcl = excludedPaths.has(node.path);
  const viaAnc = effExcluded && !directExcl;
  const indeterminate = !effExcluded && hasExcludedDescendant(node, excludedPaths);
  const checked = !effExcluded;

  const indent = 8 + depth * 20;
  const icon = node.isDir ? '📁' : '📄';

  const cbParts = [
    'type="checkbox"',
    'class="tree-check"',
    `data-path="${escHtml(node.path)}"`,
    checked || indeterminate ? 'checked' : '',
    viaAnc ? 'disabled' : '',
    indeterminate ? 'data-ind' : '',
  ].filter(Boolean);
  const cbAttrs = cbParts.join(' ');

  const toggleEl = node.isDir
    ? `<button class="tree-toggle" data-path="${escHtml(node.path)}">${node.expanded ? '▼' : '▶'}</button>`
    : `<span class="tree-toggle-ph"></span>`;

  let childrenHtml = '';
  if (node.isDir && node.expanded) {
    if (node.loading) {
      childrenHtml = `<div class="tree-children"><div class="tree-msg"><span class="spinner"></span></div></div>`;
    } else if (!node.children || node.children.length === 0) {
      childrenHtml = `<div class="tree-children"><div class="tree-msg">Empty directory</div></div>`;
    } else {
      childrenHtml = `<div class="tree-children">${renderTreeNodes(node.children, depth + 1, excludedPaths)}</div>`;
    }
  }

  return `<div class="tree-node${effExcluded ? ' is-excluded' : ''}">
    <div class="tree-row" style="padding-left:${indent}px">
      <input ${cbAttrs}>
      ${toggleEl}
      <span class="tree-icon">${icon}</span>
      <span class="tree-name">${escHtml(node.name)}</span>
      ${node.size ? `<span class="tree-size">${escHtml(node.size)}</span>` : ''}
    </div>
    ${childrenHtml}
  </div>`;
}

function renderPresetChips(): void {
  const lines = new Set(getTextareaLines());
  const el = document.getElementById('presets-chips')!;
  el.innerHTML = PRESETS.map(
    (p) =>
      `<button class="preset-chip${p.patterns.every((pat) => lines.has(pat)) ? ' active' : ''}" data-id="${escHtml(p.id)}" title="${escHtml(p.title)}">${escHtml(p.label)}</button>`
  ).join('');
  el.querySelectorAll<HTMLButtonElement>('.preset-chip').forEach((btn) => {
    btn.addEventListener('click', () => {
      const id = btn.dataset.id!;
      const preset = PRESETS.find((p) => p.id === id)!;
      const ta = getExcludeTextarea();
      let curLines = getTextareaLines();
      const lineSet = new Set(curLines);
      if (preset.patterns.every((p) => lineSet.has(p))) {
        const patSet = new Set(preset.patterns);
        curLines = curLines.filter((l) => !patSet.has(l));
      } else {
        for (const p of preset.patterns) {
          if (!lineSet.has(p)) curLines.push(p);
        }
      }
      ta.value = curLines.join('\n');
      renderPresetChips();
      renderExcludesTree();
    });
  });
}

function buildExcludes(): string[] {
  const seen = new Set<string>();
  const result: string[] = [];
  for (const line of getTextareaLines()) {
    if (!seen.has(line)) {
      seen.add(line);
      result.push(line);
    }
  }
  return result;
}

async function editJob(idx: number): Promise<void> {
  const status = await invoke<AppStatus>('get_status');
  const jobs = status.config?.jobs || [];
  const job = jobs[idx];
  editingJobIdx = idx;

  document.getElementById('job-edit-heading')!.textContent =
    idx < jobs.length ? 'Edit Job' : 'New Job';
  (document.getElementById('job-name') as HTMLInputElement).value = job?.name || '';
  (document.getElementById('job-source') as HTMLInputElement).value = job?.source || '';
  (document.getElementById('job-dest') as HTMLInputElement).value = job?.destination || '';
  (document.getElementById('job-enabled') as HTMLInputElement).checked = job?.enabled ?? true;
  (document.getElementById('job-exclude-caches') as HTMLInputElement).checked =
    job?.exclude_caches ?? true;
  const mode = job?.mode || 'Backup';
  document.querySelectorAll<HTMLInputElement>('input[name="job-mode"]').forEach((r) => {
    r.checked = r.value === mode;
  });
  (document.getElementById('btn-job-delete') as HTMLElement).style.display =
    idx < jobs.length ? '' : 'none';
  showScreen('job-edit');
  loadExcludesTree(job?.source || '', job?.excludes || []);
}

async function saveJob(): Promise<void> {
  const status = await invoke<AppStatus>('get_status');
  const config = status.config;
  if (!config) return;

  const excludes = buildExcludes();

  const modeEl = document.querySelector<HTMLInputElement>('input[name="job-mode"]:checked');
  const job: BackupJob = {
    name: (document.getElementById('job-name') as HTMLInputElement).value,
    source: (document.getElementById('job-source') as HTMLInputElement).value,
    destination: (document.getElementById('job-dest') as HTMLInputElement).value,
    excludes,
    mode: modeEl ? modeEl.value : 'Backup',
    enabled: (document.getElementById('job-enabled') as HTMLInputElement).checked,
    exclude_caches: (document.getElementById('job-exclude-caches') as HTMLInputElement).checked,
  };

  if (editingJobIdx !== null && editingJobIdx < config.jobs.length) {
    config.jobs[editingJobIdx] = job;
  } else {
    config.jobs.push(job);
  }

  await invoke('update_config', { config });
  await invoke('save_config');
  const newStatus = await invoke<AppStatus>('get_status');
  enterConfig(newStatus.mount_point!, newStatus.config!);
  (document.getElementById('btn-save-config') as HTMLElement).style.display = newStatus.config_dirty
    ? ''
    : 'none';
  (document.getElementById('unsaved-indicator') as HTMLElement).style.display =
    newStatus.config_dirty ? '' : 'none';
}

async function deleteJob(): Promise<void> {
  if (editingJobIdx === null) return;
  try {
    await invoke<BackupConfig>('delete_job', { idx: editingJobIdx });
    await invoke('save_config');
    const status = await invoke<AppStatus>('get_status');
    if (!status.mount_point || !status.config) {
      showScreen('drive-select');
      return;
    }
    enterConfig(status.mount_point, status.config);
    (document.getElementById('btn-save-config') as HTMLElement).style.display = status.config_dirty
      ? ''
      : 'none';
    (document.getElementById('unsaved-indicator') as HTMLElement).style.display =
      status.config_dirty ? '' : 'none';
  } catch (e) {
    alert('Delete failed: ' + e);
  }
}

async function saveConfig(): Promise<void> {
  try {
    await invoke('save_config');
    (document.getElementById('btn-save-config') as HTMLElement).style.display = 'none';
    (document.getElementById('unsaved-indicator') as HTMLElement).style.display = 'none';
    setStatusBar('Config saved.');
    setTimeout(() => setStatusBar(''), 2000);
  } catch (e) {
    setError('config-error', String(e));
  }
}

async function ejectDrive(): Promise<void> {
  const btn = document.getElementById('btn-eject') as HTMLButtonElement | null;
  if (btn) btn.disabled = true;
  setStatusBar('Ejecting…', true);
  if (wipePollId) {
    clearInterval(wipePollId);
    wipePollId = null;
  }
  try {
    await invoke('eject');
    selectedDevice = null;
    await refreshDrives();
    showScreen('drive-select');
  } catch (e) {
    setError('config-error', String(e));
    if (btn) btn.disabled = false;
  } finally {
    setStatusBar('');
  }
}

async function goToPreview(): Promise<void> {
  const status = await invoke<AppStatus>('get_status');
  if (status.config_dirty) {
    await invoke('save_config');
  }
  const cmds = await invoke<PreviewCommand[]>('preview_commands');
  const el = document.getElementById('preview-commands')!;
  const empty = document.getElementById('preview-empty')!;
  if (cmds.length === 0) {
    el.style.display = 'none';
    el.innerHTML = '';
    empty.style.display = '';
    (document.getElementById('btn-run-backup') as HTMLButtonElement).disabled = true;
  } else {
    el.style.display = '';
    empty.style.display = 'none';
    el.innerHTML = cmds
      .map(
        (c: PreviewCommand) => `
      <div class="preview-cmd-block">
        <div class="cmd-name">${escHtml(c.name)}</div>
        <pre>${escHtml(c.cmd)}</pre>
      </div>`
      )
      .join('');
    (document.getElementById('btn-run-backup') as HTMLButtonElement).disabled = false;
  }
  showScreen('preview');
}

// ── Backup screen ─────────────────────────────────────────────────────────────

async function startBackup(): Promise<void> {
  try {
    await invoke('start_backup');
  } catch (e) {
    alert('Failed to start backup: ' + e);
    return;
  }
  operationIsRestore = false;
  document.querySelector<HTMLElement>('#screen-backup h2')!.textContent = 'Backup';
  showScreen('backup');
  resetBackupUI();
  startBackupPoll();
}

function resetBackupUI(): void {
  (document.getElementById('backup-status-banner') as HTMLElement).style.display = 'none';
  document.getElementById('backup-job-info')!.textContent = '';
  setProgressBar(0, false, false);
  document.getElementById('backup-elapsed')!.textContent = '';
  document.getElementById('backup-log')!.innerHTML = '';
  _paused = false;
  const btnRow = document.getElementById('backup-btn-row')!;
  btnRow.innerHTML = `
    <button id="btn-backup-pause" onclick="togglePause()">Pause</button>
    <button id="btn-backup-cancel" onclick="cancelBackup()">Cancel</button>`;
}

let _paused = false;

async function togglePause(): Promise<void> {
  if (_paused) {
    await invoke('resume_backup');
    _paused = false;
    document.getElementById('btn-backup-pause')!.textContent = 'Pause';
  } else {
    await invoke('pause_backup');
    _paused = true;
    document.getElementById('btn-backup-pause')!.textContent = 'Resume';
  }
}

async function cancelBackup(): Promise<void> {
  await invoke('cancel_backup');
}

function startBackupPoll(): void {
  if (backupPollId) clearInterval(backupPollId);
  backupPollId = setInterval(pollBackup, 250);
}

async function pollBackup(): Promise<void> {
  const p = await invoke<BackupProgress>('get_backup_progress');
  updateBackupUI(p);
  if (!p.running && (p.finished || p.cancelled || p.error)) {
    clearInterval(backupPollId!);
    backupPollId = null;
  }
}

function updateBackupUI(p: BackupProgress): void {
  const banner = document.getElementById('backup-status-banner')!;
  const jobInfo = document.getElementById('backup-job-info')!;
  const elapsed = document.getElementById('backup-elapsed')!;

  const done = p.finished && !p.error;
  const hasErr = Boolean(p.error);
  setProgressBar(p.overall_fraction, hasErr, done);

  elapsed.textContent = p.elapsed ? 'Elapsed: ' + p.elapsed : '';

  if (p.error) {
    banner.className = 'banner danger';
    banner.textContent = 'Error: ' + p.error;
    banner.style.display = '';
    jobInfo.textContent = '';
  } else if (p.finished_msg) {
    banner.className = p.cancelled ? 'banner warning' : 'banner success';
    banner.textContent = p.finished_msg;
    banner.style.display = '';
    jobInfo.textContent = '';
  } else if (p.paused) {
    banner.className = 'banner warning';
    banner.textContent = 'Paused — ' + p.job_name;
    banner.style.display = '';
    jobInfo.textContent = '';
  } else {
    banner.style.display = 'none';
    jobInfo.innerHTML = p.running
      ? `<div class="job-info-row">
           <span class="job-info-name">${escHtml(p.job_name)}</span>
           <span class="job-info-file muted">${escHtml(p.current_file)}</span>
         </div>
         <div class="job-info-eta muted">ETA: ${escHtml(p.eta)}</div>`
      : '';
  }

  // Log
  const logEl = document.getElementById('backup-log')!;
  const wasAtBottom = logEl.scrollHeight - logEl.clientHeight <= logEl.scrollTop + 4;
  logEl.innerHTML = p.log_lines
    .map((l) => `<div class="${l.startsWith('>>>') ? 'log-cmd' : ''}">${escHtml(l)}</div>`)
    .join('');
  if (wasAtBottom) logEl.scrollTop = logEl.scrollHeight;

  // Swap buttons when done
  if (!p.running && (p.finished || p.cancelled || p.error)) {
    const btnRow = document.getElementById('backup-btn-row')!;
    const restoreAgainBtn = operationIsRestore
      ? `<button onclick="goToRestore()">↺ Restore Again</button>`
      : '';
    btnRow.innerHTML = `
      ${restoreAgainBtn}
      <button onclick="showConfigFromBackup()">← Back to Config</button>
      <button onclick="ejectDrive()" class="primary">Eject Drive</button>`;
  }
}

function setProgressBar(fraction: number, error: boolean, done: boolean): void {
  const bar = document.getElementById('backup-progress-bar')!;
  const label = document.getElementById('backup-progress-label')!;
  const pct = Math.round(fraction * 100);
  bar.style.width = pct + '%';
  label.textContent = pct + '%';
  bar.className = 'progress-bar' + (error ? ' error' : done ? ' done' : '');
}

async function showConfigFromBackup(): Promise<void> {
  const status = await invoke<AppStatus>('get_status');
  if (!status.mount_point || !status.config) {
    showScreen('drive-select');
    return;
  }
  enterConfig(status.mount_point, status.config);
}

// ── Restore screen ────────────────────────────────────────────────────────────

async function goToRestore(): Promise<void> {
  const [snapshots, status] = await Promise.all([
    invoke<string[]>('list_snapshots').catch(() => [] as string[]),
    invoke<AppStatus>('get_status'),
  ]);
  const jobs = status.config?.jobs || [];

  const snapshotEl = document.getElementById('restore-snapshot-list')!;
  if (snapshots.length === 0) {
    snapshotEl.innerHTML =
      '<p class="muted">No snapshots available yet — complete a backup first to create one.</p>';
  } else {
    snapshotEl.innerHTML = snapshots
      .slice()
      .reverse()
      .map(
        (s, i) => `
      <label class="radio-option">
        <input type="radio" name="restore-snapshot" value="${escHtml(s)}" ${i === 0 ? 'checked' : ''} />
        <span>${snapshotLabel(s)}</span>
      </label>`
      )
      .join('');
  }

  const jobsEl = document.getElementById('restore-jobs-list')!;
  if (jobs.length === 0) {
    jobsEl.innerHTML = '<p class="muted">No jobs configured.</p>';
  } else {
    jobsEl.innerHTML = jobs
      .map(
        (j, i) => `
      <div class="restore-job-entry">
        <label class="radio-option">
          <input type="checkbox" class="restore-job-check" data-idx="${i}" checked />
          <span>
            <strong>${escHtml(j.name)}</strong>
            <span class="radio-desc">💾 <code>${escHtml(String(j.destination))}</code><span class="dir-arrow">→</span>💻 <code>${escHtml(j.source)}</code></span>
          </span>
        </label>
        <div class="restore-subpath-row">
          <span class="subpath-label">Subpath:</span>
          <input type="text" class="restore-job-subpath-input" data-idx="${i}"
                 placeholder="(entire job)" />
        </div>
      </div>`
      )
      .join('');
  }

  (document.getElementById('restore-delete-extra') as HTMLInputElement).checked = false;
  (document.getElementById('restore-confirm-input') as HTMLInputElement).value = '';
  setError('restore-error', '');
  (document.getElementById('restore-validation-msg') as HTMLElement).style.display = 'none';
  (document.getElementById('btn-do-restore') as HTMLButtonElement).disabled = true;

  showScreen('restore');
}

function validateRestore(): void {
  const confirm = (document.getElementById('restore-confirm-input') as HTMLInputElement).value;
  const hasSnapshot =
    document.querySelector<HTMLInputElement>('input[name="restore-snapshot"]:checked') !== null;
  const hasJobs = Array.from(
    document.querySelectorAll<HTMLInputElement>('.restore-job-check')
  ).some((cb) => cb.checked);

  const msgEl = document.getElementById('restore-validation-msg')!;
  if (confirm && confirm !== 'RESTORE') {
    msgEl.textContent = 'Type exactly: RESTORE';
    msgEl.style.display = '';
  } else {
    msgEl.textContent = '';
    msgEl.style.display = 'none';
  }

  (document.getElementById('btn-do-restore') as HTMLButtonElement).disabled = !(
    confirm === 'RESTORE' &&
    hasSnapshot &&
    hasJobs
  );
}

async function goToRestorePreview(): Promise<void> {
  const snapshotInput = document.querySelector<HTMLInputElement>(
    'input[name="restore-snapshot"]:checked'
  );
  pendingRestoreSnapshot = snapshotInput?.value || null;
  pendingRestoreJobIndices = Array.from(
    document.querySelectorAll<HTMLInputElement>('.restore-job-check')
  )
    .filter((cb) => cb.checked)
    .map((cb) => parseInt(cb.dataset.idx!, 10));
  pendingRestoreSubpaths = pendingRestoreJobIndices.map((idx) => {
    const input = document.querySelector<HTMLInputElement>(
      `.restore-job-subpath-input[data-idx="${idx}"]`
    );
    return input?.value.trim() ?? '';
  });
  pendingRestoreDeleteExtra = (document.getElementById('restore-delete-extra') as HTMLInputElement)
    .checked;

  const btn = document.getElementById('btn-do-restore') as HTMLButtonElement;
  btn.disabled = true;
  btn.textContent = 'Running preview…';
  setError('restore-error', '');

  try {
    const lines = await invoke<string[]>('preview_restore', {
      snapshot: pendingRestoreSnapshot,
      jobIndices: pendingRestoreJobIndices,
      subpaths: pendingRestoreSubpaths,
      deleteExtra: pendingRestoreDeleteExtra,
    });

    const logEl = document.getElementById('restore-preview-log')!;
    const emptyEl = document.getElementById('restore-preview-empty')!;
    setError('restore-preview-error', '');

    const hasContent = lines.some((l) => l.trim());
    if (hasContent) {
      logEl.style.display = '';
      emptyEl.style.display = 'none';
      logEl.innerHTML = lines
        .map((l) => `<div class="${l.startsWith('>>>') ? 'log-cmd' : ''}">${escHtml(l)}</div>`)
        .join('');
      logEl.scrollTop = 0;
    } else {
      logEl.style.display = 'none';
      emptyEl.style.display = '';
    }

    showScreen('restore-preview');
  } catch (e) {
    setError('restore-error', String(e));
  } finally {
    btn.textContent = 'Preview Restore →';
    validateRestore();
  }
}

async function doRestore(): Promise<void> {
  setError('restore-preview-error', '');
  try {
    await invoke('start_restore', {
      snapshot: pendingRestoreSnapshot,
      jobIndices: pendingRestoreJobIndices,
      subpaths: pendingRestoreSubpaths,
      deleteExtra: pendingRestoreDeleteExtra,
    });
  } catch (e) {
    setError('restore-preview-error', String(e));
    return;
  }

  operationIsRestore = true;
  document.querySelector<HTMLElement>('#screen-backup h2')!.textContent = 'Restore';
  showScreen('backup');
  resetBackupUI();
  startBackupPoll();
}

// ── Format Setup screen ───────────────────────────────────────────────────────

async function enterFormatSetup(
  device: string,
  fstype: string | undefined,
  isDisk: boolean
): Promise<void> {
  formatDevice = device;
  formatIsDisk = isDisk;

  const drive = drives.find((d) => d.device === device);
  const infoLine = drive
    ? `${drive.device} · ${drive.display_name} · ${drive.size || 'unknown size'}`
    : device;
  document.getElementById('format-drive-info-line')!.textContent = infoLine;
  document.getElementById('format-confirm-label')!.textContent = `Type ${device} to confirm`;
  (document.getElementById('format-confirm-input') as HTMLInputElement).placeholder = device;

  (document.getElementById('format-label') as HTMLInputElement).value = 'Backup';
  (document.getElementById('format-fstype') as HTMLSelectElement).value = 'btrfs';
  const luksEl = document.getElementById('format-luks') as HTMLInputElement;
  luksEl.checked = true;
  document.getElementById('format-luks-section')!.style.display = 'contents';
  (document.getElementById('format-pass1') as HTMLInputElement).value = '';
  (document.getElementById('format-pass2') as HTMLInputElement).value = '';
  (document.getElementById('format-confirm-input') as HTMLInputElement).value = '';
  setError('format-error', '');
  setError('format-validation-msg', '');
  (document.getElementById('btn-do-format') as HTMLButtonElement).disabled = true;

  buildFormatCmdPreview(device, isDisk);
  startProbe(device, fstype);
  showScreen('format-setup');
}

function buildFormatCmdPreview(device: string, isDisk: boolean): void {
  const label =
    (document.getElementById('format-label') as HTMLInputElement).value.trim() || '<label>';
  const fstype = (document.getElementById('format-fstype') as HTMLSelectElement).value || 'btrfs';
  const encrypt = (document.getElementById('format-luks') as HTMLInputElement).checked;
  invoke<string[]>('format_command_preview', { device, isDisk, label, fstype, encrypt }).then(
    (lines) => {
      document.getElementById('format-cmd-preview')!.innerHTML = lines
        .map((l) => `<div>${escHtml(l)}</div>`)
        .join('');
    }
  );
}

function startProbe(device: string, fstype: string | undefined): void {
  document.getElementById('format-probe-content')!.innerHTML =
    '<em style="color:#9ca3af">Reading drive contents…</em>';
  invoke('start_probe_drive', { device, fstype: fstype || null });
  if (probePollId) clearInterval(probePollId);
  probePollId = setInterval(pollProbe, 300);
}

async function pollProbe(): Promise<void> {
  const info = await invoke<DriveProbeResult>('get_drive_probe');
  if (!info.finished) return;
  clearInterval(probePollId!);
  probePollId = null;

  let html = '';
  if (info.lsblk_text) html += escHtml(info.lsblk_text) + '\n';
  if (info.note) html += '\n<span style="color:#fcd34d">' + escHtml(info.note) + '</span>\n';
  if (info.df_text)
    html += '\n<span style="color:#93c5fd">Disk usage (df -h):</span>\n' + escHtml(info.df_text);
  if (info.ls_text)
    html +=
      '\n<span style="color:#93c5fd">Top-level contents (ls -lAh):</span>\n' +
      escHtml(info.ls_text);
  document.getElementById('format-probe-content')!.innerHTML = html || '(no info)';
}

function validateFormat(): void {
  const label = (document.getElementById('format-label') as HTMLInputElement).value.trim();
  const luks = (document.getElementById('format-luks') as HTMLInputElement).checked;
  const p1 = (document.getElementById('format-pass1') as HTMLInputElement).value;
  const p2 = (document.getElementById('format-pass2') as HTMLInputElement).value;
  const confirm = (
    document.getElementById('format-confirm-input') as HTMLInputElement
  ).value.trim();

  let msg = '';
  if (luks && p2 && p1 !== p2) msg = 'Passphrases do not match.';
  if (confirm && confirm !== formatDevice) msg = 'Must match exactly: ' + formatDevice;

  const msgEl = document.getElementById('format-validation-msg')!;
  if (msg) {
    msgEl.textContent = msg;
    msgEl.style.display = '';
  } else {
    msgEl.textContent = '';
    msgEl.style.display = 'none';
  }

  const passphraseOk = !luks || (p1 !== '' && p1 === p2);
  const ok = label && passphraseOk && confirm === formatDevice;
  (document.getElementById('btn-do-format') as HTMLButtonElement).disabled = !ok;
}

async function doFormat(): Promise<void> {
  const label = (document.getElementById('format-label') as HTMLInputElement).value.trim();
  const fstype = (document.getElementById('format-fstype') as HTMLSelectElement).value || 'btrfs';
  const encrypt = (document.getElementById('format-luks') as HTMLInputElement).checked;
  const passphrase = (document.getElementById('format-pass1') as HTMLInputElement).value;
  (document.getElementById('format-pass1') as HTMLInputElement).value = '';
  (document.getElementById('format-pass2') as HTMLInputElement).value = '';
  (document.getElementById('format-confirm-input') as HTMLInputElement).value = '';

  try {
    await invoke('start_format', {
      device: formatDevice,
      isDisk: formatIsDisk,
      label,
      fstype,
      encrypt,
      passphrase,
    });
  } catch (e) {
    setError('format-error', String(e));
    return;
  }

  showScreen('format-progress');
  (document.getElementById('format-progress-banner') as HTMLElement).style.display = 'none';
  document.getElementById('format-step-label')!.textContent = 'Starting…';
  document.getElementById('format-step-dots')!.innerHTML = '';
  document.getElementById('format-log')!.innerHTML = '';
  (document.getElementById('format-done-btn-row') as HTMLElement).style.display = 'none';

  if (formatPollId) clearInterval(formatPollId);
  formatPollId = setInterval(pollFormat, 300);
}

async function pollFormat(): Promise<void> {
  const p = await invoke<FormatProgress>('get_format_progress');
  updateFormatUI(p);
  if (p.finished) {
    clearInterval(formatPollId!);
    formatPollId = null;
  }
}

function updateFormatUI(p: FormatProgress): void {
  const banner = document.getElementById('format-progress-banner')!;
  if (p.error) {
    banner.className = 'banner danger';
    banner.textContent = 'Error: ' + p.error;
    banner.style.display = '';
  } else if (p.finished) {
    banner.className = 'banner success';
    banner.textContent = 'Formatting complete!';
    banner.style.display = '';
  }

  if (!p.error && !p.finished) {
    document.getElementById('format-step-label')!.textContent =
      `Step ${p.step} of ${p.total_steps}: ${p.step_name}`;
  } else {
    document.getElementById('format-step-label')!.textContent = '';
  }

  const dotsEl = document.getElementById('format-step-dots')!;
  if (p.total_steps > 0) {
    dotsEl.innerHTML = Array.from({ length: p.total_steps }, (_, i) => {
      const n = i + 1;
      let cls = 'step-dot';
      if (n < p.step || (n === p.step && p.finished && !p.error)) cls += ' done';
      else if (n === p.step) cls += p.error ? ' error' : ' active';
      return `<div class="${cls}">${n}</div>`;
    }).join('');
  }

  const logEl = document.getElementById('format-log')!;
  const wasAtBottom = logEl.scrollHeight - logEl.clientHeight <= logEl.scrollTop + 4;
  logEl.innerHTML = p.log
    .map((l) => `<div class="${l.startsWith('>>>') ? 'log-cmd' : ''}">${escHtml(l)}</div>`)
    .join('');
  if (wasAtBottom) logEl.scrollTop = logEl.scrollHeight;

  if (p.finished) {
    (document.getElementById('format-done-btn-row') as HTMLElement).style.display = '';
  }
}

// ── Wipe Free Space ───────────────────────────────────────────────────────────

async function startWipe(): Promise<void> {
  try {
    await invoke('start_wipe_free_space');
  } catch (e) {
    alert('Could not start wipe: ' + String(e));
    return;
  }
  showScreen('wipe');
  (document.getElementById('wipe-status-banner') as HTMLElement).style.display = 'none';
  document.getElementById('wipe-size-info')!.textContent = '';
  (document.getElementById('wipe-progress-bar') as HTMLElement).style.width = '0%';
  document.getElementById('wipe-progress-label')!.textContent = '0%';
  document.getElementById('wipe-btn-row')!.innerHTML =
    '<button id="btn-wipe-cancel">Cancel</button>';
  document
    .getElementById('btn-wipe-cancel')!
    .addEventListener('click', () => invoke('cancel_wipe'));
  if (wipePollId) clearInterval(wipePollId);
  wipePollId = setInterval(pollWipe, 500);
}

async function pollWipe(): Promise<void> {
  const p = await invoke<WipeProgress>('get_wipe_progress');
  updateWipeUI(p);
  if (p.finished) {
    clearInterval(wipePollId!);
    wipePollId = null;
  }
}

function updateWipeUI(p: WipeProgress): void {
  const banner = document.getElementById('wipe-status-banner')!;
  const bar = document.getElementById('wipe-progress-bar') as HTMLElement;
  const label = document.getElementById('wipe-progress-label')!;
  const sizeInfo = document.getElementById('wipe-size-info')!;

  if (p.error) {
    banner.className = 'banner danger';
    banner.textContent = 'Error: ' + p.error;
    banner.style.display = '';
  } else if (p.cancelled) {
    banner.className = 'banner warning';
    banner.textContent = 'Wipe cancelled. Free space was partially overwritten.';
    banner.style.display = '';
  } else if (p.finished) {
    banner.className = 'banner success';
    banner.textContent = 'Done — free space has been zeroed.';
    banner.style.display = '';
  }

  const frac = p.total_bytes > 0 ? Math.min(p.bytes_written / p.total_bytes, 1) : 0;
  const pct = Math.round(frac * 100);
  bar.style.width = `${pct}%`;
  label.textContent = `${pct}%`;

  if (p.total_bytes > 0) {
    sizeInfo.textContent = `${formatBytes(p.bytes_written)} of ${formatBytes(p.total_bytes)}`;
  } else if (!p.finished) {
    sizeInfo.textContent = 'Measuring available space…';
  }

  if (p.finished) {
    const btnRow = document.getElementById('wipe-btn-row')!;
    btnRow.innerHTML = '';
    const backBtn = document.createElement('button');
    backBtn.textContent = '← Back to Config';
    backBtn.addEventListener('click', () => showScreen('config'));
    btnRow.appendChild(backBtn);
  }
}

function formatBytes(bytes: number): string {
  if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
  if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
  if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB';
  if (bytes >= 1e3) return (bytes / 1e3).toFixed(1) + ' KB';
  return bytes + ' B';
}

// ── Utility ───────────────────────────────────────────────────────────────────

function parseSnapshotDate(name: string): Date | null {
  const m = name.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})$/);
  if (!m) return null;
  return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
}

function formatSnapshotDate(date: Date): string {
  const months = [
    'Jan',
    'Feb',
    'Mar',
    'Apr',
    'May',
    'Jun',
    'Jul',
    'Aug',
    'Sep',
    'Oct',
    'Nov',
    'Dec',
  ];
  const mon = months[date.getMonth()];
  const day = date.getDate();
  const hh = date.getHours().toString().padStart(2, '0');
  const mm = date.getMinutes().toString().padStart(2, '0');
  const yearSuffix =
    date.getFullYear() !== new Date().getFullYear() ? ` ${date.getFullYear()}` : '';
  return `${mon} ${day}${yearSuffix} at ${hh}:${mm}`;
}

function relativeTime(date: Date): string {
  const diffMs = Date.now() - date.getTime();
  const mins = Math.floor(diffMs / 60000);
  const hours = Math.floor(mins / 60);
  const days = Math.floor(hours / 24);
  const months = Math.floor(days / 30);
  if (months > 0) return `${months} month${months === 1 ? '' : 's'} ago`;
  if (days > 0) return `${days} day${days === 1 ? '' : 's'} ago`;
  if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
  if (mins > 0) return `${mins} minute${mins === 1 ? '' : 's'} ago`;
  return 'just now';
}

function snapshotLabel(name: string): string {
  const date = parseSnapshotDate(name);
  if (!date) return escHtml(name);
  return (
    `<strong>${escHtml(formatSnapshotDate(date))}</strong>` +
    ` <span class="radio-desc">${escHtml(relativeTime(date))} · ${escHtml(name)}</span>`
  );
}

function escHtml(s: unknown): string {
  return String(s ?? '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function setError(id: string, msg: string): void {
  const el = document.getElementById(id);
  if (!el) return;
  el.textContent = msg || '';
  el.style.display = msg ? '' : 'none';
}

// ── globals for inline onclick handlers ──────────────────────────────────────

window.toggleJob = toggleJob;
window.editJob = editJob;
window.togglePause = togglePause;
window.cancelBackup = cancelBackup;
window.showConfigFromBackup = showConfigFromBackup;
window.ejectDrive = ejectDrive;
window.goToRestore = goToRestore;

// ── Event wiring ──────────────────────────────────────────────────────────────

document.getElementById('btn-close-app')!.addEventListener('click', () => invoke('quit'));

document.getElementById('btn-refresh')!.addEventListener('click', refreshDrives);

document.getElementById('btn-open-drive')!.addEventListener('click', openSelectedDrive);

document.getElementById('btn-format-drive')!.addEventListener('click', () => {
  if (!selectedDevice) return;
  const drive = drives.find((d) => d.device === selectedDevice);
  if (!drive) return;
  enterFormatSetup(drive.device, drive.fstype, drive.dev_type === 'disk');
});

document.getElementById('btn-password-cancel')!.addEventListener('click', () => {
  showScreen('drive-select');
});
document.getElementById('btn-password-unlock')!.addEventListener('click', unlockDrive);
document.getElementById('password-input')!.addEventListener('keydown', (e: KeyboardEvent) => {
  if (e.key === 'Enter') unlockDrive();
});

document.getElementById('btn-eject')!.addEventListener('click', ejectDrive);
document.getElementById('btn-add-job')!.addEventListener('click', addJob);
document.getElementById('btn-save-config')!.addEventListener('click', saveConfig);
document.getElementById('btn-wipe-free-space')!.addEventListener('click', startWipe);
document.getElementById('btn-restore')!.addEventListener('click', goToRestore);
document.getElementById('btn-next')!.addEventListener('click', goToPreview);

document.getElementById('btn-job-cancel')!.addEventListener('click', async () => {
  const status = await invoke<AppStatus>('get_status');
  enterConfig(status.mount_point!, status.config!);
});
document.getElementById('btn-job-save')!.addEventListener('click', saveJob);
document.getElementById('btn-job-delete')!.addEventListener('click', deleteJob);
document.getElementById('job-source')!.addEventListener('blur', () => {
  const source = (document.getElementById('job-source') as HTMLInputElement).value.trim();
  if (source !== treeSource) {
    void reloadTreeSource(source);
  }
});
document.getElementById('job-excludes-manual')!.addEventListener('input', () => {
  renderExcludesTree();
  renderPresetChips();
});

document.getElementById('btn-restore-cancel')!.addEventListener('click', async () => {
  const status = await invoke<AppStatus>('get_status');
  enterConfig(status.mount_point!, status.config!);
});
document.getElementById('screen-restore')!.addEventListener('input', validateRestore);
document.getElementById('btn-do-restore')!.addEventListener('click', goToRestorePreview);

document
  .getElementById('btn-restore-preview-back')!
  .addEventListener('click', () => showScreen('restore'));
document.getElementById('btn-restore-preview-proceed')!.addEventListener('click', doRestore);

document.getElementById('btn-preview-cancel')!.addEventListener('click', async () => {
  const status = await invoke<AppStatus>('get_status');
  enterConfig(status.mount_point!, status.config!);
});
document.getElementById('btn-run-backup')!.addEventListener('click', startBackup);

document.getElementById('btn-format-cancel')!.addEventListener('click', () => {
  showScreen('drive-select');
});

['format-label', 'format-pass1', 'format-pass2', 'format-confirm-input'].forEach((id) => {
  document.getElementById(id)!.addEventListener('input', validateFormat);
});
document.getElementById('format-label')!.addEventListener('input', () => {
  if (formatDevice) buildFormatCmdPreview(formatDevice, formatIsDisk);
});
document.getElementById('format-fstype')!.addEventListener('change', () => {
  if (formatDevice) buildFormatCmdPreview(formatDevice, formatIsDisk);
});
document.getElementById('format-luks')!.addEventListener('change', () => {
  const luks = (document.getElementById('format-luks') as HTMLInputElement).checked;
  document.getElementById('format-luks-section')!.style.display = luks ? 'contents' : 'none';
  validateFormat();
  if (formatDevice) buildFormatCmdPreview(formatDevice, formatIsDisk);
});

document.getElementById('btn-do-format')!.addEventListener('click', doFormat);
document.getElementById('btn-format-done')!.addEventListener('click', async () => {
  await refreshDrives();
  showScreen('drive-select');
});

// ── Zoom ──────────────────────────────────────────────────────────────────────

const ZOOM_STEP = 0.1;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
const ZOOM_DEFAULT = 1.5;

let zoomLevel: number = parseFloat(localStorage.getItem('zoom') ?? String(ZOOM_DEFAULT));

function applyZoom() {
  document.documentElement.style.zoom = String(zoomLevel);
  localStorage.setItem('zoom', String(zoomLevel));
}

document.addEventListener('keydown', (e) => {
  if (!e.ctrlKey) return;
  if (e.key === '=' || e.key === '+') {
    e.preventDefault();
    zoomLevel = Math.min(ZOOM_MAX, Math.round((zoomLevel + ZOOM_STEP) * 100) / 100);
    applyZoom();
  } else if (e.key === '-') {
    e.preventDefault();
    zoomLevel = Math.max(ZOOM_MIN, Math.round((zoomLevel - ZOOM_STEP) * 100) / 100);
    applyZoom();
  } else if (e.key === '0') {
    e.preventDefault();
    zoomLevel = ZOOM_DEFAULT;
    applyZoom();
  }
});

// ── Init ──────────────────────────────────────────────────────────────────────

applyZoom();
showScreen('drive-select');
refreshDrives();