Upload via GUI (28 Dateien)

This commit is contained in:
2026-10-22 07:15:59 +00:00
parent 99d9629e25
commit 9b3aed6f0a
15 changed files with 4286 additions and 222 deletions
+902
View File
@@ -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();
})();
+354 -33
View File
@@ -52,16 +52,11 @@
<div class="tool-group tool-group--utility">
<span class="tool-group-title">Steuerung</span>
<button id="btnSettings" title="Einstellungen">⚙️ Settings</button>
<button id="btnBatchActions" title="Batch-Aktionen">🧩 Batch</button>
<button id="btnOpenActivityLog" title="Aktivitätsprotokoll">📝 Activity</button>
<button id="btnSettings" title="Einstellungen">⚙️ <span class="btn-label">Settings</span></button>
<button id="btnBatchActions" title="Batch-Aktionen">🧩 <span class="btn-label">Batch</span></button>
<button id="btnOpenActivityLog" title="Aktivitätsprotokoll">📝 <span class="btn-label">Activity</span></button>
<button id="btnUploadHistory" title="Verlauf der letzten Uploads">🕘 <span class="btn-label">Verlauf</span></button>
<button id="btnRetryQueueNow" class="secondary" title="Retry-Queue jetzt verarbeiten">🔁 Queue (0)</button>
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
</div>
<div class="toolbar-status-wrap">
<span class="status-dot" aria-hidden="true"></span>
<span id="status" class="status">Bereit</span>
</div>
</div>
</div>
@@ -73,15 +68,34 @@
<button id="btnLoadGiteaRepos" class="accent-btn" title="Projekte der gewählten Plattform laden">🌐 Load Projekte</button>
</div>
<div class="tool-group tool-group--repo">
<span class="tool-group-title">Repository</span>
<div class="tool-group tool-group--platform">
<input id="platform" type="hidden" value="gitea">
<div class="platform-switch" role="tablist" aria-label="Plattform auswählen">
<button type="button" class="platform-option active" data-platform="gitea" aria-pressed="true">Gitea</button>
<button type="button" class="platform-option" data-platform="github" aria-pressed="false">GitHub</button>
</div>
<div class="toolbar-status-wrap">
<span class="status-dot" aria-hidden="true"></span>
<span id="status" class="status">Bereit</span>
</div>
</div>
</div>
<!-- Nur in der Repo-Ansicht sichtbar: erscheint, sobald mindestens ein Knopf darin eingeblendet ist. -->
<div class="toolbar-row toolbar-row--context">
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
<div class="tool-group tool-group--repo-nav">
<span class="tool-group-title">Repository</span>
<button id="btnCommits" class="hidden" title="Commit History anzeigen">📊 Commits</button>
<button id="btnReleases" class="hidden" title="Releases anzeigen">📦 Releases</button>
<button id="btnPulls" class="hidden" title="Pull Requests ansehen, kommentieren und mergen">🔀 PRs</button>
<button id="btnIssues" class="hidden" title="Issues ansehen und kommentieren">🐞 Issues</button>
<button id="btnLocalChanges" class="hidden" title="Lokale Änderungen ansehen und committen">📝 Änderungen</button>
</div>
<div class="tool-group tool-group--repo-tools">
<span class="tool-group-title">Werkzeuge</span>
<button id="btnCleanupIgnored" class="hidden" title="Dateien entfernen, die laut .gitignore nicht ins Repo gehören">🧹 Aufräumen</button>
<button id="btnGitignoreTemplate" class="hidden" title="Standard-.gitignore (Node, .NET, Electron) im Repo anlegen">📄 .gitignore</button>
<button id="btnTrash" class="hidden" style="display:none" title=""><!-- removed --></button>
<button id="btnPurgeTrash" class="hidden" style="display:none" title=""><!-- removed --></button>
</div>
@@ -123,13 +137,42 @@
<div>
<div class="settings-eyebrow">Konfiguration</div>
<h2>⚙️ Einstellungen</h2>
<p class="settings-subtitle">Alle wichtigen Optionen auf einer Seite: Zugangsdaten, Verbindungscheck, Darstellung und Updates.</p>
<p class="settings-subtitle">Links den Bereich wählen. „Speichern“ übernimmt alle Änderungen aus allen Bereichen.</p>
</div>
</div>
</div>
<div class="settings-layout">
<div class="settings-column settings-column--left">
<div class="settings-shell">
<nav class="settings-nav" role="tablist" aria-label="Einstellungsbereiche" aria-orientation="vertical">
<button type="button" class="settings-tab is-active" role="tab" data-settings-tab="konten" aria-selected="true">
<span class="settings-tab-icon" aria-hidden="true">🔑</span>
<span class="settings-tab-text"><strong>Konten</strong><small>Gitea, GitHub, Verbindung</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="darstellung" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">🎨</span>
<span class="settings-tab-text"><strong>Darstellung</strong><small>Übersicht und Explorer</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="uploads" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">📤</span>
<span class="settings-tab-text"><strong>Uploads</strong><small>Ausschlüsse, Prüfungen</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="system" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">🖥️</span>
<span class="settings-tab-text"><strong>System</strong><small>Autostart, Version, Updates</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="backup" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">💾</span>
<span class="settings-tab-text"><strong>Backup</strong><small>Sichern, wiederherstellen</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="diagnose" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">🩺</span>
<span class="settings-tab-text"><strong>Diagnose</strong><small>Server prüfen, Fehlersuche</small></span>
</button>
</nav>
<div class="settings-pages">
<div class="settings-tabpanel" data-settings-panel="konten" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--credentials">
<div class="settings-panel-header">
<div>
@@ -170,6 +213,77 @@
</div>
</section>
<section class="settings-panel settings-panel--health">
<div class="settings-panel-header">
<div>
<h3>Verbindungsstatus</h3>
<p>Direkt sehen, ob URL, API und Auth sauber antworten.</p>
</div>
</div>
<div class="settings-health-box">
<div class="settings-health-row"><span>URL</span><strong id="healthUrl">Unbekannt</strong></div>
<div class="settings-health-row"><span>API</span><strong id="healthApi">Unbekannt</strong></div>
<div class="settings-health-row"><span>Auth</span><strong id="healthAuth">Unbekannt</strong></div>
<div class="settings-health-row"><span>Latenz</span><strong id="healthLatency">-</strong></div>
<div class="settings-health-row"><span>Server</span><strong id="healthVersion">-</strong></div>
<div class="settings-health-row"><span>Letzter Fehler</span><strong id="healthLastError">-</strong></div>
</div>
</section>
<section class="settings-panel settings-panel--profiles">
<div class="settings-panel-header">
<div>
<h3>Gitea-Server</h3>
<p>Mehrere Server oder Konten speichern und mit einem Klick wechseln. Tokens werden verschlüsselt gespeichert.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="fx-row">
<div class="input-group">
<label for="giteaProfileSelect">Gespeicherte Server</label>
<select id="giteaProfileSelect"></select>
</div>
<button id="btnGiteaProfileUse" class="settings-action-btn fx-browse-btn" type="button">⇄ Wechseln</button>
<button id="btnGiteaProfileDelete" class="settings-action-btn fx-browse-btn" type="button">🗑 Entfernen</button>
</div>
<div class="fx-row">
<div class="input-group">
<label for="giteaProfileName">Name für den aktuellen Server</label>
<input id="giteaProfileName" type="text" placeholder="z. B. Homelab oder Arbeit">
</div>
<button id="btnGiteaProfileSave" class="settings-action-btn fx-browse-btn" type="button">💾 Als Profil speichern</button>
</div>
<div id="giteaProfileHint" class="settings-inline-hint">Speichert URL und Token aus „Zugangsdaten“ unter diesem Namen.</div>
</div>
</section>
<section class="settings-panel settings-panel--identity">
<div class="settings-panel-header">
<div>
<h3>Git-Identität</h3>
<p>Name und E-Mail für Commits, die die App anlegt (Uploads, lokale Commits, Wiederherstellen).</p>
</div>
</div>
<div class="settings-backup-card">
<div class="fx-row">
<div class="input-group">
<label for="prefGitName">Name</label>
<input id="prefGitName" type="text" placeholder="z. B. M_Viper" autocomplete="off">
</div>
<div class="input-group">
<label for="prefGitEmail">E-Mail</label>
<input id="prefGitEmail" type="email" placeholder="z. B. name@example.com" autocomplete="off">
</div>
</div>
<div class="settings-inline-hint">Ist im Repo oder in deiner globalen Git-Konfiguration schon ein Name eingetragen, gilt dieser. Leer lassen = „Git Manager GUI“.</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="darstellung" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--display">
<div class="settings-panel-header">
<div>
@@ -226,39 +340,81 @@
</div>
</section>
<section class="settings-panel settings-panel--diagnostics">
<section class="settings-panel settings-panel--look">
<div class="settings-panel-header">
<div>
<h3>Diagnose</h3>
<p>Erstellt ein Diagnosepaket mit Logs, Plattform und letzten Fehlern für schnelles Debugging.</p>
<h3>Design &amp; Größe</h3>
<p>Hell oder dunkel und wie groß die Oberfläche angezeigt wird. Wird sofort angewendet.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="fx-row">
<div class="input-group">
<label for="prefTheme">Design</label>
<select id="prefTheme">
<option value="dark">Dunkel</option>
<option value="light">Hell</option>
<option value="system">Wie Windows</option>
</select>
</div>
<div class="input-group">
<label for="prefZoom">Größe</label>
<select id="prefZoom">
<option value="80">80 %</option>
<option value="90">90 %</option>
<option value="100">100 % (Standard)</option>
<option value="110">110 %</option>
<option value="125">125 %</option>
<option value="150">150 %</option>
</select>
</div>
</div>
<div class="settings-diagnostics-card">
<button id="btnCreateDiagnostics" class="settings-action-btn" type="button">🧪 Diagnose erstellen</button>
</div>
</section>
</div>
</div>
<div class="settings-column settings-column--right">
<section class="settings-panel settings-panel--health">
<div class="settings-tabpanel hidden" data-settings-panel="uploads" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--uploads">
<div class="settings-panel-header">
<div>
<h3>Verbindungsstatus</h3>
<p>Direkt sehen, ob URL, API und Auth sauber antworten.</p>
<h3>Upload-Regeln</h3>
<p>Was beim Hochladen geprüft und weggelassen wird.</p>
</div>
</div>
<div class="settings-health-box">
<div class="settings-health-row"><span>URL</span><strong id="healthUrl">Unbekannt</strong></div>
<div class="settings-health-row"><span>API</span><strong id="healthApi">Unbekannt</strong></div>
<div class="settings-health-row"><span>Auth</span><strong id="healthAuth">Unbekannt</strong></div>
<div class="settings-health-row"><span>Latenz</span><strong id="healthLatency">-</strong></div>
<div class="settings-health-row"><span>Server</span><strong id="healthVersion">-</strong></div>
<div class="settings-health-row"><span>Letzter Fehler</span><strong id="healthLastError">-</strong></div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="prefSecretScan">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🔐 Zugangsdaten erkennen</span>
<span class="settings-toggle-desc">Dateien wie .env, Keystores oder Bot-Tokens werden erkannt und vor dem Upload abgewählt.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="prefSecretScan" checked>
<span class="toggle-track"></span>
</span>
</label>
</div>
<div class="settings-backup-card">
<div class="input-group">
<label for="prefUploadPreview">Upload-Vorschau anzeigen</label>
<select id="prefUploadPreview">
<option value="always">Immer vor dem Hochladen</option>
<option value="warnings">Nur bei Zugangsdaten oder sehr vielen Dateien</option>
</select>
</div>
<div class="input-group input-group--wide">
<label for="prefUploadExcludes">Zusätzlich nie hochladen (zusätzlich zur .gitignore)</label>
<textarea id="prefUploadExcludes" class="settings-textarea" rows="6" spellcheck="false" placeholder="Ein Muster pro Zeile, z. B.&#10;*.log&#10;dist/&#10;.idea/&#10;*.psd"></textarea>
</div>
<div class="settings-inline-hint">Gleiche Schreibweise wie in einer .gitignore. Gilt für alle Projekte. node_modules, .git, .vscode und .idea werden immer weggelassen.</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="system" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--system">
<div class="settings-panel-header">
<div>
@@ -297,10 +453,124 @@
</div>
</section>
<section class="settings-panel settings-panel--behaviour">
<div class="settings-panel-header">
<div>
<h3>Verhalten</h3>
<p>Updates, Schließen-Knopf und Standardordner.</p>
</div>
</div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="prefAutoUpdate">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🔄 Beim Start nach Updates suchen</span>
<span class="settings-toggle-desc">Prüft kurz nach dem Start, ob eine neue Version bereitsteht.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="prefAutoUpdate" checked>
<span class="toggle-track"></span>
</span>
</label>
</div>
<div class="settings-backup-card">
<div class="input-group">
<label for="prefCloseAction">Beim Schließen des Fensters</label>
<select id="prefCloseAction">
<option value="auto">Automatisch: in den Tray, wenn die App mit Windows startet</option>
<option value="tray">Immer in den Tray (läuft im Hintergrund weiter)</option>
<option value="quit">Immer beenden</option>
</select>
</div>
<div class="input-group input-group--wide">
<label for="prefCloneDir">Standardordner zum Klonen</label>
<div class="fx-row">
<input id="prefCloneDir" type="text" placeholder="z. B. D:\Projekte">
<button id="btnPrefCloneDir" class="settings-action-btn fx-browse-btn" type="button">📂 Wählen</button>
</div>
</div>
<div class="settings-inline-hint">Wird beim Klonen mehrerer Repos (Batch) als Ziel vorgeschlagen.</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="backup" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--repobackup">
<div class="settings-panel-header">
<div>
<h3>Repo-Backup</h3>
<p>Alle Gitea-Repos automatisch in einen Ordner oder auf die NAS sichern.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="input-group input-group--wide">
<label for="repoBackupTarget">Backup-Ziel (Ordner oder Netzwerkfreigabe)</label>
<div class="fx-row">
<input id="repoBackupTarget" type="text" placeholder="z. B. \\NAS\backup\gitea oder D:\Backups\gitea">
<button id="btnRepoBackupBrowse" class="settings-action-btn fx-browse-btn" type="button">📂 Wählen</button>
</div>
</div>
<div class="fx-row">
<div class="input-group">
<label for="repoBackupInterval">Automatisch sichern</label>
<select id="repoBackupInterval">
<option value="off">Aus</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<div class="input-group">
<label for="repoBackupKeep">ZIP-Stände behalten</label>
<input id="repoBackupKeep" type="number" min="1" max="100" value="5">
</div>
</div>
<div class="fx-row">
<div class="input-group">
<label for="repoBackupTime">Frühestens um</label>
<input id="repoBackupTime" type="time">
</div>
<div class="input-group">
<label for="repoBackupNotify">Benachrichtigen</label>
<select id="repoBackupNotify">
<option value="errors">Nur bei Fehlern oder Abbruch</option>
<option value="always">Nach jedem Backup</option>
<option value="never">Nie</option>
</select>
</div>
</div>
<div class="input-group input-group--wide">
<label for="repoBackupExclude">Diese Repos nicht sichern</label>
<textarea id="repoBackupExclude" class="settings-textarea" rows="4" spellcheck="false" placeholder="Ein Muster pro Zeile, z. B.&#10;test-*&#10;M_Viper/alt-*&#10;*-archiv"></textarea>
</div>
<div class="settings-tools-grid">
<button id="btnRepoBackupRun" class="settings-action-btn" type="button">▶️ Jetzt sichern</button>
<button id="btnRepoBackupOpen" class="settings-action-btn" type="button">📂 Backup-Ordner öffnen</button>
</div>
<div id="repoBackupStatus" class="settings-inline-hint">Noch kein Backup gelaufen.</div>
<div class="settings-inline-hint">Automatische Backups laufen, solange die App geöffnet ist (auch im Tray).</div>
</div>
</section>
<section class="settings-panel settings-panel--restore">
<div class="settings-panel-header">
<div>
<h3>Wiederherstellen</h3>
<p>Ein gesichertes Repo zurück auf Gitea spielen oder lokal entpacken.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="settings-tools-grid">
<button id="btnRepoRestore" class="settings-action-btn" type="button">↺ Backup wiederherstellen</button>
</div>
<div class="settings-inline-hint">Zeigt alle Sicherungen im Backup-Ziel. Git-Spiegel enthalten die volle Historie, ZIP-Stände nur den Standard-Branch.</div>
</div>
</section>
<section class="settings-panel settings-panel--backup">
<div class="settings-panel-header">
<div>
<h3>Backup & Transfer</h3>
<h3>Einstellungen sichern & übertragen</h3>
<p>Einstellungen, Favoriten und Verlauf exportieren oder auf einem anderen PC importieren.</p>
</div>
</div>
@@ -314,6 +584,55 @@
</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="diagnose" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--diagnostics">
<div class="settings-panel-header">
<div>
<h3>Diagnose</h3>
<p>Diagnosepaket für die Fehlersuche erstellen oder die Verbindung zum Gitea-Server Schritt für Schritt prüfen.</p>
</div>
</div>
<div class="settings-diagnostics-card">
<button id="btnCreateDiagnostics" class="settings-action-btn" type="button">🧪 Diagnose erstellen</button>
<button id="btnServerDiagnosis" class="settings-action-btn" type="button">🩺 Server-Diagnose starten</button>
</div>
</section>
<section class="settings-panel settings-panel--transport">
<div class="settings-panel-header">
<div>
<h3>Schneller Git-Weg</h3>
<p>Bricht der Server beim Git-Transport ab, nutzt die App 24 Stunden lang die langsamere API. Hier siehst du den Stand.</p>
</div>
</div>
<div class="settings-backup-card">
<div id="gitTransportState" class="settings-inline-hint">Wird geprüft …</div>
<div class="settings-tools-grid">
<button id="btnGitTransportClear" class="settings-action-btn" type="button">🔓 Sperre aufheben</button>
</div>
</div>
</section>
<section class="settings-panel settings-panel--folders">
<div class="settings-panel-header">
<div>
<h3>Ordner</h3>
<p>Wo die App ihre Daten und Protokolle ablegt.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="settings-tools-grid">
<button id="btnOpenAppData" class="settings-action-btn" type="button">📂 App-Daten öffnen</button>
<button id="btnOpenLogs" class="settings-action-btn" type="button">📄 Protokolle öffnen</button>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
@@ -585,5 +904,7 @@
</div>
</div> <script src="renderer.js"></script>
<script src="features.js"></script>
<script src="settings-extra.js"></script>
</body>
</html>
+413 -22
View File
@@ -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,6 +3627,11 @@ 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');
@@ -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,7 +4921,7 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
try {
const sel = await window.electronAPI.selectFolder();
if (sel) {
showProgress(0, 'Upload...');
showProgress(0, 'Upload...', { cancellable: true });
const res = await window.electronAPI.uploadLocalFolderToGitea({
localFolder: sel,
owner,
@@ -4754,6 +4930,11 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
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);
});
+279
View File
@@ -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();
})();
+518
View File
@@ -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;
@@ -3950,3 +3961,510 @@ body.compact-mode .file-type-badge {
.fav-chip[draggable="true"] {
cursor: grab;
}
/* ===== 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; }
+117
View File
@@ -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-<version>.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);
}
+313
View File
@@ -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 (<ziel>/<owner>/<repo>.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 (<ziel>/<owner>/<repo>-archiv/<repo>-<zeit>.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 };
+224
View File
@@ -0,0 +1,224 @@
// src/backup/GiteaRepoRestore.js
// Stellt Sicherungen aus GiteaRepoBackup wieder her:
// - Git-Spiegel (<ziel>/<owner>/<repo>.git): lokal als Arbeitskopie klonen oder per
// "push --mirror" auf Gitea zurückspielen (neues Repo oder bestehendes überschreiben).
// - ZIP-Stand (<ziel>/<owner>/<repo>-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 "<repo>/". 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 };
+87
View File
@@ -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 };
+539
View File
@@ -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 };
+70
View File
@@ -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 };
+75
View File
@@ -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<Map<string, string>>} 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 };
+156 -12
View File
@@ -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,
+76
View File
@@ -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 };
+8
View File
@@ -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,