// ==UserScript== // @name TRMNL GitHub Sync // @namespace https://github.com/ExcuseMi/trmnl-userscripts // @version 0.1.1 // @description Push your TRMNL plugin code to a GitHub repository and pull it back on demand. // @author ExcuseMi // @match https://trmnl.com/* // @icon https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/refs/heads/main/images/trmnl.svg // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @downloadURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/github-sync.user.js // @updateURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/github-sync.user.js // @grant none // @run-at document-body // ==/UserScript== (function () { 'use strict'; const LOG_PREFIX = '[TRMNL GH Sync]'; const log = (...a) => console.log(LOG_PREFIX, ...a); const warn = (...a) => console.warn(LOG_PREFIX, ...a); const PANEL_ID = 'trmnl-gh-panel'; const OVERLAY_ID = 'trmnl-gh-overlay'; const BTN_ID = 'trmnl-gh-btn'; const ACCT_BTN_ID = 'trmnl-gh-account-btn'; const PUSH_BTN_ID = 'trmnl-gh-push-btn'; // quick push in page header const PULL_BTN_ID = 'trmnl-gh-pull-btn'; // pull indicator in page header const STYLE_ID = 'trmnl-gh-style'; const LAST_PUSH_SHAS_KEY = 'trmnl_gh_shas'; // prefix; keyed per plugin const LAST_PUSH_TIME_KEY = 'trmnl_gh_push_time'; // timestamp of last push, per plugin const LIST_BADGE_ATTR = 'data-gh-sync-badge'; // marks rows on plugin list page // Storage keys const GH_TOKEN_KEY = 'trmnl_gh_token'; const GH_CONFIG_KEY = 'trmnl_gh_config'; const GH_API_KEY = 'trmnl_gh_api_key'; // TRMNL API key for uploads const GH_CONFIG_REPO_KEY = 'trmnl_gh_config_repo'; // optional global config repo (owner/repo) const DEFAULT_CONFIG = { repo: '', // "owner/repo" branch: 'main', path: 'plugin', commitMsg: 'TRMNL sync: {name} (plugin {id})', autoPush: false, }; const AUTOPUSH_KEY = 'trmnl_gh_autopush_pending'; // sessionStorage const REMOTE_CONFIG_FILE = '.trmnl-sync.json'; // stored at repo root // --------------------------------------------------------------------------- // URL helpers // --------------------------------------------------------------------------- function getPluginId() { const m = window.location.pathname.match(/\/plugin_settings\/(\d+)/); return m ? m[1] : null; } function getPluginName() { // Try the settings/edit page heading first const h2 = document.querySelector('h2.font-heading'); if (h2) return h2.textContent.trim(); // On markup/edit and other sub-pages, look for a breadcrumb or nav link // that contains the plugin name (typically an ancestor link to /edit) const crumb = document.querySelector('a[href*="/plugin_settings/"][href$="/edit"]'); if (crumb) return crumb.textContent.trim(); return ''; } function isAccountPage() { return window.location.pathname.startsWith('/account'); } // --------------------------------------------------------------------------- // Config / token helpers // --------------------------------------------------------------------------- function cfgKey(pluginId) { return `${GH_CONFIG_KEY}_${pluginId}`; } function getConfig(pluginId) { try { return { ...DEFAULT_CONFIG, ...JSON.parse(localStorage.getItem(cfgKey(pluginId)) || '{}') }; } catch { return { ...DEFAULT_CONFIG }; } } function saveConfig(pluginId, cfg) { localStorage.setItem(cfgKey(pluginId), JSON.stringify(cfg)); } function getToken() { return localStorage.getItem(GH_TOKEN_KEY) || ''; } function saveToken(t) { t ? localStorage.setItem(GH_TOKEN_KEY, t) : localStorage.removeItem(GH_TOKEN_KEY); } function getTrmnlKey() { return localStorage.getItem(GH_API_KEY) || ''; } function saveTrmnlKey(k) { k ? localStorage.setItem(GH_API_KEY, k) : localStorage.removeItem(GH_API_KEY); } function getConfigRepo() { return localStorage.getItem(GH_CONFIG_REPO_KEY) || ''; } function saveConfigRepo(r) { r ? localStorage.setItem(GH_CONFIG_REPO_KEY, r) : localStorage.removeItem(GH_CONFIG_REPO_KEY); } function effectiveRepo(cfg) { return (cfg && cfg.repo) || ''; } // Returns {owner, repo, branch} for where .trmnl-sync.json lives. // If a dedicated config repo is set, use that (always on main). // Otherwise fall back to the per-plugin repo + branch. function configRepoCoords(perPluginOwner, perPluginRepo, perPluginBranch) { const cr = getConfigRepo().trim(); if (cr && cr.includes('/')) { const [o, r] = cr.split('/'); return { owner: o, repo: r, branch: 'main' }; } return { owner: perPluginOwner, repo: perPluginRepo, branch: perPluginBranch }; } function resolvePath(template, pluginId) { return template.replace(/\{id\}/g, pluginId).replace(/\/+$/, ''); } // Extract the plugin name from settings.yml content (most reliable source). function extractNameFromYaml(yaml) { const m = (yaml || '').match(/^name:\s*["']?(.+?)["']?\s*$/m); return m ? m[1].trim() : null; } function resolveMsg(template, pluginId, nameOverride) { const name = nameOverride || getPluginName() || `plugin ${pluginId}`; return template.replace(/\{id\}/g, pluginId).replace(/\{name\}/g, name); } // Accepts a full GitHub URL or bare "owner/repo" and returns "owner/repo" function normalizeRepoInput(raw) { let s = raw.trim(); // git@github.com:owner/repo.git s = s.replace(/^git@github\.com:/, ''); // https://github.com/owner/repo[.git][/] s = s.replace(/^https?:\/\/github\.com\//, ''); // strip trailing .git or / s = s.replace(/\.git$/, '').replace(/\/$/, ''); return s; } function parseRepo(cfg) { const parts = effectiveRepo(cfg).trim().split('/'); if (parts.length < 2 || !parts[0] || !parts[1]) { throw new Error('Set a repository in the GitHub Sync panel.'); } return { owner: parts[0], repo: parts[1] }; } // --------------------------------------------------------------------------- // Auto-push on save // --------------------------------------------------------------------------- function attachAutoSave(pluginId) { const form = document.querySelector('form[id^="edit_plugin_setting"]') || document.querySelector('[data-markup-target="enabledSaveButton"]')?.closest('form'); if (!form || form.dataset.ghSyncAttached) return; form.dataset.ghSyncAttached = 'true'; form.addEventListener('submit', () => { if (!getConfig(pluginId).autoPush) return; sessionStorage.setItem(AUTOPUSH_KEY, pluginId); log('Auto-push flagged for after reload'); }); } async function checkAutoPush() { const pendingId = sessionStorage.getItem(AUTOPUSH_KEY); if (!pendingId) return; sessionStorage.removeItem(AUTOPUSH_KEY); if (getPluginId() !== pendingId) return; if (!getConfig(pendingId).autoPush) return; log('Auto-push triggered for plugin', pendingId); try { // Small delay to let TRMNL finish updating the archive after page reload await new Promise(r => setTimeout(r, 1500)); const url = await push(pendingId, msg => log(msg)); log('Auto-push complete:', url); // Hide pull/push indicators — GitHub API caches for minutes so re-querying // immediately would return stale SHAs and falsely show the pull button. document.getElementById(PULL_BTN_ID)?.style.setProperty('display', 'none'); document.getElementById(PUSH_BTN_ID)?.style.setProperty('display', 'none'); } catch (e) { warn('Auto-push failed:', e.message); } } // --------------------------------------------------------------------------- // Last-push SHA tracking (for pull indicator) // --------------------------------------------------------------------------- function lastPushShasKey(pluginId) { return `${LAST_PUSH_SHAS_KEY}_${pluginId}`; } function lastPushTimeKey(pluginId) { return `${LAST_PUSH_TIME_KEY}_${pluginId}`; } function getLastPushShas(pluginId) { try { return JSON.parse(localStorage.getItem(lastPushShasKey(pluginId)) || '{}'); } catch { return {}; } } function setLastPushShas(pluginId, shas) { localStorage.setItem(lastPushShasKey(pluginId), JSON.stringify(shas)); } function recordPushTime(pluginId) { localStorage.setItem(lastPushTimeKey(pluginId), Date.now().toString()); } // How many ms since the last push (Infinity if never pushed) function pushAgeMs(pluginId) { const t = localStorage.getItem(lastPushTimeKey(pluginId)); return t ? Date.now() - parseInt(t, 10) : Infinity; } // GitHub Contents API caches directory listings for several minutes after a // push via the Git Trees API. Skip the pull-indicator check during that window. const PUSH_GRACE_MS = 6 * 60 * 1000; // --------------------------------------------------------------------------- // Diff helpers (ported from editor-backups, using gh-diff-* CSS prefix) // --------------------------------------------------------------------------- function diffLines(a, b) { const aL = a ? a.replace(/\r/g, '').split('\n') : []; const bL = b ? b.replace(/\r/g, '').split('\n') : []; const m = Math.min(aL.length, 800); const n = Math.min(bL.length, 800); const dp = Array.from({ length: m + 1 }, () => new Int16Array(n + 1)); for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) dp[i][j] = aL[i] === bL[j] ? dp[i+1][j+1]+1 : Math.max(dp[i+1][j], dp[i][j+1]); const out = []; let i = 0, j = 0; while (i < m || j < n) { if (i < m && j < n && aL[i] === bL[j]) { out.push({ t: 'eq', l: aL[i] }); i++; j++; } else if (i < m && (j >= n || dp[i+1][j] >= dp[i][j+1])) { out.push({ t: 'del', l: aL[i] }); i++; } else { out.push({ t: 'add', l: bL[j] }); j++; } } return out; } const chevronUp = ``; const chevronDown = ``; function ghBuildDiffEl(oldText, newText) { if (oldText === newText) { const p = mk('p', 'text-xs text-gray-500 dark:text-gray-400 italic py-1'); p.textContent = 'No changes in this file.'; return p; } const CONTEXT = 3; const diff = diffLines(oldText, newText); const show = new Uint8Array(diff.length); for (let i = 0; i < diff.length; i++) if (diff[i].t !== 'eq') for (let k = Math.max(0, i - CONTEXT); k <= Math.min(diff.length - 1, i + CONTEXT); k++) show[k] = 1; const pre = mk('pre', 'gh-diff'); let lastShown = -1; const rendered = new Uint8Array(diff.length); function appendIntraLine(row, marker, line, other, hlCls) { let p = 0; while (p < line.length && p < other.length && line[p] === other[p]) p++; let lEnd = line.length, oEnd = other.length; while (lEnd > p && oEnd > p && line[lEnd-1] === other[oEnd-1]) { lEnd--; oEnd--; } row.appendChild(document.createTextNode(marker + line.slice(0, p))); if (lEnd > p) row.appendChild(mk('mark', hlCls, line.slice(p, lEnd))); if (lEnd < line.length) row.appendChild(document.createTextNode(line.slice(lEnd))); } diff.forEach((d, idx) => { if (!show[idx] || rendered[idx]) return; if (lastShown !== -1 && idx > lastShown + 1) pre.appendChild(mk('div', 'gh-diff-skip', '···')); if (d.t === 'del') { let k = idx; const dels = [], adds = []; while (k < diff.length && diff[k].t === 'del' && !rendered[k]) dels.push(k++); while (k < diff.length && diff[k].t === 'add' && !rendered[k]) adds.push(k++); dels.forEach(i => rendered[i] = 1); adds.forEach(i => rendered[i] = 1); const pairs = Math.min(dels.length, adds.length); for (let p = 0; p < pairs; p++) { const ol = diff[dels[p]].l, nl = diff[adds[p]].l; const dr = mk('div', 'gh-diff-del'); appendIntraLine(dr, '− ', ol, nl, 'gh-hl-del'); pre.appendChild(dr); const ar = mk('div', 'gh-diff-add'); appendIntraLine(ar, '+ ', nl, ol, 'gh-hl-add'); pre.appendChild(ar); } for (let p = pairs; p < dels.length; p++) { const row = mk('div', 'gh-diff-del'); row.textContent = '− ' + diff[dels[p]].l; pre.appendChild(row); } for (let p = pairs; p < adds.length; p++) { const row = mk('div', 'gh-diff-add'); row.textContent = '+ ' + diff[adds[p]].l; pre.appendChild(row); } lastShown = k - 1; } else { const row = mk('div', `gh-diff-${d.t}`); row.textContent = (d.t === 'add' ? '+ ' : ' ') + d.l; pre.appendChild(row); lastShown = idx; } }); // Use a clipDiv to control vertical clipping — avoids the CSS overflow-x/y // interaction bug where setting overflow-y:visible is silently overridden by // the browser when overflow-x is already auto on the same element. let expanded = false; const clipDiv = mk('div', ''); clipDiv.style.overflow = 'hidden'; clipDiv.style.maxHeight = '10rem'; clipDiv.appendChild(pre); const expandBtn = mk('button', 'w-full flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium ' + 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 ' + 'border-t border-gray-200 dark:border-gray-700 cursor-pointer ' + 'bg-gray-50 dark:bg-gray-800 transition-colors' ); expandBtn.type = 'button'; expandBtn.innerHTML = `${chevronDown}Show full diff`; expandBtn.addEventListener('click', e => { e.stopPropagation(); expanded = !expanded; clipDiv.style.maxHeight = expanded ? 'none' : '10rem'; expandBtn.innerHTML = expanded ? `${chevronUp}Collapse` : `${chevronDown}Show full diff`; }); const wrap = mk('div', 'rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-1'); wrap.append(clipDiv, expandBtn); return wrap; } // Render a GitHub unified-diff patch string (from commits API files[].patch) // into the same styled
 element as ghBuildDiffEl.
  function renderPatch(patch) {
    const pre = mk('pre', 'gh-diff');
    if (!patch) { pre.textContent = '(binary or empty)'; return pre; }
    for (const line of patch.split('\n')) {
      let cls = 'gh-diff-eq';
      if (line.startsWith('@@'))       cls = 'gh-diff-skip';
      else if (line.startsWith('+'))   cls = 'gh-diff-add';
      else if (line.startsWith('-'))   cls = 'gh-diff-del';
      const row = mk('div', cls);
      row.textContent = line;
      pre.appendChild(row);
    }
    const wrap = mk('div', 'rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-1');
    const clip = mk('div', '');
    clip.style.overflow  = 'hidden';
    clip.style.maxHeight = '12rem';
    clip.appendChild(pre);
    const btn = mk('button',
      'w-full flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium ' +
      'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 ' +
      'border-t border-gray-200 dark:border-gray-700 cursor-pointer ' +
      'bg-gray-50 dark:bg-gray-800 transition-colors'
    );
    btn.type = 'button';
    btn.innerHTML = `${chevronDown}Show full diff`;
    let expanded = false;
    btn.addEventListener('click', e => {
      e.stopPropagation();
      expanded = !expanded;
      clip.style.maxHeight = expanded ? 'none' : '12rem';
      btn.innerHTML = expanded
        ? `${chevronUp}Collapse`
        : `${chevronDown}Show full diff`;
    });
    wrap.append(clip, btn);
    return wrap;
  }

  // ---------------------------------------------------------------------------
  // GitHub API
  // ---------------------------------------------------------------------------

  async function ghFetch(path, opts = {}) {
    const token = getToken();
    if (!token) throw new Error('GitHub token not configured. Open the GitHub Sync panel to set it.');
    const res = await fetch(`https://api.github.com${path}`, {
      cache: 'no-store',
      ...opts,
      headers: {
        Accept: 'application/vnd.github+json',
        Authorization: `Bearer ${token}`,
        'X-GitHub-Api-Version': '2022-11-28',
        ...(opts.headers || {}),
      },
    });
    if (res.status === 204) return null;
    const body = await res.json().catch(() => ({}));
    if (!res.ok) throw new Error(`GitHub ${res.status}: ${body.message || res.statusText}`);
    return body;
  }

  // Check if a repo exists and is accessible.
  // Returns { error: string|null, isPublic: bool }.
  async function ghCheckRepo(owner, repo) {
    try {
      const data = await ghFetch(`/repos/${owner}/${repo}`);
      return { error: null, isPublic: data.private === false };
    } catch (e) {
      if (e.message.includes('404')) return { error: `Repository "${owner}/${repo}" not found or not accessible.`, isPublic: false };
      if (e.message.includes('401') || e.message.includes('403')) return { error: `No access to "${owner}/${repo}" — check your token permissions.`, isPublic: false };
      return { error: e.message, isPublic: false };
    }
  }

  // ---------------------------------------------------------------------------
  // Remote config: read/write .trmnl-sync.json at the repo root
  // Keys stored: branch, path, commitMsg, autoPush  (repo + tokens stay local)
  // ---------------------------------------------------------------------------

  // Returns the full parsed .trmnl-sync.json object, or null if not found.
  async function loadAllRemoteConfig(owner, repo, branch) {
    const c = configRepoCoords(owner, repo, branch);
    try {
      const data = await ghFetch(
        `/repos/${c.owner}/${c.repo}/contents/${REMOTE_CONFIG_FILE}?ref=${encodeURIComponent(c.branch)}`
      );
      const parsed = JSON.parse(b64ToUtf8(data.content));
      return parsed;
    } catch (e) {
      if (e.message.includes('404')) return null;
      throw e;
    }
  }

  // Write a full config data object to the localStorage cache.
  // The GitHub config repo is the source of truth; this is only a local cache
  // so that non-panel operations (push, pull, auto-push) don't need a network round-trip.
  function cacheConfigData(data) {
    const g = data['_global'] || {};
    if (g.ghToken)      saveToken(g.ghToken);
    if (g.trmnlApiKey)  saveTrmnlKey(g.trmnlApiKey);
    if (g.configRepo)   saveConfigRepo(g.configRepo);
    for (const [id, pc] of Object.entries(data)) {
      if (id === '_global' || !pc || typeof pc !== 'object') continue;
      saveConfig(id, { ...DEFAULT_CONFIG, ...pc });
    }
  }

  // Serialise all remote config writes in the same tab so they never race.
  let _configSaveLock = Promise.resolve();

  async function saveRemoteConfig(owner, repo, branch, pluginId, cfg) {
    const result = _configSaveLock.then(() => _doSaveRemoteConfig(owner, repo, branch, pluginId, cfg));
    _configSaveLock = result.catch(() => {});
    return result;
  }

  async function _doSaveRemoteConfig(owner, repo, branch, pluginId, cfg, retries = 3) {
    const c = configRepoCoords(owner, repo, branch);
    let existingSha;
    let all = {};

    // Always GET the current file so the SHA is fresh — eliminates stale-SHA 409s
    // regardless of how many tabs or how quickly the user saves.
    try {
      const data = await ghFetch(
        `/repos/${c.owner}/${c.repo}/contents/${REMOTE_CONFIG_FILE}?ref=${encodeURIComponent(c.branch)}`
      );
      existingSha = data.sha;
      all = JSON.parse(b64ToUtf8(data.content));
    } catch (e) {
      if (!e.message.includes('404')) throw e;
      // File doesn't exist yet — first-time setup, create from scratch.
    }

    all['_global'] = { ghToken: getToken(), trmnlApiKey: getTrmnlKey(), configRepo: getConfigRepo() };
    // Store the full config including repo so other PCs can restore it from the config file.
    // Omit empty-string repo to avoid poisoning the file with blank entries that would
    // overwrite a valid repo on another PC when the file is loaded back.
    const stored = { ...cfg };
    if (!stored.repo) delete stored.repo;
    all[pluginId] = stored;

    const json    = JSON.stringify(all, null, 2) + '\n';
    const content = btoa(unescape(encodeURIComponent(json)));
    const body    = { message: 'chore: update TRMNL sync config', content, branch: c.branch };
    if (existingSha) body.sha = existingSha;

    try {
      await ghFetch(`/repos/${c.owner}/${c.repo}/contents/${REMOTE_CONFIG_FILE}`, {
        method:  'PUT',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(body),
      });
    } catch (e) {
      if (retries > 0 && e.message.includes('409')) {
        // Concurrent write from another tab/device — back off and retry with fresh GET.
        await new Promise(r => setTimeout(r, 300 * (4 - retries))); // 300 / 600 / 900 ms
        return _doSaveRemoteConfig(owner, repo, branch, pluginId, cfg, retries - 1);
      }
      throw e;
    }
  }

  // ---------------------------------------------------------------------------
  // TRMNL archive helpers
  // ---------------------------------------------------------------------------

  async function fetchArchive(pluginId) {
    const res = await fetch(`https://trmnl.com/api/plugin_settings/${pluginId}/archive`);
    if (!res.ok) throw new Error(`TRMNL archive fetch failed: ${res.status}`);
    const zip = await JSZip.loadAsync(await res.arrayBuffer());
    const files = {};
    await Promise.all(
      Object.entries(zip.files)
        .filter(([, e]) => !e.dir)
        .map(async ([name, e]) => { files[name] = await e.async('text'); })
    );
    return files;
  }

  async function buildAndUpload(pluginId, files) {
    const key = getTrmnlKey();
    if (!key) throw new Error('TRMNL API key not set. Configure it in the GitHub Sync panel.');
    const zip = new JSZip();
    for (const [name, content] of Object.entries(files)) {
      if (content != null) zip.file(name, content);
    }
    const raw  = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
    const blob = new Blob([raw], { type: 'application/zip' });
    const fd   = new FormData();
    fd.append('file', blob, 'archive.zip');
    const res = await fetch(`https://trmnl.com/api/plugin_settings/${pluginId}/archive`, {
      method: 'POST', headers: { Authorization: `Bearer ${key}` }, body: fd,
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`TRMNL upload ${res.status}${text ? ': ' + text.slice(0, 200) : ''}`);
    }
  }

  // Normalize line endings to \n (GitHub always stores with \n).
  // TRMNL's archive may return \r\n, causing false SHA mismatches.
  function normalizeLF(s) { return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); }

  // Compute the git blob SHA that GitHub uses, so we can diff without downloading file content.
  // Formula: SHA1("blob " + byteLength + "\0" + content)
  async function gitBlobSha(content) {
    const enc    = new TextEncoder();
    const body   = enc.encode(normalizeLF(content));
    const header = enc.encode(`blob ${body.length}\0`);
    const buf    = new Uint8Array(header.length + body.length);
    buf.set(header);
    buf.set(body, header.length);
    const digest = await crypto.subtle.digest('SHA-1', buf);
    return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
  }

  // ---------------------------------------------------------------------------
  // Push: TRMNL → GitHub
  //
  // Uses the Git Trees API to produce a single atomic commit for all files.
  // ---------------------------------------------------------------------------

  async function push(pluginId, onLog) {
    const cfg = getConfig(pluginId);
    const { owner, repo } = parseRepo(cfg);
    const branch   = cfg.branch.trim() || 'main';
    const basePath = resolvePath(cfg.path, pluginId);

    onLog('Fetching TRMNL archive…');
    const files = await fetchArchive(pluginId);
    const names = Object.keys(files);
    if (!names.length) throw new Error('Plugin archive is empty.');
    onLog(`${names.length} files: ${names.join(', ')}`);

    // Use name from settings.yml (reliable across all TRMNL pages)
    const pluginName = extractNameFromYaml(files['settings.yml'] || files['settings.yaml'] || '');
    const message    = resolveMsg(cfg.commitMsg, pluginId, pluginName);

    // ── Change detection: compare git blob SHAs with what GitHub already has ──
    onLog('Checking for changes…');
    let currentShas = {};
    try {
      const dir = await ghFetch(
        `/repos/${owner}/${repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`
      );
      if (Array.isArray(dir)) {
        for (const f of dir) { if (f.type === 'file') currentShas[f.name] = f.sha; }
      }
    } catch (e) {
      if (!e.message.includes('404') && !e.message.includes('409')) throw e;
      // 404 = path doesn't exist yet; 409 = repo is empty — treat both as first push (all-new)
    }
    const trmnlShas = Object.fromEntries(
      await Promise.all(names.map(async n => [n, await gitBlobSha(files[n])]))
    );
    const changed = names.filter(n => trmnlShas[n] !== currentShas[n]);
    const deleted = Object.keys(currentShas).filter(n => !files[n]);
    if (!changed.length && !deleted.length) {
      onLog('Nothing to commit — plugin already matches GitHub.');
      return null;
    }
    onLog(`Changes: ${[...changed, ...deleted.map(n => `${n} (deleted)`)].join(', ')}`);

    onLog(`Reading branch "${branch}"…`);
    let headSha    = null;
    let baseTree   = null;
    let repoIsEmpty = false;
    try {
      const refData    = await ghFetch(`/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
      headSha          = refData.object.sha;
      const headCommit = await ghFetch(`/repos/${owner}/${repo}/git/commits/${headSha}`);
      baseTree         = headCommit.tree.sha;
    } catch (e) {
      if (e.message.includes('409')) {
        repoIsEmpty = true; // Git Data API unavailable until repo has at least one commit
        onLog('Repository is empty — will initialize via Contents API.');
      } else if (e.message.includes('404')) {
        onLog(`Branch "${branch}" not found — will create it on first push.`);
      } else {
        throw e;
      }
    }

    // ── Empty repo: Git Data API (blobs/trees/commits) returns 409 on repos with no commits.
    // Use the Contents API instead — it works on empty repos and initializes the branch.
    // This creates one commit per file; subsequent pushes use the efficient Data API path.
    if (repoIsEmpty) {
      onLog('Initializing repository…');
      const pushedShas = {};
      let lastCommitSha = null;
      for (const name of names) {
        const res = await ghFetch(`/repos/${owner}/${repo}/contents/${basePath}/${name}`, {
          method:  'PUT',
          headers: { 'Content-Type': 'application/json' },
          body:    JSON.stringify({
            message: name === names[names.length - 1] ? message : `Initialize: add ${name}`,
            content: btoa(unescape(encodeURIComponent(normalizeLF(files[name])))),
            branch,
          }),
        });
        pushedShas[name] = res.content.sha;
        lastCommitSha = res.commit.sha;
      }
      setLastPushShas(pluginId, pushedShas);
      recordPushTime(pluginId);
      onLog(`✓ Pushed ${names.length} files to ${owner}/${repo}/${basePath}`);
      return `https://github.com/${owner}/${repo}/commit/${lastCommitSha}`;
    }

    onLog('Creating blobs…');
    // pushedShas maps filename → the exact SHA GitHub assigns to each blob.
    // These will eventually match what the Contents API directory listing returns,
    // so we store them as our baseline instead of locally-computed SHAs.
    const pushedShas = {};
    const treeItems = await Promise.all(names.map(async name => {
      const blobData = await ghFetch(`/repos/${owner}/${repo}/git/blobs`, {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ content: normalizeLF(files[name]), encoding: 'utf-8' }),
      });
      pushedShas[name] = blobData.sha;
      return { path: `${basePath}/${name}`, mode: '100644', type: 'blob', sha: blobData.sha };
    }));

    onLog('Creating tree…');
    const treePayload = { tree: treeItems };
    if (baseTree) treePayload.base_tree = baseTree; // omit for a brand-new empty repo
    const tree = await ghFetch(`/repos/${owner}/${repo}/git/trees`, {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify(treePayload),
    });

    onLog('Creating commit…');
    const commit = await ghFetch(`/repos/${owner}/${repo}/git/commits`, {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify({ message, tree: tree.sha, parents: headSha ? [headSha] : [] }),
    });

    if (headSha) {
      onLog('Updating branch…');
      await ghFetch(`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`, {
        method:  'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ sha: commit.sha, force: true }),
      });
    } else {
      onLog(`Creating branch "${branch}"…`);
      await ghFetch(`/repos/${owner}/${repo}/git/refs`, {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ ref: `refs/heads/${branch}`, sha: commit.sha }),
      });
    }

    setLastPushShas(pluginId, pushedShas);
    recordPushTime(pluginId);
    onLog(`✓ Pushed ${names.length} files to ${owner}/${repo}/${basePath}`);
    return `https://github.com/${owner}/${repo}/commit/${commit.sha}`;
  }

  // ---------------------------------------------------------------------------
  // Pull: GitHub → TRMNL
  // ---------------------------------------------------------------------------

  async function pull(pluginId, onLog) {
    const cfg = getConfig(pluginId);
    const { owner, repo } = parseRepo(cfg);
    if (!getTrmnlKey()) throw new Error('TRMNL API key not configured. Open the GitHub Sync panel to set it.');
    const branch   = cfg.branch.trim() || 'main';
    const basePath = resolvePath(cfg.path, pluginId);

    onLog(`Fetching file list from ${owner}/${repo}/${basePath}…`);
    let dir;
    try {
      dir = await ghFetch(`/repos/${owner}/${repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`);
    } catch (e) {
      throw new Error(`Could not read path "${basePath}": ${e.message}`);
    }
    if (!Array.isArray(dir)) throw new Error(`"${basePath}" is not a directory in the repository.`);

    const fileEntries = dir.filter(f => f.type === 'file');
    if (!fileEntries.length) throw new Error(`No files found at "${basePath}".`);
    onLog(`${fileEntries.length} files: ${fileEntries.map(f => f.name).join(', ')}`);

    onLog('Downloading files…');
    const files = {};
    await Promise.all(fileEntries.map(async entry => {
      const data = await ghFetch(
        `/repos/${owner}/${repo}/contents/${entry.path}?ref=${encodeURIComponent(branch)}`
      );
      // GitHub returns base64 content with embedded newlines
      files[entry.name] = b64ToUtf8(data.content);
    }));

    onLog('Uploading to TRMNL…');
    await buildAndUpload(pluginId, files);
    onLog('✓ Pull complete! Redirecting…');

    setTimeout(() => {
      if (window.Turbo?.cache) window.Turbo.cache.clear();
      window.location.href = `https://trmnl.com/plugin_settings/${pluginId}/edit`;
    }, 1200);
  }

  // ---------------------------------------------------------------------------
  // DOM helpers
  // ---------------------------------------------------------------------------

  // Decode a GitHub base64-encoded file content string to a proper UTF-8 string.
  // atob() alone returns a binary (Latin-1) string and corrupts non-ASCII characters.
  function b64ToUtf8(b64) {
    const binary = atob(b64.replace(/\n/g, ''));
    const bytes  = Uint8Array.from(binary, c => c.charCodeAt(0));
    return new TextDecoder().decode(bytes);
  }

  function mk(tag, cls, text) {
    const el = document.createElement(tag);
    if (cls)  el.className = cls;
    if (text !== undefined) el.textContent = text;
    return el;
  }

  function mkInput(placeholder, value, type = 'text') {
    const el = mk('input',
      'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg ' +
      'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 ' +
      'focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
    );
    el.type        = type;
    el.placeholder = placeholder;
    el.value       = value;
    el.style.boxSizing = 'border-box';
    return el;
  }

  function mkFieldGroup(labelText) {
    const wrap = mk('div', 'mb-4');
    wrap.appendChild(mk('label', 'block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1', labelText));
    return wrap;
  }

  function mkSectionHeader(text) {
    return mk('p',
      'text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3',
      text
    );
  }

  // ---------------------------------------------------------------------------
  // Panel
  // ---------------------------------------------------------------------------

  // Repo picker — searchable dropdown of the user's GitHub repos
  async function showRepoPicker(anchorEl, onSelect) {
    const PICKER_ID = 'gh-repo-picker';
    document.getElementById(PICKER_ID)?.remove();

    const picker     = document.createElement('div');
    picker.id        = PICKER_ID;
    picker.className = 'gh-repo-picker';

    const searchIn        = document.createElement('input');
    searchIn.type         = 'text';
    searchIn.placeholder  = 'Search repos…';
    searchIn.className    = 'gh-repo-picker-search';
    searchIn.autocomplete = 'off';

    const list       = document.createElement('div');
    list.className   = 'gh-repo-picker-list';
    list.textContent = 'Loading…';

    picker.append(searchIn, list);

    const rect = anchorEl.getBoundingClientRect();
    const pickerW = Math.max(rect.width, 300);
    const left    = Math.min(rect.left, window.innerWidth - pickerW - 8);
    picker.style.cssText = `position:fixed;left:${Math.max(8, left)}px;top:${rect.bottom + 4}px;` +
      `width:${pickerW}px;z-index:999999;`;
    document.body.appendChild(picker);
    searchIn.focus();

    let repos = [];

    function renderList(items) {
      list.innerHTML = '';
      if (!items.length) { list.textContent = 'No repos found.'; return; }
      items.slice(0, 80).forEach(r => {
        const btn = document.createElement('button');
        btn.type      = 'button';
        btn.className = 'gh-repo-picker-item';
        const nameSpan       = document.createElement('span');
        nameSpan.textContent = r.full_name;
        btn.appendChild(nameSpan);
        if (r.private) {
          const tag       = document.createElement('span');
          tag.className   = 'gh-repo-picker-private';
          tag.textContent = 'private';
          btn.appendChild(tag);
        }
        btn.addEventListener('mousedown', e => {
          e.preventDefault();
          onSelect(r.full_name);
          picker.remove();
        });
        list.appendChild(btn);
      });
    }

    try {
      repos = await ghFetch('/user/repos?per_page=100&sort=updated&type=all');
      renderList(repos);
    } catch(e) {
      list.textContent = 'Error: ' + e.message;
    }

    searchIn.addEventListener('input', () => {
      const q = searchIn.value.toLowerCase();
      renderList(repos.filter(r => r.full_name.toLowerCase().includes(q)));
    });

    function onOutsideClick(e) {
      if (!picker.contains(e.target)) {
        picker.remove();
        document.removeEventListener('mousedown', onOutsideClick);
      }
    }
    setTimeout(() => document.addEventListener('mousedown', onOutsideClick), 0);
  }

  // remoteCfg: the per-plugin object read directly from the GitHub config repo.
  // When present it is the source of truth; localStorage is only the fallback cache.
  function buildPanel(pluginId, remoteCfg = null) {
    const overlay = mk('div', '');
    overlay.id = OVERLAY_ID;
    overlay.addEventListener('click', closePanel);

    const panel = mk('div', '');
    panel.id = PANEL_ID;

    // ── Header ────────────────────────────────────────────────────────────────
    const hdr = mk('div',
      'flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0'
    );
    const iconBtnCls =
      'w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 ' +
      'hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';

    const closeBtn = mk('button', iconBtnCls, '✕');
    closeBtn.type = 'button';
    closeBtn.addEventListener('click', closePanel);

    const title = mk('div', 'flex items-center gap-2');
    title.innerHTML = ghIcon(15);
    title.appendChild(mk('span', 'text-sm font-semibold text-gray-900 dark:text-gray-100', 'GitHub Sync'));

    // Expand / restore panel width toggle
    let panelExpanded = false;
    const expandPanelBtn = mk('button', iconBtnCls);
    expandPanelBtn.type  = 'button';
    expandPanelBtn.title = 'Expand panel';
    expandPanelBtn.innerHTML =
      `` +
      `` +
      ``;
    expandPanelBtn.addEventListener('click', () => {
      panelExpanded = !panelExpanded;
      panel.style.width = panelExpanded ? 'min(95vw, 1100px)' : '';
      expandPanelBtn.title = panelExpanded ? 'Restore panel size' : 'Expand panel';
      expandPanelBtn.innerHTML = panelExpanded
        ? ``
        : ``;
    });

    const hdrBtns = mk('div', 'flex items-center gap-1 ml-auto');
    hdrBtns.append(expandPanelBtn, closeBtn);
    hdr.append(title, hdrBtns);
    panel.appendChild(hdr);

    // ── Scrollable body ───────────────────────────────────────────────────────
    const scroll = mk('div', 'flex-1 overflow-y-auto');

    // ── Shared helper: chip+Change / Not set+Set pattern for secret fields ────
    function mkSecretSection(sectionTitle, currentValue, placeholder, hint, saveFn) {
      const sec = mk('div', 'px-5 py-4 border-b border-gray-200 dark:border-gray-700');
      sec.appendChild(mkSectionHeader(sectionTitle));

      const chipSetCls   = 'gh-chip-set';
      const chipUnsetCls = 'gh-chip-unset';
      const linkBtnCls = 'gh-link-btn';
      const primaryBtnCls =
        'cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex items-center ' +
        'transition duration-150 gap-1.5 border-0 ' +
        'text-white bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-500';

      const row = mk('div', 'flex items-center gap-2 flex-wrap');
      const inputWrap = mk('div', 'w-full mt-2 hidden');
      const inp = mkInput(placeholder, currentValue, 'password');
      inputWrap.appendChild(inp);

      if (currentValue) {
        const chip      = mk('span', chipSetCls, '✓ Saved');
        const changeBtn = mk('button', linkBtnCls, 'Change');
        changeBtn.type  = 'button';
        changeBtn.style.setProperty('background', 'none', 'important');
        changeBtn.addEventListener('click', () => {
          inputWrap.classList.toggle('hidden');
          if (!inputWrap.classList.contains('hidden')) inp.focus();
        });
        inp.addEventListener('change', () => {
          const v = inp.value.trim();
          saveFn(v);
          chip.textContent = v ? '✓ Saved' : 'Cleared — reload panel';
        });
        row.append(chip, changeBtn);
      } else {
        const chip   = mk('span', chipUnsetCls, 'Not set');
        const setBtn = mk('button', primaryBtnCls, 'Set');
        setBtn.type  = 'button';
        setBtn.addEventListener('click', () => {
          inputWrap.classList.toggle('hidden');
          if (!inputWrap.classList.contains('hidden')) inp.focus();
        });
        const saveBtn = mk('button', primaryBtnCls, 'Save');
        saveBtn.type  = 'button';
        saveBtn.addEventListener('click', () => {
          const v = inp.value.trim();
          if (!v) return;
          saveFn(v);
          chip.className   = chipSetCls;
          chip.textContent = '✓ Saved';
          inputWrap.classList.add('hidden');
        });
        inputWrap.appendChild(saveBtn);
        row.append(chip, setBtn);
      }

      sec.append(row, inputWrap);
      if (hint) sec.appendChild(hint);
      return sec;
    }

    // ── Global Setup (collapsible) ────────────────────────────────────────────
    function isGlobalConfigured() {
      return !!(getToken() && getTrmnlKey() && getConfigRepo());
    }

    const globalSec  = mk('div', 'border-b border-gray-200 dark:border-gray-700 flex-shrink-0');
    const globalHdr  = mk('div',
      'gh-global-hdr flex items-center justify-between px-5 py-3 cursor-pointer select-none ' +
      'hover:bg-gray-50 transition-colors'
    );
    const globalArrow = mk('span', 'text-gray-500 dark:text-gray-400 text-xs mr-2', isGlobalConfigured() ? '▸' : '▾');
    const globalTitleRow = mk('div', 'flex items-center');
    globalTitleRow.append(globalArrow, mk('span',
      'text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400', 'Global Setup'
    ));
    const globalChip = mk('span', isGlobalConfigured() ? 'gh-chip-set' : 'gh-chip-unset',
      isGlobalConfigured() ? '✓ Ready' : 'Setup required'
    );
    globalHdr.append(globalTitleRow, globalChip);

    const globalBody = mk('div', 'px-5 pb-5 pt-3 space-y-4');
    globalBody.style.display = isGlobalConfigured() ? 'none' : '';

    globalHdr.addEventListener('click', () => {
      const open = globalBody.style.display !== 'none';
      globalBody.style.display = open ? 'none' : '';
      globalArrow.textContent  = open ? '▸' : '▾';
    });

    function updateGlobalChip() {
      const ok = isGlobalConfigured();
      globalChip.className   = ok ? 'gh-chip-set' : 'gh-chip-unset';
      globalChip.textContent = ok ? '✓ Ready' : 'Setup required';
      if (ok) {
        globalBody.style.display = 'none';
        globalArrow.textContent  = '▸';
      }
    }

    // Compact key field used inside the global section
    function mkKeyField(currentValue, placeholder, onSave) {
      const wrap     = mk('div', '');
      const inputWrap = mk('div', 'mt-1.5 hidden');
      const inp      = mkInput(placeholder, currentValue, 'password');
      inputWrap.appendChild(inp);
      const row = mk('div', 'flex items-center gap-2');
      if (currentValue) {
        const chip = mk('span', 'gh-chip-set', '✓ Saved');
        const changeBtn = mk('button', 'gh-link-btn', 'Change');
        changeBtn.type = 'button';
        changeBtn.style.setProperty('background', 'none', 'important');
        changeBtn.addEventListener('click', () => {
          inputWrap.classList.toggle('hidden');
          if (!inputWrap.classList.contains('hidden')) inp.focus();
        });
        inp.addEventListener('change', () => { onSave(inp.value.trim()); updateGlobalChip(); });
        row.append(chip, changeBtn);
      } else {
        const chip    = mk('span', 'gh-chip-unset', 'Not set');
        const setBtn  = mk('button', 'gh-link-btn', 'Set');
        setBtn.type   = 'button';
        setBtn.style.setProperty('background', 'none', 'important');
        const saveBtn = mk('button',
          'mt-1.5 cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex ' +
          'items-center transition duration-150 border-0 ' +
          'text-white bg-primary-500 dark:bg-primary-600 hover:bg-primary-600', 'Save'
        );
        saveBtn.type = 'button';
        saveBtn.addEventListener('click', () => {
          const v = inp.value.trim(); if (!v) return;
          onSave(v);
          chip.className = 'gh-chip-set'; chip.textContent = '✓ Saved';
          inputWrap.classList.add('hidden'); updateGlobalChip();
        });
        setBtn.addEventListener('click', () => {
          inputWrap.classList.toggle('hidden');
          if (!inputWrap.classList.contains('hidden')) inp.focus();
        });
        inputWrap.appendChild(saveBtn);
        row.append(chip, setBtn);
      }
      wrap.append(row, inputWrap);
      return wrap;
    }

    // GitHub token
    const tokenFieldWrap = mk('div', '');
    tokenFieldWrap.appendChild(mk('p', 'text-xs font-medium text-gray-600 dark:text-gray-400 mb-1', 'GitHub Token'));
    tokenFieldWrap.appendChild(mkKeyField(getToken(), 'ghp_xxxxxxxxxxxx', saveToken));
    const tokenHint = mk('p', 'text-xs text-gray-500 dark:text-gray-400 mt-1');
    tokenHint.innerHTML = 'Needs "repo" scope. Create one.';
    tokenFieldWrap.appendChild(tokenHint);
    globalBody.appendChild(tokenFieldWrap);

    // Config repo
    const cfgRepoWrap    = mk('div', '');
    const cfgRepoStatus  = mk('p', 'text-xs mt-1');
    const cfgRepoWarning = mk('div',
      'hidden mt-2 px-3 py-2 rounded-lg border border-red-400 bg-red-50 dark:bg-red-950/50 dark:border-red-700 text-red-700 dark:text-red-300 text-xs font-medium'
    );
    cfgRepoWarning.innerHTML =
      '⚠ This repository is PUBLIC. Your GitHub token and TRMNL API key are exposed to the world. ' +
      'Delete or revoke them immediately, then recreate this repo as private.';
    const cfgRepoIn     = mkInput('https://github.com/you/trmnl-github-sync', getConfigRepo());
    cfgRepoWrap.appendChild(mk('p', 'text-xs font-medium text-gray-600 dark:text-gray-400 mb-1', 'Config repository'));
    cfgRepoWrap.append(cfgRepoIn, cfgRepoStatus, cfgRepoWarning);
    globalBody.appendChild(cfgRepoWrap);

    // TRMNL API key
    const trmnlFieldWrap = mk('div', '');
    trmnlFieldWrap.appendChild(mk('p', 'text-xs font-medium text-gray-600 dark:text-gray-400 mb-1', 'TRMNL User API Key'));
    trmnlFieldWrap.appendChild(mkKeyField(getTrmnlKey(), 'user_xxxxx', saveTrmnlKey));
    trmnlFieldWrap.appendChild(mk('p', 'text-xs text-gray-500 dark:text-gray-400 mt-1',
      'Required for Pull from GitHub. Found on your account page.'));
    globalBody.appendChild(trmnlFieldWrap);

    // Paste-to-bootstrap
    const pasteSec    = mk('div', 'border-t border-gray-100 dark:border-gray-700/60 pt-3');
    const pasteToggle = mk('button', 'gh-link-btn flex items-center gap-1 text-xs', '▸ Set up on another PC — paste config');
    pasteToggle.type  = 'button';
    pasteToggle.style.setProperty('background', 'none', 'important');
    const pasteBody   = mk('div', 'mt-2 hidden');
    const pasteArea   = mk('textarea',
      'w-full h-28 px-3 py-2 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-lg ' +
      'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-none'
    );
    pasteArea.placeholder = 'Paste the contents of .trmnl-sync.json here…';
    pasteArea.style.boxSizing = 'border-box';
    const pasteApplyBtn = mk('button',
      'mt-2 cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex items-center ' +
      'transition duration-150 border-0 text-white bg-primary-500 hover:bg-primary-600', 'Apply'
    );
    pasteApplyBtn.type = 'button';
    const pasteStatus  = mk('p', 'text-xs mt-1');
    pasteApplyBtn.addEventListener('click', () => {
      try {
        const data = JSON.parse(pasteArea.value.trim());
        // Cache credentials + all plugin configs locally, then reopen.
        // openPanel will re-fetch from GitHub (using the now-cached token + configRepo)
        // and buildPanel will render from that authoritative source.
        cacheConfigData(data);
        pasteStatus.textContent = '✓ Applied! Reopening panel…';
        pasteStatus.style.color = '#16a34a';
        setTimeout(() => { closePanel(); openPanel(); }, 800);
      } catch (e) {
        pasteStatus.textContent = `Error: ${e.message}`;
        pasteStatus.style.color = '#dc2626';
      }
    });
    pasteToggle.addEventListener('click', () => {
      const hidden = pasteBody.classList.toggle('hidden');
      pasteToggle.textContent = (hidden ? '▸' : '▾') + ' Set up on another PC — paste config';
      pasteToggle.style.setProperty('background', 'none', 'important');
    });
    pasteBody.append(pasteArea, pasteApplyBtn, pasteStatus);
    pasteSec.append(pasteToggle, pasteBody);
    globalBody.appendChild(pasteSec);

    // Reset all local storage
    const resetSec = mk('div', 'border-t border-gray-100 dark:border-gray-700/60 pt-3');
    const resetBtn = mk('button',
      'gh-link-btn flex items-center gap-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300',
      '⚠ Reset all local data'
    );
    resetBtn.type = 'button';
    resetBtn.style.setProperty('background', 'none', 'important');
    const resetStatus = mk('p', 'text-xs mt-1');
    resetBtn.addEventListener('click', () => {
      if (!confirm('Clear all GitHub Sync data from this browser? This removes your token, API key, config repo, and all cached plugin configs. The .trmnl-sync.json on GitHub is not affected.')) return;
      const keys = Object.keys(localStorage).filter(k => k.startsWith('trmnl_gh'));
      keys.forEach(k => localStorage.removeItem(k));
      resetStatus.className   = 'text-xs mt-1 text-green-600 dark:text-green-400';
      resetStatus.textContent = `✓ Cleared ${keys.length} item(s). Closing panel…`;
      setTimeout(() => closePanel(), 1200);
    });
    resetSec.append(resetBtn, resetStatus);
    globalBody.appendChild(resetSec);

    // Config repo validation + optional auto-load
    // triggerLoad=false on initial render because openPanel already pre-fetched
    let cfgRepoTimer;
    function validateCfgRepo(raw, triggerLoad = true) {
      const normalized = raw.trim() ? normalizeRepoInput(raw) : '';
      const parts = normalized.split('/');
      const valid = !normalized || (parts.length === 2 && !!parts[0] && !!parts[1]);
      if (!normalized) {
        cfgRepoStatus.className = 'text-xs mt-1 text-gray-500 dark:text-gray-400';
        cfgRepoStatus.innerHTML =
          'Where .trmnl-sync.json lives. Recommended: create a private ' +
          'trmnl-github-sync repo.';
      } else {
        cfgRepoStatus.className = valid ? 'gh-repo-ok' : 'gh-repo-err';
        cfgRepoStatus.textContent = valid ? `✓ ${normalized}` : 'Use "owner/repo" or paste a GitHub URL.';
      }
      if (valid && normalized) {
        cfgRepoIn.value = normalized;
        saveConfigRepo(normalized);
        updateGlobalChip();
        if (triggerLoad) {
          clearTimeout(cfgRepoTimer);
          cfgRepoTimer = setTimeout(async () => {
            if (getToken()) {
              cfgRepoStatus.className   = 'text-xs mt-1 text-gray-500 dark:text-gray-400 italic';
              cfgRepoStatus.textContent = 'Checking repository…';
              const [o, r] = normalized.split('/');
              const { error, isPublic } = await ghCheckRepo(o, r);
              cfgRepoWarning.classList.toggle('hidden', !isPublic);
              if (error) {
                cfgRepoStatus.className   = 'gh-repo-err';
                cfgRepoStatus.textContent = error;
                return;
              }
            }
            reloadFromConfigRepo(normalized);
          }, 600);
        }
      }
    }

    // When the user changes the config repo field, reload the panel from scratch
    // so openPanel pre-fetches from the new repo and buildPanel gets fresh data.
    async function reloadFromConfigRepo(repoStr) {
      if (!getToken()) return;
      cfgRepoStatus.className   = 'text-xs mt-1 text-gray-500 dark:text-gray-400 italic';
      cfgRepoStatus.textContent = 'Loading from GitHub…';
      try {
        const [o, r] = repoStr.split('/');
        const { error, isPublic } = await ghCheckRepo(o, r);
        cfgRepoWarning.classList.toggle('hidden', !isPublic);
        if (error) { cfgRepoStatus.className = 'gh-repo-err'; cfgRepoStatus.textContent = error; return; }
        cfgRepoStatus.className   = 'gh-repo-ok';
        cfgRepoStatus.textContent = `✓ ${repoStr}`;
      } catch (e) {
        cfgRepoStatus.className   = 'gh-repo-err';
        cfgRepoStatus.textContent = `Could not reach repo: ${e.message}`;
        return;
      }
      // Reopen panel — openPanel will fetch from the new configRepo and pass
      // the remote data directly to buildPanel as the source of truth.
      setTimeout(() => { closePanel(); openPanel(); }, 400);
    }

    validateCfgRepo(getConfigRepo(), false); // show initial state only — openPanel pre-fetched
    // Silently check visibility on open so the warning shows for an already-configured repo
    if (getToken() && getConfigRepo()) {
      const [_o, _r] = getConfigRepo().split('/');
      ghCheckRepo(_o, _r).then(({ isPublic }) => cfgRepoWarning.classList.toggle('hidden', !isPublic));
    }
    cfgRepoIn.addEventListener('input',  () => validateCfgRepo(cfgRepoIn.value, false));
    cfgRepoIn.addEventListener('change', () => validateCfgRepo(cfgRepoIn.value, true));

    globalSec.append(globalHdr, globalBody);
    scroll.appendChild(globalSec);

    // ── Repository Config (collapsible when configured) ───────────────────────
    // remoteCfg is the source of truth when available; fall back to localStorage cache.
    const cfg    = remoteCfg ? { ...DEFAULT_CONFIG, ...remoteCfg } : getConfig(pluginId);
    const isRepoConfigured = !!effectiveRepo(cfg);

    const cfgSec = mk('div', 'border-b border-gray-200 dark:border-gray-700 flex-shrink-0');

    // Clickable header row (always visible)
    const cfgHdrRow = mk('div',
      'gh-repo-hdr flex items-center justify-between px-5 py-3 cursor-pointer select-none transition-colors'
    );
    const cfgArrow    = mk('span', 'text-gray-500 dark:text-gray-400 text-xs mr-2', isRepoConfigured ? '▸' : '▾');
    const cfgTitleGrp = mk('div', 'flex items-center flex-shrink-0');
    cfgTitleGrp.append(cfgArrow, mk('span',
      'text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400', 'Repository'
    ));
    const cfgSummary = mk('span', 'text-xs text-gray-500 dark:text-gray-400 italic truncate ml-2');
    if (isRepoConfigured) cfgSummary.textContent = `${effectiveRepo(cfg)} · ${cfg.branch || 'main'}`;
    cfgHdrRow.append(cfgTitleGrp, cfgSummary);

    // Collapsible body
    const cfgBody = mk('div', 'px-5 pb-5 pt-3');
    cfgBody.style.display = isRepoConfigured ? 'none' : '';

    cfgHdrRow.addEventListener('click', () => {
      const open = cfgBody.style.display !== 'none';
      cfgBody.style.display = open ? 'none' : '';
      cfgArrow.textContent  = open ? '▸' : '▾';
    });

    // Sync status lives at top of body
    const cfgBodyHdr = mk('div', 'flex items-center justify-end mb-3');
    const syncStatus = mk('span', 'text-xs text-gray-500 dark:text-gray-400 italic');
    cfgBodyHdr.appendChild(syncStatus);
    cfgBody.appendChild(cfgBodyHdr);

    // Repo — accepts full GitHub URLs or bare owner/repo
    const repoGroup  = mkFieldGroup('Repository');
    const repoInputRow = mk('div', 'flex gap-1.5');
    const repoIn     = mkInput('https://github.com/owner/repo or owner/repo', cfg.repo);
    repoIn.style.flex = '1';
    const browseBtn  = mk('button',
      'flex-shrink-0 cursor-pointer px-2.5 py-1.5 text-xs font-medium rounded-lg border ' +
      'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 ' +
      'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 ' +
      'transition-colors focus:outline-none', 'Browse ▾'
    );
    browseBtn.type = 'button';
    browseBtn.title = 'Select from your GitHub repositories';
    browseBtn.addEventListener('click', () => {
      if (!getToken()) { alert('Set your GitHub token first.'); return; }
      showRepoPicker(repoInputRow, full_name => {
        repoIn.value = full_name;
        repoIn.dispatchEvent(new Event('input'));
        repoIn.dispatchEvent(new Event('change'));
      });
    });
    repoInputRow.append(repoIn, browseBtn);
    const repoStatus = document.createElement('p');

    const repoLink = document.createElement('a');
    repoLink.target = '_blank';
    repoLink.rel    = 'noopener noreferrer';
    repoLink.style.cssText = 'color:#3b82f6;text-decoration:underline;font-size:0.7rem;margin-left:0.375rem;display:none';
    repoLink.textContent = '↗ Open';

    function validateRepo(raw) {
      const normalized = normalizeRepoInput(raw);
      const parts      = normalized.split('/');
      const valid      = parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
      repoStatus.className   = valid ? 'gh-repo-ok' : (raw.trim() ? 'gh-repo-err' : '');
      repoStatus.textContent = valid
        ? `✓ ${normalized}`
        : (raw.trim() ? 'Paste a GitHub URL or use "owner/repo" format.' : '');
      repoLink.style.display = valid ? '' : 'none';
      if (valid) {
        repoLink.href = `https://github.com/${normalized}`;
        // Use cfg.branch here — branchIn may not be declared yet on initial call.
        // The branchIn change listener keeps cfgSummary in sync after that.
        cfgSummary.textContent = `${normalized} · ${cfg.branch || 'main'}`;
      }
      return valid ? normalized : null;
    }

    // Show validation state for whatever is already saved
    validateRepo(cfg.repo);

    repoIn.addEventListener('input', () => validateRepo(repoIn.value));
    repoIn.addEventListener('change', async () => {
      const normalized = validateRepo(repoIn.value);
      if (normalized) {
        repoIn.value = normalized;
        saveConfig(pluginId, { ...getConfig(pluginId), repo: normalized });
        doRemoteSave();
        if (getToken()) {
          repoStatus.className   = 'text-xs mt-1 text-gray-500 dark:text-gray-400 italic';
          repoStatus.textContent = 'Checking repository…';
          const [o, r] = normalized.split('/');
          const { error } = await ghCheckRepo(o, r);
          repoStatus.className   = error ? 'gh-repo-err' : 'gh-repo-ok';
          repoStatus.textContent = error || `✓ ${normalized}`;
        }
      }
    });

    // "Create repo" button — opens GitHub's new-repo page with the name prefilled
    function slugify(s) {
      return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
    }
    const createRepoBtn = mk('button',
      'mt-1.5 inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ' +
      'border cursor-pointer transition-colors focus:outline-none ' +
      'border-primary-300 dark:border-primary-700 bg-primary-50 dark:bg-primary-900/30 ' +
      'text-primary-600 dark:text-primary-400 hover:bg-primary-100 dark:hover:bg-primary-900/60'
    );
    createRepoBtn.type = 'button';
    let suggestedName = `trmnl-plugin-${pluginId}`;
    createRepoBtn.textContent = `+ Create "${suggestedName}" on GitHub`;
    // Always start hidden — shown once we have the real name from settings.yml
    createRepoBtn.style.display = 'none';
    const createStatus = mk('p', 'text-xs mt-1 text-gray-500 dark:text-gray-400');

    // Fetch archive to get the plugin name from settings.yml (most reliable source)
    fetchArchive(pluginId).then(files => {
      const nameFromYaml = extractNameFromYaml(files['settings.yml'] || files['settings.yaml'] || '');
      suggestedName = `trmnl-${slugify(nameFromYaml || getPluginName() || `plugin-${pluginId}`)}-plugin`;
      createRepoBtn.textContent = `+ Create "${suggestedName}" on GitHub`;
      if (!effectiveRepo(cfg) && !repoIn.value.trim()) {
        createRepoBtn.style.display = '';
        createStatus.textContent = 'A new tab will open to create the repo. Come back and paste the URL above.';
      }
    }).catch(() => {
      // Fallback: show with DOM-derived name if archive fetch fails
      suggestedName = `trmnl-${slugify(getPluginName() || `plugin-${pluginId}`)}-plugin`;
      createRepoBtn.textContent = `+ Create "${suggestedName}" on GitHub`;
      if (!effectiveRepo(cfg) && !repoIn.value.trim()) {
        createRepoBtn.style.display = '';
        createStatus.textContent = 'A new tab will open to create the repo. Come back and paste the URL above.';
      }
    });

    repoIn.addEventListener('input', () => {
      const empty = !repoIn.value.trim();
      createRepoBtn.style.display = empty ? '' : 'none';
      createStatus.textContent    = empty ? 'A new tab will open to create the repo. Come back and paste the URL above.' : '';
    });

    createRepoBtn.addEventListener('click', () => {
      window.open(
        `https://github.com/new?name=${encodeURIComponent(suggestedName)}&visibility=public`,
        '_blank', 'noopener,noreferrer'
      );
    });

    repoStatus.style.display = 'inline';
    const repoStatusRow = mk('div', 'flex items-center flex-wrap gap-1 mt-1');
    repoStatusRow.append(repoStatus, repoLink);
    repoGroup.append(repoInputRow, createRepoBtn, createStatus, repoStatusRow);
    cfgBody.appendChild(repoGroup);

    // Branch
    const branchGroup = mkFieldGroup('Branch');
    const branchIn    = mkInput('main', cfg.branch);
    branchIn.addEventListener('change', () => {
      saveConfig(pluginId, { ...getConfig(pluginId), branch: branchIn.value.trim() });
      cfgSummary.textContent = `${repoIn.value.trim() || cfg.repo} · ${branchIn.value.trim() || 'main'}`;
      doRemoteSave();
    });
    branchGroup.appendChild(branchIn);
    cfgBody.appendChild(branchGroup);

    // Path — with live resolved preview
    const pathGroup = mkFieldGroup('Path in repo');
    const pathIn    = mkInput('plugin', cfg.path);
    const pathHint  = mk('p', 'text-xs text-gray-500 dark:text-gray-400 mt-1',
      `Resolved: ${resolvePath(cfg.path, pluginId)}`
    );
    pathIn.addEventListener('input', () => {
      pathHint.textContent = `Resolved: ${resolvePath(pathIn.value, pluginId)}`;
    });
    pathIn.addEventListener('change', () => {
      saveConfig(pluginId, { ...getConfig(pluginId), path: pathIn.value.trim() });
      doRemoteSave();
    });
    pathGroup.append(pathIn, pathHint);
    pathGroup.appendChild(mk('p', 'text-xs text-gray-500 dark:text-gray-400',
      '{id} is replaced with the plugin ID.'
    ));
    cfgBody.appendChild(pathGroup);

    // Commit message
    const msgGroup = mkFieldGroup('Commit message');
    const msgIn    = mkInput('TRMNL sync: {name} (plugin {id})', cfg.commitMsg);
    msgIn.addEventListener('change', () => { saveConfig(pluginId, { ...getConfig(pluginId), commitMsg: msgIn.value.trim() }); doRemoteSave(); });
    msgGroup.appendChild(msgIn);
    msgGroup.appendChild(mk('p', 'text-xs text-gray-500 dark:text-gray-400 mt-1',
      '{id} = plugin ID, {name} = plugin name.'
    ));
    cfgBody.appendChild(msgGroup);

    // Auto-push toggle
    const autoWrap  = mk('div', 'flex items-start gap-3 py-1');
    const autoChk   = document.createElement('input');
    autoChk.type    = 'checkbox';
    autoChk.id      = 'gh-autopush-toggle';
    autoChk.checked = !!cfg.autoPush;
    autoChk.className =
      'mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 cursor-pointer flex-shrink-0 ' +
      'text-primary-600 focus:ring-primary-500';
    autoChk.addEventListener('change', () => { saveConfig(pluginId, { ...getConfig(pluginId), autoPush: autoChk.checked }); doRemoteSave(); });

    const autoLbl = mk('label', 'flex flex-col cursor-pointer select-none');
    autoLbl.htmlFor = 'gh-autopush-toggle';
    autoLbl.appendChild(mk('span', 'text-xs font-medium text-gray-700 dark:text-gray-300', 'Auto-push on save'));
    autoLbl.appendChild(mk('span', 'text-xs text-gray-500 dark:text-gray-400',
      'Automatically commit to GitHub whenever you save the plugin.'
    ));
    autoWrap.append(autoChk, autoLbl);
    cfgBody.appendChild(autoWrap);

    // ── Remote config sync ────────────────────────────────────────────────────

    async function doRemoteSave() {
      if (!getToken()) { syncStatus.textContent = 'Set a GitHub token in Global Setup first.'; return; }
      const c = getConfig(pluginId);
      const hasRepo = getConfigRepo() || c.repo;
      if (!hasRepo) { syncStatus.textContent = 'Set a repository first.'; return; }
      try {
        const owner    = c.repo ? parseRepo(c).owner : '';
        const repoName = c.repo ? parseRepo(c).repo  : '';
        syncStatus.textContent = 'Saving…';
        await saveRemoteConfig(owner, repoName, c.branch || 'main', pluginId, c);
        syncStatus.textContent = 'Saved ✓';
        setTimeout(() => { syncStatus.textContent = ''; }, 3000);
      } catch (e) {
        warn('Remote config save failed:', e.message);
        syncStatus.textContent = `Save failed: ${e.message.slice(0, 80)}`;
      }
    }

    // Buttons row: Save config + Reset config
    const cfgBtnRow = mk('div', 'flex items-center gap-3 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700/60');
    const saveCfgBtn = mk('button',
      'cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex items-center ' +
      'transition duration-150 gap-1.5 border-0 ' +
      'text-white bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-500'
    );
    saveCfgBtn.type = 'button';
    saveCfgBtn.textContent = 'Save config';
    saveCfgBtn.addEventListener('click', doRemoteSave);

    const resetCfgBtn = mk('button', 'gh-link-btn', 'Reset config');
    resetCfgBtn.type  = 'button';
    resetCfgBtn.title = 'Clear local config for this plugin';
    resetCfgBtn.style.setProperty('background', 'none', 'important');
    resetCfgBtn.addEventListener('click', () => {
      if (!confirm(
        `Clear GitHub Sync config for plugin ${pluginId}?\n\n` +
        `This removes local settings only — it does not delete the GitHub repository or remote config file.`
      )) return;
      localStorage.removeItem(cfgKey(pluginId));
      localStorage.removeItem(lastPushShasKey(pluginId));
      localStorage.removeItem(lastPushTimeKey(pluginId));
      closePanel();
      setTimeout(openPanel, 350);
    });

    cfgBtnRow.append(saveCfgBtn, resetCfgBtn);
    cfgBody.appendChild(cfgBtnRow);

    cfgSec.append(cfgHdrRow, cfgBody);
    scroll.appendChild(cfgSec);

    // ── Actions ───────────────────────────────────────────────────────────────
    const actionSec = mk('div', 'px-5 py-4');
    actionSec.appendChild(mkSectionHeader(`Actions — Plugin ${pluginId}`));

    // Log area
    const logArea = mk('div',
      'rounded-lg border border-gray-200 dark:border-gray-700 ' +
      'bg-gray-50 dark:bg-gray-800 p-3 mb-4 font-mono text-xs ' +
      'text-gray-600 dark:text-gray-400 overflow-y-auto gh-log'
    );
    const logPlaceholder = mk('span', 'text-gray-500 dark:text-gray-400 italic', 'Ready.');
    logArea.appendChild(logPlaceholder);

    function appendLog(msg, isError = false) {
      if (logArea.querySelector('span.italic')) logArea.querySelector('span.italic').remove();
      const line = document.createElement('div');
      if (isError) line.style.color = '#ef4444';
      line.textContent = msg;
      logArea.appendChild(line);
      logArea.scrollTop = logArea.scrollHeight;
    }

    function clearLog() {
      logArea.replaceChildren();
      logArea.appendChild(mk('span', 'text-gray-500 dark:text-gray-400 italic', 'Ready.'));
    }

    // Push button
    const pushBtn = mk('button',
      'w-full cursor-pointer font-medium rounded-lg text-sm px-4 py-2.5 mb-2.5 ' +
      'inline-flex items-center justify-center gap-2 transition duration-150 border-0 ' +
      'text-white bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-500 ' +
      'disabled:opacity-50 disabled:cursor-not-allowed'
    );
    pushBtn.type = 'button';
    pushBtn.innerHTML =
      `Push to GitHub`;

    // Pull button
    const pullBtn = mk('button',
      'w-full cursor-pointer font-medium rounded-lg text-sm px-4 py-2.5 ' +
      'inline-flex items-center justify-center gap-2 transition duration-150 ' +
      'border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 ' +
      'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 ' +
      'disabled:opacity-50 disabled:cursor-not-allowed'
    );
    pullBtn.type = 'button';
    pullBtn.innerHTML =
      `Pull from GitHub`;

    function setBusy(busy) {
      pushBtn.disabled = busy;
      pullBtn.disabled = busy;
    }

    pushBtn.addEventListener('click', async () => {
      clearLog();
      setBusy(true);
      const span = pushBtn.querySelector('span');
      span.textContent = 'Pushing…';
      try {
        const commitUrl = await push(pluginId, appendLog);
        if (commitUrl) {
          if (logArea.querySelector('span.italic')) logArea.querySelector('span.italic').remove();
          const linkLine = document.createElement('div');
          const a = document.createElement('a');
          a.href   = commitUrl;
          a.target = '_blank';
          a.rel    = 'noopener noreferrer';
          a.style.color = '#3b82f6';
          a.style.textDecoration = 'underline';
          a.textContent = commitUrl;
          linkLine.appendChild(a);
          logArea.appendChild(linkLine);
          logArea.scrollTop = logArea.scrollHeight;
        }
        // Push succeeded — we are now in sync. Don't re-query GitHub immediately
        // (API caches for minutes after a push). Trust local state instead.
        diffContainer.style.display = 'none';
        compareBtn.querySelector('span').textContent = 'Compare with GitHub';
        // Hide pull button: we just pushed so GitHub matches TRMNL
        document.getElementById(PULL_BTN_ID)?.style.setProperty('display', 'none');
        // Commits will be stale too — just prompt user to refresh manually
        const staleNote = mk('p', 'text-xs text-gray-500 dark:text-gray-400 italic mt-1',
          'Commits may take a moment to reflect the push — click ↺ to refresh.');
        commitsList.appendChild(staleNote);
      } catch (e) {
        warn('Push failed:', e.message);
        appendLog(`✗ ${e.message}`, true);
      } finally {
        span.textContent = 'Push to GitHub';
        setBusy(false);
      }
    });

    pullBtn.addEventListener('click', async () => {
      const cfg2 = getConfig(pluginId);
      const resolved = resolvePath(cfg2.path, pluginId);
      if (!confirm(
        `Pull from GitHub and overwrite plugin ${pluginId}?\n\n` +
        `Source: ${cfg2.repo}/${resolved} (${cfg2.branch || 'main'})\n\n` +
        `This will replace the current plugin content.`
      )) return;

      clearLog();
      setBusy(true);
      const span = pullBtn.querySelector('span');
      span.textContent = 'Pulling…';
      try {
        await pull(pluginId, appendLog);
        // pull() redirects on success; no need to re-enable buttons
      } catch (e) {
        warn('Pull failed:', e.message);
        appendLog(`✗ ${e.message}`, true);
        span.textContent = 'Pull from GitHub';
        setBusy(false);
      }
    });

    // Compare with GitHub button + diff container
    const compareBtn = mk('button',
      'mt-4 w-full cursor-pointer font-medium rounded-lg text-xs px-4 py-2 ' +
      'inline-flex items-center justify-center gap-2 transition duration-150 ' +
      'border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 ' +
      'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 ' +
      'disabled:opacity-50 disabled:cursor-not-allowed'
    );
    compareBtn.type = 'button';
    compareBtn.innerHTML =
      `Compare with GitHub`;
    const diffContainer = mk('div', 'mt-3');
    diffContainer.style.display = 'none';

    compareBtn.addEventListener('click', async () => {
      const spanEl = compareBtn.querySelector('span');
      if (diffContainer.style.display !== 'none') {
        diffContainer.style.display = 'none';
        spanEl.textContent = 'Compare with GitHub';
        return;
      }
      compareBtn.disabled = true;
      spanEl.textContent = 'Comparing…';
      diffContainer.style.display = '';
      diffContainer.replaceChildren(mk('p', 'text-xs text-gray-400 italic', 'Loading…'));
      try {
        const c2 = getConfig(pluginId);
        const { owner: o2, repo: r2 } = parseRepo(c2);
        const branch2   = c2.branch.trim() || 'main';
        const basePath2 = resolvePath(c2.path, pluginId);
        const [localFiles, dir] = await Promise.all([
          fetchArchive(pluginId),
          ghFetch(`/repos/${o2}/${r2}/contents/${basePath2}?ref=${encodeURIComponent(branch2)}`),
        ]);
        if (!Array.isArray(dir)) throw new Error(`"${basePath2}" is not a directory.`);
        const ghFiles = {};
        await Promise.all(dir.filter(f => f.type === 'file').map(async f => {
          const d = await ghFetch(`/repos/${o2}/${r2}/contents/${f.path}?ref=${encodeURIComponent(branch2)}`);
          ghFiles[f.name] = b64ToUtf8(d.content);
        }));
        const allNames = [...new Set([...Object.keys(localFiles), ...Object.keys(ghFiles)])].sort();
        if (!allNames.length) throw new Error('No files to compare.');
        diffContainer.replaceChildren();
        for (const name of allNames) {
          diffContainer.appendChild(mk('div', 'text-xs font-semibold text-gray-500 dark:text-gray-400 mt-3 mb-1', name));
          diffContainer.appendChild(ghBuildDiffEl(normalizeLF(ghFiles[name] ?? ''), normalizeLF(localFiles[name] ?? '')));
        }
        spanEl.textContent = 'Hide comparison';
      } catch (e) {
        diffContainer.replaceChildren(mk('p', 'gh-repo-err', `✗ ${e.message}`));
        spanEl.textContent = 'Compare with GitHub';
      } finally {
        compareBtn.disabled = false;
      }
    });

    // ── Recent commits ────────────────────────────────────────────────────────
    const commitsSec  = mk('div', 'px-5 py-4 border-t border-gray-200 dark:border-gray-700');
    const commitsHdr  = mk('div', 'flex items-center justify-between mb-3');
    const commitsTitle = mkSectionHeader('Recent commits');
    commitsTitle.style.marginBottom = '0';
    const refreshCommitsBtn = mk('button', 'gh-commits-refresh', '↺');
    refreshCommitsBtn.type  = 'button';
    refreshCommitsBtn.title = 'Refresh commits';
    commitsHdr.append(commitsTitle, refreshCommitsBtn);

    const commitsList = mk('div', 'space-y-2');
    commitsSec.append(commitsHdr, commitsList);

    async function loadCommits() {
      const c2 = getConfig(pluginId);
      if (!c2.repo || !getToken()) {
        commitsList.replaceChildren(mk('p', 'text-xs text-gray-500 dark:text-gray-400 italic', 'Configure a repository to see commits.'));
        return;
      }
      refreshCommitsBtn.disabled = true;
      refreshCommitsBtn.textContent = '…';
      commitsList.replaceChildren(mk('p', 'text-xs text-gray-500 dark:text-gray-400 italic', 'Loading…'));
      try {
        const { owner: o2, repo: r2 } = parseRepo(c2);
        const branch2   = c2.branch.trim() || 'main';
        const basePath2 = resolvePath(c2.path, pluginId);
        const commits = await ghFetch(
          `/repos/${o2}/${r2}/commits?path=${encodeURIComponent(basePath2)}&sha=${encodeURIComponent(branch2)}&per_page=5`
        );
        commitsList.replaceChildren();
        if (!commits.length) {
          commitsList.appendChild(mk('p', 'text-xs text-gray-500 dark:text-gray-400 italic', 'No commits yet for this path.'));
          return;
        }
        for (const c of commits) {
          const shortSha = c.sha.slice(0, 7);
          const msg      = c.commit.message.split('\n')[0];
          const author   = c.commit.author.name;
          const dateStr  = new Date(c.commit.author.date).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });

          const entry   = mk('div', 'border-b border-gray-100 dark:border-gray-800 last:border-0');
          const row     = mk('div', 'flex items-start gap-2 py-1.5');
          const shaLink = mk('a', 'gh-commit-sha flex-shrink-0', shortSha);
          shaLink.href   = c.html_url;
          shaLink.target = '_blank';
          shaLink.rel    = 'noopener noreferrer';

          const info       = mk('div', 'flex-1 min-w-0');
          const msgRow     = mk('div', 'flex items-center gap-1.5');
          const msgSpan    = mk('p', 'text-xs text-gray-700 dark:text-gray-300 truncate flex-1', msg);
          const diffToggle = mk('button', 'gh-commit-diff-toggle flex-shrink-0 text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300');
          diffToggle.type  = 'button';
          diffToggle.title = 'Show diff';
          diffToggle.innerHTML = chevronDown;
          msgRow.append(msgSpan, diffToggle);
          info.appendChild(msgRow);
          info.appendChild(mk('p', 'text-xs text-gray-500 dark:text-gray-400 mt-0.5', `${author} · ${dateStr}`));
          row.append(shaLink, info);

          const diffArea = mk('div', '');
          diffArea.style.display = 'none';
          let diffLoaded = false;

          diffToggle.addEventListener('click', async () => {
            const open = diffArea.style.display !== 'none';
            if (open) {
              diffArea.style.display = 'none';
              diffToggle.innerHTML   = chevronDown;
              diffToggle.title       = 'Show diff';
              return;
            }
            diffArea.style.display = '';
            diffToggle.innerHTML   = chevronUp;
            diffToggle.title       = 'Hide diff';
            if (diffLoaded) return;
            diffLoaded = true;
            diffArea.replaceChildren(mk('p', 'text-xs text-gray-400 italic pb-1', 'Loading diff…'));
            try {
              const detail = await ghFetch(`/repos/${o2}/${r2}/commits/${c.sha}`);
              const relevant = (detail.files || []).filter(f =>
                f.filename.startsWith(basePath2 + '/') || f.filename === basePath2
              );
              if (!relevant.length) {
                diffArea.replaceChildren(mk('p', 'text-xs text-gray-400 italic pb-1', 'No changes in this plugin\u2019s path.'));
                return;
              }
              diffArea.replaceChildren();
              for (const f of relevant) {
                const fname = f.filename.replace(basePath2 + '/', '');
                diffArea.appendChild(mk('div', 'text-xs font-semibold text-gray-500 dark:text-gray-400 mt-2 mb-1', fname));
                diffArea.appendChild(renderPatch(f.patch || ''));
              }
            } catch(e) {
              diffArea.replaceChildren(mk('p', 'gh-repo-err text-xs pb-1', `✗ ${e.message.slice(0, 80)}`));
            }
          });

          entry.append(row, diffArea);
          commitsList.appendChild(entry);
        }
      } catch (e) {
        if (e.message.includes('409')) {
          commitsList.replaceChildren(mk('p', 'text-xs text-gray-500 dark:text-gray-400 italic', 'No commits yet — repository is empty.'));
        } else {
          commitsList.replaceChildren(mk('p', 'gh-repo-err text-xs', `Failed to load: ${e.message.slice(0, 80)}`));
        }
      } finally {
        refreshCommitsBtn.disabled    = false;
        refreshCommitsBtn.textContent = '↺';
      }
    }

    refreshCommitsBtn.addEventListener('click', () => loadCommits());

    actionSec.append(logArea, pushBtn, pullBtn, compareBtn, diffContainer);
    scroll.appendChild(actionSec);

    loadCommits();
    scroll.appendChild(commitsSec);

    panel.append(hdr, scroll);
    document.body.append(overlay, panel);
    requestAnimationFrame(() => {
      overlay.classList.add('gh-visible');
      panel.classList.add('gh-visible');
    });
  }

  function closePanel() {
    [PANEL_ID, OVERLAY_ID].forEach(id => {
      const el = document.getElementById(id);
      if (!el) return;
      el.classList.remove('gh-visible');
      setTimeout(() => el.remove(), 300);
    });
  }

  async function openPanel() {
    if (document.getElementById(PANEL_ID)) { closePanel(); return; }
    const pluginId = getPluginId();
    if (!pluginId) return;

    // GitHub config repo is the source of truth. Fetch it before building the panel
    // so fields always reflect the real stored config, regardless of local cache state.
    let remoteData = null;
    if (getToken() && getConfigRepo()) {
      const btn = document.getElementById(BTN_ID);
      const spinSVG =
        `` +
        ``;
      btn?.insertAdjacentHTML('afterbegin', spinSVG);
      try {
        const [o, r] = getConfigRepo().split('/');
        remoteData = await loadAllRemoteConfig(o, r, 'main');
        if (remoteData) cacheConfigData(remoteData); // update local cache
      } catch (e) {
        warn('Config pre-load failed:', e.message);
      } finally {
        btn?.querySelector('.gh-spin')?.remove();
      }
    }

    // Pass the remote config for this plugin directly — buildPanel uses it as
    // the authoritative source rather than reading back from localStorage.
    const remoteCfg = remoteData ? (remoteData[pluginId] || null) : null;
    buildPanel(pluginId, remoteCfg);
  }

  // ---------------------------------------------------------------------------
  // Header buttons (main GitHub panel opener + quick push + pull indicator)
  // ---------------------------------------------------------------------------

  // Fetch both the GitHub directory listing and the TRMNL archive, then update
  // the push/pull header button visibility based on actual content differences.
  async function refreshSyncButtons(pluginId) {
    const cfg = getConfig(pluginId);
    if (!effectiveRepo(cfg) || !getToken()) return;

    const pushBtn = document.getElementById(PUSH_BTN_ID);
    const pullBtn = document.getElementById(PULL_BTN_ID);

    try {
      const { owner, repo } = parseRepo(cfg);
      const branch   = cfg.branch.trim() || 'main';
      const basePath = resolvePath(cfg.path, pluginId);

      const [dir, localFiles] = await Promise.all([
        ghFetch(`/repos/${owner}/${repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`).catch(e => {
          if (e.message.includes('404') || e.message.includes('409')) return null;
          throw e;
        }),
        fetchArchive(pluginId),
      ]);

      const ghShas = {};
      if (Array.isArray(dir)) {
        for (const f of dir) { if (f.type === 'file') ghShas[f.name] = f.sha; }
      }

      const localNames = Object.keys(localFiles);
      const localShas  = Object.fromEntries(
        await Promise.all(localNames.map(async n => [n, await gitBlobSha(localFiles[n])]))
      );

      // Push button: show only if TRMNL content differs from GitHub
      const hasPushable = !cfg.autoPush && (
        localNames.some(n => localShas[n] !== ghShas[n]) ||
        Object.keys(ghShas).some(n => !localFiles[n])
      );
      if (pushBtn) pushBtn.style.display = hasPushable ? '' : 'none';

      // Pull button: only show if we have a prior push record AND GitHub has drifted from it.
      // Without a stored record we have no baseline — avoid false positives.
      // Also skip during the grace period after a push (GitHub Contents API cache is stale).
      const storedShas    = getLastPushShas(pluginId);
      const hasRecord     = Object.keys(storedShas).length > 0;
      const recentlyPushed = pushAgeMs(pluginId) < PUSH_GRACE_MS;
      const hasPullable   = !recentlyPushed && hasRecord && (
        Object.entries(ghShas).some(([n, sha]) => storedShas[n] !== sha) ||
        Object.keys(storedShas).some(n => !ghShas[n])
      );
      if (pullBtn) {
        pullBtn.style.display = hasPullable ? '' : 'none';
        if (hasPullable) pullBtn.title = 'GitHub has changes — click to open panel';
      }
    } catch { /* silently ignore — buttons stay in their current state */ }
  }

  // Lightweight pull-only check (no archive fetch) — used after push to quickly
  // update the pull indicator without re-fetching the full archive.
  async function checkPullButton(pluginId) {
    const pullBtn = document.getElementById(PULL_BTN_ID);
    if (!pullBtn) return;
    const cfg = getConfig(pluginId);
    if (!effectiveRepo(cfg) || !getToken()) return;
    try {
      const { owner, repo } = parseRepo(cfg);
      const branch   = cfg.branch.trim() || 'main';
      const basePath = resolvePath(cfg.path, pluginId);
      const dir = await ghFetch(
        `/repos/${owner}/${repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`
      );
      if (!Array.isArray(dir)) return;
      const storedShas    = getLastPushShas(pluginId);
      const hasRecord     = Object.keys(storedShas).length > 0;
      const recentlyPushed = pushAgeMs(pluginId) < PUSH_GRACE_MS;
      const ghShas = {};
      for (const f of dir) { if (f.type === 'file') ghShas[f.name] = f.sha; }
      const hasPullable = !recentlyPushed && hasRecord && (
        Object.entries(ghShas).some(([n, sha]) => storedShas[n] !== sha) ||
        Object.keys(storedShas).some(n => !ghShas[n])
      );
      pullBtn.style.display = hasPullable ? '' : 'none';
      if (hasPullable) pullBtn.title = 'GitHub has changes — click to open panel';
    } catch { /* silently ignore */ }
  }

  function injectButton() {
    if (document.getElementById(BTN_ID)) return true;
    const pluginId = getPluginId();
    if (!pluginId) return true; // nothing to inject; stop observing

    const h2 = document.querySelector('h2.font-heading');
    if (!h2) return false;

    const baseBtnCls =
      'inline-flex items-center gap-1.5 px-2.5 py-1 flex-shrink-0 ' +
      'text-xs font-medium rounded-full border cursor-pointer transition-colors ' +
      'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 ' +
      'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 ' +
      'focus:outline-none focus:ring-2 focus:ring-primary-500';

    // Main "GitHub" panel button
    const btn = mk('button', baseBtnCls);
    btn.id    = BTN_ID;
    btn.type  = 'button';
    btn.title = 'GitHub Sync';
    btn.innerHTML = ghIcon(14) + `GitHub`;
    btn.addEventListener('click', openPanel);

    // Quick push button — only shown when autoPush is off and a repo is configured
    const cfg = getConfig(pluginId);
    const pushBtn = mk('button', 'gh-hdr-push-btn');
    pushBtn.id   = PUSH_BTN_ID;
    pushBtn.type = 'button';
    pushBtn.title = 'Push to GitHub';
    pushBtn.innerHTML =
      `Push`;
    // Always start hidden — refreshSyncButtons will show it if there are actual changes
    pushBtn.style.display = 'none';

    pushBtn.addEventListener('click', async () => {
      if (pushBtn.disabled) return;
      pushBtn.disabled = true;
      const mainBtn = document.getElementById(BTN_ID);
      const spinnerHTML = ``;
      if (mainBtn) mainBtn.insertAdjacentHTML('afterbegin', spinnerHTML);
      const span = pushBtn.querySelector('span');
      const orig = span.textContent;
      span.textContent = '…';
      try {
        const result = await push(pluginId, () => {});
        span.textContent = result ? '✓' : '—';
        // Hide both buttons immediately — GitHub API caches for minutes after a push,
        // so re-querying would return stale data and falsely show the pull indicator.
        document.getElementById(PULL_BTN_ID)?.style.setProperty('display', 'none');
        if (result) document.getElementById(PUSH_BTN_ID)?.style.setProperty('display', 'none');
      } catch (e) {
        span.textContent = '✗';
        pushBtn.title = e.message;
      } finally {
        pushBtn.disabled = false;
        mainBtn?.querySelector('.gh-spin')?.remove();
        setTimeout(() => { span.textContent = orig; pushBtn.title = 'Push to GitHub'; }, 2500);
      }
    });

    // Pull indicator button — hidden by default, shown when GitHub has newer files
    const pullBtn = mk('button', 'gh-hdr-pull-btn');
    pullBtn.id    = PULL_BTN_ID;
    pullBtn.type  = 'button';
    pullBtn.title = 'GitHub has changes';
    pullBtn.style.display = 'none';
    pullBtn.innerHTML =
      `Pull`;
    pullBtn.addEventListener('click', openPanel);

    // Insert next to h2.
    // editor-backups always wraps h2 in a new div.flex.items-center.gap-2 via insertBefore,
    // so we must not create our own wrapper (would cause nesting). Instead:
    //   • If a gap-2 wrapper is already present (editor-backups ran first) → append inside it.
    //   • Otherwise append to h2's current parent and watch for editor-backups wrapping h2,
    //     then move the buttons into the new wrapper so they stay adjacent to the title.
    const parent = h2.parentElement;
    if (parent && parent.classList.contains('gap-2')) {
      parent.append(btn, pushBtn, pullBtn);
    } else {
      parent.append(btn, pushBtn, pullBtn);
      // Watch in case editor-backups runs after us and wraps h2
      const wrapObs = new MutationObserver(() => {
        const newParent = h2.parentElement;
        if (newParent !== parent && newParent.classList.contains('gap-2')) {
          newParent.append(btn, pushBtn, pullBtn);
          wrapObs.disconnect();
        }
      });
      wrapObs.observe(parent, { childList: true });
      setTimeout(() => wrapObs.disconnect(), 10_000);
    }

    // Async: check if GitHub has newer files, show spinner on main btn while checking
    const mainSpan = btn.querySelector('span');
    const spinnerHTML = ``;
    btn.innerHTML = btn.innerHTML.replace('GitHub', `${spinnerHTML}GitHub`);
    refreshSyncButtons(pluginId).finally(() => {
      btn.querySelector('.gh-spin')?.remove();
    });

    log('Buttons injected for plugin', pluginId);
    return true;
  }

  // ---------------------------------------------------------------------------
  // Account page: "Use in GitHub Sync" button next to the API key
  // ---------------------------------------------------------------------------

  function injectAccountButton() {
    if (document.getElementById(ACCT_BTN_ID)) return true;
    const apiInput = document.querySelector('input[id$="-apiKey-copy-btn"]');
    if (!apiInput) return false;
    const apiValue = apiInput.value.trim();
    if (!apiValue) return false;

    const isAlreadySaved = getTrmnlKey() === apiValue;
    const baseCls =
      'inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ' +
      'cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 mt-3 ';
    const clsDefault =
      'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 ' +
      'text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700';
    const clsSaved =
      'text-primary-500 bg-primary-100 dark:bg-primary-900 border-primary-300 dark:border-primary-700';

    const btn = mk('button',
      baseCls + (isAlreadySaved ? clsSaved : clsDefault),
      isAlreadySaved ? '✓ Saved in GitHub Sync' : '↓ Use in GitHub Sync'
    );
    btn.id   = ACCT_BTN_ID;
    btn.type = 'button';
    btn.addEventListener('click', () => {
      saveTrmnlKey(apiValue);
      btn.textContent = '✓ Saved in GitHub Sync';
      btn.className   = baseCls + clsSaved;
    });

    const flexRow   = apiInput.closest('.flex.items-center');
    if (!flexRow) return false;
    const container = flexRow.closest('.p-6') || flexRow.parentElement;
    const docsP     = container.querySelector('a[href*="help.trmnl.com"]')?.closest('p');
    (docsP || container).insertAdjacentElement('afterend', btn);
    return true;
  }

  // ---------------------------------------------------------------------------
  // Styles
  // ---------------------------------------------------------------------------

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const s = mk('style');
    s.id = STYLE_ID;
    s.textContent = `
      #${OVERLAY_ID} {
        position: fixed; inset: 0; background: rgba(0,0,0,.45);
        z-index: 9998; opacity: 0; transition: opacity .25s;
      }
      #${OVERLAY_ID}.gh-visible { opacity: 1; }

      #${PANEL_ID} {
        position: fixed; top: 0; right: 0; bottom: 0; width: min(480px, 95vw);
        background: #fff; z-index: 9999;
        box-shadow: -4px 0 40px rgba(0,0,0,.15);
        display: flex; flex-direction: column;
        transform: translateX(100%);
        transition: transform .3s cubic-bezier(.4,0,.2,1);
        font-family: ui-sans-serif, system-ui, sans-serif;
        font-size: 13px; color: #111827;
      }
      #${PANEL_ID}.gh-visible { transform: translateX(0); }
      .dark #${PANEL_ID} { background: #111827; color: #e5e7eb; }

      .gh-log {
        white-space: pre-wrap; word-break: break-all;
        min-height: 5rem; max-height: 10rem;
      }

      /* Global setup header — dark mode hover (Tailwind dark: class doesn't compile here) */
      .dark #${PANEL_ID} .gh-global-hdr:hover { background: rgba(31,41,55,.5); }

      /* Repository section header */
      #${PANEL_ID} .gh-repo-hdr:hover { background: #f9fafb; }
      .dark #${PANEL_ID} .gh-repo-hdr:hover { background: rgba(31,41,55,.5); }

      /* "Change" / link-style button — !important so site button styles can't win */
      #${PANEL_ID} .gh-link-btn {
        background: none !important; border: 0 !important; padding: 0 !important;
        box-shadow: none !important;
        font-size: 0.75rem; line-height: 1rem;
        text-decoration: underline; cursor: pointer;
        color: #9ca3af !important;
      }
      .dark #${PANEL_ID} .gh-link-btn { color: #6b7280 !important; }
      #${PANEL_ID} .gh-link-btn:hover { color: #4b5563 !important; }
      .dark #${PANEL_ID} .gh-link-btn:hover { color: #d1d5db !important; }

      /* Saved / Not-set chips */
      .gh-chip-set {
        display: inline-flex; align-items: center; gap: 0.375rem;
        padding: 0.25rem 0.625rem; border-radius: 9999px;
        font-size: 0.75rem; font-weight: 500;
        background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0;
      }
      .dark .gh-chip-set {
        background: rgba(6,78,59,.25); color: #6ee7b7; border-color: rgba(52,211,153,.3);
      }
      .gh-chip-unset {
        display: inline-flex; align-items: center;
        padding: 0.25rem 0.625rem; border-radius: 9999px;
        font-size: 0.75rem; font-weight: 500;
        background: #f3f4f6; color: #6b7280; border: 1px solid #e5e7eb;
      }
      .dark .gh-chip-unset {
        background: #1f2937; color: #9ca3af; border-color: #374151;
      }

      /* Repo validation hint */
      .gh-repo-ok  { font-size: 0.75rem; color: #16a34a; }
      .dark .gh-repo-ok  { color: #4ade80; }
      .gh-repo-err { font-size: 0.75rem; color: #dc2626; }
      .dark .gh-repo-err { color: #f87171; }

      /* Spinner for async operations on the main header button */
      @keyframes gh-spin { to { transform: rotate(360deg); } }
      .gh-spin { animation: gh-spin 0.8s linear infinite; flex-shrink: 0; }

      /* Header quick-push button */
      .gh-hdr-push-btn {
        display: inline-flex; align-items: center; gap: 0.25rem;
        padding: 0.2rem 0.6rem; border-radius: 9999px;
        font-size: 0.7rem; font-weight: 500; cursor: pointer;
        border: 1px solid #bbf7d0 !important;
        background: #f0fdf4 !important; color: #15803d !important;
        transition: background 0.15s;
      }
      .gh-hdr-push-btn:hover { background: #dcfce7 !important; }
      .gh-hdr-push-btn:disabled { opacity: 0.6; cursor: not-allowed; }
      .dark .gh-hdr-push-btn {
        background: rgba(6,78,59,.25) !important; color: #6ee7b7 !important;
        border-color: rgba(52,211,153,.3) !important;
      }
      .dark .gh-hdr-push-btn:hover { background: rgba(6,78,59,.4) !important; }

      /* Header pull-indicator button */
      .gh-hdr-pull-btn {
        display: inline-flex; align-items: center; gap: 0.25rem;
        padding: 0.2rem 0.6rem; border-radius: 9999px;
        font-size: 0.7rem; font-weight: 500; cursor: pointer;
        border: 1px solid #bfdbfe !important;
        background: #eff6ff !important; color: #2563eb !important;
        transition: background 0.15s;
      }
      .gh-hdr-pull-btn:hover { background: #dbeafe !important; }
      .dark .gh-hdr-pull-btn {
        background: rgba(30,58,138,.25) !important; color: #93c5fd !important;
        border-color: rgba(96,165,250,.3) !important;
      }
      .dark .gh-hdr-pull-btn:hover { background: rgba(30,58,138,.4) !important; }

      /* Diff view */
      .gh-diff {
        font-family: ui-monospace, 'Cascadia Code', monospace;
        font-size: 11px; line-height: 1.6; margin: 0; padding: 0;
        overflow-x: auto; white-space: pre;
        background: #f8fafc;
      }
      .dark .gh-diff { background: #0f172a; }
      .gh-diff-add  { background: #dcfce7; color: #15803d; display: block; }
      .gh-diff-del  { background: #fee2e2; color: #dc2626; display: block; }
      .gh-diff-eq   { color: #9ca3af; display: block; }
      .gh-diff-skip { color: #d1d5db; font-style: italic; display: block; padding-left: 1.5rem; }
      .dark .gh-diff-add  { background: #14532d; color: #86efac; }
      .dark .gh-diff-del  { background: #7f1d1d; color: #fca5a5; }
      .dark .gh-diff-eq   { color: #374151; }
      .dark .gh-diff-skip { color: #4b5563; }
      .gh-hl-del { background: rgba(185,28,28,.25); border-radius: 2px; padding: 0 1px; }
      .gh-hl-add { background: rgba(21,128,61,.25);  border-radius: 2px; padding: 0 1px; }

      /* Commits refresh icon button */
      #${PANEL_ID} .gh-commits-refresh {
        background: none; border: none; padding: 0.2rem 0.4rem;
        font-size: 1rem; line-height: 1; cursor: pointer; border-radius: 4px;
        color: #9ca3af; transition: color 0.15s, background 0.15s;
      }
      #${PANEL_ID} .gh-commits-refresh:hover { color: #4b5563; background: #f3f4f6; }
      .dark #${PANEL_ID} .gh-commits-refresh:hover { color: #d1d5db; background: #374151; }
      #${PANEL_ID} .gh-commits-refresh:disabled { opacity: 0.4; cursor: not-allowed; }

      /* Commit SHA badge */
      .gh-commit-sha {
        font-family: ui-monospace, monospace; font-size: 0.7rem;
        padding: 0.1rem 0.4rem; border-radius: 4px;
        background: #f3f4f6; color: #374151;
        text-decoration: none; border: 1px solid #e5e7eb;
        transition: background 0.15s;
      }
      .gh-commit-sha:hover { background: #e5e7eb; }
      .dark .gh-commit-sha { background: #1f2937; color: #d1d5db; border-color: #374151; }
      .dark .gh-commit-sha:hover { background: #374151; }

      /* Commit diff toggle chevron */
      .gh-commit-diff-toggle {
        background: none; border: none; padding: 0.1rem 0.2rem;
        line-height: 1; cursor: pointer; border-radius: 3px; transition: color 0.15s;
      }

      /* Plugin list page — GitHub managed badge */
      .gh-list-badge {
        display: inline-flex; align-items: center; gap: 0.3rem;
        padding: 0.15rem 0.45rem 0.15rem 0.3rem; border-radius: 5px; cursor: pointer;
        color: #6b7280; text-decoration: none; transition: color 0.15s, background 0.15s;
        flex-shrink: 0; border: 1px solid transparent; margin-left: 0.5rem;
      }
      .gh-list-badge:hover { color: #111827; background: #f3f4f6; border-color: #e5e7eb; }
      .dark .gh-list-badge { color: #9ca3af; }
      .dark .gh-list-badge:hover { color: #e5e7eb; background: #374151; border-color: #4b5563; }
      .gh-list-badge-repo {
        font-size: 0.7rem; font-family: ui-monospace, monospace;
        max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      }

      /* Repo picker dropdown */
      .gh-repo-picker {
        background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
        box-shadow: 0 8px 24px rgba(0,0,0,.12); overflow: hidden;
      }
      .dark .gh-repo-picker { background: #1f2937; border-color: #374151; }
      .gh-repo-picker-search {
        display: block; width: 100%; box-sizing: border-box;
        padding: 0.5rem 0.75rem; font-size: 0.8rem;
        border: none; border-bottom: 1px solid #e5e7eb; outline: none;
        background: #f9fafb; color: #111827;
      }
      .dark .gh-repo-picker-search { background: #111827; color: #f9fafb; border-color: #374151; }
      .gh-repo-picker-list {
        max-height: 220px; overflow-y: auto; padding: 0.25rem;
        font-size: 0.78rem; color: #6b7280;
      }
      .dark .gh-repo-picker-list { color: #9ca3af; }
      .gh-repo-picker-item {
        display: flex; align-items: center; justify-content: space-between;
        width: 100%; box-sizing: border-box; text-align: left;
        padding: 0.35rem 0.6rem; border-radius: 5px; border: none; cursor: pointer;
        background: none; color: #111827; font-size: 0.78rem; gap: 0.5rem;
      }
      .dark .gh-repo-picker-item { color: #f3f4f6; }
      .gh-repo-picker-item:hover { background: #f3f4f6; }
      .dark .gh-repo-picker-item:hover { background: #374151; }
      .gh-repo-picker-private {
        font-size: 0.65rem; padding: 0.1rem 0.35rem; border-radius: 3px;
        background: #fef9c3; color: #854d0e; flex-shrink: 0;
      }
      .dark .gh-repo-picker-private { background: #422006; color: #fbbf24; }
    `;
    document.head.appendChild(s);
  }

  // ---------------------------------------------------------------------------
  // Plugin list page — inject GitHub icon next to managed plugins
  // ---------------------------------------------------------------------------

  const GH_MARK_PATH = `M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z`;
  function ghIcon(size = 14) {
    return ``;
  }
  const GH_OCTICON = ghIcon(14);

  function isListPage() {
    return location.pathname === '/plugin_settings' && location.search.includes('keyname=private_plugin');
  }

  function tryInjectListBadges() {
    const rows = document.querySelectorAll('[data-action*="plugin-settings#editSetting"]');
    rows.forEach(row => {
      if (row.getAttribute(LIST_BADGE_ATTR)) return; // already processed
      const id = row.getAttribute('data-plugin-settings-id');
      if (!id) { row.setAttribute(LIST_BADGE_ATTR, 'skip'); return; }
      const rowCfg = getConfig(id);
      if (!effectiveRepo(rowCfg)) return; // no config yet — observer will retry once config loads
      const actionsDiv = row.closest('.flex.items-center.text-sm.cursor-pointer')
        ?.querySelector('.flex.items-center.flex-shrink-0');
      if (!actionsDiv) return; // not in DOM yet — observer will retry
      const badge = document.createElement('a');
      badge.className = 'gh-list-badge';
      badge.href      = `https://github.com/${effectiveRepo(rowCfg)}`;
      badge.target    = '_blank';
      badge.rel       = 'noopener noreferrer';
      badge.title     = `Open on GitHub: ${effectiveRepo(rowCfg)}`;
      const repoLabel = document.createElement('span');
      repoLabel.className   = 'gh-list-badge-repo';
      repoLabel.textContent = effectiveRepo(rowCfg).split('/')[1] || effectiveRepo(rowCfg);
      badge.innerHTML = GH_OCTICON;
      badge.appendChild(repoLabel);
      actionsDiv.prepend(badge);
      row.setAttribute(LIST_BADGE_ATTR, 'done');
    });
  }

  async function setupListPage() {
    injectStyle();
    tryInjectListBadges();

    const target = document.querySelector('[data-controller="plugin-settings"]') || document.documentElement;
    const obs = new MutationObserver(() => tryInjectListBadges());
    obs.observe(target, { childList: true, subtree: true });
    // No hard disconnect — turbo navigation replaces the document, cleaning up automatically

    // Proactively load remote config so badges appear even before the panel is opened
    const cr = getConfigRepo();
    if (cr && cr.includes('/') && getToken()) {
      const [o, r] = cr.split('/');
      try {
        const data = await loadAllRemoteConfig(o, r, 'main');
        if (data) { cacheConfigData(data); tryInjectListBadges(); }
      } catch (e) {
        warn('List page: Config preload failed:', e.message);
      }
    }
  }

  // ---------------------------------------------------------------------------
  // Init
  // ---------------------------------------------------------------------------

  function setup() {
    if (isListPage()) { setupListPage(); return; }

    injectStyle();

    if (isAccountPage()) {
      if (!injectAccountButton()) {
        const obs = new MutationObserver(() => { if (injectAccountButton()) obs.disconnect(); });
        obs.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => obs.disconnect(), 15_000);
      }
      return;
    }

    const pluginId = getPluginId();
    if (!pluginId) return;

    checkAutoPush();

    function tryAttach() {
      attachAutoSave(pluginId);
      return !!document.querySelector('form[data-gh-sync-attached="true"]');
    }
    if (!tryAttach()) {
      const obs = new MutationObserver(() => { if (tryAttach()) obs.disconnect(); });
      obs.observe(document.body, { childList: true, subtree: true });
      setTimeout(() => obs.disconnect(), 60_000);
    }

    if (!injectButton()) {
      const obs = new MutationObserver(() => { if (injectButton()) obs.disconnect(); });
      obs.observe(document.body, { childList: true, subtree: true });
    }
  }

  function onNavigate() {
    if (isListPage()) { setupListPage(); return; }
    closePanel();
    if (isAccountPage()) { injectAccountButton(); return; }
    const pluginId = getPluginId();
    if (pluginId) {
      checkAutoPush();
      attachAutoSave(pluginId);
    }
    if (!injectButton()) {
      const obs = new MutationObserver(() => { if (injectButton()) obs.disconnect(); });
      obs.observe(document.body, { childList: true, subtree: true });
      setTimeout(() => obs.disconnect(), 15_000);
    }
  }

  document.addEventListener('turbo:load', () => onNavigate());

  window.navigation?.addEventListener('navigate', () => setTimeout(onNavigate, 0));

  log('Script loaded.');
  setup();

})();