fusible 0.18.0

Electronic linter: a fuse that blows to block firmware code generation
(() => {
  const POLL_MS = 30000;
  const CHECK_EVERY = 4;
  const GIT_DOWNLOAD = 'https://git-scm.com/downloads';
  const state = { status: null, busy: false, draft: '', notice: null, ticks: 0 };

  const view = () => document.getElementById('sync-view');
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;

  const STYLE = `
    #sync-view { flex: none; max-height: 58%; overflow-y: auto; border-top: 1px solid var(--border); padding-bottom: 8px; }
    #sync-view[hidden] { display: none; }
    .sync-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px 4px; font-size: 10px; letter-spacing: 0.05em; color: var(--text-subtle); }
    .sync-head .sync-title { flex: 1; }
    .sync-line { padding: 2px 16px 8px; font-size: 11px; line-height: 1.5; color: var(--text); }
    .sync-line.subtle { color: var(--text-subtle); }
    .sync-box { display: block; width: calc(100% - 32px); margin: 0 16px 8px; min-height: 52px; resize: vertical; padding: 6px 8px; background: var(--bg); color: var(--text); border: 1px solid var(--border); font: inherit; font-size: 11px; user-select: text; }
    .sync-box:focus-visible { border-color: var(--green); outline: none; }
    .sync-actions { display: flex; gap: 2px; padding: 0 12px 8px; }
    .sync-actions .btn { flex: 1; justify-content: center; padding: 0 6px; }
    .sync-actions .btn.primary { background: var(--green); color: var(--on-green); }
    .sync-actions .btn:disabled { opacity: 0.4; cursor: default; }
    .sync-note { margin: 0 16px 8px; padding: 6px 8px; font-size: 11px; line-height: 1.5; background: color-mix(in srgb, var(--green) 14%, transparent); }
    .sync-note.error { background: color-mix(in srgb, var(--alert) 10%, transparent); color: var(--alert); }
    .sync-note pre { margin-top: 6px; white-space: pre-wrap; word-break: break-word; color: var(--text-subtle); user-select: text; }
    .sync-card { margin: 0 12px 8px; padding: 8px; border: 1px solid var(--alert); font-size: 11px; line-height: 1.5; }
    .sync-card b { color: var(--alert); word-break: break-all; }
    .sync-card .sync-actions { padding: 8px 0 0; }
    .sync-more { padding: 0 16px; font-size: 11px; }
    .sync-more summary { cursor: pointer; padding: 4px 0; color: var(--text-subtle); font-size: 10px; letter-spacing: 0.05em; }
    .sync-row { display: flex; gap: 8px; padding: 3px 0; line-height: 1.4; }
    .sync-row .sync-kind { flex: none; width: 56px; color: var(--text-subtle); font-size: 9px; }
    .sync-row .sync-what { min-width: 0; word-break: break-all; }
    .sync-row .sync-who { display: block; color: var(--text-subtle); font-size: 10px; }
    .sync-link { color: var(--green); }
  `;

  function ago(seconds) {
    const diff = Math.max(0, Date.now() / 1000 - seconds);
    if (diff < 90) return 'just now';
    const units = [[86400, 'day'], [3600, 'hour'], [60, 'minute']];
    const [size, name] = units.find(([s]) => diff >= s);
    return `${plural(Math.floor(diff / size), name)} ago`;
  }

  async function call(method, url, body) {
    const options = { method, cache: 'no-store' };
    if (method === 'POST') Object.assign(options, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
    const response = await fetch(url, options);
    const reply = await response.json().catch(() => ({}));
    return { ok: response.ok, reply };
  }

  function summary(s) {
    const stuck = s.conflicts.length;
    if (stuck) return `${plural(stuck, 'file')} ${stuck === 1 ? 'needs' : 'need'} your decision.`;
    const parts = [];
    if (s.changes.length) parts.push(`${plural(s.changes.length, 'change')} not saved`);
    if (s.has_remote && s.upstream && s.ahead) parts.push(`${plural(s.ahead, 'save')} not shared yet`);
    if (s.behind) parts.push(`${plural(s.behind, 'update')} waiting`);
    if (parts.length) return parts.join(', ') + '.';
    return s.has_remote ? 'Everything is saved and shared.' : 'Everything is saved.';
  }

  function localOnly(s) {
    return s.has_remote ? '' : '<div class="sync-line subtle">Saved on this computer only. Add a remote to share.</div>';
  }

  function conflictCard(s, path) {
    const author = s.conflict_authors[path];
    const who = author ? `${escapeHtml(author)} and you both changed ` : 'Both you and someone else changed ';
    return `<div class="sync-card" data-path="${escapeHtml(path)}">${who}<b>${escapeHtml(path)}</b>. Pick the version to keep.
      <div class="sync-actions">
        <button class="btn" data-keep="mine" title="Keep the version you made">KEEP MINE</button>
        <button class="btn" data-keep="theirs" title="Keep the version from ${escapeHtml(author || 'the other person')}">KEEP THEIRS</button>
      </div></div>`;
  }

  function changesHtml(s) {
    if (!s.changes.length) return '';
    const rows = s.changes.map((c) => `<div class="sync-row"><span class="sync-kind">${escapeHtml(c.kind.toUpperCase())}</span><span class="sync-what">${escapeHtml(c.path)}</span></div>`);
    return `<details class="sync-more"><summary>CHANGES (${s.changes.length})</summary>${rows.join('')}</details>`;
  }

  function savesHtml(s) {
    if (!s.last_saves.length) return '';
    const rows = s.last_saves.map((v) => `<div class="sync-row" title="${escapeHtml(v.files.join('\n'))}"><span class="sync-what">${escapeHtml(v.message)}<span class="sync-who">${escapeHtml(v.author)} · ${escapeHtml(ago(v.when))}</span></span></div>`);
    return `<details class="sync-more"><summary>SAVES (${s.last_saves.length})</summary>${rows.join('')}</details>`;
  }

  function noticeHtml() {
    const n = state.notice;
    if (!n) return '';
    const details = n.details ? `<details><summary>DETAILS</summary><pre>${escapeHtml(n.details)}</pre></details>` : '';
    return `<div class="sync-note${n.error ? ' error' : ''}" role="${n.error ? 'alert' : 'status'}">${escapeHtml(n.text)}${details}</div>`;
  }

  function missingHtml() {
    return `<div class="sync-head"><span class="sync-title">SYNC</span></div>
      <div class="sync-line">Install Git to sync your work.</div>
      <div class="sync-line subtle">Git is the free program that shares your project with your team. Install it, then come back.</div>
      <div class="sync-line"><a class="sync-link" href="${GIT_DOWNLOAD}" target="_blank" rel="noopener noreferrer">Download Git</a></div>
      <div class="sync-actions"><button class="btn" id="sync-recheck">${state.busy ? 'CHECKING...' : 'RE-CHECK'}</button></div>`;
  }

  function panelHtml(s) {
    const stuck = s.conflicts.length > 0;
    const off = state.busy || stuck ? 'disabled' : '';
    const noUpdates = state.busy || stuck || !s.upstream ? 'disabled' : '';
    const warn = s.fetch_error ? `<div class="sync-line subtle">Could not check for updates: ${escapeHtml(s.fetch_error)}</div>` : '';
    return `<div class="sync-head"><span class="sync-title">SYNC</span>
        <button class="btn" id="sync-refresh" title="Check for updates" ${state.busy ? 'disabled' : ''}>CHECK</button></div>
      <div class="sync-line" id="sync-status">${escapeHtml(summary(s))}</div>${localOnly(s)}${warn}
      ${s.conflicts.map((p) => conflictCard(s, p)).join('')}
      <textarea class="sync-box" id="sync-message" rows="2" placeholder="What did you change?" aria-label="What did you change?"></textarea>
      <div class="sync-actions">
        <button class="btn primary" id="sync-save" ${off}>${state.busy ? 'WORKING...' : 'SAVE'}</button>
        <button class="btn" id="sync-update" ${noUpdates}>GET UPDATES</button>
      </div>${noticeHtml()}${changesHtml(s)}${savesHtml(s)}`;
  }

  function render() {
    const root = view();
    const s = state.status;
    if (!root) return;
    const shown = !!(s && s.repo);
    root.hidden = !shown;
    if (!shown) return;
    const focused = document.activeElement && document.activeElement.id;
    const opened = [...root.querySelectorAll('details.sync-more[open]')].map((d) => d.querySelector('summary').textContent.split(' ')[0]);
    root.innerHTML = s.available === false ? missingHtml() : panelHtml(s);
    root.querySelectorAll('details.sync-more').forEach((d) => {
      d.open = opened.includes(d.querySelector('summary').textContent.split(' ')[0]);
    });
    const box = byIdIn(root, 'sync-message');
    if (box) {
      box.value = state.draft;
      if (focused === 'sync-message') box.focus();
    }
  }

  const byIdIn = (root, id) => root.querySelector(`#${id}`);

  async function load(check) {
    if (state.busy) return;
    try {
      const { ok, reply } = await call('GET', `/api/git${check ? '?check=1' : ''}`);
      if (ok) state.status = reply;
    } catch (err) {
      console.warn('sync status unavailable', err);
    }
    render();
  }

  async function act(url, body) {
    state.busy = true;
    state.notice = null;
    render();
    try {
      const { ok, reply } = await call('POST', url, body);
      if (reply.status) state.status = reply.status;
      state.notice = ok ? { text: reply.message } : { text: reply.error || 'That did not work.', details: reply.details, error: true };
      if (ok && url.endsWith('/save')) state.draft = '';
    } catch (err) {
      state.notice = { text: 'Cannot reach Fusible Studio.', details: String(err), error: true };
    }
    state.busy = false;
    render();
  }

  function onClick(event) {
    const button = event.target.closest('button');
    if (!button || button.disabled) return;
    if (button.id === 'sync-save') act('/api/git/save', { message: state.draft });
    else if (button.id === 'sync-update') act('/api/git/update');
    else if (button.id === 'sync-refresh' || button.id === 'sync-recheck') recheck();
    else if (button.dataset.keep) {
      const path = button.closest('.sync-card').dataset.path;
      act('/api/git/resolve', { path, keep: button.dataset.keep });
    }
  }

  async function recheck() {
    state.notice = null;
    await load(true);
  }

  function start() {
    if (!view()) return;
    const style = document.createElement('style');
    style.textContent = STYLE;
    document.head.appendChild(style);
    view().addEventListener('click', onClick);
    view().addEventListener('input', (event) => {
      if (event.target.id === 'sync-message') state.draft = event.target.value;
    });
    load(true);
    setInterval(() => load(++state.ticks % CHECK_EVERY === 0), POLL_MS);
  }

  window.SyncPanel = { reload: () => load(true) };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
})();