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 @@
Steuerung - - - + + + + - -
- -
- - Bereit
@@ -73,15 +68,34 @@ -
- Repository +
+
+ + Bereit +
+
+
+ + +
+ +
+ Repository + + + +
+
+ Werkzeuge + +
@@ -123,197 +137,502 @@
Konfiguration

⚙️ Einstellungen

-

Alle wichtigen Optionen auf einer Seite: Zugangsdaten, Verbindungscheck, Darstellung und Updates.

+

Links den Bereich wählen. „Speichern“ übernimmt alle Änderungen aus allen Bereichen.

-
-
-
-
-
-

Zugangsdaten

-

API-Zugriffe für GitHub und Gitea konfigurieren.

+
+ + +
+
+
+
+
+
+

Zugangsdaten

+

API-Zugriffe für GitHub und Gitea konfigurieren.

+
-
-
-
-
-

GitHub

- -
-
- - -
- -
Hinweis: Der Token wird direkt über api.github.com geprüft.
-
+
+
+
+

GitHub

+ +
+
+ + +
+ +
Hinweis: Der Token wird direkt über api.github.com geprüft.
+
-
-
-

Gitea

- -
-
- - -
-
- - -
-
Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000
-
-
-
- -
-
-
-

Darstellung

-

Übersicht und Explorer an deinen Arbeitsstil anpassen.

+
+
+

Gitea

+ +
+
+ + +
+
+ + +
+
Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000
+
-
+
-
- - - - - - - - -
- - -
-
-
-

Diagnose

-

Erstellt ein Diagnosepaket mit Logs, Plattform und letzten Fehlern für schnelles Debugging.

+
+
+
+

Verbindungsstatus

+

Direkt sehen, ob URL, API und Auth sauber antworten.

+
-
-
- -
-
+
+
URLUnbekannt
+
APIUnbekannt
+
AuthUnbekannt
+
Latenz-
+
Server-
+
Letzter Fehler-
+
+ + +
+
+
+

Gitea-Server

+

Mehrere Server oder Konten speichern und mit einem Klick wechseln. Tokens werden verschlüsselt gespeichert.

+
+
+
+
+
+ + +
+ + +
+
+
+ + +
+ +
+
Speichert URL und Token aus „Zugangsdaten“ unter diesem Namen.
+
+
+ +
+
+
+

Git-Identität

+

Name und E-Mail für Commits, die die App anlegt (Uploads, lokale Commits, Wiederherstellen).

+
+
+
+
+
+ + +
+
+ + +
+
+
Ist im Repo oder in deiner globalen Git-Konfiguration schon ein Name eingetragen, gilt dieser. Leer lassen = „Git Manager GUI“.
+
+
+
-
-
-
-
-

Verbindungsstatus

-

Direkt sehen, ob URL, API und Auth sauber antworten.

+
+
+ -
-
-
-

System

-

Verhalten beim Windows-Start steuern.

-
-
-
- -
-
+ -
-
-
-

App & Updates

-

Version prüfen und neue Releases direkt anstoßen.

-
-
+ -
-
- - -
- -
-
+ -
-
-
-

Backup & Transfer

-

Einstellungen, Favoriten und Verlauf exportieren oder auf einem anderen PC importieren.

-
-
-
- - +
+ +
+
+
+

Design & Größe

+

Hell oder dunkel und wie groß die Oberfläche angezeigt wird. Wird sofort angewendet.

