Files
Git-Manager-Gui/renderer/features.js
T
2026-10-22 09:13:16 +00:00

919 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
const blocked = res.blocked || [];
if (res.previewMode === 'warnings' && secrets.length === 0 && blocked.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.' }));
}
if (blocked.length) {
const list = el('div', { class: 'fx-list' });
for (const b of blocked.slice(0, 25)) {
list.append(el('div', { class: 'fx-list-row' }, [
el('span', { class: 'fx-mono', text: b.targetPath || b.path }),
el('span', { class: 'fx-muted', text: b.reason })
]));
}
m.body.append(el('div', { class: 'fx-alert fx-alert--danger' }, [
el('strong', { text: `🛡️ ${blocked.length} Datei(en) gesperrt und werden nicht hochgeladen` }),
el('div', { text: 'Dateien mit Zugangsdaten werden nie hochgeladen. Die Sperre lässt sich unter Einstellungen → Uploads abschalten.' }),
list
]));
}
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();
})();