diff --git a/renderer/features.js b/renderer/features.js new file mode 100644 index 0000000..3c380e4 --- /dev/null +++ b/renderer/features.js @@ -0,0 +1,902 @@ +// 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(); +})(); diff --git a/renderer/index.html b/renderer/index.html index 1fcc265..569ab5d 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -52,16 +52,11 @@
API-Zugriffe für GitHub und Gitea konfigurieren.
+API-Zugriffe für GitHub und Gitea konfigurieren.
+Übersicht und Explorer an deinen Arbeitsstil anpassen.
+Erstellt ein Diagnosepaket mit Logs, Plattform und letzten Fehlern für schnelles Debugging.
+Direkt sehen, ob URL, API und Auth sauber antworten.
+Mehrere Server oder Konten speichern und mit einem Klick wechseln. Tokens werden verschlüsselt gespeichert.
+Name und E-Mail für Commits, die die App anlegt (Uploads, lokale Commits, Wiederherstellen).
+Direkt sehen, ob URL, API und Auth sauber antworten.
+ @@ -585,5 +904,7 @@