+
-
- Export enthält Tokens nur verschlüsselt, nie als Klartext. Der Import ist für andere PCs geeignet. +
+
+
+ + +
+
+ + +
+
-
-
+ +
+
+ + + + + + + + @@ -585,5 +904,7 @@ + + \ No newline at end of file diff --git a/renderer/renderer.js b/renderer/renderer.js index 755abfa..230009e 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -364,6 +364,35 @@ function loadRepos() { // Einmalig auf Modulebene definiert, damit die Funktion sofort ab Programmstart // verfügbar ist (früher versehentlich in loadRepoContents definiert -> beim // Klick vor dem ersten Repo-Laden "resetProjectGravurTitle is not a function"). +// Pfadanzeige "repo / ordner / unterordner". Jeder Teil ist klickbar und springt +// direkt in diesen Ordner; der aktuelle Ordner (letzter Teil) ist nicht klickbar. +function setProjectGravurTitle(repo, path, owner) { + const gravurTitle = document.getElementById('project-gravur-title'); + if (!gravurTitle) return; + gravurTitle.textContent = ''; + const parts = String(path || '').split('/').filter(Boolean); + const addCrumb = (text, className, targetPath) => { + const el = document.createElement('span'); + el.className = className; + el.textContent = text; + if (owner && targetPath !== null) { + el.classList.add('gravur-link'); + el.title = targetPath ? `Zu "${targetPath}" springen` : `Zum Hauptordner von ${repo}`; + el.onclick = () => loadRepoContents(owner, repo, targetPath); + } + gravurTitle.appendChild(el); + }; + addCrumb(repo, 'gravur-repo', parts.length ? '' : null); + parts.forEach((part, i) => { + const sep = document.createElement('span'); + sep.className = 'gravur-sub'; + sep.textContent = ' / '; + gravurTitle.appendChild(sep); + const isLast = i === parts.length - 1; + addCrumb(part, 'gravur-sub', isLast ? null : parts.slice(0, i + 1).join('/')); + }); +} + window.resetProjectGravurTitle = function() { const gravurTitle = document.getElementById('project-gravur-title'); if (gravurTitle) gravurTitle.textContent = ''; @@ -388,6 +417,11 @@ async function loadGithubRepos(requestId = null) { const btnReleases = $('btnReleases'); if (btnCommits) btnCommits.classList.add('hidden'); if (btnReleases) btnReleases.classList.add('hidden'); + const btnCleanupIgnored = $('btnCleanupIgnored'); + if (btnCleanupIgnored) btnCleanupIgnored.classList.add('hidden'); + const btnGitignoreTemplate = $('btnGitignoreTemplate'); + if (btnGitignoreTemplate) btnGitignoreTemplate.classList.add('hidden'); + if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('list'); const grid = $('explorerGrid'); if (grid) grid.style.gridTemplateColumns = ''; @@ -860,16 +894,54 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl // viele Objekte gedroppt wurden. Das war die Ursache für die langen // Wartezeiten und Zeitüberschreitungen (vorher: ein kompletter Clone+Push // pro einzelner Datei bzw. pro Ordner). - showProgress(15, `Bereite Upload von ${safePaths.length} Objekt(en) vor ...`); + // Vorschau: welche Dateien gehen hoch, was sortiert die .gitignore aus, sind Geheimnisse dabei? + let excludeFiles = []; + if (typeof window.uploadPreviewGate === 'function') { + const gate = await window.uploadPreviewGate({ paths: safePaths, destPath, repo }); + if (!gate.proceed) return { ok: false, cancelled: true, error: 'Upload abgebrochen' }; + excludeFiles = gate.excludeFiles || []; + } + + showProgress(15, `Bereite Upload von ${safePaths.length} Objekt(en) vor ...`, { cancellable: true }); try { - const res = await withTimeout(window.electronAPI.uploadPathsToGitea({ + let res = await withTimeout(window.electronAPI.uploadPathsToGitea({ paths: safePaths, owner, repo, destPath, branch, + excludeFiles, messagePrefix: 'Upload via GUI' }), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout + + // Teilfehler: nur die fehlgeschlagenen Dateien erneut senden (höchstens 3 Wiederholungen). + for (let attempt = 0; attempt < 3; attempt++) { + const failedItems = !res?.ok && !res?.cancelled && Array.isArray(res?.retryItems) ? res.retryItems : []; + if (failedItems.length === 0) break; + showProgress(100, 'Upload teilweise fehlgeschlagen'); + const uploadedInfo = res.uploadedCount ? `${res.uploadedCount} Datei(en) wurden hochgeladen, ` : ''; + const again = await showActionConfirmModal({ + title: 'Upload teilweise fehlgeschlagen', + message: `${uploadedInfo}${failedItems.length} Datei(en) nicht.\n\n${res.error || ''}\n\nNur die fehlgeschlagenen Dateien erneut senden?`, + confirmText: 'Nur Fehlgeschlagene wiederholen', + danger: true + }); + if (!again) break; + showProgress(15, `Wiederhole ${failedItems.length} Datei(en) ...`, { cancellable: true }); + res = await withTimeout(window.electronAPI.uploadPathsToGitea({ + items: failedItems, + owner, + repo, + destPath, + branch, + messagePrefix: 'Upload via GUI (Wiederholung)' + }), 900000, 'upload-paths-to-gitea'); + } + + if (res?.cancelled) { + hideProgress(); + return { ok: false, cancelled: true, error: 'Upload abgebrochen' }; + } showProgress(100, 'Upload abgeschlossen'); console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:result', res); try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:result', { count: safePaths.length, ok: res?.ok, failedCount: res?.failedCount }); } catch (_) {} @@ -1378,6 +1450,9 @@ function openSettingsForFix(focusId = null) { if (!modal) return; modal.classList.remove('hidden'); $('settingsWatermarkCard')?.classList.add('hidden'); + // Zugangsdaten liegen im Reiter "Allgemein" + if (typeof window.showSettingsTab === 'function') window.showSettingsTab('general'); + if (window.gmPrefs) window.gmPrefs.takeSnapshot(); renderSettingsHealth(); requestAnimationFrame(() => { syncSettingsPanelHeights(); @@ -2112,6 +2187,8 @@ function syncSettingsPanelHeights() { if (window.matchMedia('(max-width: 1120px)').matches) return; if (credentialsPanel && healthPanel) { + // Nur angleichen, wenn beide Karten nebeneinander stehen (im Bereich "Konten" stehen sie untereinander) + if (Math.abs(credentialsPanel.getBoundingClientRect().top - healthPanel.getBoundingClientRect().top) > 4) return; const topRightTargetHeight = Math.ceil(credentialsPanel.getBoundingClientRect().height); if (topRightTargetHeight > 0) { healthPanel.style.minHeight = `${topRightTargetHeight}px`; @@ -2614,16 +2691,90 @@ function ensureProgressUI() { barWrap.appendChild(bar); container.appendChild(barWrap); + + // Abbrechen-Knopf: nur bei Uploads sichtbar (showProgress mit { cancellable: true }). + const cancel = document.createElement('button'); + cancel.id = 'folderProgressCancel'; + cancel.textContent = CANCEL_LABEL; + cancel.style.cssText = ` + display: none; + margin-top: 10px; + padding: 5px 14px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.25); + background: transparent; + color: #fff; + cursor: pointer; + font-size: 12px; + `; + cancel.addEventListener('click', async () => { + cancel.disabled = true; + cancel.textContent = 'Wird abgebrochen …'; + try { await window.electronAPI.cancelUpload(); } catch (_) {} + }); + container.appendChild(cancel); + + // Esc bricht die laufende Übertragung ab, solange kein Dialog offen ist. + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (container.style.display === 'none' || cancel.style.display === 'none' || cancel.disabled) return; + if (document.querySelector('.modal:not(.hidden)')) return; + cancel.click(); + }); + document.body.appendChild(container); } -function showProgress(percent, text) { +const CANCEL_LABEL = 'Abbrechen (Esc)'; + +function formatTransferBytes(bytes) { + if (!(bytes > 0)) return '0 KB'; + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2).replace('.', ',')} GB`; +} + +function formatRemaining(seconds) { + const s = Math.round(seconds); + if (s < 60) return `${Math.max(1, s)} Sek.`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} Min. ${s % 60} Sek.`; + return `${Math.floor(m / 60)} Std. ${m % 60} Min.`; +} + +// Text für den Fortschrittsbalken: Dateien, übertragene MB und geschätzte Restzeit. +// Die Restzeit ist eine Hochrechnung und erscheint erst, wenn genug Daten vorliegen. +let transferStartedAt = 0; +let transferLabel = ''; +function describeTransferProgress(label, p) { + if (!transferStartedAt || transferLabel !== label || !p.percent) { + transferStartedAt = Date.now(); + transferLabel = label; + } + let text = `${label}: ${p.processed}/${p.total} Dateien`; + if (p.totalBytes > 0) { + text += ` · ${formatTransferBytes(p.totalBytes * p.percent / 100)} / ${formatTransferBytes(p.totalBytes)}`; + } + const elapsed = (Date.now() - transferStartedAt) / 1000; + if (p.percent >= 5 && p.percent < 100 && elapsed >= 3) { + text += ` · noch ca. ${formatRemaining(elapsed * (100 - p.percent) / p.percent)}`; + } + return text; +} + +function showProgress(percent, text, { cancellable = false } = {}) { ensureProgressUI(); const container = $('folderProgressContainer'); const bar = $('folderProgressBar'); const txt = $('folderProgressText'); + const cancel = $('folderProgressCancel'); if (txt) txt.innerText = text || ''; if (bar) bar.style.width = `${Math.min(100, Math.max(0, percent))}%`; + if (cancel) { + cancel.style.display = cancellable ? 'inline-block' : 'none'; + // Ein neuer Upload startet wieder mit aktivem Knopf; bei laufendem Abbruch bleibt er gesperrt. + if (!cancellable) { cancel.disabled = false; cancel.textContent = CANCEL_LABEL; } + } if (container) container.style.display = 'block'; } @@ -2632,6 +2783,9 @@ function hideProgress() { if (container) { setTimeout(() => { container.style.display = 'none'; + // Knopf für den nächsten Vorgang zurücksetzen (nach einem Abbruch ist er gesperrt). + const cancel = $('folderProgressCancel'); + if (cancel) { cancel.disabled = false; cancel.textContent = CANCEL_LABEL; cancel.style.display = 'none'; } }, 500); } } @@ -3473,7 +3627,12 @@ async function loadGiteaRepos(preloadedData = null, requestId = null) { const btnReleases = $('btnReleases'); if (btnCommits) btnCommits.classList.add('hidden'); if (btnReleases) btnReleases.classList.add('hidden'); - + const btnCleanupIgnored = $('btnCleanupIgnored'); + if (btnCleanupIgnored) btnCleanupIgnored.classList.add('hidden'); + const btnGitignoreTemplate = $('btnGitignoreTemplate'); + if (btnGitignoreTemplate) btnGitignoreTemplate.classList.add('hidden'); + if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('list'); + // WICHTIG: Grid-Layout zurücksetzen const grid = $('explorerGrid'); if (grid) { @@ -3879,7 +4038,9 @@ async function loadGiteaRepos(preloadedData = null, requestId = null) { branch: getDefaultBranch(owner, repoName) }); console.log('[UPLOAD_DEBUG][renderer] repoCard:dropResult', res); - if (!res.ok) { + if (res.cancelled) { + setStatus('Upload abgebrochen'); + } else if (!res.ok) { showError('Fehler: ' + (res.error || 'Upload fehlgeschlagen')); await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen'); setStatus('Upload fehlgeschlagen'); @@ -3916,11 +4077,9 @@ async function loadGiteaRepos(preloadedData = null, requestId = null) { } async function loadRepoContents(owner, repo, path) { - // Projekttitel als Gravur unterhalb der Toolbar setzen (nur Repo-Name) - const gravurTitle = document.getElementById('project-gravur-title'); - if (gravurTitle) { - gravurTitle.textContent = repo; - } + // Projekttitel als Gravur unterhalb der Toolbar setzen: Repo-Name bleibt + // sichtbar, ein geöffneter Unterordner wird als Pfad dahinter angezeigt. + setProjectGravurTitle(repo, path, owner); currentState.view = 'gitea-repo'; currentState.owner = owner; currentState.repo = repo; @@ -3940,6 +4099,20 @@ async function loadRepoContents(owner, repo, path) { btnReleases.onclick = () => loadRepoReleases(owner, repo); } + // Aufräumen nutzt die Gitea-API und ist daher nur dort verfügbar. + const btnCleanupIgnored = $('btnCleanupIgnored'); + if (btnCleanupIgnored) { + const isGitea = currentState.platform !== 'github'; + btnCleanupIgnored.classList.toggle('hidden', !isGitea); + btnCleanupIgnored.onclick = () => cleanupGitignoredFiles(owner, repo, currentState.path || ''); + } + const btnGitignoreTemplate = $('btnGitignoreTemplate'); + if (btnGitignoreTemplate) { + btnGitignoreTemplate.classList.remove('hidden'); + btnGitignoreTemplate.onclick = () => createGitignoreForRepo(owner, repo); + } + if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('repo', owner, repo); + // WICHTIG: Grid-Layout zurücksetzen const grid = $('explorerGrid'); if (grid) { @@ -4063,7 +4236,9 @@ async function loadRepoContents(owner, repo, path) { destPath: targetPath, branch: getDefaultBranch(owner, repo) }); - if (!res.ok) { + if (res.cancelled) { + setStatus('Upload abgebrochen'); + } else if (!res.ok) { showError('Upload error: ' + (res.error || 'Unbekannter Fehler')); await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen'); } @@ -4123,6 +4298,7 @@ async function selectLocalFolder() { setStatus('Local: ' + folder); currentState.view = 'local'; updateNavigationUI(); + if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('local'); await refreshLocalTree(folder); await loadBranches(folder); @@ -4745,15 +4921,20 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate = try { const sel = await window.electronAPI.selectFolder(); if (sel) { - showProgress(0, 'Upload...'); - const res = await window.electronAPI.uploadLocalFolderToGitea({ - localFolder: sel, - owner, - repo: repoName, - destPath: '', + showProgress(0, 'Upload...', { cancellable: true }); + const res = await window.electronAPI.uploadLocalFolderToGitea({ + localFolder: sel, + owner, + repo: repoName, + destPath: '', branch: getDefaultBranch(owner, repoName), messagePrefix: 'Upload folder via GUI' }); + if (res?.cancelled) { + hideProgress(); + setStatus('Upload abgebrochen'); + return; + } if (!res?.ok) { showError('Upload fehlgeschlagen: ' + (res?.error || 'Unbekannter Fehler')); await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen'); @@ -4915,10 +5096,11 @@ function showGiteaItemContextMenu(ev, item, owner, repo) { }); } else { addItem('📥', 'Ordner herunterladen', async () => { - showProgress(0, `Lade ${item.name}...`); + showProgress(0, `Lade ${item.name}...`, { cancellable: true }); const res = await window.electronAPI.downloadGiteaFolder({ owner, repo, path: item.path }); hideProgress(); - setStatus(res.ok ? `Gespeichert: ${res.savedTo}` : 'Download fehlgeschlagen'); + if (res.cancelled) setStatus('Download abgebrochen'); + else setStatus(res.ok ? `Gespeichert: ${res.savedTo}` : 'Download fehlgeschlagen'); }); } @@ -5340,6 +5522,193 @@ function showActionConfirmModal({ title, message, confirmText = 'Bestätigen', d }); } +// Entfernt Dateien aus dem Repo, die laut .gitignore des passenden lokalen Ordners +// nicht hineingehören (z. B. build/ oder .gradle/ aus früheren Uploads). +// Auswahldialog für die .gitignore-Vorlagen; liefert die gewählten Schlüssel oder null. +function pickGitignoreTemplates(templates) { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'modal confirm-modal'; + modal.style.zIndex = '99999'; + const card = document.createElement('div'); + card.className = 'modalContent card confirm-modal-content'; + + const titleEl = document.createElement('h2'); + titleEl.className = 'confirm-modal-title'; + titleEl.textContent = '📄 .gitignore anlegen'; + + const msgEl = document.createElement('p'); + msgEl.className = 'confirm-modal-message'; + msgEl.textContent = 'Der allgemeine Teil (Betriebssystem, Editor, .env, Logs) ist immer dabei. Zusätzlich wählen:'; + + const list = document.createElement('div'); + list.style.cssText = 'display:flex;flex-direction:column;gap:6px;margin:10px 0 14px;'; + for (const t of templates) { + const label = document.createElement('label'); + label.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;'; + const box = document.createElement('input'); + box.type = 'checkbox'; + box.value = t.key; + label.appendChild(box); + label.appendChild(document.createTextNode(t.label)); + list.appendChild(label); + } + + const btnWrap = document.createElement('div'); + btnWrap.className = 'modal-buttons confirm-modal-buttons'; + const okBtn = document.createElement('button'); + okBtn.className = 'confirm-modal-btn confirm-modal-btn--primary'; + okBtn.textContent = 'Anlegen'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary'; + cancelBtn.textContent = 'Abbrechen'; + btnWrap.appendChild(okBtn); + btnWrap.appendChild(cancelBtn); + + card.appendChild(titleEl); + card.appendChild(msgEl); + card.appendChild(list); + card.appendChild(btnWrap); + modal.appendChild(card); + document.body.appendChild(modal); + + const onKey = (e) => { if (e.key === 'Escape') close(null); }; + function close(value) { + document.removeEventListener('keydown', onKey); + modal.remove(); + resolve(value); + } + document.addEventListener('keydown', onKey); + okBtn.onclick = () => close([...list.querySelectorAll('input:checked')].map(i => i.value)); + cancelBtn.onclick = () => close(null); + }); +} + +// Schreibt eine Standard-.gitignore aus den gewählten Vorlagen ins Repo. +async function createGitignoreForRepo(owner, repo) { + const tpl = await window.electronAPI.getGitignoreTemplates(); + const keys = await pickGitignoreTemplates((tpl && tpl.templates) || []); + if (!keys) return; + + const built = await window.electronAPI.buildGitignore({ keys }); + const ref = getDefaultBranch(owner, repo); + const platform = currentState.platform; + + const existing = await window.electronAPI.getGiteaFileContent({ owner, repo, path: '.gitignore', ref, platform }); + if (existing && existing.ok) { + const overwrite = await showActionConfirmModal({ + title: '.gitignore existiert bereits', + message: `In "${repo}" gibt es schon eine .gitignore. Sie wird ersetzt, der alte Stand bleibt in der Commit-Historie.`, + confirmText: 'Ersetzen', + danger: true + }); + if (!overwrite) return; + } + + showProgress(30, '.gitignore wird geschrieben...'); + const res = await window.electronAPI.writeGiteaFile({ + owner, repo, path: '.gitignore', content: built.content, ref, platform + }); + hideProgress(); + if (res && res.ok) { + showSuccess('.gitignore angelegt'); + loadRepoContents(owner, repo, currentState.path); + } else { + showError('.gitignore konnte nicht angelegt werden: ' + (res?.error || 'Unbekannter Fehler')); + } +} + +// Zeigt die zuletzt aufgezeichneten Uploads; der Bestätigen-Knopf leert den Verlauf. +async function showUploadHistory() { + const res = await window.electronAPI.getUploadHistory(); + const history = (res && res.history) || []; + if (history.length === 0) { + await showInfoModal('Upload-Verlauf', 'Noch keine Uploads aufgezeichnet.'); + return; + } + const icons = { ok: '✅', abgebrochen: '⏹️', fehler: '❌' }; + const lines = history.slice(0, 25).map((e) => { + const when = new Date(e.at).toLocaleString('de-DE'); + const files = e.files != null ? `${e.files} Datei(en)` : '–'; + const failed = e.failed ? `, ${e.failed} fehlgeschlagen` : ''; + const took = e.durationMs ? ` · ${formatRemaining(e.durationMs / 1000)}` : ''; + const err = e.error ? `\n ${e.error}` : ''; + return `${icons[e.status] || '•'} ${when} · ${e.repo || '?'} · ${files}${failed} · ${e.kind}${took}${err}`; + }); + if (history.length > 25) lines.push(`… und ${history.length - 25} ältere Einträge`); + const clear = await showActionConfirmModal({ + title: 'Upload-Verlauf', + message: lines.join('\n'), + confirmText: 'Verlauf leeren' + }); + if (clear) await window.electronAPI.clearUploadHistory(); +} + +async function cleanupGitignoredFiles(owner, repo, repoPath) { + const where = repoPath ? `${repo}/${repoPath}` : repo; + const proceed = await showActionConfirmModal({ + title: 'Repo aufräumen', + message: `Wähle im nächsten Schritt den lokalen Ordner, der zu "${where}" gehört. Dessen .gitignore entscheidet, welche Dateien im Repo nicht hingehören. Vor dem Löschen siehst du die Liste.`, + confirmText: 'Ordner wählen' + }); + if (!proceed) return; + + const localFolder = await window.electronAPI.selectFolder(); + if (!localFolder) return; + + showProgress(0, 'Suche Dateien, die laut .gitignore nicht ins Repo gehören...'); + const found = await window.electronAPI.findGitignoredInRepo({ + owner, repo, repoPath, localFolder, branch: getDefaultBranch(owner, repo) + }); + hideProgress(); + if (!found || !found.ok) { + await showInfoModal('Aufräumen fehlgeschlagen', found?.error || 'Unbekannter Fehler', true); + return; + } + if (found.files.length === 0) { + await showInfoModal('Nichts zu tun', `Alle ${found.scanned} Dateien in "${where}" sind laut .gitignore in Ordnung.`); + return; + } + + // Übersicht nach oberster Ebene gruppieren, damit auch tausende Dateien lesbar bleiben. + const groups = new Map(); + const prefixLen = repoPath ? repoPath.length + 1 : 0; + for (const p of found.files) { + const rel = p.slice(prefixLen); + const top = rel.includes('/') ? rel.slice(0, rel.indexOf('/') + 1) : rel; + groups.set(top, (groups.get(top) || 0) + 1); + } + const lines = [...groups.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 12) + .map(([name, count]) => `• ${name} (${count} Dateien)`); + if (groups.size > 12) lines.push(`• … und ${groups.size - 12} weitere`); + + const ok = await showActionConfirmModal({ + title: `${found.files.length} Dateien entfernen?`, + message: `Diese Dateien liegen in "${where}", gehören laut .gitignore aber nicht ins Repo:\n\n${lines.join('\n')}\n\nSie werden nur auf dem Server gelöscht, lokal bleibt alles erhalten. Die Löschung ist über die Commit-Historie nachvollziehbar.`, + confirmText: 'Entfernen', + danger: true + }); + if (!ok) return; + + showProgress(0, `Entferne ${found.files.length} Dateien...`); + const res = await window.electronAPI.deleteGiteaPaths({ + owner, repo, paths: found.files, branch: found.branch, + message: 'Remove files ignored by .gitignore via Git Manager GUI' + }); + hideProgress(); + if (res && res.ok) { + showSuccess(`${res.deleted} Dateien in ${res.commits} Commits entfernt`); + } else { + const detail = res?.failed?.length + ? `${res.deleted} entfernt, ${res.failed.length} fehlgeschlagen:\n` + res.failed.slice(0, 5).map(f => `${f.path}: ${f.error}`).join('\n') + : (res?.error || 'Unbekannter Fehler'); + await showInfoModal('Aufräumen unvollständig', detail, true); + } + loadRepoContents(owner, repo, currentState.path); +} + function showInfoModal(title, message, danger = false) { return showActionConfirmModal({ title, @@ -5503,7 +5872,7 @@ function setupBackgroundContextMenu() { ); } else if (clipboard.item.isLocal) { addItem('📋', `⬆️ Von Lokal einfügen: "${clipboard.item.name}"`, async () => { - showProgress(0, `Lade "${clipboard.item.name}" hoch...`); + showProgress(0, `Lade "${clipboard.item.name}" hoch...`, { cancellable: true }); try { const res = clipboard.item.isDirectory ? await window.electronAPI.uploadLocalFolderToGitea({ @@ -5521,6 +5890,7 @@ function setupBackgroundContextMenu() { destPath: currentPath, branch: getDefaultBranch(owner, repo) }); + if (res?.cancelled) { setStatus('Upload abgebrochen'); return; } if (!res?.ok) throw new Error(res?.error || 'Upload fehlgeschlagen'); showSuccess(`"${clipboard.item.name}" nach Gitea kopiert`); loadRepoContents(owner, repo, currentState.path); @@ -5548,14 +5918,15 @@ function setupBackgroundContextMenu() { ); } else if (clipboard.item.isGitea) { addItem('📋', `⬇️ Von Gitea einfügen: "${clipboard.item.name}"`, async () => { - showProgress(0, `Lade "${clipboard.item.name}" herunter...`); + showProgress(0, `Lade "${clipboard.item.name}" herunter...`, { cancellable: true }); try { - await window.electronAPI.downloadGiteaFolder({ + const dl = await window.electronAPI.downloadGiteaFolder({ owner: clipboard.item.owner, repo: clipboard.item.repo, giteaPath: clipboard.item.path, localPath: selectedFolder }); + if (dl?.cancelled) { setStatus('Download abgebrochen'); return; } showSuccess(`"${clipboard.item.name}" nach Lokal kopiert`); refreshLocalTree(selectedFolder); } catch(e) { showError('Cross-Paste fehlgeschlagen'); } @@ -5636,7 +6007,9 @@ function setupGlobalDropZone() { destPath: targetPath, branch: getDefaultBranch(owner, repo) }); - if (!res.ok) { + if (res.cancelled) { + setStatus('Upload abgebrochen'); + } else if (!res.ok) { console.error('Upload error:', res.error); showError('Error: ' + (res.error || 'Upload failed')); } @@ -5876,6 +6249,10 @@ window.addEventListener('DOMContentLoaded', async () => { }; } + if ($('btnUploadHistory')) { + $('btnUploadHistory').onclick = () => { showUploadHistory(); }; + } + if ($('btnOpenActivityLog')) { $('btnOpenActivityLog').onclick = () => { $('activityLogModal')?.classList.remove('hidden'); @@ -5979,14 +6356,18 @@ window.addEventListener('DOMContentLoaded', async () => { logActivity('info', `Batch gestartet: ${action} (${repos.length} Repos)`); try { + if (action === 'clone') showProgress(0, `Klone 0/${repos.length} Repos ...`, { cancellable: true }); const res = await window.electronAPI.runBatchRepoAction({ action, repos, options }); + if (action === 'clone') hideProgress(); if (!res.ok) { showError(res.error || 'Batch-Aktion fehlgeschlagen'); return; } const summary = res.summary || { total: repos.length, success: 0, failed: 0 }; - if (summary.failed > 0) { + if (res.cancelled) { + showWarning(`Batch abgebrochen: ${summary.success} von ${summary.total} Repos erledigt.`); + } else if (summary.failed > 0) { showWarning(`Batch beendet: ${summary.success}/${summary.total} erfolgreich, ${summary.failed} fehlgeschlagen.`); } else { showSuccess(`Batch erfolgreich: ${summary.success}/${summary.total}`); @@ -6157,7 +6538,9 @@ window.addEventListener('DOMContentLoaded', async () => { } if ($('btnCloseSettings')) { - $('btnCloseSettings').onclick = () => { + $('btnCloseSettings').onclick = async () => { + // Bei ungespeicherten Änderungen erst nachfragen (settings-extra.js) + if (typeof window.settingsCanClose === 'function' && !(await window.settingsCanClose())) return; $('settingsWatermarkCard')?.classList.add('hidden'); $('settingsModal').classList.add('hidden'); }; @@ -6537,11 +6920,11 @@ window.addEventListener('DOMContentLoaded', async () => { // Progress listeners window.electronAPI.onFolderUploadProgress(p => { - showProgress(p.percent, `Upload: ${p.processed}/${p.total}`); + showProgress(p.percent, describeTransferProgress('Upload', p), { cancellable: true }); }); - + window.electronAPI.onFolderDownloadProgress(p => { - showProgress(p.percent, `Download: ${p.processed}/${p.total}`); + showProgress(p.percent, describeTransferProgress('Download', p), { cancellable: true }); }); if (window.electronAPI.onRetryQueueUpdated) { @@ -6566,6 +6949,9 @@ window.addEventListener('DOMContentLoaded', async () => { if (!payload) return; if (payload.status === 'running') { logActivity('info', `Batch ${payload.action}: ${payload.repo} (${payload.index}/${payload.total})`); + if (payload.action === 'clone') { + showProgress(Math.round(((payload.index - 1) / payload.total) * 100), `Klone ${payload.repo} (${payload.index}/${payload.total})`, { cancellable: true }); + } } else if (payload.status === 'error') { logActivity('error', `Batch ${payload.action}: ${payload.repo} - ${payload.error || 'Fehler'}`); } @@ -8215,7 +8601,12 @@ document.addEventListener('DOMContentLoaded', () => { // 2. AUTOMATISCHER UPDATE-CHECK BEIM START // Etwas spaeter, damit initiales UI/Repo-Laden nicht ausgebremst wird. - setTimeout(() => { + // Lässt sich unter Einstellungen → System abschalten. + setTimeout(async () => { + try { + const res = await window.electronAPI.prefsGet(); + if (res && res.ok && res.prefs.autoUpdateCheck === false) return; + } catch (_) {} window.electronAPI.checkForUpdates({ silent: true }); }, 12000); }); \ No newline at end of file diff --git a/renderer/settings-extra.js b/renderer/settings-extra.js new file mode 100644 index 0000000..ba296c6 --- /dev/null +++ b/renderer/settings-extra.js @@ -0,0 +1,279 @@ +// renderer/settings-extra.js +// Ergänzungen der Einstellungen: App-Einstellungen (Identität, Uploads, Design, System), +// ein gemeinsames "Speichern" für alle Bereiche, Warnung bei ungespeicherten Änderungen, +// mehrere Gitea-Server, Token anzeigen/entfernen und Diagnose-Hilfen. +// Wird nach renderer.js und features.js geladen und nutzt deren globale Helfer. + +(function () { + 'use strict'; + + let savedPrefs = null; + + /* ---------- Design ---------- */ + const systemDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; + + function applyTheme(theme) { + const effective = theme === 'system' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme; + document.documentElement.dataset.theme = effective === 'light' ? 'light' : 'dark'; + } + if (systemDark) { + systemDark.addEventListener('change', () => { if (savedPrefs && savedPrefs.theme === 'system') applyTheme('system'); }); + } + + /* ---------- Laden ---------- */ + async function loadPrefs() { + const res = await window.electronAPI.prefsGet(); + if (!res || !res.ok) return null; + savedPrefs = res.prefs; + applyTheme(savedPrefs.theme); + return savedPrefs; + } + + function fillPrefFields(p) { + if (!p) return; + const set = (id, v) => { const el = $(id); if (!el) return; if (el.type === 'checkbox') el.checked = !!v; else el.value = v; }; + set('prefGitName', p.gitName); + set('prefGitEmail', p.gitEmail); + set('prefTheme', p.theme); + set('prefZoom', String(p.zoom)); + if ($('prefZoom') && !$('prefZoom').value) $('prefZoom').value = '100'; + set('prefAutoUpdate', p.autoUpdateCheck); + set('prefCloseAction', p.closeAction); + set('prefCloneDir', p.defaultCloneDir); + set('prefSecretScan', p.uploadSecretScan); + set('prefUploadPreview', p.uploadPreview); + set('prefUploadExcludes', p.uploadExtraExcludes); + } + + function collectPrefs() { + const val = (id) => ($(id) ? $(id).value : undefined); + const chk = (id) => ($(id) ? $(id).checked : undefined); + return { + gitName: val('prefGitName'), + gitEmail: val('prefGitEmail'), + theme: val('prefTheme'), + zoom: Number(val('prefZoom')), + autoUpdateCheck: chk('prefAutoUpdate'), + closeAction: val('prefCloseAction'), + defaultCloneDir: val('prefCloneDir'), + uploadSecretScan: chk('prefSecretScan'), + uploadPreview: val('prefUploadPreview'), + uploadExtraExcludes: val('prefUploadExcludes') + }; + } + + /* ---------- Ungespeicherte Änderungen ---------- */ + let snapshot = null; + + function readFormState() { + const state = {}; + document.querySelectorAll('#settingsModal input, #settingsModal select, #settingsModal textarea').forEach(el => { + if (!el.id || el.type === 'file') return; + state[el.id] = el.type === 'checkbox' ? el.checked : el.value; + }); + return JSON.stringify(state); + } + + async function takeSnapshot() { + // Erst nachdem alle Felder befüllt sind (Zugangsdaten, App-Einstellungen, Backup) + fillPrefFields(await loadPrefs()); + if (window.gmBackupSettings) await window.gmBackupSettings.load(); + await loadProfiles(); + await refreshTransportState(); + snapshot = readFormState(); + } + + // Wird vom "Abbrechen"-Knopf in renderer.js gefragt + window.settingsCanClose = async function () { + if (!snapshot || readFormState() === snapshot) return true; + const discard = await showActionConfirmModal({ + title: 'Ungespeicherte Änderungen', + message: 'Du hast Einstellungen geändert, aber nicht gespeichert. Änderungen verwerfen?', + confirmText: 'Verwerfen', + danger: true + }); + if (discard && savedPrefs) applyTheme(savedPrefs.theme); // Vorschau zurücknehmen + return discard; + }; + + /* ---------- Gemeinsames Speichern ---------- */ + async function saveExtras() { + const problems = []; + const res = await window.electronAPI.prefsSet(collectPrefs()); + if (res && res.prefs) { savedPrefs = res.prefs; applyTheme(savedPrefs.theme); fillPrefFields(savedPrefs); } + if (!res || !res.ok) problems.push(res && res.error ? res.error : 'App-Einstellungen nicht gespeichert'); + if (window.gmBackupSettings) { + const b = await window.gmBackupSettings.save(true); + if (!b || !b.ok) problems.push('Backup-Einstellung nicht gespeichert' + (b && b.error ? ': ' + b.error : '')); + } + snapshot = null; + if (problems.length) showWarning(problems.join(' · ')); + } + + /* ---------- Mehrere Gitea-Server ---------- */ + async function loadProfiles() { + const sel = $('giteaProfileSelect'); + if (!sel) return; + const creds = (await window.electronAPI.loadCredentials()) || {}; + const profiles = Array.isArray(creds.giteaProfiles) ? creds.giteaProfiles : []; + sel.textContent = ''; + if (!profiles.length) { + sel.append(new Option('Noch keine Profile gespeichert', '')); + sel.disabled = true; + } else { + sel.disabled = false; + for (const p of profiles) { + const active = p.url === creds.giteaURL && p.token === creds.giteaToken; + sel.append(new Option(`${p.name} – ${p.url}${active ? ' (aktiv)' : ''}`, p.name)); + } + } + $('btnGiteaProfileUse').disabled = $('btnGiteaProfileDelete').disabled = !profiles.length; + } + + async function saveProfile() { + const name = $('giteaProfileName').value.trim(); + const url = $('giteaURL').value.trim(); + const token = $('giteaToken').value.trim(); + if (!name) { showWarning('Bitte einen Namen für das Profil eingeben.'); $('giteaProfileName').focus(); return; } + if (!url || !token) { showWarning('Bitte zuerst Gitea-URL und Token unter „Zugangsdaten“ eintragen.'); return; } + const creds = (await window.electronAPI.loadCredentials()) || {}; + const profiles = (Array.isArray(creds.giteaProfiles) ? creds.giteaProfiles : []).filter(p => p.name !== name); + profiles.push({ name, url, token }); + profiles.sort((a, b) => a.name.localeCompare(b.name, 'de')); + await window.electronAPI.saveCredentials({ ...creds, giteaProfiles: profiles }); + $('giteaProfileName').value = ''; + await loadProfiles(); + snapshot = readFormState(); + showSuccess(`Profil „${name}“ gespeichert`); + } + + async function useProfile() { + const name = $('giteaProfileSelect').value; + const creds = (await window.electronAPI.loadCredentials()) || {}; + const p = (creds.giteaProfiles || []).find(x => x.name === name); + if (!p) return; + $('giteaURL').value = p.url; + $('giteaToken').value = p.token; + // Wie ein normales "Speichern": übernimmt den Server und lädt danach die Repos neu + $('btnSaveSettings').click(); + setTimeout(() => { if (typeof loadRepos === 'function') loadRepos(); }, 600); + showInfo(`Wechsle zu „${p.name}“ …`); + } + + async function deleteProfile() { + const name = $('giteaProfileSelect').value; + if (!name) return; + const ok = await showActionConfirmModal({ + title: 'Profil entfernen', + message: `Das gespeicherte Profil „${name}“ wird entfernt. Der aktuell eingetragene Server bleibt unverändert.`, + confirmText: 'Entfernen', + danger: true + }); + if (!ok) return; + const creds = (await window.electronAPI.loadCredentials()) || {}; + await window.electronAPI.saveCredentials({ ...creds, giteaProfiles: (creds.giteaProfiles || []).filter(p => p.name !== name) }); + await loadProfiles(); + snapshot = readFormState(); + } + + /* ---------- Token anzeigen / entfernen ---------- */ + function addTokenTools(inputId) { + const input = $(inputId); + if (!input || input.dataset.tools) return; + input.dataset.tools = '1'; + const wrap = document.createElement('div'); + wrap.className = 'token-field'; + input.parentNode.insertBefore(wrap, input); + wrap.appendChild(input); + const eye = document.createElement('button'); + eye.type = 'button'; + eye.className = 'token-tool'; + eye.title = 'Token anzeigen'; + eye.textContent = '👁'; + eye.onclick = () => { + const show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + eye.title = show ? 'Token verbergen' : 'Token anzeigen'; + eye.classList.toggle('is-on', show); + }; + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'token-tool'; + clear.title = 'Token entfernen (wirksam nach „Speichern“)'; + clear.textContent = '✕'; + clear.onclick = () => { input.value = ''; input.focus(); }; + wrap.append(eye, clear); + } + + /* ---------- Diagnose ---------- */ + async function refreshTransportState() { + const el = $('gitTransportState'); + const btn = $('btnGitTransportClear'); + if (!el) return; + const res = await window.electronAPI.gitTransportState(); + const blocked = (res && res.blocked) || []; + if (!blocked.length) { + el.textContent = '✅ Nicht gesperrt: Uploads, Löschen und Backup nutzen den schnellen Git-Weg, sobald der Server ihn anbietet.'; + if (btn) btn.disabled = true; + return; + } + el.textContent = blocked.map(b => `⛔ ${b.host} gesperrt seit ${new Date(b.since).toLocaleString('de-DE')} – die App nutzt dort die langsamere API.`).join('\n'); + if (btn) { btn.disabled = false; btn.dataset.host = blocked[0].host; } + } + + async function openFolder(which) { + const r = await window.electronAPI.openKnownFolder(which); + if (!r || !r.ok) showWarning((r && r.error) || 'Ordner konnte nicht geöffnet werden'); + } + + /* ---------- Verdrahtung ---------- */ + function wire() { + addTokenTools('githubToken'); + addTokenTools('giteaToken'); + + const on = (id, fn) => { const el = $(id); if (el) el.addEventListener('click', fn); }; + on('btnGiteaProfileSave', saveProfile); + on('btnGiteaProfileUse', useProfile); + on('btnGiteaProfileDelete', deleteProfile); + on('btnPrefCloneDir', async () => { const d = await window.electronAPI.selectFolder(); if (d) $('prefCloneDir').value = d; }); + on('btnRepoBackupOpen', () => openFolder('backup')); + on('btnOpenAppData', () => openFolder('appData')); + on('btnOpenLogs', () => openFolder('logs')); + on('btnGitTransportClear', async (e) => { + const r = await window.electronAPI.gitTransportClear(e.currentTarget.dataset.host || ''); + if (r && r.ok) showSuccess('Sperre aufgehoben. Der nächste Upload probiert wieder den schnellen Git-Weg.'); + refreshTransportState(); + }); + + // Design als Vorschau sofort zeigen; "Abbrechen" nimmt sie zurück + const theme = $('prefTheme'); + if (theme) theme.addEventListener('change', () => applyTheme(theme.value)); + + // Gemeinsames Speichern: läuft zusätzlich zum bisherigen Speichern der Zugangsdaten + on('btnSaveSettings', () => { saveExtras(); }); + + // Beim Öffnen der Einstellungen Stand merken (für die Warnung beim Abbrechen) + on('btnSettings', () => setTimeout(takeSnapshot, 80)); + + // Standardordner beim Klonen mehrerer Repos vorschlagen + on('btnBatchActions', () => setTimeout(() => { + const field = $('batchCloneTarget'); + if (field && !field.value && savedPrefs && savedPrefs.defaultCloneDir) field.value = savedPrefs.defaultCloneDir; + }, 50)); + + // Diagnose-Bereich: Sperrstatus aktualisieren, wenn er geöffnet wird + const diagTab = document.querySelector('#settingsModal [data-settings-tab="diagnose"]'); + if (diagTab) diagTab.addEventListener('click', refreshTransportState); + } + + // Gemeinsamer Zugriff für features.js / renderer.js + window.gmPrefs = { + get: () => savedPrefs, + load: loadPrefs, + takeSnapshot + }; + + loadPrefs().catch(() => {}); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); + else wire(); +})(); diff --git a/renderer/style.css b/renderer/style.css index 85ee1fc..48e0362 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -21,6 +21,13 @@ transition: color 0.2s; margin-bottom: 2px; } +.project-gravur-title .gravur-repo { + color: rgba(174, 189, 216, 0.45); + font-weight: 600; +} +.project-gravur-title .gravur-sub { + color: rgba(174, 189, 216, 0.22); +} .project-gravur-separator { width: 100%; height: 2px; @@ -2437,6 +2444,10 @@ input[type="checkbox"] { margin: 10px 0 0; color: var(--text-secondary); line-height: 1.5; + white-space: pre-line; + overflow-wrap: anywhere; + max-height: 60vh; + overflow-y: auto; } .confirm-modal-buttons { margin-top: 18px; @@ -3949,4 +3960,511 @@ body.compact-mode .file-type-badge { .fav-chip[draggable="true"] { cursor: grab; -} \ No newline at end of file +} + +/* ===== Klickbarer Projektpfad ===== */ +.project-gravur-title .gravur-link { + pointer-events: auto; + cursor: pointer; + border-radius: 4px; + transition: color var(--transition-fast); +} +.project-gravur-title .gravur-link:hover { + color: var(--accent-primary); + text-decoration: underline; +} + +/* ===== Neue Dialoge (features.js) ===== */ +.fx-modal { z-index: 99990; } +.fx-card { + width: min(560px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} +.fx-card--wide { width: min(1000px, calc(100vw - 32px)); } +.fx-header { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-md); } +.fx-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; } +.fx-close { + background: transparent; border: 1px solid var(--outline); color: var(--text-secondary); + border-radius: var(--radius-sm); width: 32px; height: 32px; cursor: pointer; flex-shrink: 0; +} +.fx-close:hover { color: var(--text-primary); border-color: var(--outline-strong); } +.fx-body { overflow-y: auto; display: flex; flex-direction: column; gap: var(--spacing-md); min-height: 0; flex: 1; } +.fx-body h3 { margin: var(--spacing-sm) 0 0; font-size: 14px; color: var(--text-secondary); } +.fx-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--spacing-sm); } +.fx-lead { color: var(--text-primary); margin: 0; } +.fx-muted { color: var(--text-muted); font-size: 12px; } +.fx-mono { font-family: Consolas, "Courier New", monospace; font-size: 12.5px; overflow-wrap: anywhere; } +.fx-text { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-secondary); line-height: 1.5; } +.fx-empty, .fx-busy { color: var(--text-muted); padding: var(--spacing-xl); text-align: center; } +.fx-btn { + min-height: 36px; padding: 0 14px; border-radius: var(--radius-sm); cursor: pointer; + border: 1px solid var(--outline); background: var(--bg-tertiary); color: var(--text-primary); +} +.fx-btn:hover:not(:disabled) { border-color: var(--outline-strong); background: var(--surface-hover); } +.fx-btn:disabled { opacity: 0.5; cursor: default; } +.fx-btn--primary { background: var(--accent-gradient); color: #04101f; border-color: transparent; font-weight: 600; } +.fx-btn--primary:hover:not(:disabled) { background: var(--accent-gradient); filter: brightness(1.08); } +.fx-btn--danger { border-color: rgba(239, 68, 68, 0.5); color: #fca5a5; } +.fx-input { + width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: var(--radius-sm); + border: 1px solid var(--outline); background: var(--bg-tertiary); color: var(--text-primary); +} +.fx-footer .fx-input { flex: 1 1 260px; width: auto; } +.fx-select { width: auto; } +.fx-textarea { min-height: 80px; resize: vertical; font-family: inherit; } +.fx-textarea--tall { min-height: 200px; } +.fx-row { display: flex; gap: var(--spacing-sm); align-items: flex-end; flex-wrap: wrap; } +.fx-row > .input-group, .fx-row > input { flex: 1 1 160px; } +.fx-row-end { display: flex; justify-content: flex-end; } +.fx-inline-check { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 13px; } +.fx-alert { border-radius: var(--radius-md); padding: var(--spacing-md); display: flex; flex-direction: column; gap: var(--spacing-sm); } +.fx-alert--warn { background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.4); color: #fcd34d; } +.fx-alert--danger { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.45); color: #fca5a5; } +.fx-alert .fx-list { background: rgba(0, 0, 0, 0.18); } +.fx-section summary { cursor: pointer; color: var(--text-secondary); } +.fx-list { + display: flex; flex-direction: column; border: 1px solid var(--outline); + border-radius: var(--radius-md); overflow: auto; max-height: 260px; +} +.fx-list--compact { max-height: 200px; } +.fx-list--cards { border: none; max-height: none; gap: var(--spacing-sm); overflow: visible; } +.fx-list-row { + display: flex; align-items: center; gap: var(--spacing-sm); padding: 6px 10px; + border-bottom: 1px solid var(--outline); color: var(--text-secondary); min-width: 0; +} +.fx-list-row:last-child { border-bottom: none; } +.fx-list-row > .fx-mono { flex: 1; min-width: 0; } +.fx-list-row--check { cursor: pointer; } +.fx-list-row.is-active { background: var(--surface-hover); } +.fx-list-head { font-weight: 600; color: var(--text-primary); } +.fx-linkish { background: none; border: none; color: var(--text-primary); text-align: left; cursor: pointer; padding: 0; flex: 1; min-width: 0; } +.fx-linkish:hover { color: var(--accent-primary); } +.fx-icon-btn { background: none; border: 1px solid transparent; color: var(--text-muted); cursor: pointer; border-radius: 6px; padding: 2px 6px; } +.fx-icon-btn:hover { border-color: var(--outline-strong); color: var(--text-primary); } +.fx-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--outline); color: var(--text-secondary); white-space: nowrap; } +.fx-badge--new, .fx-badge--A { border-color: rgba(34, 197, 94, 0.5); color: #86efac; } +.fx-badge--D { border-color: rgba(239, 68, 68, 0.5); color: #fca5a5; } +.fx-badge--M, .fx-badge--R { border-color: rgba(88, 213, 255, 0.45); color: var(--accent-primary); } +.fx-badge--U { border-color: rgba(245, 158, 11, 0.5); color: #fcd34d; } +.fx-split { display: grid; grid-template-columns: minmax(240px, 2fr) 3fr; gap: var(--spacing-md); min-height: 320px; } +.fx-pane { border: 1px solid var(--outline); border-radius: var(--radius-md); overflow: auto; max-height: 52vh; margin: 0; } +.fx-diff { padding: var(--spacing-md); font-family: Consolas, "Courier New", monospace; font-size: 12px; white-space: pre; color: var(--text-secondary); background: rgba(0, 0, 0, 0.2); } +.fx-diff-add { color: #86efac; background: rgba(34, 197, 94, 0.08); } +.fx-diff-del { color: #fca5a5; background: rgba(239, 68, 68, 0.08); } +.fx-diff-hunk { color: var(--accent-primary); } +.fx-tabs { display: flex; gap: var(--spacing-xs); } +.fx-tab { padding: 6px 14px; border-radius: 999px; border: 1px solid var(--outline); background: transparent; color: var(--text-secondary); cursor: pointer; } +.fx-tab.is-active { background: var(--surface-hover); color: var(--text-primary); border-color: var(--outline-strong); } +.fx-item { + display: flex; flex-direction: column; gap: 4px; text-align: left; cursor: pointer; width: 100%; + padding: var(--spacing-md); border-radius: var(--radius-md); border: 1px solid var(--outline); + background: var(--panel-soft); color: var(--text-primary); +} +.fx-item:hover { border-color: var(--outline-strong); background: var(--surface-hover); } +.fx-item-title { font-weight: 600; overflow-wrap: anywhere; } +.fx-labels { display: flex; flex-wrap: wrap; gap: 4px; } +.fx-label { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--outline-strong); color: var(--text-secondary); } +.fx-comments { display: flex; flex-direction: column; gap: var(--spacing-sm); } +.fx-comment { border-left: 2px solid var(--outline-strong); padding: 4px 0 4px var(--spacing-md); } +.fx-check { border: 1px solid var(--outline); border-radius: var(--radius-md); padding: var(--spacing-md); display: flex; flex-direction: column; gap: 6px; } +.fx-check--fail { border-color: rgba(239, 68, 68, 0.45); background: rgba(239, 68, 68, 0.06); } +.fx-check--warn { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.05); } +.fx-check-head { display: flex; align-items: center; gap: var(--spacing-sm); } +.fx-hint { + margin: 0; padding: var(--spacing-sm) var(--spacing-md); border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.25); color: var(--text-secondary); white-space: pre-wrap; + font-family: Consolas, "Courier New", monospace; font-size: 12px; user-select: text; +} +#repoBackupStatus { white-space: pre-line; } + +@media (max-width: 760px) { + .fx-split { grid-template-columns: 1fr; } +} + +/* ===== Reiter in den Einstellungen ===== */ +.settings-tabs { + display: flex; + gap: 6px; + margin: 0 0 14px; + padding: 4px; + width: fit-content; + max-width: 100%; + border: 1px solid var(--outline); + border-radius: 999px; + background: var(--panel-soft); +} +.settings-tab { + padding: 7px 18px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + font-weight: 600; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} +.settings-tab:hover { color: var(--text-primary); } +.settings-tab.is-active { + background: var(--surface-hover); + border-color: var(--outline-strong); + color: var(--text-primary); +} +.settings-tab:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; } +.settings-layout--backup { grid-template-columns: minmax(0, 1.18fr) minmax(300px, 0.82fr); } +@media (max-width: 1120px) { + .settings-layout--backup { grid-template-columns: 1fr; } +} + +.fx-browse-btn { flex: 0 0 auto; min-height: 44px; white-space: nowrap; } +.fx-row > input#repoBackupTarget { min-width: 0; } + +.fx-indent { padding-left: 28px; } +.fx-restore-status:empty { display: none; } +.fx-restore-status { display: flex; flex-direction: column; gap: var(--spacing-sm); } + +/* Radio und Checkbox in den neuen Dialogen nicht wie Textfelder strecken */ +.fx-modal input[type="radio"], +.fx-modal input[type="checkbox"] { + width: auto; + min-width: 0; + height: auto; + flex: 0 0 auto; + margin: 0; + padding: 0; + box-shadow: none; + accent-color: var(--accent-primary); +} +.fx-modal .fx-list-row--check > span:not(.fx-badge):not(.fx-muted) { flex: 1; min-width: 0; } + +/* ===== Einstellungen: Navigation links, ein Bereich rechts ===== */ +.settings-shell { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 16px; + align-items: start; +} +.settings-nav { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + background: var(--panel-soft); + position: sticky; + top: 0; +} +.settings-nav .settings-tab { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; + font-weight: 400; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); +} +.settings-nav .settings-tab:hover { + background: var(--surface); + color: var(--text-primary); +} +.settings-nav .settings-tab.is-active { + background: var(--surface-hover); + border-color: var(--outline-strong); + color: var(--text-primary); + box-shadow: inset 3px 0 0 var(--accent-primary); +} +.settings-tab-icon { + font-size: 18px; + width: 24px; + text-align: center; + flex-shrink: 0; +} +.settings-tab-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.settings-tab-text strong { + font-size: 14px; + font-weight: 600; +} +.settings-tab-text small { + font-size: 11.5px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.settings-nav .settings-tab.is-active .settings-tab-text small { color: var(--text-secondary); } +/* Feste Höhe: Beim Wechsel der Bereiche springt das Fenster nicht */ +.settings-pages { + height: min(60vh, 580px); + overflow-y: auto; + padding-right: 4px; + scrollbar-gutter: stable; +} +.settings-pages .settings-column { + display: grid; + gap: 14px; + max-width: 820px; +} + +@media (max-width: 900px) { + .settings-shell { grid-template-columns: 1fr; } + .settings-nav { + flex-direction: row; + overflow-x: auto; + position: static; + } + .settings-nav .settings-tab { width: auto; flex: 0 0 auto; } + .settings-tab-text small { display: none; } + .settings-pages { height: auto; max-height: 60vh; } +} + +/* Bildlaufleiste im App-Look */ +.settings-pages::-webkit-scrollbar { width: 8px; } +.settings-pages::-webkit-scrollbar-track { background: transparent; } +.settings-pages::-webkit-scrollbar-thumb { background: var(--outline-strong); border-radius: 999px; } +.settings-pages::-webkit-scrollbar-thumb:hover { background: rgba(110, 188, 255, 0.45); } +.settings-pages::-webkit-scrollbar-button { display: none; height: 0; } + +/* ===== Fortschrittsfenster Repo-Backup ===== */ +.fx-progress-head { + display: flex; + align-items: center; + gap: 18px; +} +.fx-progress-percent { + font-size: 40px; + font-weight: 700; + line-height: 1; + min-width: 110px; + background: var(--accent-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-variant-numeric: tabular-nums; +} +.fx-progress-now { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.fx-progress-now .fx-lead { overflow-wrap: anywhere; font-weight: 600; } +.fx-progress-track { + height: 10px; + border-radius: 999px; + background: var(--bg-tertiary); + border: 1px solid var(--outline); + overflow: hidden; +} +.fx-progress-fill { + height: 100%; + width: 0; + border-radius: 999px; + background: var(--accent-gradient); + box-shadow: 0 0 12px rgba(88, 213, 255, 0.45); + transition: width 300ms ease; +} +.fx-progress-meta { + color: var(--text-secondary); + font-size: 13px; + font-variant-numeric: tabular-nums; +} +.fx-backup-list { max-height: 34vh; } +.fx-backup-list .fx-list-row > .fx-mono { flex: 0 1 auto; min-width: 140px; } +.fx-backup-list .fx-list-row > .fx-muted { + margin-left: auto; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 55%; +} +.fx-backup-icon { width: 22px; text-align: center; flex-shrink: 0; } + +/* Bildlaufleisten in den Dialogen im App-Look */ +.fx-modal ::-webkit-scrollbar { width: 8px; height: 8px; } +.fx-modal ::-webkit-scrollbar-track { background: transparent; } +.fx-modal ::-webkit-scrollbar-thumb { background: var(--outline-strong); border-radius: 999px; } +.fx-modal ::-webkit-scrollbar-button { display: none; height: 0; width: 0; } + +/* ===== Token-Felder: anzeigen / entfernen ===== */ +.token-field { position: relative; display: flex; align-items: center; } +.token-field > input { flex: 1; padding-right: 76px !important; } +.token-field .token-tool { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + line-height: 1; +} +.token-field .token-tool:nth-of-type(1) { right: 38px; } +.token-field .token-tool:nth-of-type(2) { right: 6px; } +.token-field .token-tool:hover { color: var(--text-primary); border-color: var(--outline-strong); } +.token-field .token-tool.is-on { color: var(--accent-primary); } + +/* Mehrzeilige Felder in den Einstellungen */ +.settings-textarea { + width: 100%; + box-sizing: border-box; + min-height: 90px; + resize: vertical; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--outline); + background: var(--bg-tertiary); + color: var(--text-primary); + font-family: Consolas, "Courier New", monospace; + font-size: 13px; + line-height: 1.5; +} +.settings-textarea:focus { outline: none; border-color: var(--accent-primary); } +.settings-backup-card > .fx-row + .fx-row, +.settings-backup-card > .fx-row + .input-group, +.settings-backup-card > .input-group + .input-group { margin-top: 10px; } + +/* ===== Helles Design ===== */ +:root[data-theme="light"] { + --bg-primary: #eef2f8; + --bg-secondary: #e4eaf3; + --bg-tertiary: #ffffff; + --surface: #f3f6fb; + --surface-hover: #e3ebf7; + --panel: rgba(255, 255, 255, 0.92); + --panel-strong: rgba(255, 255, 255, 0.98); + --panel-soft: rgba(255, 255, 255, 0.8); + --outline: rgba(30, 60, 110, 0.14); + --outline-strong: rgba(36, 112, 210, 0.38); + --accent-primary: #0f86c9; + --accent-secondary: #2f5fd6; + --accent-gradient: linear-gradient(135deg, #12a3d8 0%, #2f80ed 54%, #3b5fd9 100%); + --text-primary: #13213a; + --text-secondary: #3d4f6e; + --text-muted: #6a7a94; + --shadow-sm: 0 6px 16px rgba(20, 40, 80, 0.08); + --shadow-md: 0 12px 28px rgba(20, 40, 80, 0.12); + --shadow-lg: 0 20px 44px rgba(20, 40, 80, 0.16); + --shadow-glow: 0 0 18px rgba(47, 128, 237, 0.18); + color-scheme: light; +} +:root[data-theme="light"] body { background: var(--bg-primary); color: var(--text-primary); } +:root[data-theme="light"] input, +:root[data-theme="light"] select, +:root[data-theme="light"] textarea { + background: #ffffff; + color: var(--text-primary); + border-color: var(--outline); +} +:root[data-theme="light"] .modalContent, +:root[data-theme="light"] .card, +:root[data-theme="light"] .settings-panel, +:root[data-theme="light"] .settings-auth-card, +:root[data-theme="light"] .settings-health-box, +:root[data-theme="light"] .item-card, +:root[data-theme="light"] .fx-item, +:root[data-theme="light"] .fx-list, +:root[data-theme="light"] .settings-toggle-row { + background: #ffffff; + border-color: var(--outline); + color: var(--text-primary); +} +:root[data-theme="light"] #toolbar, +:root[data-theme="light"] #titlebar-strip, +:root[data-theme="light"] .fav-history-sidebar, +:root[data-theme="light"] #main, +:root[data-theme="light"] .explorer-grid { + background: transparent; +} +:root[data-theme="light"] button:not(.fx-btn--primary):not(.accent-btn):not(.settings-tab):not(.token-tool):not(.fx-close):not(.fx-linkish):not(.fx-icon-btn) { + background: #ffffff; + color: var(--text-primary); + border-color: var(--outline); +} +:root[data-theme="light"] .item-name, +:root[data-theme="light"] .settings-toggle-title, +:root[data-theme="light"] h2, :root[data-theme="light"] h3, :root[data-theme="light"] h4 { color: var(--text-primary); } +:root[data-theme="light"] .fx-diff { background: #f6f8fc; } +:root[data-theme="light"] .fx-hint { background: #f1f4f9; } +:root[data-theme="light"] .fx-alert--warn { color: #8a5a00; background: #fff6e0; } +:root[data-theme="light"] .fx-alert--danger { color: #a02020; background: #fdecec; } +:root[data-theme="light"] .modal { background: rgba(20, 35, 60, 0.35); } +:root[data-theme="light"] .project-gravur-title { color: rgba(40, 60, 100, 0.35); } +:root[data-theme="light"] .project-gravur-title .gravur-repo { color: rgba(30, 50, 90, 0.7); } + +/* Helles Design: Kopfbereich, Schalter und Karten mit eigenen Farbverläufen */ +:root[data-theme="light"] #toolbar button:not(.accent-btn):not(.platform-option), +:root[data-theme="light"] #toolbar .status, +:root[data-theme="light"] .win-btn { + background-image: none !important; + background-color: #ffffff !important; + color: var(--text-primary) !important; + border: 1px solid var(--outline) !important; + box-shadow: var(--shadow-sm) !important; +} +:root[data-theme="light"] #toolbar button:not(.accent-btn):not(.platform-option):hover, +:root[data-theme="light"] .win-btn:hover { background-color: var(--surface-hover) !important; } +:root[data-theme="light"] .win-btn--close:hover { background-color: #e5484d !important; color: #fff !important; } +:root[data-theme="light"] .toolbar-brand-copy strong { color: var(--text-primary) !important; -webkit-text-fill-color: currentColor; text-shadow: none; } +:root[data-theme="light"] .toolbar-kicker { color: var(--text-muted) !important; } +:root[data-theme="light"] .platform-switch { + background: #ffffff !important; + border-color: var(--outline) !important; +} +:root[data-theme="light"] .platform-option { color: var(--text-secondary) !important; } +:root[data-theme="light"] .platform-option.active { color: #ffffff !important; } +:root[data-theme="light"] .activity-heatmap-card { + background: #ffffff !important; + border-color: var(--outline) !important; + color: var(--text-primary) !important; +} +:root[data-theme="light"] .activity-heatmap-card * { color: inherit; } +:root[data-theme="light"] .input-group label, +:root[data-theme="light"] .settings-auth-input label { color: var(--text-secondary) !important; } +:root[data-theme="light"] .toggle-track { background: #c9d3e3 !important; } +:root[data-theme="light"] .toggle-switch input:checked + .toggle-track { background: var(--accent-primary) !important; } + +/* ===== Lesbarkeit (Kontrastprüfung) ===== */ +/* Auswahllisten: Windows zeichnet die geöffnete Liste selbst – Farben überall festlegen */ +:root { color-scheme: dark; } +:root[data-theme="light"] { color-scheme: light; } +select option { background: #1a2233; color: #f5f8ff; } +select option:checked { background: #2d4ea4; color: #ffffff; } +:root[data-theme="light"] select option { background: #ffffff; color: #13213a; } +:root[data-theme="light"] select option:checked { background: #d6e6fb; color: #13213a; } + +/* Dunkles Design: Hinweistexte in Karten heller */ +.settings-inline-hint, .settings-toggle-desc, .settings-auth-spacer, +#repoBackupStatus, #gitTransportState, .repo-size-badge { color: #a3b2cc; } +.token-field .token-tool { color: #a3b2cc; } +.project-gravur-title .gravur-repo { color: rgba(174, 189, 216, 0.75); } +.project-gravur-title .gravur-sub { color: rgba(174, 189, 216, 0.55); } +.titlebar-strip-title { color: rgba(174, 189, 216, 0.7) !important; } + +/* Helles Design */ +:root[data-theme="light"] .settings-inline-hint, +:root[data-theme="light"] .settings-toggle-desc, +:root[data-theme="light"] .settings-auth-spacer, +:root[data-theme="light"] #repoBackupStatus, +:root[data-theme="light"] #gitTransportState, +:root[data-theme="light"] .repo-size-badge, +:root[data-theme="light"] .token-field .token-tool { color: #4f6180; } +:root[data-theme="light"] #giteaUrlHint, :root[data-theme="light"] #githubTokenHint, +:root[data-theme="light"] #healthApi, :root[data-theme="light"] #healthAuth, +:root[data-theme="light"] #healthUrl { color: #15803d !important; } +:root[data-theme="light"] .platform-option.active { color: #04101f !important; } +:root[data-theme="light"] .project-gravur-title .gravur-sub { color: rgba(40, 60, 100, 0.6); } +:root[data-theme="light"] .titlebar-strip-title { color: #4f6180 !important; } +:root[data-theme="light"] .activity-log-category { color: #1e4f8a !important; } diff --git a/scripts/build-setup.js b/scripts/build-setup.js new file mode 100644 index 0000000..2ab4010 --- /dev/null +++ b/scripts/build-setup.js @@ -0,0 +1,117 @@ +'use strict'; + +/* + * build-setup.js – eigenes Setup (installer/) mit der App zusammenbauen + * --------------------------------------------------------------------- + * Läuft nach "electron-builder --win dir" (siehe "build" in package.json): + * + * 1. dist/win-unpacked (die fertige App inkl. eingebautem Git) als ZIP packen + * 2. installer/ (WPF, .NET Framework 4.8) mit der Version aus package.json bauen + * 3. ZIP hinten an die Setup-.exe hängen: + * [Setup.exe][ZIP][8 Byte ZIP-Länge][8 Byte "GITMGRPK"] + * (das Setup liest den Anhang selbst aus, siehe installer/Payload.cs) + * 4. SHA-256-Prüfsumme daneben schreiben (der Updater der App prüft sie) + * 5. Zwischenstände wegräumen → übrig bleibt dist/Git-Manager-GUI-Setup-.exe + * + * Benötigt das .NET SDK (dotnet) – das Setup selbst läuft danach auf jedem + * Windows 10/11 ohne Zusatzinstallation. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const DIST = path.join(ROOT, 'dist'); +const APP_DIR = path.join(DIST, 'win-unpacked'); +const APP_EXE = 'Git Manager GUI.exe'; +const WORK = path.join(DIST, '.setup'); +const ZIP = path.join(WORK, 'app.zip'); +const MAGIC = Buffer.from('GITMGRPK', 'ascii'); + +function schritt(text) { + console.log(`\n» ${text}`); +} + +function sha256(file) { + const hash = crypto.createHash('sha256'); + const fd = fs.openSync(file, 'r'); + try { + const buf = Buffer.alloc(8 * 1024 * 1024); + let n; + while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) hash.update(buf.subarray(0, n)); + } finally { fs.closeSync(fd); } + return hash.digest('hex'); +} + +function main() { + const version = require(path.join(ROOT, 'package.json')).version; + const outName = `Git-Manager-GUI-Setup-${version}.exe`; + const OUT = path.join(DIST, outName); + + if (!fs.existsSync(path.join(APP_DIR, APP_EXE))) { + throw new Error(`${APP_DIR}\\${APP_EXE} fehlt – vorher "electron-builder --win dir" ausführen.`); + } + if (!fs.existsSync(path.join(APP_DIR, 'resources', 'mingit', 'cmd', 'git.exe'))) { + throw new Error('Eingebautes Git fehlt in resources/mingit – extraResources in package.json prüfen.'); + } + fs.rmSync(WORK, { recursive: true, force: true }); + fs.mkdirSync(WORK, { recursive: true }); + + schritt('App wird gepackt …'); + // .NET-ZIP über PowerShell: in jedem Windows enthalten, keine zusätzliche npm-Abhängigkeit + const ps = [ + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + `[System.IO.Compression.ZipFile]::CreateFromDirectory('${APP_DIR.replace(/'/g, "''")}', '${ZIP.replace(/'/g, "''")}', [System.IO.Compression.CompressionLevel]::Optimal, $false)`, + ].join('; '); + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'inherit' }); + + schritt(`Setup ${version} wird gebaut …`); + const stubDir = path.join(WORK, 'stub'); + execFileSync('dotnet', [ + 'build', path.join(ROOT, 'installer', 'GitManagerSetup.csproj'), + '-c', 'Release', `-p:Version=${version}`, '-p:DebugType=none', + '--nologo', '-v', 'q', '-o', stubDir, + ], { stdio: 'inherit' }); + const stub = path.join(stubDir, 'Git-Manager-GUI-Setup.exe'); + if (!fs.existsSync(stub)) throw new Error('Build des Setups fehlgeschlagen.'); + + schritt('App wird ans Setup angehängt …'); + const zipSize = fs.statSync(ZIP).size; + const footer = Buffer.alloc(16); + footer.writeBigInt64LE(BigInt(zipSize), 0); + MAGIC.copy(footer, 8); + fs.rmSync(OUT, { force: true }); + fs.copyFileSync(stub, OUT); + // ZIP in Blöcken anhängen statt komplett in den Speicher zu laden + const fd = fs.openSync(OUT, 'a'); + try { + const src = fs.openSync(ZIP, 'r'); + try { + const buf = Buffer.alloc(8 * 1024 * 1024); + let n; + while ((n = fs.readSync(src, buf, 0, buf.length, null)) > 0) fs.writeSync(fd, buf, 0, n); + } finally { fs.closeSync(src); } + fs.writeSync(fd, footer); + } finally { fs.closeSync(fd); } + + schritt('Prüfsumme wird geschrieben …'); + const sum = sha256(OUT); + fs.writeFileSync(`${OUT}.sha256`, `${sum} ${outName}\n`); + + schritt('Aufräumen …'); + for (const t of [WORK, APP_DIR, path.join(DIST, 'builder-debug.yml'), path.join(DIST, 'builder-effective-config.yaml')]) { + fs.rmSync(t, { recursive: true, force: true }); + } + + const mb = (fs.statSync(OUT).size / 1024 / 1024).toFixed(1); + console.log(`\nFertig: ${OUT} (${mb} MB)\nSHA-256: ${sum}`); +} + +try { + main(); +} catch (err) { + console.error(`\nFehler: ${err.message}`); + process.exit(1); +} diff --git a/src/backup/GiteaRepoBackup.js b/src/backup/GiteaRepoBackup.js new file mode 100644 index 0000000..a72ae0d --- /dev/null +++ b/src/backup/GiteaRepoBackup.js @@ -0,0 +1,313 @@ +// src/backup/GiteaRepoBackup.js +// Sichert alle Gitea-Repos des Benutzers in einen Ordner (z. B. NAS-Freigabe). +// +// Bevorzugt als Git-Spiegel (//.git): vollständige Historie, alle Branches +// und Tags, und ab dem zweiten Lauf werden nur Änderungen geholt. Liefert der Server keine +// Git-Pakete (early EOF), wird stattdessen ein ZIP-Archiv des Standard-Branches über die API +// geladen (//-archiv/-.zip); davon bleiben die neuesten N. + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const https = require('https'); +const axios = require('axios'); + +const INTERVALS = { off: 0, daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 }; +const SCHEDULER_TICK_MS = 15 * 60 * 1000; + +function stamp(d = new Date()) { + const p = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}`; +} + +function safeName(s) { + return String(s).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); +} + +// Token nur pro Git-Aufruf mitgeben, nie in einer Repo- oder Spiegel-Konfiguration speichern. +function authHeaderArgs(ctx) { + const basic = Buffer.from(`${ctx.token}:x-oauth-basic`, 'utf8').toString('base64'); + return ['-c', `http.extraheader=Authorization: Basic ${basic}`]; +} + +// Ausschlussmuster: eine Zeile je Muster, * und ? als Platzhalter, ohne "/" passend auf den Repo-Namen. +function excludeMatcher(text) { + const pats = String(text || '').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); + const res = pats.map(p => ({ + full: p.includes('/'), + re: new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i') + })); + return (fullName) => res.some(({ full, re }) => re.test(full ? fullName : fullName.split('/').pop())); +} + +function createRepoBackup({ configPath, getContext, giteaCall, runGit, isPackFailure, redact }) { + let running = null; + // Laufender Zustand für die Fortschrittsanzeige. Wird bei jeder Änderung an alle Zuhörer gemeldet, + // damit auch ein später geöffnetes Fenster (oder ein geplantes Backup) den Stand zeigen kann. + let live = null; + const listeners = new Set(); + let lastBytesEmit = 0; + + function snapshot() { + if (!live) return null; + const { abort, ...rest } = live; + return { ...rest, results: live.results.slice() }; + } + function emit(force = true) { + if (!live) return; + if (!force) { + const now = Date.now(); + if (now - lastBytesEmit < 250) return; + lastBytesEmit = now; + } + const s = snapshot(); + for (const fn of listeners) { try { fn(s); } catch (_) {} } + } + function setLive(patch, force = true) { + if (!live) return; + Object.assign(live, patch); + emit(force); + } + + const defaults = { + targetDir: '', interval: 'off', keep: 5, lastRun: null, lastResult: null, + excludeRepos: '', // Muster je Zeile, z. B. "M_Viper/test-*" oder "*-alt" + time: '', // frühester Zeitpunkt für automatische Backups ("HH:MM"), leer = jederzeit + notify: 'errors' // 'errors' | 'always' | 'never' + }; + + function readConfig() { + try { return { ...defaults, ...JSON.parse(fs.readFileSync(configPath, 'utf8')) }; } catch (_) { return { ...defaults }; } + } + function writeConfig(cfg) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); + } + + function getState() { + return { config: readConfig(), running: !!running, progress: snapshot() }; + } + + function setConfig(data) { + const cfg = readConfig(); + if (typeof data.targetDir === 'string') cfg.targetDir = data.targetDir.trim(); + if (data.interval in INTERVALS) cfg.interval = data.interval; + if (data.keep != null) cfg.keep = Math.min(100, Math.max(1, Number(data.keep) || 5)); + if (typeof data.excludeRepos === 'string') cfg.excludeRepos = data.excludeRepos.replace(/\r/g, '').slice(0, 20000); + if (typeof data.time === 'string') cfg.time = /^([01]\d|2[0-3]):[0-5]\d$/.test(data.time) ? data.time : ''; + if (['errors', 'always', 'never'].includes(data.notify)) cfg.notify = data.notify; + writeConfig(cfg); + return getState(); + } + + async function listAllRepos(ctx) { + const all = []; + for (let page = 1; page <= 100; page++) { + const list = await giteaCall(ctx, 'get', '/user/repos', { params: { page, limit: 50 } }); + if (!list || !list.length) break; + all.push(...list); + if (list.length < 50) break; + } + return all; + } + + async function backupAsMirror(ctx, repo, dir) { + const auth = authHeaderArgs(ctx); + if (fs.existsSync(path.join(dir, 'HEAD'))) { + setLive({ step: 'Git-Spiegel wird aktualisiert …', bytes: 0 }); + return runGit([...auth, '-C', dir, 'remote', 'update', '--prune'], { timeoutMs: 30 * 60000 }); + } + setLive({ step: 'Git-Spiegel wird angelegt …', bytes: 0 }); + fs.mkdirSync(path.dirname(dir), { recursive: true }); + const r = await runGit([...auth, 'clone', '--mirror', repo.clone_url, dir], { timeoutMs: 60 * 60000 }); + if (r.status !== 0) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} } + return r; + } + + async function backupAsZip(ctx, repo, folder, keep, signal) { + const branch = repo.default_branch || 'main'; + fs.mkdirSync(folder, { recursive: true }); + const file = path.join(folder, `${safeName(repo.name)}-${stamp()}.zip`); + const lib = ctx.base.startsWith('https:') ? https : http; + const agent = new lib.Agent({ keepAlive: false, autoSelectFamily: true }); + const tmp = `${file}.part`; + setLive({ step: `ZIP vom Branch ${branch} wird geladen …`, bytes: 0, bytesTotal: null }); + try { + const res = await axios.get(`${ctx.apiBase}/repos/${encodeURIComponent(repo.owner.login)}/${encodeURIComponent(repo.name)}/archive/${encodeURIComponent(branch)}.zip`, { + headers: ctx.headers, responseType: 'stream', timeout: 30 * 60000, signal, + httpAgent: agent, httpsAgent: agent, validateStatus: () => true, maxRedirects: 5 + }); + if (res.status !== 200) { + res.data.resume(); + throw new Error(`Archiv-Download HTTP ${res.status}`); + } + const total = Number(res.headers['content-length']) || null; + if (total) setLive({ bytesTotal: total }); + let bytes = 0; + await new Promise((resolve, reject) => { + const out = fs.createWriteStream(tmp); + res.data.on('data', (chunk) => { bytes += chunk.length; setLive({ bytes }, false); }); + res.data.on('error', reject); + out.on('error', reject); + out.on('finish', resolve); + res.data.pipe(out); + }); + setLive({ bytes }); + fs.renameSync(tmp, file); + } catch (e) { + try { fs.rmSync(tmp, { force: true }); } catch (_) {} + if (signal && signal.aborted) throw new Error('Abgebrochen'); + throw e; + } finally { + agent.destroy(); + } + // Ältere Archive über der Aufbewahrungsgrenze entfernen + const zips = fs.readdirSync(folder).filter(f => f.endsWith('.zip')).sort(); + for (const old of zips.slice(0, Math.max(0, zips.length - keep))) { + try { fs.rmSync(path.join(folder, old), { force: true }); } catch (_) {} + } + return file; + } + + async function run() { + const cfg = readConfig(); + if (!cfg.targetDir) throw new Error('Kein Backup-Ziel festgelegt.'); + fs.mkdirSync(cfg.targetDir, { recursive: true }); + const ctx = getContext(); + setLive({ phase: 'list', step: 'Repos werden abgefragt …' }); + const all = await listAllRepos(ctx); + const excluded = excludeMatcher(cfg.excludeRepos); + const repos = all.filter(r => !excluded(`${r.owner.login}/${r.name}`)); + setLive({ excludedCount: all.length - repos.length }); + const results = live.results; + let mirrorBroken = false; + setLive({ phase: 'running', total: repos.length, repos: repos.map(r => `${r.owner.login}/${r.name}`) }); + + for (let i = 0; i < repos.length; i++) { + if (live.cancelRequested) break; + const repo = repos[i]; + const full = `${repo.owner.login}/${repo.name}`; + setLive({ current: i + 1, repo: full, step: 'Wird vorbereitet …', bytes: 0, bytesTotal: null }); + const ownerDir = path.join(cfg.targetDir, safeName(repo.owner.login)); + const finish = (r) => { results.push(r); setLive({ done: results.length }); }; + + if (repo.empty) { finish({ repo: full, ok: true, method: 'leer' }); continue; } + + // Scheitert der Spiegel einmal am Server (early EOF), nicht für jedes Repo erneut probieren. + let mirrorError = null; + if (!mirrorBroken) { + const r = await backupAsMirror(ctx, repo, path.join(ownerDir, `${safeName(repo.name)}.git`)); + if (r.status === 0) { finish({ repo: full, ok: true, method: 'Git-Spiegel' }); continue; } + if (isPackFailure(r.stderr)) { + mirrorBroken = true; + setLive({ mirrorBroken: true }); + } else if (!/could not read|authentication|403|401/i.test(r.stderr)) { + mirrorError = redact(r.stderr, ctx.token).trim().slice(0, 200); + } + } + if (live.cancelRequested) break; + live.abort = new AbortController(); + try { + const file = await backupAsZip(ctx, repo, path.join(ownerDir, `${safeName(repo.name)}-archiv`), cfg.keep, live.abort.signal); + finish({ repo: full, ok: true, method: 'ZIP', file: path.basename(file), bytes: live.bytes }); + } catch (e) { + if (live.cancelRequested) break; + finish({ repo: full, ok: false, method: 'ZIP', error: redact(e.message, ctx.token) + (mirrorError ? ` (Git-Spiegel vorher: ${mirrorError})` : '') }); + } finally { + live.abort = null; + } + } + + const failed = results.filter(r => !r.ok); + const summary = { + at: new Date().toISOString(), + total: repos.length, + excluded: all.length - repos.length, + ok: results.length - failed.length, + failed: failed.length, + skipped: repos.length - results.length, + cancelled: !!live.cancelRequested, + durationMs: Date.now() - live.startedAt, + mirror: results.filter(r => r.method === 'Git-Spiegel').length, + zip: results.filter(r => r.method === 'ZIP' && r.ok).length, + mirrorBroken, + errors: failed.slice(0, 10) + }; + const after = readConfig(); + after.lastRun = summary.at; + after.lastResult = summary; + writeConfig(after); + try { + fs.writeFileSync(path.join(cfg.targetDir, 'backup-protokoll.json'), JSON.stringify({ ...summary, results }, null, 2), 'utf8'); + } catch (_) {} + console.log(`[RepoBackup] ${summary.cancelled ? 'abgebrochen' : 'fertig'}: ${summary.ok}/${summary.total} gesichert (${summary.mirror} Spiegel, ${summary.zip} ZIP), ${summary.failed} Fehler`); + return summary; + } + + async function runNow(onProgress) { + if (running) return { ok: false, error: 'Ein Backup läuft bereits.', running: true }; + live = { + phase: 'start', startedAt: Date.now(), total: 0, current: 0, done: 0, repo: null, repos: [], + step: 'Startet …', bytes: 0, bytesTotal: null, results: [], mirrorBroken: false, + cancelRequested: false, abort: null, targetDir: readConfig().targetDir + }; + if (onProgress) listeners.add(onProgress); + emit(); + running = run(); + try { + const summary = await running; + setLive({ phase: 'done', summary, step: summary.cancelled ? 'Abgebrochen' : 'Fertig' }); + return { ok: summary.failed === 0 && !summary.cancelled, summary }; + } catch (e) { + setLive({ phase: 'error', error: e.message, step: 'Fehlgeschlagen' }); + return { ok: false, error: e.message }; + } finally { + if (onProgress) listeners.delete(onProgress); + running = null; + live = null; + } + } + + // Bricht nach dem aktuellen Schritt ab; ein laufender ZIP-Download wird sofort beendet. + function cancel() { + if (!running || !live) return { ok: false, error: 'Es läuft kein Backup.' }; + live.cancelRequested = true; + if (live.abort) { try { live.abort.abort(); } catch (_) {} } + setLive({ step: 'Wird abgebrochen …' }); + return { ok: true }; + } + + function onProgress(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + } + + function isDue(now = new Date()) { + const cfg = readConfig(); + const every = INTERVALS[cfg.interval] || 0; + if (!every || !cfg.targetDir) return false; + const last = cfg.lastRun ? new Date(cfg.lastRun).getTime() : 0; + if (!cfg.time) return !last || now.getTime() - last >= every; + // Mit Uhrzeit: erst ab HH:MM, und nur wenn seit dem letzten Lauf (fast) ein Intervall vergangen ist. + // 12 Stunden Spielraum, damit sich der Zeitpunkt nicht durch späte Läufe immer weiter verschiebt. + const [h, m] = cfg.time.split(':').map(Number); + const slot = new Date(now); slot.setHours(h, m, 0, 0); + if (slot > now) slot.setDate(slot.getDate() - 1); + return last < slot.getTime() && now.getTime() - last >= every - 12 * 3600 * 1000; + } + + function startScheduler() { + const tick = async () => { + if (running || !isDue()) return; + console.log('[RepoBackup] Geplantes Backup startet'); + try { await runNow(null); } catch (e) { console.warn('[RepoBackup] Geplantes Backup fehlgeschlagen:', e.message); } + }; + // unref: Der Zeitplaner allein hält den Prozess nicht am Leben. + setTimeout(tick, 2 * 60 * 1000).unref?.(); + setInterval(tick, SCHEDULER_TICK_MS).unref?.(); + } + + return { getState, setConfig, runNow, cancel, onProgress, startScheduler, isDue }; +} + +module.exports = { createRepoBackup, authHeaderArgs, excludeMatcher }; diff --git a/src/backup/GiteaRepoRestore.js b/src/backup/GiteaRepoRestore.js new file mode 100644 index 0000000..7b941d9 --- /dev/null +++ b/src/backup/GiteaRepoRestore.js @@ -0,0 +1,224 @@ +// src/backup/GiteaRepoRestore.js +// Stellt Sicherungen aus GiteaRepoBackup wieder her: +// - Git-Spiegel (//.git): lokal als Arbeitskopie klonen oder per +// "push --mirror" auf Gitea zurückspielen (neues Repo oder bestehendes überschreiben). +// - ZIP-Stand (//-archiv/*.zip): lokal entpacken oder als neues Repo +// auf Gitea anlegen (per Git-Push, bei Serverproblemen über die Contents-API). + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const unzipper = require('unzipper'); + +function isEmptyOrMissing(dir) { + try { return fs.readdirSync(dir).length === 0; } catch (_) { return true; } +} + +// Sicherungen im Backup-Ordner finden +function listBackups(targetDir) { + const repos = new Map(); + if (!targetDir || !fs.existsSync(targetDir)) return []; + for (const owner of fs.readdirSync(targetDir)) { + const ownerDir = path.join(targetDir, owner); + let entries; + try { + if (!fs.statSync(ownerDir).isDirectory()) continue; + entries = fs.readdirSync(ownerDir); + } catch (_) { continue; } + for (const entry of entries) { + const full = path.join(ownerDir, entry); + let st; + try { st = fs.statSync(full); } catch (_) { continue; } + if (!st.isDirectory()) continue; + if (entry.endsWith('.git') && fs.existsSync(path.join(full, 'HEAD'))) { + const name = entry.slice(0, -4); + const key = `${owner}/${name}`; + const rec = repos.get(key) || { owner, repo: name, mirror: null, zips: [] }; + let updated = st.mtime; + for (const marker of ['FETCH_HEAD', 'packed-refs', 'refs']) { + try { const m = fs.statSync(path.join(full, marker)).mtime; if (m > updated) updated = m; } catch (_) {} + } + rec.mirror = { path: full, updated: updated.toISOString() }; + repos.set(key, rec); + } else if (entry.endsWith('-archiv')) { + const name = entry.slice(0, -'-archiv'.length); + const key = `${owner}/${name}`; + const rec = repos.get(key) || { owner, repo: name, mirror: null, zips: [] }; + for (const f of fs.readdirSync(full).filter(f => f.endsWith('.zip'))) { + try { + const zst = fs.statSync(path.join(full, f)); + rec.zips.push({ path: path.join(full, f), file: f, size: zst.size, date: zst.mtime.toISOString() }); + } catch (_) {} + } + rec.zips.sort((a, b) => b.date.localeCompare(a.date)); + repos.set(key, rec); + } + } + } + return [...repos.values()].sort((a, b) => `${a.owner}/${a.repo}`.localeCompare(`${b.owner}/${b.repo}`)); +} + +// Gitea-Archive enthalten einen Oberordner "/". Er wird beim Entpacken entfernt. +async function extractZip(zipPath, dest) { + const dir = await unzipper.Open.file(zipPath); + const files = dir.files.filter(f => f.type === 'File'); + const tops = new Set(files.map(f => f.path.split('/')[0])); + const strip = tops.size === 1 && files.every(f => f.path.includes('/')) ? [...tops][0] + '/' : ''; + const root = path.resolve(dest); + // Erst alle Pfade prüfen, dann schreiben: ein manipuliertes Archiv soll gar nichts entpacken. + // Schutz vor Pfaden, die aus dem Zielordner herausführen ("zip slip"). + const plan = []; + for (const f of files) { + const rel = strip ? f.path.slice(strip.length) : f.path; + if (!rel) continue; + const out = path.resolve(root, rel); + if (out === root || !out.startsWith(root + path.sep)) throw new Error(`Unsicherer Pfad im Archiv: ${f.path}`); + plan.push({ f, out }); + } + let count = 0; + for (const { f, out } of plan) { + fs.mkdirSync(path.dirname(out), { recursive: true }); + await new Promise((resolve, reject) => { + f.stream().on('error', reject).pipe(fs.createWriteStream(out)).on('error', reject).on('finish', resolve); + }); + count++; + } + return count; +} + +function collectFiles(root) { + const items = []; + (function walk(dir) { + for (const entry of fs.readdirSync(dir)) { + if (entry === '.git') continue; + const full = path.join(dir, entry); + const st = fs.statSync(full); + if (st.isDirectory()) walk(full); + else if (st.isFile()) items.push({ localFile: full, targetPath: path.relative(root, full).split(path.sep).join('/') }); + } + })(root); + return items; +} + +function createRepoRestore({ getContext, giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs, identity }) { + + async function restoreLocal({ kind, source, destParent, repo }) { + if (!destParent || !fs.existsSync(destParent)) throw new Error('Zielordner nicht gefunden'); + const dest = path.join(destParent, repo); + if (!isEmptyOrMissing(dest)) throw new Error(`"${dest}" existiert bereits und ist nicht leer. Bitte einen anderen Ordner wählen.`); + + if (kind === 'mirror') { + const r = await runGit(['clone', source, dest], { timeoutMs: 30 * 60000 }); + if (r.status !== 0) throw new Error(r.stderr.trim() || 'Klonen fehlgeschlagen'); + // origin soll wieder auf Gitea zeigen, nicht auf den Backup-Ordner. + // Der Spiegel kennt die ursprüngliche Adresse (ohne Token, der wird nur pro Aufruf übergeben). + const remote = await runGit(['-C', source, 'config', '--get', 'remote.origin.url']); + const url = remote.stdout.trim(); + if (/^https?:/i.test(url)) await runGit(['-C', dest, 'remote', 'set-url', 'origin', url]); + const br = await runGit(['-C', dest, 'branch', '-r']); + return { ok: true, path: dest, branches: br.stdout.split('\n').filter(l => l.trim() && !l.includes('->')).length }; + } + + if (kind === 'zip') { + fs.mkdirSync(dest, { recursive: true }); + const count = await extractZip(source, dest); + return { ok: true, path: dest, files: count }; + } + throw new Error('Unbekannte Sicherungsart'); + } + + async function createGiteaRepo(ctx, owner, name, isPrivate) { + const me = await giteaCall(ctx, 'get', '/user'); + const body = { name, private: !!isPrivate, auto_init: false, description: `Wiederhergestellt aus Backup (${new Date().toLocaleDateString('de-DE')})` }; + try { + return owner && owner !== me.login + ? await giteaCall(ctx, 'post', `/orgs/${encodeURIComponent(owner)}/repos`, { data: body }) + : await giteaCall(ctx, 'post', '/user/repos', { data: body }); + } catch (e) { + if (e.status === 409) throw new Error(`Ein Repo "${owner || me.login}/${name}" existiert bereits. Bitte einen anderen Namen wählen.`); + if (e.status === 404 || e.status === 403) throw new Error(`Repo kann unter "${owner}" nicht angelegt werden (keine Rechte oder Organisation fehlt): ${e.message}`); + throw e; + } + } + + async function restoreToGitea({ kind, source, owner, repo, mode, newName, isPrivate }, onProgress) { + const ctx = getContext(); + const progress = (text) => { if (onProgress) onProgress({ text }); }; + + let target; + let created = false; + if (mode === 'overwrite') { + if (kind !== 'mirror') throw new Error('Überschreiben ist nur mit einem Git-Spiegel möglich, weil ein ZIP-Stand keine Historie enthält.'); + progress('Prüfe Ziel-Repo ...'); + target = await giteaCall(ctx, 'get', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); + } else { + const name = String(newName || '').trim(); + if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('Ungültiger Repo-Name. Erlaubt sind Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich.'); + progress(`Lege Repo "${name}" an ...`); + target = await createGiteaRepo(ctx, owner, name, isPrivate); + created = true; + } + const full = target.full_name || `${target.owner.login}/${target.name}`; + + if (kind === 'mirror') { + progress('Übertrage Branches und Tags ...'); + const r = await runGit([...authHeaderArgs(ctx), '-C', source, 'push', '--mirror', target.clone_url], { timeoutMs: 60 * 60000 }); + if (r.status !== 0) { + const msg = redact(r.stderr, ctx.token).trim(); + throw new Error(isPackFailure(msg) + ? `Der Server hat die Übertragung abgebrochen (${msg.split('\n').pop()}). Die Server-Diagnose zeigt die Ursache.${created ? ` Das leere Repo "${full}" wurde bereits angelegt.` : ''}` + : msg || 'Push fehlgeschlagen'); + } + // Standard-Branch wie im Backup setzen + const head = await runGit(['-C', source, 'symbolic-ref', '--short', 'HEAD']); + const branch = head.status === 0 ? head.stdout.trim() : null; + if (branch && branch !== target.default_branch) { + try { await giteaCall(ctx, 'patch', `/repos/${encodeURIComponent(target.owner.login)}/${encodeURIComponent(target.name)}`, { data: { default_branch: branch } }); } catch (_) {} + } + return { ok: true, repo: full, url: target.html_url, method: 'Git-Spiegel' }; + } + + if (kind === 'zip') { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-restore-')); + try { + progress('Entpacke Archiv ...'); + const count = await extractZip(source, tmp); + if (!count) throw new Error('Das Archiv ist leer.'); + const branch = target.default_branch || 'main'; + + progress(`Übertrage ${count} Dateien per Git ...`); + const who = identity ? identity() : { name: 'Git Manager GUI', email: 'gui@gitmanager.local' }; + const id = ['-c', `user.name=${who.name}`, '-c', `user.email=${who.email}`]; + let pushed = false; + let pushErr = ''; + if ((await runGit(['-C', tmp, 'init', '-q'])).status === 0) { + await runGit(['-C', tmp, 'add', '-A']); + await runGit([...id, '-C', tmp, 'commit', '-q', '-m', `Wiederhergestellt aus Backup ${path.basename(source)}`]); + const p = await runGit([...authHeaderArgs(ctx), '-C', tmp, 'push', target.clone_url, `HEAD:refs/heads/${branch}`], { timeoutMs: 30 * 60000 }); + pushed = p.status === 0; + pushErr = redact(p.stderr, ctx.token).trim(); + } + if (pushed) return { ok: true, repo: full, url: target.html_url, method: 'Git', files: count }; + + // Git-Weg nicht nutzbar: Dateien über die API hochladen + progress(`Git-Push nicht möglich, lade ${count} Dateien über die API hoch ...`); + const result = await uploadItemsViaGiteaApi({ + token: ctx.token, url: ctx.base, owner: target.owner.login, repo: target.name, branch, + items: collectFiles(tmp), message: `Wiederhergestellt aus Backup ${path.basename(source)}`, + onProgress: (pct) => progress(`Lade über die API hoch ... ${pct} %`) + }); + if (result.failed.length) { + throw new Error(`${result.failed.length} von ${count} Dateien konnten nicht hochgeladen werden (z. B. ${result.failed[0].path}: ${result.failed[0].error}). Git-Fehler vorher: ${pushErr.slice(0, 200)}`); + } + return { ok: true, repo: full, url: target.html_url, method: 'API', files: count }; + } finally { + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} + } + } + throw new Error('Unbekannte Sicherungsart'); + } + + return { listBackups, restoreLocal, restoreToGitea }; +} + +module.exports = { createRepoRestore, listBackups, extractZip }; diff --git a/src/features/appPrefs.js b/src/features/appPrefs.js new file mode 100644 index 0000000..58d934d --- /dev/null +++ b/src/features/appPrefs.js @@ -0,0 +1,87 @@ +// src/features/appPrefs.js +// Allgemeine App-Einstellungen, die keine Geheimnisse enthalten (Tokens liegen verschlüsselt in +// den Zugangsdaten). Gespeichert als userData/app-settings.json. + +const fs = require('fs'); +const path = require('path'); + +const DEFAULTS = { + // Konten + gitName: '', + gitEmail: '', + // Uploads + uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster + uploadSecretScan: true, // Dateien mit Zugangsdaten erkennen und vorab abwählen + uploadPreview: 'always', // 'always' | 'warnings' (nur bei Auffälligkeiten anzeigen) + // Darstellung + theme: 'dark', // 'dark' | 'light' | 'system' + zoom: 100, // Prozent + // System + autoUpdateCheck: true, + closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit' + defaultCloneDir: '' +}; + +const CHOICES = { + uploadPreview: ['always', 'warnings'], + theme: ['dark', 'light', 'system'], + closeAction: ['auto', 'tray', 'quit'] +}; + +function clean(key, value) { + const def = DEFAULTS[key]; + if (CHOICES[key]) return CHOICES[key].includes(value) ? value : def; + if (typeof def === 'boolean') return !!value; + if (key === 'zoom') { + const n = Math.round(Number(value)); + return Number.isFinite(n) ? Math.min(150, Math.max(70, n)) : def; + } + if (key === 'gitEmail') { + const v = String(value || '').trim().slice(0, 200); + return v === '' || /^[^\s@<>]+@[^\s@<>]+$/.test(v) ? v : def; + } + if (key === 'gitName') return String(value || '').replace(/[<>\n\r]/g, '').trim().slice(0, 100); + if (key === 'uploadExtraExcludes') return String(value || '').replace(/\r/g, '').slice(0, 20000); + return String(value || '').trim().slice(0, 1000); +} + +function createPrefs(file) { + const listeners = new Set(); + let cache = null; + + function get() { + if (!cache) { + let stored = {}; + try { stored = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch (_) {} + cache = {}; + for (const key of Object.keys(DEFAULTS)) cache[key] = key in stored ? clean(key, stored[key]) : DEFAULTS[key]; + } + return { ...cache }; + } + + /** Übernimmt nur bekannte Schlüssel; liefert die bereinigten Werte und eine Liste abgelehnter Eingaben. */ + function set(patch) { + const next = get(); + const rejected = []; + for (const [key, value] of Object.entries(patch || {})) { + if (!(key in DEFAULTS)) continue; + const v = clean(key, value); + if (key === 'gitEmail' && String(value || '').trim() && v !== String(value).trim()) rejected.push(key); + next[key] = v; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(next, null, 2), 'utf8'); + cache = next; + for (const fn of listeners) { try { fn(get()); } catch (_) {} } + return { prefs: get(), rejected }; + } + + function onChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { get, set, onChange, DEFAULTS }; +} + +module.exports = { createPrefs, DEFAULTS }; diff --git a/src/features/featuresIpc.js b/src/features/featuresIpc.js new file mode 100644 index 0000000..323393f --- /dev/null +++ b/src/features/featuresIpc.js @@ -0,0 +1,539 @@ +// src/features/featuresIpc.js +// IPC-Handler für: Upload-Vorschau mit Geheimnis-Warnung, Server-Diagnose, +// lokale Änderungen (Status/Diff/Commit), Pull Requests & Issues und Repo-Backup. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const dns = require('dns').promises; +const net = require('net'); +const http = require('http'); +const https = require('https'); +const { spawn } = require('child_process'); +const axios = require('axios'); +const { normalizeAndValidateBaseUrl, uploadItemsViaGiteaApi } = require('../git/apiHandler.js'); +const { scanForSecrets } = require('./secretScan.js'); +const { createRepoBackup, authHeaderArgs } = require('../backup/GiteaRepoBackup.js'); +const { commitIdentity } = require('../git/gitBinary.js'); +const { createRepoRestore } = require('../backup/GiteaRepoRestore.js'); + +const agentOptions = { keepAlive: true, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 }; +const api = axios.create({ + httpAgent: new http.Agent(agentOptions), + httpsAgent: new https.Agent(agentOptions), + timeout: 30000, + validateStatus: () => true +}); + +const enc = encodeURIComponent; +const repoApiPath = (owner, repo) => `/repos/${enc(owner)}/${enc(repo)}`; + +function giteaContext(readCredentials) { + const c = readCredentials() || {}; + if (!c.giteaURL || !c.giteaToken) throw new Error('Gitea-Zugangsdaten fehlen. Bitte in den Einstellungen eintragen.'); + const base = normalizeAndValidateBaseUrl(c.giteaURL); + return { base, apiBase: `${base}/api/v1`, token: c.giteaToken, headers: { Authorization: `token ${c.giteaToken}` } }; +} + +async function giteaCall(ctx, method, apiPath, { params, data, timeout } = {}) { + const res = await api.request({ method, url: ctx.apiBase + apiPath, params, data, headers: ctx.headers, timeout }); + if (res.status >= 200 && res.status < 300) return res.data; + const err = new Error((res.data && res.data.message) || `HTTP ${res.status}`); + err.status = res.status; + throw err; +} + +// Git mit Zeitlimit, ohne den Hauptprozess zu blockieren. +function runGit(args, { cwd, timeoutMs = 60000 } = {}) { + return new Promise((resolve) => { + let child; + try { + child = spawn('git', args, { cwd, windowsHide: true, env: process.env }); + } catch (e) { + resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) }); + return; + } + const out = []; + const err = []; + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch (_) {} }, timeoutMs); + child.stdout.on('data', c => out.push(c)); + child.stderr.on('data', c => err.push(c)); + child.on('error', e => err.push(Buffer.from(String(e && e.message || e)))); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ + status: timedOut ? -2 : code, + timedOut, + stdout: Buffer.concat(out).toString('utf8'), + stderr: Buffer.concat(err).toString('utf8') + }); + }); + child.stdin.end(); + }); +} + +// Token nie in Fehlermeldungen oder Logs durchreichen. +function redact(text, token) { + let s = String(text || ''); + if (token) s = s.split(token).join('***'); + return s.replace(/\/\/[^/@\s]+@/g, '//***@'); +} + +function isPackFailure(stderr) { + return /early eof|index-pack|bad pack header|unexpected disconnect|remote end hung up|rpc failed/i.test(String(stderr || '')); +} + +/* ---------------------------------------------------------------- + Upload-Vorschau + ---------------------------------------------------------------- */ +async function previewUpload({ paths, destPath }, deps) { + const { items, skipped, ignored } = await deps.buildItemsFromPaths(paths, destPath); + const files = []; + let totalBytes = 0; + for (const item of items) { + let size = 0; + try { size = (await fs.promises.stat(item.localFile)).size; } catch (_) {} + totalBytes += size; + files.push({ localFile: item.localFile, targetPath: item.targetPath, size }); + } + const prefs = deps.prefs ? deps.prefs.get() : { uploadSecretScan: true, uploadPreview: 'always' }; + // Geheimnis-Erkennung lässt sich unter Einstellungen → Uploads abschalten + const secrets = prefs.uploadSecretScan ? await scanForSecrets(files) : new Map(); + for (const f of files) { + const reason = secrets.get(f.localFile); + if (reason) f.secret = reason; + } + const ignoredEntries = [ + ...skipped.filter(s => s.error === 'gitignore').map(s => ({ path: s.path, isDir: isDirSafe(s.path) })), + ...(ignored || []) + ]; + return { + ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size, + previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan + }; +} + +function isDirSafe(p) { + try { return fs.statSync(p).isDirectory(); } catch (_) { return false; } +} + +/* ---------------------------------------------------------------- + Server-Diagnose + ---------------------------------------------------------------- */ +const SERVER_HINT_COMMANDS = 'docker logs --tail 300 gitea 2>&1 | grep -iE "pack|upload-pack|error|killed"\ndf -h\nfree -m'; + +async function probeFamily(ctx, family) { + const lib = ctx.base.startsWith('https:') ? https : http; + const agent = new lib.Agent({ family, keepAlive: false }); + const started = Date.now(); + try { + const res = await axios.get(`${ctx.apiBase}/version`, { + httpAgent: agent, httpsAgent: agent, timeout: 8000, validateStatus: () => true + }); + return { ok: res.status < 500, ms: Date.now() - started, status: res.status }; + } catch (e) { + return { ok: false, error: e.code || e.message }; + } finally { + agent.destroy(); + } +} + +async function runDiagnosis(data, deps) { + const checks = []; + const add = (label, status, detail, hint = null) => checks.push({ label, status, detail, hint }); + + let ctx; + try { + ctx = giteaContext(deps.readCredentials); + add('Zugangsdaten', 'ok', `Server: ${ctx.base}`); + } catch (e) { + add('Zugangsdaten', 'fail', e.message, 'In den Einstellungen Gitea-URL und Token eintragen.'); + return { ok: true, checks }; + } + + // Namensauflösung und Erreichbarkeit pro Adressfamilie + const host = new URL(ctx.base).hostname.replace(/^\[|\]$/g, ''); + let families = []; + if (net.isIP(host)) { + families = [net.isIP(host)]; + add('Namensauflösung', 'ok', `IP-Adresse direkt angegeben (IPv${families[0]})`); + } else { + try { + const addrs = await dns.lookup(host, { all: true }); + const v4 = addrs.filter(a => a.family === 4).map(a => a.address); + const v6 = addrs.filter(a => a.family === 6).map(a => a.address); + if (v4.length) families.push(4); + if (v6.length) families.push(6); + add('Namensauflösung', 'ok', [v4.length ? `IPv4: ${v4.join(', ')}` : null, v6.length ? `IPv6: ${v6.join(', ')}` : null].filter(Boolean).join(' · ')); + } catch (e) { + add('Namensauflösung', 'fail', `${host} lässt sich nicht auflösen (${e.code || e.message})`, + 'DNS-Eintrag (bei ipv64.net: DynDNS-Update) prüfen und ob der Name richtig geschrieben ist.'); + return { ok: true, checks }; + } + } + const reach = {}; + for (const fam of families) reach[fam] = await probeFamily(ctx, fam); + const working = families.filter(f => reach[f].ok); + for (const fam of families) { + const r = reach[fam]; + if (r.ok) add(`Erreichbar über IPv${fam}`, 'ok', `Antwort in ${r.ms} ms`); + else add(`Erreichbar über IPv${fam}`, working.length ? 'warn' : 'fail', `Keine Verbindung (${r.error || 'HTTP ' + r.status})`, + working.length + ? `Die App weicht automatisch auf IPv${working[0]} aus. Falls IPv${fam} gewollt ist: Firewall/Portfreigabe und ${fam === 6 ? 'AAAA' : 'A'}-Eintrag prüfen.` + : 'Server läuft nicht oder Port/Firewall/Reverse-Proxy blockiert.'); + } + if (!working.length) return { ok: true, checks }; + + // API und Token + let login = null; + try { + const v = await giteaCall(ctx, 'get', '/version'); + add('Gitea-API', 'ok', `Gitea ${v && v.version ? v.version : '(Version unbekannt)'}`); + } catch (e) { + add('Gitea-API', 'fail', e.message, 'Gitea-URL prüfen, sie muss auf die Startseite von Gitea zeigen (ohne /api).'); + return { ok: true, checks }; + } + try { + const u = await giteaCall(ctx, 'get', '/user'); + login = u.login; + add('Token', 'ok', `Angemeldet als ${u.login}`); + } catch (e) { + add('Token', 'fail', e.status === 401 ? 'Token ungültig oder abgelaufen' : e.message, 'In Gitea unter Einstellungen → Anwendungen einen neuen Token erzeugen.'); + return { ok: true, checks }; + } + + // Git-Transport mit einem echten Repo testen + let owner = data && data.owner; + let repo = data && data.repo; + if (!owner || !repo) { + try { + const repos = await giteaCall(ctx, 'get', '/user/repos', { params: { limit: 1 } }); + if (repos && repos[0]) { owner = repos[0].owner.login; repo = repos[0].name; } + } catch (_) {} + } + if (!owner || !repo) { + add('Git-Transport', 'warn', 'Kein Repository zum Testen gefunden'); + } else { + const u = new URL(ctx.base); + const authUrl = `${u.protocol}//${enc(ctx.token)}@${u.host}${u.pathname.replace(/\/$/, '')}/${owner}/${repo}.git`; + const lr = await runGit(['ls-remote', '--heads', authUrl], { timeoutMs: 30000 }); + if (lr.status !== 0) { + add('Git-Anmeldung', 'fail', `ls-remote für ${owner}/${repo} fehlgeschlagen: ${redact(lr.stderr, ctx.token).trim().slice(0, 300)}`, + 'Token braucht Lese- und Schreibrechte für Repositories. Reverse-Proxy muss /info/refs durchlassen.'); + } else { + add('Git-Anmeldung', 'ok', `${owner}/${repo} erreichbar`); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-diag-')); + try { + const started = Date.now(); + let pk = await runGit(['clone', '--bare', '--depth', '1', '--filter=tree:0', authUrl, path.join(tmp, 'r.git')], { timeoutMs: 90000 }); + if (pk.status !== 0 && /filter|not recognized|unsupported/i.test(pk.stderr) && !isPackFailure(pk.stderr)) { + pk = await runGit(['clone', '--bare', '--depth', '1', authUrl, path.join(tmp, 'r2.git')], { timeoutMs: 90000 }); + } + if (pk.status === 0) { + add('Git-Pakete', 'ok', `Server liefert Pakete aus (${Date.now() - started} ms)`); + if (deps.clearGitTransportBlock && deps.clearGitTransportBlock(u.host)) { + add('Schneller Git-Weg', 'ok', 'Sperre aufgehoben: Uploads und Löschungen laufen wieder über Git statt über die langsame API.'); + } + } else if (pk.timedOut) { + add('Git-Pakete', 'fail', 'Zeitüberschreitung beim Abholen eines Pakets', 'Verbindung oder Server sehr langsam. Reverse-Proxy-Timeouts prüfen.'); + } else if (isPackFailure(pk.stderr)) { + add('Git-Pakete', 'fail', + 'Der Server bricht beim Ausliefern von Git-Paketen ab (early EOF). Deshalb laufen Uploads und Löschungen über die langsame API.', + 'Das liegt am Server. Häufige Ursachen: Datenträger voll, zu wenig RAM für git pack-objects, defektes Repo oder ein Proxy, der die Antwort abschneidet. Auf dem Server prüfen:\n' + SERVER_HINT_COMMANDS); + } else { + add('Git-Pakete', 'fail', redact(pk.stderr, ctx.token).trim().slice(0, 300)); + } + } finally { + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} + } + } + } + + add('Speicherplatz & RAM', 'info', 'Lassen sich über die Gitea-API nicht abfragen.', 'Auf dem Server prüfen mit:\ndf -h\nfree -m'); + return { ok: true, checks, login }; +} + +/* ---------------------------------------------------------------- + Lokale Änderungen + ---------------------------------------------------------------- */ +const STATUS_LABELS = { M: 'geändert', A: 'neu', D: 'gelöscht', R: 'umbenannt', C: 'kopiert', U: 'Konflikt', '?': 'neu (nicht verfolgt)', T: 'Typ geändert' }; + +function parsePorcelainZ(out) { + const parts = out.split('\0'); + const files = []; + for (let i = 0; i < parts.length; i++) { + const entry = parts[i]; + if (!entry) continue; + const x = entry[0]; + const y = entry[1]; + const file = entry.slice(3); + let from = null; + if (x === 'R' || x === 'C') { from = parts[i + 1]; i++; } + const code = x === '?' ? '?' : (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) ? 'U' : (y !== ' ' ? y : x); + files.push({ path: file, from, code, label: STATUS_LABELS[code] || code, staged: x !== ' ' && x !== '?' }); + } + return files; +} + +async function localGit(data, deps = {}) { + const folder = data && data.folder; + if (!folder || !fs.existsSync(folder)) return { ok: false, error: 'Ordner nicht gefunden' }; + const top = await runGit(['-C', folder, 'rev-parse', '--show-toplevel']); + if (top.status !== 0) return { ok: false, notRepo: true, error: 'Dieser Ordner ist kein Git-Repository.' }; + const root = path.resolve(top.stdout.trim()); + + switch (data.action) { + case 'status': { + const st = await runGit(['-C', root, 'status', '--porcelain=v1', '-z', '-uall']); + if (st.status !== 0) return { ok: false, error: st.stderr.trim() }; + const br = await runGit(['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD']); + const ahead = await runGit(['-C', root, 'rev-list', '--count', '@{u}..HEAD']); + return { + ok: true, root, + branch: br.status === 0 ? br.stdout.trim() : '(noch kein Commit)', + ahead: ahead.status === 0 ? Number(ahead.stdout.trim()) || 0 : null, + files: parsePorcelainZ(st.stdout) + }; + } + case 'diff': { + const file = String(data.file || ''); + if (!file) return { ok: false, error: 'Keine Datei' }; + const abs = path.join(root, file); + if (data.code === '?' || data.code === 'A') { + try { + const st = await fs.promises.stat(abs); + if (st.size > 300 * 1024) return { ok: true, diff: `Neue Datei (${Math.round(st.size / 1024)} KB), zu groß für die Vorschau.` }; + const buf = await fs.promises.readFile(abs); + if (buf.subarray(0, 8000).includes(0)) return { ok: true, diff: 'Neue Binärdatei' }; + return { ok: true, diff: buf.toString('utf8').split('\n').map(l => '+' + l).join('\n'), allNew: true }; + } catch (e) { + return { ok: false, error: e.message }; + } + } + const d = await runGit(['-C', root, 'diff', 'HEAD', '--no-color', '--', file]); + if (d.status !== 0) return { ok: false, error: d.stderr.trim() }; + return { ok: true, diff: d.stdout.length > 400000 ? d.stdout.slice(0, 400000) + '\n… (gekürzt)' : (d.stdout || 'Keine inhaltlichen Unterschiede (z. B. nur Zeilenenden).') }; + } + case 'commit': { + const files = Array.isArray(data.files) ? data.files.filter(Boolean) : []; + const message = String(data.message || '').trim(); + if (!files.length) return { ok: false, error: 'Keine Dateien ausgewählt' }; + if (!message) return { ok: false, error: 'Commit-Nachricht fehlt' }; + // Nur die ausgewählten Dateien in den Commit nehmen. + await runGit(['-C', root, 'reset', '-q']); + for (let i = 0; i < files.length; i += 100) { + const r = await runGit(['-C', root, 'add', '-A', '--', ...files.slice(i, i + 100)]); + if (r.status !== 0) return { ok: false, error: r.stderr.trim() }; + } + let c = await runGit(['-C', root, 'commit', '-m', message]); + if (c.status !== 0 && /tell me who you are|user\.email|user\.name/i.test(c.stderr)) { + const id = commitIdentity(deps.prefs && deps.prefs.get()); + c = await runGit(['-C', root, '-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-m', message]); + } + if (c.status !== 0) return { ok: false, error: (c.stderr || c.stdout).trim() }; + return { ok: true, output: c.stdout.trim() }; + } + case 'push': { + const p = await runGit(['-C', root, 'push', 'origin', 'HEAD'], { timeoutMs: 600000 }); + if (p.status !== 0) { + return { ok: false, error: redact(p.stderr || p.stdout, null).trim() || 'Push fehlgeschlagen', + hint: isPackFailure(p.stderr) ? 'Der Server bricht die Übertragung ab. Server-Diagnose in den Einstellungen ausführen.' : null }; + } + return { ok: true, output: p.stderr.trim() }; + } + case 'discard': { + const file = String(data.file || ''); + if (!file) return { ok: false, error: 'Keine Datei' }; + if (data.code === '?') { + await fs.promises.rm(path.join(root, file), { force: true }); + return { ok: true }; + } + const r = await runGit(['-C', root, 'checkout', 'HEAD', '--', file]); + return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr.trim() }; + } + default: + return { ok: false, error: 'Unbekannte Aktion' }; + } +} + +/* ---------------------------------------------------------------- + Pull Requests & Issues + ---------------------------------------------------------------- */ +function mapUser(u) { return u ? { login: u.login, avatar: u.avatar_url } : null; } +function mapIssue(i) { + return { + number: i.number, title: i.title, body: i.body || '', state: i.state, + user: mapUser(i.user), comments: i.comments, created: i.created_at, updated: i.updated_at, + labels: (i.labels || []).map(l => ({ name: l.name, color: l.color })), url: i.html_url, + isPull: !!i.pull_request + }; +} +function mapPull(p) { + return { + ...mapIssue(p), isPull: true, + head: p.head && p.head.label, base: p.base && p.base.label, + mergeable: p.mergeable, merged: !!p.merged + }; +} + +async function giteaCollab(data, deps) { + const ctx = giteaContext(deps.readCredentials); + const { owner, repo } = data || {}; + if (!owner || !repo) return { ok: false, error: 'Repository fehlt' }; + const rp = repoApiPath(owner, repo); + const idx = Number(data.index); + const state = ['open', 'closed', 'all'].includes(data.state) ? data.state : 'open'; + const page = Math.max(1, Number(data.page) || 1); + + switch (data.action) { + case 'listPulls': { + const list = await giteaCall(ctx, 'get', `${rp}/pulls`, { params: { state, page, limit: 30, sort: 'recentupdate' } }); + return { ok: true, items: (list || []).map(mapPull) }; + } + case 'listIssues': { + const list = await giteaCall(ctx, 'get', `${rp}/issues`, { params: { state, page, limit: 30, type: 'issues' } }); + return { ok: true, items: (list || []).map(mapIssue) }; + } + case 'get': { + const item = data.isPull + ? mapPull(await giteaCall(ctx, 'get', `${rp}/pulls/${idx}`)) + : mapIssue(await giteaCall(ctx, 'get', `${rp}/issues/${idx}`)); + const comments = await giteaCall(ctx, 'get', `${rp}/issues/${idx}/comments`); + return { + ok: true, item, + comments: (comments || []).map(c => ({ id: c.id, body: c.body || '', user: mapUser(c.user), created: c.created_at })) + }; + } + case 'comment': { + const body = String(data.body || '').trim(); + if (!body) return { ok: false, error: 'Kommentar ist leer' }; + await giteaCall(ctx, 'post', `${rp}/issues/${idx}/comments`, { data: { body } }); + return { ok: true }; + } + case 'setState': { + if (!['open', 'closed'].includes(data.state)) return { ok: false, error: 'Ungültiger Status' }; + await giteaCall(ctx, 'patch', `${rp}/issues/${idx}`, { data: { state: data.state } }); + return { ok: true }; + } + case 'merge': { + const style = ['merge', 'rebase', 'rebase-merge', 'squash'].includes(data.style) ? data.style : 'merge'; + await giteaCall(ctx, 'post', `${rp}/pulls/${idx}/merge`, { + data: { Do: style, delete_branch_after_merge: !!data.deleteBranch }, + timeout: 120000 + }); + return { ok: true }; + } + case 'createIssue': { + const title = String(data.title || '').trim(); + if (!title) return { ok: false, error: 'Titel fehlt' }; + const created = await giteaCall(ctx, 'post', `${rp}/issues`, { data: { title, body: String(data.body || '') } }); + return { ok: true, item: mapIssue(created) }; + } + default: + return { ok: false, error: 'Unbekannte Aktion' }; + } +} + +/* ---------------------------------------------------------------- + Registrierung + ---------------------------------------------------------------- */ +function wrap(fn) { + return async (event, data) => { + try { + return await fn(data || {}, event); + } catch (e) { + console.error('[features]', e && e.message ? e.message : e); + return { ok: false, error: e && e.message ? e.message : String(e) }; + } + }; +} + +function registerFeatureIpc(deps) { + const { ipcMain } = deps; + ipcMain.handle('preview-upload-paths', wrap((data) => previewUpload(data, deps))); + ipcMain.handle('run-server-diagnosis', wrap((data) => runDiagnosis(data, deps))); + ipcMain.handle('local-git', wrap((data) => localGit(data, deps))); + + // App-Einstellungen (ohne Geheimnisse) + ipcMain.handle('prefs-get', wrap(() => ({ ok: true, prefs: deps.prefs ? deps.prefs.get() : {} }))); + ipcMain.handle('prefs-set', wrap((data) => { + if (!deps.prefs) return { ok: false, error: 'Einstellungen nicht verfügbar' }; + const r = deps.prefs.set(data); + return { ok: r.rejected.length === 0, prefs: r.prefs, rejected: r.rejected, + error: r.rejected.includes('gitEmail') ? 'Die E-Mail-Adresse ist ungültig und wurde nicht übernommen.' : undefined }; + })); + + // Gesperrter schneller Git-Weg (nach "early EOF") anzeigen und von Hand freigeben + ipcMain.handle('git-transport-state', wrap(() => { + const blocks = deps.getGitTransportBlocks ? deps.getGitTransportBlocks() : {}; + return { ok: true, blocked: Object.entries(blocks).map(([host, since]) => ({ host, since: new Date(since).toISOString() })) }; + })); + ipcMain.handle('git-transport-clear', wrap((data) => { + const host = String((data && data.host) || ''); + return { ok: !!(deps.clearGitTransportBlock && deps.clearGitTransportBlock(host)) }; + })); + ipcMain.handle('gitea-collab', wrap((data) => giteaCollab(data, deps))); + + const backup = createRepoBackup({ + configPath: path.join(deps.app.getPath('userData'), 'repo-backup.json'), + getContext: () => giteaContext(deps.readCredentials), + giteaCall, + runGit, + isPackFailure, + redact + }); + ipcMain.handle('repo-backup-get-config', wrap(() => ({ ok: true, ...backup.getState() }))); + ipcMain.handle('repo-backup-set-config', wrap((data) => ({ ok: true, ...backup.setConfig(data) }))); + // Fortschritt an alle Fenster melden – auch bei einem geplanten Backup und für später geöffnete Ansichten + backup.onProgress((p) => { + if (deps.broadcast) deps.broadcast('repo-backup-progress', p); + // Windows-Benachrichtigung am Ende – je nach Einstellung immer, nur bei Problemen oder nie + if (!p || (p.phase !== 'done' && p.phase !== 'error') || !deps.notify) return; + const mode = backup.getState().config.notify; + const s = p.summary; + const problem = p.phase === 'error' || (s && (s.failed > 0 || s.cancelled)); + if (mode === 'never' || (mode === 'errors' && !problem)) return; + if (p.phase === 'error') deps.notify('Repo-Backup fehlgeschlagen', p.error || 'Unbekannter Fehler'); + else if (s.cancelled) deps.notify('Repo-Backup abgebrochen', `${s.ok} von ${s.total} Repos gesichert.`); + else if (s.failed) deps.notify('Repo-Backup mit Fehlern', `${s.ok} von ${s.total} gesichert, ${s.failed} fehlgeschlagen. Details in den Einstellungen unter Backup.`); + else deps.notify('Repo-Backup fertig', `${s.ok} von ${s.total} Repos gesichert.`); + }); + + // Ordner im Explorer öffnen – nur feste, bekannte Ziele, keine beliebigen Pfade aus dem Renderer + ipcMain.handle('open-known-folder', wrap(async (data) => { + const targets = { + backup: () => backup.getState().config.targetDir, + appData: () => deps.app.getPath('userData'), + logs: () => deps.app.getPath('logs') + }; + const resolve = targets[data && data.which]; + const dir = resolve && resolve(); + if (!dir) return { ok: false, error: 'Kein Ordner festgelegt.' }; + if (!fs.existsSync(dir)) return { ok: false, error: `Ordner "${dir}" nicht gefunden.` }; + const err = deps.openPath ? await deps.openPath(dir) : 'Öffnen nicht möglich'; + return err ? { ok: false, error: err } : { ok: true, path: dir }; + })); + ipcMain.handle('repo-backup-run-now', wrap(() => backup.runNow(null))); + ipcMain.handle('repo-backup-cancel', wrap(() => backup.cancel())); + backup.startScheduler(); + + const restore = createRepoRestore({ + getContext: () => giteaContext(deps.readCredentials), + giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs, + identity: () => commitIdentity(deps.prefs && deps.prefs.get()) + }); + ipcMain.handle('repo-backup-list', wrap((data) => { + const dir = (data && data.dir) || backup.getState().config.targetDir; + if (!dir) return { ok: false, error: 'Kein Backup-Ordner festgelegt.' }; + if (!fs.existsSync(dir)) return { ok: false, error: `Backup-Ordner "${dir}" nicht gefunden.` }; + return { ok: true, dir, backups: restore.listBackups(dir) }; + })); + ipcMain.handle('repo-restore-local', wrap((data) => restore.restoreLocal(data))); + ipcMain.handle('repo-restore-gitea', wrap((data, event) => restore.restoreToGitea(data, (p) => { + try { event.sender.send('repo-restore-progress', p); } catch (_) {} + }))); + return { backup, restore }; +} + +module.exports = { registerFeatureIpc, parsePorcelainZ }; diff --git a/src/features/gitignoreTemplates.js b/src/features/gitignoreTemplates.js new file mode 100644 index 0000000..4c4d916 --- /dev/null +++ b/src/features/gitignoreTemplates.js @@ -0,0 +1,70 @@ +// Vorlagen für die .gitignore, die per Knopf in ein Repo geschrieben werden. + +const GENERAL = `# Betriebssystem / Editor +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ + +# Zugangsdaten und Logs +.env +.env.* +*.pem +*.key +*.log +`; + +const TEMPLATES = { + node: { + label: 'Node.js', + content: `# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm/ +.cache/ +` + }, + dotnet: { + label: '.NET', + content: `# .NET +bin/ +obj/ +.vs/ +*.user +*.suo +*.nupkg +` + }, + electron: { + label: 'Electron', + content: `# Electron +dist/ +out/ +release/ +*.exe +*.blockmap +*.sha256 +` + } +}; + +function listTemplates() { + return Object.entries(TEMPLATES).map(([key, t]) => ({ key, label: t.label })); +} + +// Baut die Datei aus dem allgemeinen Teil und den gewählten Vorlagen (unbekannte Schlüssel entfallen). +function buildGitignore(keys) { + const parts = [GENERAL]; + const seen = new Set(); + for (const key of keys || []) { + if (seen.has(key) || !Object.prototype.hasOwnProperty.call(TEMPLATES, key)) continue; + seen.add(key); + parts.push(TEMPLATES[key].content); + } + return parts.join('\n'); +} + +module.exports = { listTemplates, buildGitignore }; diff --git a/src/features/secretScan.js b/src/features/secretScan.js new file mode 100644 index 0000000..026679e --- /dev/null +++ b/src/features/secretScan.js @@ -0,0 +1,75 @@ +// src/features/secretScan.js +// Erkennt Dateien, die Zugangsdaten enthalten oder selbst welche sind, bevor sie hochgeladen werden. +// Geprüft wird der Dateiname und bei kleinen Textdateien der Inhalt. + +const fs = require('fs'); +const path = require('path'); + +// Vorlagen wie ".env.example" sind gewollt und werden nicht gemeldet. +const TEMPLATE_SUFFIX = /\.(example|sample|template|dist|defaults?)$/i; + +const NAME_RULES = [ + [/^\.env(\..+)?$/i, 'Umgebungsvariablen (.env) enthalten meist Passwörter und Tokens'], + [/\.(keystore|jks|p12|pfx)$/i, 'Signatur-Keystore: damit können fremde App-Updates signiert werden'], + [/\.(pem|key|ppk)$/i, 'Privater Schlüssel oder Zertifikat'], + [/^id_(rsa|dsa|ecdsa|ed25519)$/i, 'Privater SSH-Schlüssel'], + [/^google-services\.json$/i, 'Firebase-Konfiguration mit API-Schlüssel'], + [/^GoogleService-Info\.plist$/i, 'Firebase-Konfiguration (iOS) mit API-Schlüssel'], + [/^(credentials|secrets?|service[-_]?account.*)\.json$/i, 'Zugangsdaten-Datei'], + [/^(keystore|signing)\.properties$/i, 'Passwörter für den Signatur-Keystore'], + [/^local\.properties$/i, 'Lokale Einstellungen, oft mit Pfaden und Signatur-Passwörtern'], + [/^(\.npmrc|\.pypirc|\.netrc|\.git-credentials)$/i, 'Zugangsdaten für Paketdienste oder Git'] +]; + +const CONTENT_RULES = [ + [/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/, 'enthält einen privaten Schlüssel'], + [/\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/, 'enthält einen GitHub-Token'], + [/\bglpat-[A-Za-z0-9_-]{20,}\b/, 'enthält einen GitLab-Token'], + [/\bAKIA[0-9A-Z]{16}\b/, 'enthält einen AWS-Zugangsschlüssel'], + [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, 'enthält einen Slack-Token'], + [/\bsk_live_[A-Za-z0-9]{20,}\b/, 'enthält einen Stripe-Live-Schlüssel'], + [/\bAIza[0-9A-Za-z_-]{35}\b/, 'enthält einen Google-API-Schlüssel'], + [/\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/, 'enthält einen Telegram-Bot-Token'], + [/discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]{20,}/i, 'enthält einen Discord-Webhook'], + [/\b[MNO][A-Za-z\d_-]{23,27}\.[\w-]{6}\.[\w-]{27,40}\b/, 'enthält einen Discord-Bot-Token'] +]; + +const MAX_SCAN_BYTES = 512 * 1024; +const MAX_SCAN_FILES = 3000; + +function checkName(fileName) { + if (TEMPLATE_SUFFIX.test(fileName)) return null; + for (const [re, reason] of NAME_RULES) if (re.test(fileName)) return reason; + return null; +} + +async function checkContent(filePath, size) { + if (size > MAX_SCAN_BYTES || size === 0) return null; + let buf; + try { buf = await fs.promises.readFile(filePath); } catch (_) { return null; } + // Binärdateien überspringen + if (buf.subarray(0, 8000).includes(0)) return null; + const text = buf.toString('utf8'); + for (const [re, reason] of CONTENT_RULES) if (re.test(text)) return reason; + return null; +} + +/** + * @param {Array<{localFile: string, size: number}>} files + * @returns {Promise>} localFile -> Grund + */ +async function scanForSecrets(files) { + const found = new Map(); + let scanned = 0; + for (const f of files) { + const byName = checkName(path.basename(f.localFile)); + if (byName) { found.set(f.localFile, byName); continue; } + if (scanned >= MAX_SCAN_FILES) continue; + scanned++; + const byContent = await checkContent(f.localFile, f.size); + if (byContent) found.set(f.localFile, byContent); + } + return found; +} + +module.exports = { scanForSecrets, checkName }; diff --git a/src/git/apiHandler.js b/src/git/apiHandler.js index 7a97680..a56cfea 100644 --- a/src/git/apiHandler.js +++ b/src/git/apiHandler.js @@ -7,14 +7,16 @@ const fs = require('fs'); const http = require('http'); const https = require('https'); -// IPv4 bevorzugen – verhindert ETIMEDOUT wenn der Hostname nur per IPv6 erreichbar wäre -// oder Node.js fälschlicherweise IPv6 vorranging versucht. -const ipv4HttpAgent = new http.Agent({ family: 4, keepAlive: true }); -const ipv4HttpsAgent = new https.Agent({ family: 4, keepAlive: true }); +// Dual-Stack: IPv4 und IPv6 werden unterstützt. autoSelectFamily (Happy Eyeballs) +// probiert beide Adressfamilien, damit weder reine IPv6- noch reine IPv4-Server +// in einen Timeout laufen. +const agentOptions = { keepAlive: true, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 }; +const dualStackHttpAgent = new http.Agent(agentOptions); +const dualStackHttpsAgent = new https.Agent(agentOptions); const axiosInstance = axios.create({ - httpAgent: ipv4HttpAgent, - httpsAgent: ipv4HttpsAgent, + httpAgent: dualStackHttpAgent, + httpsAgent: dualStackHttpsAgent, }); // Kurzlebiger Cache für die Default-Branch-Auflösung (HEAD -> z.B. "main"). @@ -2094,7 +2096,34 @@ async function resolveGiteaBranchName({ token, base, owner, repo, branch }) { // Ermittelt die SHAs bereits vorhandener Dateien. Die Contents-API verlangt bei einem // "update" die SHA der Vorgängerversion. Abgefragt wird pro Verzeichnis, nicht pro Datei. +// Schnellweg: der komplette Dateibaum kommt über die Git-Trees-API in wenigen Seiten +// statt einer Anfrage pro Verzeichnis. Verzeichnislisten sind außerdem je nach Server +// begrenzt und lieferten bei großen Ordnern unvollständige SHAs ("already exists"). +async function fetchShasViaTree({ token, base, owner, repo, branch }) { + const shaByPath = new Map(); + const url = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(branch)}`; + for (let page = 1; page <= 200; page++) { + const res = await axiosInstance.get(url, { + params: { recursive: true, per_page: 1000, page }, + headers: { Authorization: `token ${token}` }, + timeout: 60000 + }); + const tree = (res.data && res.data.tree) || []; + for (const node of tree) { + if (node.type === 'blob' && node.path && node.sha) shaByPath.set(node.path, node.sha); + } + const total = res.data && res.data.total_count; + if (!tree.length || !res.data.truncated || (total && shaByPath.size >= total)) break; + } + return shaByPath; +} + async function fetchExistingFileShas({ token, base, owner, repo, branch, paths }) { + try { + return await fetchShasViaTree({ token, base, owner, repo, branch }); + } catch (e) { + console.warn('[ApiUpload] Trees-API nicht nutzbar, nutze Verzeichnislisten:', e.message || e); + } const shaByPath = new Map(); const dirs = new Set(paths.map(p => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''))); @@ -2116,12 +2145,28 @@ async function fetchExistingFileShas({ token, base, owner, repo, branch, paths } } // Ein Commit mit mehreren Dateiänderungen. -async function changeGiteaFiles({ token, base, owner, repo, branch, files, message }) { +// Fehler, mit dem ein vom Nutzer abgebrochener Upload die Aufrufer-Kette verlaesst. +function createUploadCancelledError() { + const err = new Error('Upload abgebrochen'); + err.code = 'UPLOAD_CANCELLED'; + return err; +} + +function isUploadCancelledError(err) { + return !!err && err.code === 'UPLOAD_CANCELLED'; +} + +function throwIfUploadCancelled(signal) { + if (signal && signal.aborted) throw createUploadCancelledError(); +} + +async function changeGiteaFiles({ token, base, owner, repo, branch, files, message, signal }) { const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`; const res = await axiosInstance.post( endpoint, { branch, message, files }, { + signal, headers: { Authorization: `token ${token}` }, timeout: 180000, maxBodyLength: Infinity, @@ -2145,10 +2190,11 @@ function isPayloadTooLargeError(err) { * @param {function(number):void} [onProgress] Fortschritt in Prozent * @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>} */ -async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress }) { +async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress, signal }) { const base = normalizeBase(url); if (!base) throw new Error('Invalid Gitea base URL'); if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen'); + throwIfUploadCancelled(signal); const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch }); const shaByPath = await fetchExistingFileShas({ @@ -2193,7 +2239,8 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, if (current.length > 0) batches.push(current); // Ein Paket absenden; bei zu großem Body rekursiv halbieren. - const sendBatch = async (batch) => { + const sendBatch = async (batch, retried = false) => { + throwIfUploadCancelled(signal); const files = batch.map(item => { const existingSha = shaByPath.get(item.targetPath); const entry = { @@ -2206,7 +2253,7 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, }); try { - await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message }); + await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message, signal }); commits++; for (const item of batch) { uploaded.push(item.targetPath); @@ -2215,6 +2262,30 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100))); return; } catch (err) { + // Abbruch darf nicht als Dateifehler in `failed` landen. + throwIfUploadCancelled(signal); + const errText = String((err.response && err.response.data && err.response.data.message) || err.message || ''); + + // Datei existiert schon (SHA fehlte/veraltet): SHAs frisch laden und einmal wiederholen. + if (!retried && /already exists|\[sha\]|does not match|sha/i.test(errText)) { + try { + const fresh = await fetchExistingFileShas({ + token, base, owner, repo, branch: branchName, paths: batch.map(i => i.targetPath) + }); + shaByPath.clear(); + for (const [k, v] of fresh) shaByPath.set(k, v); + } catch (_) {} + return sendBatch(batch, true); + } + + // Weiterhin Fehler: Paket halbieren, damit eine einzelne Datei nicht alle anderen blockiert. + if (retried && batch.length > 1 && /already exists|sha/i.test(errText)) { + const half = Math.ceil(batch.length / 2); + await sendBatch(batch.slice(0, half), true); + await sendBatch(batch.slice(half), true); + return; + } + // Body zu groß: aufteilen und erneut versuchen. if (isPayloadTooLargeError(err) && batch.length > 1) { const half = Math.ceil(batch.length / 2); @@ -2235,15 +2306,88 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, } }; - for (const batch of batches) { - await sendBatch(batch); + for (let i = 0; i < batches.length; i++) { + await sendBatch(batches[i]); + console.log(`[ApiUpload] Paket ${i + 1}/${batches.length} fertig (${uploaded.length} hochgeladen, ${failed.length} fehlgeschlagen)`); } if (onProgress) onProgress(100); return { branch: branchName, commits, uploaded, failed }; } +// Alle Dateien eines Branches als Map Pfad -> SHA (über die Git-Trees-API). +async function listGiteaRepoFiles({ token, url, owner, repo, branch = 'HEAD' }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch }); + const files = await fetchShasViaTree({ token, base, owner, repo, branch: branchName }); + return { branch: branchName, files }; +} + +// Gemessen auf git.viper.ipv64.net: 100 Dateien ca. 3 s, 1000 Dateien über 2 min pro Commit. +// Gitea verarbeitet die Dateien eines Commits einzeln, große Pakete lohnen sich daher nicht. +const API_DELETE_FILES_PER_COMMIT = 100; + +/** + * Löscht Dateien paketweise: ein Commit pro bis zu 100 Dateien statt einem Commit pro Datei. + * Entweder `prefix` (Datei oder Ordner samt Inhalt) oder eine Liste `paths` angeben. + * + * @returns {Promise<{branch: string, deleted: number, commits: number, failed: Array<{path: string, error: string}>}>} + */ +async function deleteGiteaPathsBatched({ token, url, owner, repo, branch = 'HEAD', prefix = null, paths = null, message, onProgress }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + const { branch: branchName, files } = await listGiteaRepoFiles({ token, url: base, owner, repo, branch }); + + let targets; + if (prefix != null) { + const clean = String(prefix).replace(/^\/+|\/+$/g, ''); + targets = [...files.keys()].filter(p => p === clean || p.startsWith(`${clean}/`)); + } else { + targets = (paths || []).filter(p => files.has(p)); + } + + const failed = []; + let deleted = 0; + let commits = 0; + + const sendBatch = async (batch) => { + const ops = batch.map(p => ({ operation: 'delete', path: p, sha: files.get(p) })); + try { + await changeGiteaFiles({ + token, base, owner, repo, branch: branchName, files: ops, + message: message || (batch.length === 1 ? `Delete ${batch[0]} via Git Manager GUI` : `Delete ${batch.length} files via Git Manager GUI`) + }); + deleted += batch.length; + commits++; + } catch (err) { + // Eine problematische Datei soll nicht das ganze Paket blockieren. + if (batch.length > 1) { + const half = Math.ceil(batch.length / 2); + await sendBatch(batch.slice(0, half)); + await sendBatch(batch.slice(half)); + return; + } + const detail = (err.response && err.response.data && err.response.data.message) || err.message || String(err); + failed.push({ path: batch[0], error: String(detail) }); + } + }; + + for (let i = 0; i < targets.length; i += API_DELETE_FILES_PER_COMMIT) { + await sendBatch(targets.slice(i, i + API_DELETE_FILES_PER_COMMIT)); + if (onProgress) onProgress(Math.round(Math.min(targets.length, i + API_DELETE_FILES_PER_COMMIT) / targets.length * 100)); + console.log(`[ApiDelete] ${deleted}/${targets.length} gelöscht, ${failed.length} fehlgeschlagen`); + } + + return { branch: branchName, deleted, commits, failed, total: targets.length }; +} + module.exports = { + createUploadCancelledError, + isUploadCancelledError, + throwIfUploadCancelled, + listGiteaRepoFiles, + deleteGiteaPathsBatched, normalizeAndValidateBaseUrl, createRepoGitHub, createRepoGitea, diff --git a/src/git/gitBinary.js b/src/git/gitBinary.js new file mode 100644 index 0000000..e89c572 --- /dev/null +++ b/src/git/gitBinary.js @@ -0,0 +1,76 @@ +// src/git/gitBinary.js +// Die App bringt ihr eigenes Git mit (MinGit unter vendor/mingit bzw. resources/mingit), +// damit auf dem PC kein Git installiert sein muss. +// +// Statt jeden Aufruf umzustellen, wird der mitgelieferte Ordner beim Start vorne in den +// PATH dieses Prozesses gesetzt. spawnSync('git') und simple-git finden dadurch immer das +// eingebaute Git. simple-git akzeptiert als "binary" ohnehin keine Pfade mit Leerzeichen, +// und das Installationsverzeichnis "Git Manager GUI" enthält welche. + +const fs = require('fs'); +const path = require('path'); + +function findBundledGitDir() { + if (process.platform !== 'win32') return null; + const candidates = [ + // Installierte App: electron-builder legt extraResources neben app.asar ab. + process.resourcesPath && path.join(process.resourcesPath, 'mingit', 'cmd'), + // Entwicklung (npm start) + path.join(__dirname, '..', '..', 'vendor', 'mingit', 'cmd') + ].filter(Boolean); + return candidates.find(dir => fs.existsSync(path.join(dir, 'git.exe'))) || null; +} + +let bundledGitDir; + +function useBundledGit() { + if (bundledGitDir !== undefined) return bundledGitDir; + bundledGitDir = findBundledGitDir(); + + // Nie auf eine Eingabe im (unsichtbaren) Terminal warten: die App übergibt Tokens selbst. + process.env.GIT_TERMINAL_PROMPT = '0'; + process.env.GCM_INTERACTIVE = 'never'; + + if (bundledGitDir) { + const key = Object.keys(process.env).find(k => k.toUpperCase() === 'PATH') || 'Path'; + process.env[key] = `${bundledGitDir}${path.delimiter}${process.env[key] || ''}`; + console.log(`[Git] Nutze eingebautes Git: ${bundledGitDir}`); + } else { + console.warn('[Git] Eingebautes Git nicht gefunden, nutze Git aus dem PATH'); + } + return bundledGitDir; +} + +// Git-Identität aus den App-Einstellungen als Rückfallwert setzen. Sie landet in der +// System-Ebene (niedrigste Priorität): ein Name/eine E-Mail aus ~/.gitconfig oder aus dem +// Repo selbst gewinnt weiterhin. Dafür zeigt GIT_CONFIG_SYSTEM auf eine eigene Datei, die +// die Standard-Konfiguration des eingebauten Git einbindet. +function applyGitIdentity({ name, email } = {}, userDataDir) { + const dir = bundledGitDir === undefined ? findBundledGitDir() : bundledGitDir; + if (!dir || !userDataDir) return false; + if (!name && !email) { + delete process.env.GIT_CONFIG_SYSTEM; + try { fs.rmSync(path.join(userDataDir, 'git-system.gitconfig'), { force: true }); } catch (_) {} + return false; + } + const quote = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + const baseConfig = path.join(dir, '..', 'etc', 'gitconfig').split(path.sep).join('/'); + const lines = ['[include]', `\tpath = ${quote(baseConfig)}`, '[user]']; + if (name) lines.push(`\tname = ${quote(name)}`); + if (email) lines.push(`\temail = ${quote(email)}`); + const file = path.join(userDataDir, 'git-system.gitconfig'); + fs.mkdirSync(userDataDir, { recursive: true }); + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + process.env.GIT_CONFIG_SYSTEM = file; + return true; +} + +// Name/E-Mail für Commits, die die App selbst anlegt (Upload-Klone, Wiederherstellung). +function commitIdentity(prefs) { + return { + name: (prefs && prefs.gitName) || 'Git Manager GUI', + email: (prefs && prefs.gitEmail) || 'gui@gitmanager.local' + }; +} + +module.exports = { useBundledGit, findBundledGitDir, applyGitIdentity, commitIdentity }; diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 9522f56..67dfdd5 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -274,8 +274,16 @@ function safeWriteFile(filePath, content) { } } +// IPv6-Literale kommen aus new URL().hostname mit eckigen Klammern ("[::1]"). +// http.request/net erwarten den Host ohne Klammern. +function bareHostname(hostname) { + const h = String(hostname || ''); + return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h; +} + // ===== EXPORTS ===== module.exports = { + bareHostname, logger, normalizeBranch, isSafeBranch,