// renderer/features.js // Oberfläche für: Upload-Vorschau mit Geheimnis-Warnung, lokale Änderungen, Pull Requests & // Issues, Server-Diagnose und Repo-Backup. Wird nach renderer.js geladen und nutzt dessen // globale Helfer ($, currentState, selectedFolder, showProgress, showSuccess, ...). // Inhalte vom Server werden ausschließlich per textContent eingesetzt, nie als HTML. (function () { 'use strict'; /* ---------- kleine Helfer ---------- */ function el(tag, props = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (v == null) continue; if (k === 'class') node.className = v; else if (k === 'text') node.textContent = v; else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else if (k in node) node[k] = v; else node.setAttribute(k, v); } for (const c of [].concat(children)) if (c != null) node.append(c); return node; } function fmtBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } function fmtDate(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } catch (_) { return iso; } } function fxModal({ title, wide = false }) { const titleEl = el('h2', { class: 'fx-title', text: title }); const closeBtn = el('button', { class: 'fx-close', type: 'button', title: 'Schließen', text: '✕' }); const body = el('div', { class: 'fx-body' }); const footer = el('div', { class: 'fx-footer' }); const card = el('div', { class: `modalContent card fx-card${wide ? ' fx-card--wide' : ''}` }, [ el('div', { class: 'fx-header' }, [titleEl, closeBtn]), body, footer ]); const root = el('div', { class: 'modal fx-modal' }, [card]); let onClose = null; const close = (result) => { root.remove(); document.removeEventListener('keydown', onKey); if (onClose) onClose(result); }; const onKey = (e) => { if (e.key !== 'Escape') return; // Nur den obersten Dialog schließen (z. B. eine Rückfrage über diesem Fenster) if (document.querySelector('.confirm-modal') || root.nextElementSibling && root.nextElementSibling.classList.contains('fx-modal')) return; close(undefined); }; closeBtn.onclick = () => close(undefined); root.addEventListener('mousedown', (e) => { if (e.target === root) close(undefined); }); document.addEventListener('keydown', onKey); document.body.appendChild(root); return { root, body, footer, close, setTitle: (t) => { titleEl.textContent = t; }, onClose: (fn) => { onClose = fn; } }; } function button(text, onclick, variant = 'secondary') { return el('button', { type: 'button', class: `fx-btn fx-btn--${variant}`, text, onclick }); } function busy(container, text) { container.textContent = ''; container.append(el('div', { class: 'fx-busy', text })); } /* ---------- Knöpfe je nach Ansicht ---------- */ window.updateFeatureButtons = function (mode, owner, repo) { const isGitea = currentState.platform !== 'github'; const btnPulls = $('btnPulls'); const btnIssues = $('btnIssues'); const btnLocal = $('btnLocalChanges'); const showRepo = mode === 'repo' && isGitea; if (btnPulls) { btnPulls.classList.toggle('hidden', !showRepo); btnPulls.onclick = () => openCollab(owner, repo, 'pulls'); } if (btnIssues) { btnIssues.classList.toggle('hidden', !showRepo); btnIssues.onclick = () => openCollab(owner, repo, 'issues'); } if (btnLocal) { btnLocal.classList.toggle('hidden', mode !== 'local'); btnLocal.onclick = () => openLocalChanges(selectedFolder); } }; /* ================================================================ 1. Upload-Vorschau mit Geheimnis-Warnung ================================================================ */ window.uploadPreviewGate = async function ({ paths, destPath, repo }) { showProgress(5, 'Prüfe Dateien vor dem Upload ...'); let res; try { res = await window.electronAPI.previewUploadPaths({ paths, destPath }); } catch (e) { res = { ok: false, error: String(e) }; } finally { hideProgress(); } if (!res || !res.ok) { // Die Vorschau darf einen Upload nie verhindern. showWarning('Upload-Vorschau nicht möglich: ' + (res && res.error ? res.error : 'unbekannter Fehler')); return { proceed: true, excludeFiles: [] }; } const files = res.files || []; const secrets = files.filter(f => f.secret); const ignored = res.ignored || []; const baseDir = paths.length ? paths[0].replace(/[\\/][^\\/]+$/, '') : ''; const relLocal = (p) => (baseDir && p.startsWith(baseDir) ? p.slice(baseDir.length + 1) : p).split('\\').join('/'); // Einstellung "Upload-Vorschau: nur bei Auffälligkeiten" – ohne Zugangsdaten und ohne Dateiflut direkt hochladen if (res.previewMode === 'warnings' && secrets.length === 0 && files.length > 0 && files.length <= 1000) { return { proceed: true, excludeFiles: [] }; } return new Promise((resolve) => { const m = fxModal({ title: '📤 Upload-Vorschau', wide: true }); let settled = false; const finish = (result) => { if (!settled) { settled = true; m.close(); resolve(result); } }; m.onClose(() => { if (!settled) { settled = true; resolve({ proceed: false }); } }); const target = destPath ? `${repo}/${destPath}` : repo; m.body.append(el('p', { class: 'fx-lead', text: `${files.length} Dateien (${fmtBytes(res.totalBytes || 0)}) werden nach „${target}“ hochgeladen.` })); if (files.length > 1000) { m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: `Das sind sehr viele Dateien. Prüfe, ob Build-Ordner (z. B. build/, dist/, node_modules/) in der .gitignore stehen.` })); } if (files.length === 0) { m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: 'Es bleibt keine Datei zum Hochladen übrig.' })); } const secretChecks = []; if (secrets.length) { const list = el('div', { class: 'fx-list' }); for (const f of secrets) { const cb = el('input', { type: 'checkbox', checked: true }); secretChecks.push({ cb, file: f.localFile }); list.append(el('label', { class: 'fx-list-row fx-list-row--check' }, [ cb, el('span', { class: 'fx-mono', text: f.targetPath }), el('span', { class: 'fx-muted', text: f.secret }) ])); } m.body.append(el('div', { class: 'fx-alert fx-alert--danger' }, [ el('strong', { text: `⚠️ ${secrets.length} Datei(en) enthalten vermutlich Zugangsdaten` }), el('div', { text: 'Angehakte Dateien werden NICHT hochgeladen. Haken nur entfernen, wenn die Datei wirklich ins Repo gehört (z. B. bei einem privaten Repo bewusst gewollt).' }), list, el('div', { class: 'fx-muted', text: 'Hinweis: Dateien, die schon im Repo liegen, bleiben dort, bis du sie löschst. Ein geleakter Schlüssel sollte zusätzlich erneuert werden.' }) ])); } if (ignored.length) { const list = el('div', { class: 'fx-list fx-list--compact' }); ignored.slice(0, 25).forEach(i => list.append(el('div', { class: 'fx-list-row fx-mono', text: relLocal(i.path) + (i.isDir ? '/' : '') }))); if (ignored.length > 25) list.append(el('div', { class: 'fx-list-row fx-muted', text: `… und ${ignored.length - 25} weitere` })); m.body.append(el('div', { class: 'fx-section' }, [ el('h3', { text: `🚫 Von .gitignore aussortiert (${ignored.length})` }), list ])); } if (files.length) { const list = el('div', { class: 'fx-list fx-list--compact' }); files.slice(0, 500).forEach(f => list.append(el('div', { class: 'fx-list-row' }, [ el('span', { class: 'fx-mono', text: f.targetPath }), el('span', { class: 'fx-muted', text: fmtBytes(f.size) }) ]))); if (files.length > 500) list.append(el('div', { class: 'fx-list-row fx-muted', text: `… und ${files.length - 500} weitere` })); m.body.append(el('details', { class: 'fx-section' }, [ el('summary', { text: `Alle ${files.length} Dateien anzeigen` }), list ])); } m.footer.append( button('Abbrechen', () => finish({ proceed: false })), button(files.length ? 'Hochladen' : 'Schließen', () => { if (!files.length) return finish({ proceed: false }); const excludeFiles = secretChecks.filter(s => s.cb.checked).map(s => s.file); if (excludeFiles.length >= files.length) return finish({ proceed: false }); finish({ proceed: true, excludeFiles }); }, 'primary') ); }); }; /* ================================================================ 2. Lokale Änderungen: Status, Diff, Commit, Push ================================================================ */ async function openLocalChanges(folder) { if (!folder) { showWarning('Bitte zuerst einen lokalen Ordner öffnen.'); return; } const m = fxModal({ title: '📝 Lokale Änderungen', wide: true }); const info = el('div', { class: 'fx-lead' }); const listPane = el('div', { class: 'fx-pane fx-pane--list' }); const diffPane = el('pre', { class: 'fx-pane fx-diff', text: 'Datei anklicken, um die Änderungen zu sehen.' }); m.body.append(info, el('div', { class: 'fx-split' }, [listPane, diffPane])); const msg = el('input', { type: 'text', class: 'fx-input', placeholder: 'Commit-Nachricht, z. B. „Login-Fehler behoben“' }); const btnCommit = button('Commit', () => commit(false), 'primary'); const btnCommitPush = button('Commit & Push', () => commit(true), 'primary'); const btnRefresh = button('↻ Aktualisieren', () => load()); const btnPushOnly = button('⬆ Nur pushen', () => push()); m.footer.append(msg, btnRefresh, btnPushOnly, btnCommit, btnCommitPush); let files = []; const checks = new Map(); async function load() { busy(listPane, 'Lade Status ...'); const res = await window.electronAPI.localGit({ action: 'status', folder }); if (!res.ok) { listPane.textContent = ''; listPane.append(el('div', { class: 'fx-alert fx-alert--warn', text: res.notRepo ? 'Dieser Ordner ist noch kein Git-Repository. Über „Push“ in der Werkzeugleiste kannst du ihn als Repo anlegen und hochladen.' : (res.error || 'Status nicht lesbar') })); [btnCommit, btnCommitPush, btnPushOnly].forEach(b => { b.disabled = true; }); return; } files = res.files; info.textContent = `Branch: ${res.branch}` + (res.ahead ? ` · ${res.ahead} Commit(s) noch nicht gepusht` : '') + ` · ${files.length} geänderte Datei(en)`; btnPushOnly.disabled = !res.ahead; listPane.textContent = ''; checks.clear(); if (!files.length) { listPane.append(el('div', { class: 'fx-empty', text: '✅ Keine Änderungen. Alles ist committet.' })); diffPane.textContent = ''; return; } const all = el('input', { type: 'checkbox', checked: true, onchange: () => { for (const cb of checks.values()) cb.checked = all.checked; } }); listPane.append(el('label', { class: 'fx-list-row fx-list-row--check fx-list-head' }, [all, el('span', { text: 'Alle auswählen' })])); for (const f of files) { const cb = el('input', { type: 'checkbox', checked: true }); checks.set(f.path, cb); const name = el('button', { type: 'button', class: 'fx-linkish fx-mono', text: f.from ? `${f.from} → ${f.path}` : f.path, onclick: () => showDiff(f, row) }); const discard = el('button', { type: 'button', class: 'fx-icon-btn', title: 'Änderung verwerfen', text: '↺', onclick: () => discardFile(f) }); const row = el('div', { class: 'fx-list-row fx-list-row--check' }, [ cb, el('span', { class: `fx-badge fx-badge--${f.code === '?' ? 'new' : f.code}`, text: f.label }), name, discard ]); listPane.append(row); } } async function showDiff(f, row) { listPane.querySelectorAll('.fx-list-row.is-active').forEach(r => r.classList.remove('is-active')); row.classList.add('is-active'); diffPane.textContent = 'Lade Unterschiede ...'; const res = await window.electronAPI.localGit({ action: 'diff', folder, file: f.path, code: f.code }); diffPane.textContent = ''; if (!res.ok) { diffPane.textContent = res.error || 'Diff nicht verfügbar'; return; } for (const line of String(res.diff).split('\n')) { let cls = ''; if (line.startsWith('+') && !line.startsWith('+++')) cls = 'fx-diff-add'; else if (line.startsWith('-') && !line.startsWith('---')) cls = 'fx-diff-del'; else if (line.startsWith('@@')) cls = 'fx-diff-hunk'; diffPane.append(el('span', { class: cls, text: line + '\n' })); } } async function discardFile(f) { const ok = await showActionConfirmModal({ title: 'Änderung verwerfen', message: f.code === '?' ? `Die neue Datei "${f.path}" wird gelöscht. Das lässt sich nicht rückgängig machen.` : `Alle nicht committeten Änderungen an "${f.path}" gehen verloren. Das lässt sich nicht rückgängig machen.`, confirmText: 'Verwerfen', danger: true }); if (!ok) return; const res = await window.electronAPI.localGit({ action: 'discard', folder, file: f.path, code: f.code }); if (!res.ok) showError('Verwerfen fehlgeschlagen: ' + res.error); await load(); } async function commit(andPush) { const selected = files.filter(f => checks.get(f.path) && checks.get(f.path).checked).map(f => f.path); if (!selected.length) { showWarning('Keine Datei ausgewählt.'); return; } if (!msg.value.trim()) { showWarning('Bitte eine Commit-Nachricht eingeben.'); msg.focus(); return; } [btnCommit, btnCommitPush].forEach(b => { b.disabled = true; }); try { const res = await window.electronAPI.localGit({ action: 'commit', folder, files: selected, message: msg.value }); if (!res.ok) { showError('Commit fehlgeschlagen: ' + res.error); return; } showSuccess(`${selected.length} Datei(en) committet`); msg.value = ''; if (andPush) await push(); } finally { [btnCommit, btnCommitPush].forEach(b => { b.disabled = false; }); await load(); } } async function push() { showProgress(30, 'Pushe ...'); const res = await window.electronAPI.localGit({ action: 'push', folder }); hideProgress(); if (res.ok) showSuccess('Push erfolgreich'); else await showInfoModal('Push fehlgeschlagen', (res.error || '') + (res.hint ? '\n\n' + res.hint : ''), true); await load(); } await load(); } /* ================================================================ 3. Pull Requests & Issues ================================================================ */ async function openCollab(owner, repo, kind) { const isPulls = kind === 'pulls'; const m = fxModal({ title: `${isPulls ? '🔀 Pull Requests' : '🐞 Issues'} · ${repo}`, wide: true }); let state = 'open'; let page = 1; function renderList() { m.setTitle(`${isPulls ? '🔀 Pull Requests' : '🐞 Issues'} · ${repo}`); m.body.textContent = ''; m.footer.textContent = ''; const tabs = el('div', { class: 'fx-tabs' }, [ ['open', 'Offen'], ['closed', 'Geschlossen'] ].map(([s, label]) => el('button', { type: 'button', class: `fx-tab${s === state ? ' is-active' : ''}`, text: label, onclick: () => { state = s; page = 1; renderList(); } }))); const list = el('div', { class: 'fx-list fx-list--cards' }); m.body.append(tabs, list); if (!isPulls) m.footer.append(button('+ Neues Issue', () => renderNewIssue(), 'primary')); loadPage(list); } async function loadPage(list) { const loading = el('div', { class: 'fx-busy', text: 'Lade ...' }); list.append(loading); const res = await window.electronAPI.giteaCollab({ action: isPulls ? 'listPulls' : 'listIssues', owner, repo, state, page }); loading.remove(); if (!res.ok) { list.append(el('div', { class: 'fx-alert fx-alert--danger', text: res.error })); return; } if (!res.items.length && page === 1) { list.append(el('div', { class: 'fx-empty', text: state === 'open' ? (isPulls ? 'Keine offenen Pull Requests.' : 'Keine offenen Issues.') : 'Nichts gefunden.' })); return; } for (const it of res.items) { const meta = [`#${it.number}`, it.user ? `von ${it.user.login}` : null, `aktualisiert ${fmtDate(it.updated)}`, it.comments ? `💬 ${it.comments}` : null] .filter(Boolean).join(' · '); const card = el('button', { type: 'button', class: 'fx-item', onclick: () => renderDetail(it.number) }, [ el('div', { class: 'fx-item-title', text: (it.merged ? '🟣 ' : it.state === 'open' ? '🟢 ' : '🔴 ') + it.title }), el('div', { class: 'fx-muted', text: meta + (isPulls && it.head ? ` · ${it.head} → ${it.base}` : '') }), it.labels.length ? el('div', { class: 'fx-labels' }, it.labels.map(l => { const lab = el('span', { class: 'fx-label', text: l.name }); if (/^[0-9a-f]{6}$/i.test(String(l.color).replace('#', ''))) lab.style.borderColor = '#' + String(l.color).replace('#', ''); return lab; })) : null ]); list.append(card); } if (res.items.length === 30) { const more = button('Mehr laden', () => { more.remove(); page++; loadPage(list); }); list.append(more); } } async function renderDetail(number) { m.body.textContent = ''; m.footer.textContent = ''; busy(m.body, 'Lade ...'); const res = await window.electronAPI.giteaCollab({ action: 'get', owner, repo, index: number, isPull: isPulls }); m.body.textContent = ''; if (!res.ok) { m.body.append(el('div', { class: 'fx-alert fx-alert--danger', text: res.error })); m.footer.append(button('← Zurück', renderList)); return; } const it = res.item; m.setTitle(`#${it.number} ${it.title}`); const meta = [it.state === 'open' ? 'offen' : (it.merged ? 'gemergt' : 'geschlossen'), it.user ? `von ${it.user.login}` : null, `erstellt ${fmtDate(it.created)}`] .filter(Boolean).join(' · '); m.body.append(el('div', { class: 'fx-muted', text: meta + (isPulls && it.head ? ` · ${it.head} → ${it.base}` : '') })); m.body.append(el('div', { class: 'fx-text', text: it.body || '(keine Beschreibung)' })); const comments = el('div', { class: 'fx-comments' }); for (const c of res.comments) { comments.append(el('div', { class: 'fx-comment' }, [ el('div', { class: 'fx-muted', text: `${c.user ? c.user.login : '?'} · ${fmtDate(c.created)}` }), el('div', { class: 'fx-text', text: c.body }) ])); } m.body.append(el('h3', { text: `Kommentare (${res.comments.length})` }), comments); const input = el('textarea', { class: 'fx-input fx-textarea', placeholder: 'Kommentar schreiben ...' }); const send = button('Kommentieren', async () => { if (!input.value.trim()) return; send.disabled = true; const r = await window.electronAPI.giteaCollab({ action: 'comment', owner, repo, index: number, body: input.value }); send.disabled = false; if (r.ok) { showSuccess('Kommentar gespeichert'); renderDetail(number); } else showError('Kommentar fehlgeschlagen: ' + r.error); }, 'primary'); m.body.append(input, el('div', { class: 'fx-row-end' }, [send])); m.footer.append(button('← Zurück', renderList)); if (it.url) m.footer.append(button('Im Browser öffnen', () => window.electronAPI.openExternalUrl(it.url))); if (isPulls && it.state === 'open' && !it.merged) { const style = el('select', { class: 'fx-input fx-select' }, [ ['merge', 'Merge-Commit'], ['squash', 'Squash'], ['rebase', 'Rebase'], ['rebase-merge', 'Rebase + Merge-Commit'] ].map(([v, t]) => el('option', { value: v, text: t }))); const delBranch = el('input', { type: 'checkbox' }); const mergeBtn = button('Mergen', async () => { const ok = await showActionConfirmModal({ title: 'Pull Request mergen', message: `#${it.number} „${it.title}“ wird per ${style.options[style.selectedIndex].text} in ${it.base} übernommen.` + (delBranch.checked ? `\nDer Branch ${it.head} wird danach gelöscht.` : ''), confirmText: 'Mergen' }); if (!ok) return; mergeBtn.disabled = true; const r = await window.electronAPI.giteaCollab({ action: 'merge', owner, repo, index: number, style: style.value, deleteBranch: delBranch.checked }); mergeBtn.disabled = false; if (r.ok) { showSuccess(`#${number} gemergt`); renderDetail(number); } else await showInfoModal('Mergen fehlgeschlagen', r.error + (it.mergeable === false ? '\n\nDer Pull Request hat Konflikte und muss erst angepasst werden.' : ''), true); }, 'primary'); if (it.mergeable === false) m.footer.append(el('span', { class: 'fx-muted', text: '⚠️ Konflikte' })); m.footer.append(style, el('label', { class: 'fx-inline-check' }, [delBranch, el('span', { text: 'Branch löschen' })]), mergeBtn); } if (!it.merged) { const toState = it.state === 'open' ? 'closed' : 'open'; m.footer.append(button(toState === 'closed' ? 'Schließen' : 'Wieder öffnen', async () => { const r = await window.electronAPI.giteaCollab({ action: 'setState', owner, repo, index: number, state: toState }); if (r.ok) renderDetail(number); else showError(r.error); }, toState === 'closed' ? 'danger' : 'secondary')); } } function renderNewIssue() { m.setTitle(`🐞 Neues Issue · ${repo}`); m.body.textContent = ''; m.footer.textContent = ''; const title = el('input', { type: 'text', class: 'fx-input', placeholder: 'Titel' }); const body = el('textarea', { class: 'fx-input fx-textarea fx-textarea--tall', placeholder: 'Beschreibung (optional)' }); m.body.append(title, body); m.footer.append(button('← Zurück', renderList), button('Issue anlegen', async () => { if (!title.value.trim()) { title.focus(); return; } const r = await window.electronAPI.giteaCollab({ action: 'createIssue', owner, repo, title: title.value, body: body.value }); if (r.ok) { showSuccess(`Issue #${r.item.number} angelegt`); renderDetail(r.item.number); } else showError(r.error); }, 'primary')); title.focus(); } renderList(); } /* ================================================================ 4. Server-Diagnose ================================================================ */ async function openDiagnosis() { const m = fxModal({ title: '🩺 Server-Diagnose', wide: true }); busy(m.body, 'Prüfe Server, Verbindung und Git-Transport ... (bis zu einer Minute)'); m.footer.append(button('Schließen', () => m.close())); const inRepo = currentState.view === 'gitea-repo' && currentState.owner && currentState.repo; const res = await window.electronAPI.runServerDiagnosis(inRepo ? { owner: currentState.owner, repo: currentState.repo } : {}); m.body.textContent = ''; if (!res || !res.ok) { m.body.append(el('div', { class: 'fx-alert fx-alert--danger', text: (res && res.error) || 'Diagnose fehlgeschlagen' })); return; } m.body.append(el('div', { class: 'fx-muted', text: 'Verwendet die gespeicherten Zugangsdaten.' })); const icons = { ok: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' }; for (const c of res.checks) { m.body.append(el('div', { class: `fx-check fx-check--${c.status}` }, [ el('div', { class: 'fx-check-head' }, [el('span', { text: icons[c.status] || '•' }), el('strong', { text: c.label })]), el('div', { class: 'fx-text', text: c.detail || '' }), c.hint ? el('pre', { class: 'fx-hint', text: c.hint }) : null ])); } const bad = res.checks.filter(c => c.status === 'fail').length; m.setTitle(bad ? `🩺 Server-Diagnose: ${bad} Problem(e)` : '🩺 Server-Diagnose: alles in Ordnung'); } /* ================================================================ 5. Repo-Backup (Einstellungen) ================================================================ */ /* ---------- Fortschrittsfenster für das Repo-Backup ---------- */ let backupView = null; function fmtDuration(ms) { const s = Math.max(0, Math.round(ms / 1000)); if (s < 60) return `${s} s`; const m = Math.floor(s / 60); return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`; } function backupPercent(p) { if (!p || !p.total) return 0; if (p.phase === 'done') return 100; // Laufendes Repo anteilig mitzählen, wenn die Größe des Downloads bekannt ist const partial = p.bytesTotal ? Math.min(0.99, p.bytes / p.bytesTotal) : 0; return Math.min(99, Math.floor(((p.done || 0) + partial) / p.total * 100)); } function openBackupProgress(initial) { if (backupView) { backupView.update(initial); return; } const m = fxModal({ title: '⇣ Backup läuft', wide: true }); const percentEl = el('div', { class: 'fx-progress-percent', text: '0 %' }); const fill = el('div', { class: 'fx-progress-fill' }); const bar = el('div', { class: 'fx-progress-track' }, [fill]); const currentEl = el('div', { class: 'fx-lead', text: 'Startet …' }); const stepEl = el('div', { class: 'fx-muted' }); const metaEl = el('div', { class: 'fx-progress-meta' }); const list = el('div', { class: 'fx-list fx-backup-list' }); const warn = el('div', { class: 'fx-alert fx-alert--warn hidden', text: 'Der Server liefert keine Git-Pakete. Die Repos werden deshalb als ZIP gesichert: nur der Standard-Branch, ohne Historie.' }); m.body.append(el('div', { class: 'fx-progress-head' }, [percentEl, el('div', { class: 'fx-progress-now' }, [currentEl, stepEl])]), bar, metaEl, warn, list); const cancelBtn = button('Abbrechen', async () => { const ok = await showActionConfirmModal({ title: 'Backup abbrechen?', message: 'Bereits gesicherte Repos bleiben erhalten. Ein laufender ZIP-Download wird sofort beendet, ein laufender Git-Spiegel erst nach dem aktuellen Repo.', confirmText: 'Abbrechen', danger: true }); if (ok) { cancelBtn.disabled = true; await window.electronAPI.repoBackupCancel(); } }, 'danger'); const closeBtn = button('Im Hintergrund weiterlaufen lassen', () => m.close()); m.footer.append(cancelBtn, closeBtn); m.onClose(() => { backupView = null; clearInterval(timer); }); let last = null; let rowsFor = ''; const rows = new Map(); // Verstrichene Zeit auch ohne neue Meldung weiterzählen const timer = setInterval(() => { if (last && last.phase !== 'done') renderMeta(last); }, 1000); function renderMeta(p) { const elapsed = Date.now() - (p.startedAt || Date.now()); const parts = [`${p.done || 0} von ${p.total || '?'} Repos`, `Dauer ${fmtDuration(p.summary ? p.summary.durationMs : elapsed)}`]; if (p.phase === 'running' && p.done >= 1 && p.total > p.done) { parts.push(`noch ca. ${fmtDuration(elapsed / p.done * (p.total - p.done))}`); } metaEl.textContent = parts.join(' · '); } function update(p) { if (!p) return; last = p; const pct = backupPercent(p); percentEl.textContent = `${pct} %`; fill.style.width = `${pct}%`; warn.classList.toggle('hidden', !p.mirrorBroken); renderMeta(p); if (p.phase === 'list' || p.phase === 'start') { currentEl.textContent = p.step || 'Startet …'; stepEl.textContent = p.targetDir ? `Ziel: ${p.targetDir}` : ''; } else if (p.phase === 'running') { currentEl.textContent = p.repo ? `${p.current}/${p.total}: ${p.repo}` : 'Läuft …'; let step = p.step || ''; if (p.bytes) step += ` ${fmtBytes(p.bytes)}${p.bytesTotal ? ` von ${fmtBytes(p.bytesTotal)}` : ''}`; stepEl.textContent = step; } else if (p.phase === 'done') { const s = p.summary || {}; m.setTitle(s.cancelled ? '⇣ Backup abgebrochen' : s.failed ? '⇣ Backup mit Fehlern beendet' : '⇣ Backup fertig'); currentEl.textContent = `${s.ok} von ${s.total} Repos gesichert` + (s.failed ? `, ${s.failed} Fehler` : '') + (s.skipped ? `, ${s.skipped} übersprungen` : ''); stepEl.textContent = `${s.mirror} als Git-Spiegel, ${s.zip} als ZIP · Protokoll: backup-protokoll.json im Backup-Ordner`; percentEl.textContent = s.cancelled ? '–' : '100 %'; fill.style.width = s.cancelled ? fill.style.width : '100%'; cancelBtn.classList.add('hidden'); closeBtn.textContent = 'Schließen'; } else if (p.phase === 'error') { m.setTitle('⇣ Backup fehlgeschlagen'); currentEl.textContent = p.error || 'Unbekannter Fehler'; stepEl.textContent = ''; cancelBtn.classList.add('hidden'); closeBtn.textContent = 'Schließen'; } // Liste aller Repos mit Zustand const repos = p.repos || []; if (repos.join('|') !== rowsFor) { rowsFor = repos.join('|'); list.textContent = ''; rows.clear(); for (const name of repos) { const icon = el('span', { class: 'fx-backup-icon', text: '○' }); const info = el('span', { class: 'fx-muted', text: 'wartet' }); rows.set(name, { row: el('div', { class: 'fx-list-row' }, [icon, el('span', { class: 'fx-mono', text: name }), info]), icon, info }); list.append(rows.get(name).row); } } const byRepo = new Map((p.results || []).map(r => [r.repo, r])); for (const [name, r] of rows) { const res = byRepo.get(name); r.row.classList.toggle('is-active', !res && name === p.repo && p.phase === 'running'); if (res) { r.icon.textContent = res.ok ? '✅' : '❌'; r.info.textContent = res.ok ? (res.method === 'leer' ? 'leeres Repo' : res.method + (res.bytes ? ` · ${fmtBytes(res.bytes)}` : '')) : res.error; r.info.title = res.error || ''; } else if (name === p.repo && p.phase === 'running') { r.icon.textContent = '⏳'; r.info.textContent = 'läuft …'; } else if (p.phase === 'done') { r.icon.textContent = '–'; r.info.textContent = 'übersprungen'; } } const active = list.querySelector('.fx-list-row.is-active'); if (active) active.scrollIntoView({ block: 'nearest' }); } backupView = { update }; update(initial || { phase: 'start', step: 'Startet …', startedAt: Date.now(), results: [], repos: [] }); } function renderBackupStatus(state) { const statusEl = $('repoBackupStatus'); if (!statusEl) return; const runBtn = $('btnRepoBackupRun'); if (runBtn) runBtn.textContent = state.running ? '⏳ Fortschritt anzeigen' : '▶️ Jetzt sichern'; if (state.running) { const p = state.progress; statusEl.textContent = p && p.total ? `⏳ Backup läuft: ${backupPercent(p)} % · ${p.current}/${p.total}: ${p.repo || ''}` : '⏳ Backup läuft …'; return; } const r = state.config && state.config.lastResult; if (!r) { statusEl.textContent = 'Noch kein Backup gelaufen.'; return; } let text = `Letztes Backup: ${fmtDate(r.at)} · ${r.ok}/${r.total} gesichert (${r.mirror} als Git-Spiegel, ${r.zip} als ZIP)`; if (r.cancelled) text += ' · abgebrochen'; if (r.durationMs) text += ` · Dauer ${fmtDuration(r.durationMs)}`; if (r.failed) text += ` · ${r.failed} Fehler: ` + r.errors.map(e => `${e.repo} (${e.error})`).join('; '); if (r.mirrorBroken) text += '\nDer Server liefert keine Git-Pakete, daher ZIP-Archive: nur der Standard-Branch, ohne Historie. Server-Diagnose zeigt die Ursache.'; statusEl.textContent = text; } async function loadBackupSettings() { try { const state = await window.electronAPI.repoBackupGetConfig(); if (!state || !state.ok) return; const c = state.config; if ($('repoBackupTarget')) $('repoBackupTarget').value = c.targetDir || ''; if ($('repoBackupInterval')) $('repoBackupInterval').value = c.interval || 'off'; if ($('repoBackupKeep')) $('repoBackupKeep').value = c.keep || 5; if ($('repoBackupTime')) $('repoBackupTime').value = c.time || ''; if ($('repoBackupNotify')) $('repoBackupNotify').value = c.notify || 'errors'; if ($('repoBackupExclude')) $('repoBackupExclude').value = c.excludeRepos || ''; renderBackupStatus(state); } catch (_) {} } async function saveBackupSettings(silent) { const res = await window.electronAPI.repoBackupSetConfig({ targetDir: $('repoBackupTarget').value, interval: $('repoBackupInterval').value, keep: $('repoBackupKeep').value, time: $('repoBackupTime') ? $('repoBackupTime').value : '', notify: $('repoBackupNotify') ? $('repoBackupNotify').value : 'errors', excludeRepos: $('repoBackupExclude') ? $('repoBackupExclude').value : '' }); if (res && res.ok) { renderBackupStatus(res); if (!silent) showSuccess('Backup-Einstellung gespeichert'); } else if (!silent) showError('Speichern fehlgeschlagen: ' + (res && res.error)); return res; } /* ================================================================ 6. Backup wiederherstellen ================================================================ */ async function openRestore() { const m = fxModal({ title: '↺ Backup wiederherstellen', wide: true }); let dir = null; async function renderList(customDir) { m.setTitle('↺ Backup wiederherstellen'); m.footer.textContent = ''; busy(m.body, 'Suche Sicherungen ...'); const res = await window.electronAPI.repoBackupList(customDir ? { dir: customDir } : {}); m.body.textContent = ''; const pick = button('📂 Anderen Ordner wählen', async () => { const folder = await window.electronAPI.selectFolder(); if (folder) renderList(folder); }); m.footer.append(pick, button('Schließen', () => m.close())); if (!res || !res.ok) { m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: (res && res.error) || 'Backup-Ordner nicht lesbar' })); return; } dir = res.dir; m.body.append(el('div', { class: 'fx-muted', text: `Quelle: ${dir}` })); if (!res.backups.length) { m.body.append(el('div', { class: 'fx-empty', text: 'In diesem Ordner wurden keine Sicherungen gefunden.' })); return; } const list = el('div', { class: 'fx-list fx-list--cards' }); for (const b of res.backups) { const parts = []; if (b.mirror) parts.push(`Git-Spiegel · Stand ${fmtDate(b.mirror.updated)}`); if (b.zips.length) parts.push(`${b.zips.length} ZIP-Stand${b.zips.length > 1 ? 'e' : ''} · neuester ${fmtDate(b.zips[0].date)}`); list.append(el('button', { type: 'button', class: 'fx-item', onclick: () => renderDetail(b) }, [ el('div', { class: 'fx-item-title', text: `${b.owner}/${b.repo}` }), el('div', { class: 'fx-muted', text: parts.join(' · ') }) ])); } m.body.append(list); } function renderDetail(b) { m.setTitle(`↺ ${b.owner}/${b.repo} wiederherstellen`); m.body.textContent = ''; m.footer.textContent = ''; // Welche Sicherung? const sources = []; if (b.mirror) sources.push({ kind: 'mirror', path: b.mirror.path, label: `Git-Spiegel – volle Historie, alle Branches und Tags (Stand ${fmtDate(b.mirror.updated)})`, date: b.mirror.updated }); for (const z of b.zips) sources.push({ kind: 'zip', path: z.path, label: `ZIP vom ${fmtDate(z.date)} – nur Standard-Branch, ohne Historie (${fmtBytes(z.size)})`, date: z.date }); let chosen = sources[0]; const srcList = el('div', { class: 'fx-list' }); sources.forEach((src, i) => { const radio = el('input', { type: 'radio', name: 'fxRestoreSource', checked: i === 0, onchange: () => { chosen = src; updateMode(); } }); srcList.append(el('label', { class: 'fx-list-row fx-list-row--check' }, [radio, el('span', { text: src.label })])); }); m.body.append(el('h3', { text: '1. Sicherung wählen' }), srcList); // Lokal entpacken const localBtn = button('📂 Zielordner wählen und entpacken', async () => { const parent = await window.electronAPI.selectFolder(); if (!parent) return; setBusy(true, chosen.kind === 'mirror' ? 'Klone Spiegel ...' : 'Entpacke Archiv ...'); const r = await window.electronAPI.repoRestoreLocal({ kind: chosen.kind, source: chosen.path, destParent: parent, repo: b.repo }); setBusy(false); if (!r.ok) { await showInfoModal('Entpacken fehlgeschlagen', r.error, true); return; } showSuccess(`Wiederhergestellt nach ${r.path}`); showResult(`✅ Lokal wiederhergestellt: ${r.path}` + (r.branches ? ` (${r.branches} Branches)` : r.files ? ` (${r.files} Dateien)` : ''), [ button('In der App öffnen', () => openLocalInApp(r.path, m), 'primary') ]); }); m.body.append(el('h3', { text: '2a. Lokal entpacken' }), el('div', { class: 'fx-muted', text: `Legt den Ordner „${b.repo}“ im gewählten Verzeichnis an. Ein vorhandener, nicht leerer Ordner wird nie überschrieben.` }), el('div', {}, [localBtn])); // Auf Gitea zurückspielen const modeNew = el('input', { type: 'radio', name: 'fxRestoreMode', checked: true, onchange: updateMode }); const modeOverwrite = el('input', { type: 'radio', name: 'fxRestoreMode', onchange: updateMode }); const nameInput = el('input', { type: 'text', class: 'fx-input', value: `${b.repo}-wiederhergestellt` }); const privateCb = el('input', { type: 'checkbox', checked: true }); const overwriteNote = el('div', { class: 'fx-muted' }); const giteaBtn = button('Auf Gitea zurückspielen', () => restoreGitea(), 'primary'); m.body.append(el('h3', { text: '2b. Auf Gitea zurückspielen' }), el('label', { class: 'fx-list-row fx-list-row--check' }, [modeNew, el('span', { text: 'Als neues Repo anlegen' })]), el('div', { class: 'fx-row fx-indent' }, [nameInput, el('label', { class: 'fx-inline-check' }, [privateCb, el('span', { text: 'Privat' })])]), el('label', { class: 'fx-list-row fx-list-row--check' }, [modeOverwrite, el('span', { text: `Bestehendes Repo „${b.owner}/${b.repo}“ überschreiben` })]), overwriteNote, el('div', {}, [giteaBtn])); const status = el('div', { class: 'fx-lead fx-restore-status' }); m.body.append(status); m.footer.append(button('← Zurück', () => renderList(dir))); let offProgress = null; function setBusy(on, text) { [localBtn, giteaBtn].forEach(btn => { btn.disabled = on; }); status.textContent = on ? `⏳ ${text}` : ''; if (on && !offProgress) offProgress = window.electronAPI.onRepoRestoreProgress(p => { if (p && p.text) status.textContent = `⏳ ${p.text}`; }); if (!on && offProgress) { offProgress(); offProgress = null; } } function showResult(text, actions = []) { status.textContent = ''; status.append(el('div', { text }), el('div', { class: 'fx-row' }, actions)); } function updateMode() { const zip = chosen.kind === 'zip'; modeOverwrite.disabled = zip; if (zip && modeOverwrite.checked) modeNew.checked = true; nameInput.disabled = !modeNew.checked; privateCb.disabled = !modeNew.checked; overwriteNote.textContent = zip ? 'Überschreiben geht nur mit einem Git-Spiegel, weil ein ZIP-Stand keine Historie enthält.' : 'Achtung: Setzt alle Branches und Tags auf den Stand der Sicherung. Neuere Commits auf Gitea gehen verloren.'; } updateMode(); async function restoreGitea() { const overwrite = modeOverwrite.checked; const name = nameInput.value.trim(); if (!overwrite && !name) { nameInput.focus(); return; } const ok = await showActionConfirmModal(overwrite ? { title: 'Repo überschreiben', message: `Alle Branches und Tags von „${b.owner}/${b.repo}“ werden auf den Stand vom ${fmtDate(chosen.date)} gesetzt.\n\nCommits, Branches und Tags, die danach auf Gitea entstanden sind, gehen verloren. Das lässt sich nur mit einer neueren Sicherung rückgängig machen.`, confirmText: 'Überschreiben', danger: true } : { title: 'Neues Repo anlegen', message: `Das Repo „${b.owner}/${name}“ wird ${privateCb.checked ? 'privat' : 'öffentlich'} angelegt und mit der Sicherung vom ${fmtDate(chosen.date)} befüllt.`, confirmText: 'Anlegen und befüllen' }); if (!ok) return; setBusy(true, 'Starte ...'); const r = await window.electronAPI.repoRestoreGitea({ kind: chosen.kind, source: chosen.path, owner: b.owner, repo: b.repo, mode: overwrite ? 'overwrite' : 'new', newName: name, isPrivate: privateCb.checked }); setBusy(false); if (!r.ok) { await showInfoModal('Wiederherstellen fehlgeschlagen', r.error, true); return; } showSuccess(`${r.repo} wiederhergestellt`); showResult(`✅ Auf Gitea wiederhergestellt: ${r.repo} (über ${r.method}${r.files ? `, ${r.files} Dateien` : ''})`, r.url ? [button('Im Browser öffnen', () => window.electronAPI.openExternalUrl(r.url))] : []); } } renderList(null); } function openLocalInApp(folder, modal) { modal.close(); $('settingsModal')?.classList.add('hidden'); selectedFolder = folder; currentState.view = 'local'; setStatus('Local: ' + folder); if (typeof updateNavigationUI === 'function') updateNavigationUI(); window.updateFeatureButtons('local'); if (typeof refreshLocalTree === 'function') refreshLocalTree(folder); if (typeof loadBranches === 'function') loadBranches(folder); } // Bereiche in den Einstellungen (Navigation links). "general" bleibt als alter Name für "konten" gültig. window.showSettingsTab = function (name) { if (name === 'general') name = 'konten'; document.querySelectorAll('#settingsModal [data-settings-tab]').forEach(tab => { const active = tab.dataset.settingsTab === name; tab.classList.toggle('is-active', active); tab.setAttribute('aria-selected', String(active)); }); document.querySelectorAll('#settingsModal [data-settings-panel]').forEach(panel => { panel.classList.toggle('hidden', panel.dataset.settingsPanel !== name); }); if (name === 'backup') loadBackupSettings(); // Höhenabgleich der Karten braucht sichtbare Elemente if (name === 'konten' && typeof syncSettingsPanelHeights === 'function') requestAnimationFrame(syncSettingsPanelHeights); const pages = document.querySelector('#settingsModal .settings-pages'); if (pages) pages.scrollTop = 0; }; // Für das gemeinsame "Speichern" in settings-extra.js window.gmBackupSettings = { load: loadBackupSettings, save: saveBackupSettings }; function wireSettings() { document.querySelectorAll('#settingsModal [data-settings-tab]').forEach(tab => { tab.addEventListener('click', () => window.showSettingsTab(tab.dataset.settingsTab)); }); const restoreBtn = $('btnRepoRestore'); if (restoreBtn) restoreBtn.onclick = () => openRestore(); const diag = $('btnServerDiagnosis'); if (diag) diag.onclick = () => openDiagnosis(); const browse = $('btnRepoBackupBrowse'); if (browse) browse.onclick = async () => { const folder = await window.electronAPI.selectFolder(); if (folder) $('repoBackupTarget').value = folder; }; const save = $('btnRepoBackupSave'); if (save) save.onclick = () => saveBackupSettings(false); const run = $('btnRepoBackupRun'); if (run) run.onclick = async () => { const state = await window.electronAPI.repoBackupGetConfig(); // Läuft schon ein Backup (z. B. ein geplantes), nur den Fortschritt zeigen if (state && state.running) { openBackupProgress(state.progress); return; } if (!$('repoBackupTarget').value.trim()) { showWarning('Bitte zuerst ein Backup-Ziel wählen.'); return; } await saveBackupSettings(true); openBackupProgress(null); const res = await window.electronAPI.repoBackupRunNow(); if (!res.ok && !res.summary && !res.running) showError('Backup fehlgeschlagen: ' + res.error); }; // Fortschritt kommt für jedes Backup an alle Fenster – auch für geplante Backups window.electronAPI.onRepoBackupProgress((p) => { if (!p) return; const finished = p.phase === 'done' || p.phase === 'error'; renderBackupStatus({ running: !finished, progress: p }); if (backupView) backupView.update(p); if (finished) { const s = p.summary; if (p.phase === 'error') showError('Backup fehlgeschlagen: ' + p.error); else if (s && s.cancelled) showWarning(`Backup abgebrochen: ${s.ok} von ${s.total} Repos gesichert`); else if (s && s.failed) showWarning(`Backup mit ${s.failed} Fehler(n) beendet`); else if (s) showSuccess(`Backup fertig: ${s.ok}/${s.total} Repos gesichert`); setTimeout(loadBackupSettings, 300); } }); // Beim Öffnen der Einstellungen den aktuellen Stand anzeigen const btnSettings = $('btnSettings'); if (btnSettings) btnSettings.addEventListener('click', () => { window.showSettingsTab('konten'); setTimeout(loadBackupSettings, 50); }); loadBackupSettings(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wireSettings); else wireSettings(); })();