Upload via GUI (85 Dateien)
This commit is contained in:
@@ -24,6 +24,15 @@ Verwalte deine Repositories intuitiv ohne Kommandozeile — mit Favoriten, Verla
|
||||
- 📤 **Drag & Drop Upload** — ziehe Dateien ins App-Fenster zum hochladen
|
||||
- 📥 **Drag & Drop Download** — ziehe Repos raus zum herunterladen
|
||||
- 🔄 **Rename & Move** — Dateien verwalten direkt im Editor
|
||||
- ⏹️ **Abbrechen** — Uploads, Downloads und Batch-Clones lassen sich per Knopf oder <kbd>Esc</kbd> stoppen
|
||||
- ⏱️ **Fortschritt mit MB und Restzeit** — bei großen Uploads siehst du, ob es noch läuft
|
||||
- 🔁 **Nur Fehlgeschlagene wiederholen** — nach einem Teilfehler nicht alles neu hochladen
|
||||
- ⏸️ **Pausieren und Fortsetzen** — Übertragungen anhalten, auch über das Tray-Menü
|
||||
- 🕘 **Upload-Verlauf** — die letzten 100 Uploads, filterbar nach Ergebnis und Repo, Klick öffnet das Repo, Export als CSV
|
||||
- 🛡️ **Zugangsdaten-Sperre** — `credentials.json`, `.env`, Schlüssel und Ähnliches werden auf allen Upload-Wegen nie hochgeladen
|
||||
- 🔔 **Windows-Meldung bei Upload-Ende** — wenn die App im Tray versteckt oder nicht im Vordergrund ist
|
||||
- 📄 **.gitignore anlegen** — Vorlagen für Node.js, .NET, Electron, Python, Java, PHP und Unity
|
||||
- 📦 **Release für den Updater** — Installer wählen, Prüfsumme abgleichen, Tag prüfen, `.sha256` mit hochladen
|
||||
|
||||
### 📊 Versionscontrol
|
||||
- 📊 **Commit-History** mit Filter nach Autor und Nachricht
|
||||
@@ -131,7 +140,7 @@ npm start
|
||||
| <kbd>Ctrl</kbd>+<kbd>K</kbd> | Repo-Suche fokussieren |
|
||||
| <kbd>Ctrl</kbd>+<kbd>S</kbd> | Datei im Editor speichern |
|
||||
| <kbd>Ctrl</kbd>+<kbd>F</kbd> | Im Editor suchen |
|
||||
| <kbd>Escape</kbd> | Suche schließen |
|
||||
| <kbd>Escape</kbd> | Suche schließen · laufenden Upload/Download abbrechen (wenn kein Dialog offen ist) |
|
||||
| <kbd>Rechtsklick</kbd> | Kontextmenü (Repos & Dateien) |
|
||||
| <kbd>Drag</kbd> raus | Datei/Repo herunterladen |
|
||||
| <kbd>Drag</kbd> rein | Datei hochladen |
|
||||
@@ -176,6 +185,19 @@ Fehler gefunden oder Idee für neue Features?
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
### v2.2.1
|
||||
- ⏹️ Abbrechen-Knopf (und <kbd>Esc</kbd>) für Uploads, Ordner-Downloads und Batch-Clones
|
||||
- ⏸️ Pausieren und Fortsetzen im Fortschrittsbalken und im Tray-Menü
|
||||
- 🔁 „Nur Fehlgeschlagene wiederholen“ bei Teilfehlern von Drag-&-Drop- und Ordner-Uploads
|
||||
- ⏱️ Fortschrittsanzeige mit übertragenen MB und geschätzter Restzeit
|
||||
- 🕘 Upload-Verlauf mit Filter, Klick zum Öffnen des Repos und CSV-Export
|
||||
- 🛡️ Feste Sperre für Dateien mit Zugangsdaten auf allen Upload-Wegen (abschaltbar)
|
||||
- 🔔 Windows-Meldung, wenn ein Upload endet, während die App nicht vorn ist
|
||||
- 📄 `.gitignore`-Vorlagen im Repo, 📦 Release-Assistent für den Updater
|
||||
- 🎨 Aufgeräumte Toolbar mit eigener Repo-Leiste, neues Tray-Menü mit Hover-Markierung
|
||||
- ✨ Startanimation wie bei AeroMC: das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (abschaltbar, beim Autostart im Tray nie)
|
||||
- 🧪 `npm run test:ui`: Oberflächen-Testlauf
|
||||
|
||||
### v2.1.3 (13. Mai 2026)
|
||||
- ✨ Projektname wird korrekt ausgeblendet bei Wechsel zur Übersicht
|
||||
- 🐛 Bug-Fix: Zurück-Button setzt Gravur korrekt zurück
|
||||
|
||||
@@ -2,9 +2,17 @@ const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { listTemplates, buildGitignore } = require('../src/features/gitignoreTemplates.js');
|
||||
|
||||
test('listTemplates nennt Node, .NET und Electron', () => {
|
||||
const keys = listTemplates().map(t => t.key);
|
||||
assert.deepStrictEqual(keys, ['node', 'dotnet', 'electron']);
|
||||
test('listTemplates nennt alle Vorlagen mit Beschriftung', () => {
|
||||
const list = listTemplates();
|
||||
assert.deepStrictEqual(list.map(t => t.key), ['node', 'dotnet', 'electron', 'python', 'java', 'php', 'unity']);
|
||||
assert.ok(list.every(t => t.label));
|
||||
});
|
||||
|
||||
test('buildGitignore: Vorlagen für Python, Java, PHP und Unity enthalten ihre Kerneinträge', () => {
|
||||
assert.match(buildGitignore(['python']), /__pycache__\//);
|
||||
assert.match(buildGitignore(['java']), /target\//);
|
||||
assert.match(buildGitignore(['php']), /vendor\//);
|
||||
assert.match(buildGitignore(['unity']), /\[Ll\]ibrary\//);
|
||||
});
|
||||
|
||||
test('buildGitignore enthält immer den allgemeinen Teil', () => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createPauseGate } = require('../src/features/pauseGate.js');
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
test('Ohne Pause läuft wait() sofort durch', async () => {
|
||||
const gate = createPauseGate();
|
||||
await gate.wait();
|
||||
await gate.wait(new AbortController().signal);
|
||||
assert.strictEqual(gate.isPaused(), false);
|
||||
});
|
||||
|
||||
test('Während der Pause bleibt wait() stehen und läuft nach resume() weiter', async () => {
|
||||
const gate = createPauseGate();
|
||||
gate.pause();
|
||||
let done = false;
|
||||
const p = gate.wait().then(() => { done = true; });
|
||||
await tick();
|
||||
assert.strictEqual(done, false);
|
||||
assert.strictEqual(gate.waitingCount(), 1);
|
||||
gate.resume();
|
||||
await p;
|
||||
assert.strictEqual(done, true);
|
||||
assert.strictEqual(gate.waitingCount(), 0);
|
||||
});
|
||||
|
||||
test('resume() gibt alle Wartenden frei', async () => {
|
||||
const gate = createPauseGate();
|
||||
gate.pause();
|
||||
const all = Promise.all([gate.wait(), gate.wait(), gate.wait()]);
|
||||
await tick();
|
||||
assert.strictEqual(gate.waitingCount(), 3);
|
||||
gate.resume();
|
||||
await all;
|
||||
});
|
||||
|
||||
test('Abbruch während der Pause löst wait() mit dem Abbruchfehler aus', async () => {
|
||||
const gate = createPauseGate();
|
||||
gate.pause();
|
||||
const c = new AbortController();
|
||||
const p = gate.wait(c.signal);
|
||||
await tick();
|
||||
c.abort();
|
||||
await assert.rejects(p, (e) => e.code === 'UPLOAD_CANCELLED');
|
||||
assert.strictEqual(gate.waitingCount(), 0);
|
||||
});
|
||||
|
||||
test('Bereits abgebrochenes Signal wirft sofort, auch ohne Pause', async () => {
|
||||
const gate = createPauseGate();
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
await assert.rejects(gate.wait(c.signal), (e) => e.code === 'UPLOAD_CANCELLED');
|
||||
});
|
||||
|
||||
test('onChange meldet nur echte Wechsel', () => {
|
||||
const states = [];
|
||||
const gate = createPauseGate({ onChange: (p) => states.push(p) });
|
||||
assert.strictEqual(gate.pause(), true);
|
||||
assert.strictEqual(gate.pause(), false);
|
||||
assert.strictEqual(gate.resume(), true);
|
||||
assert.strictEqual(gate.resume(), false);
|
||||
assert.deepStrictEqual(states, [true, false]);
|
||||
});
|
||||
|
||||
test('Ein abgebrochener Wartender blockiert die übrigen nicht', async () => {
|
||||
const gate = createPauseGate();
|
||||
gate.pause();
|
||||
const c = new AbortController();
|
||||
const a = gate.wait(c.signal).catch((e) => e.code);
|
||||
const b = gate.wait();
|
||||
await tick();
|
||||
c.abort();
|
||||
assert.strictEqual(await a, 'UPLOAD_CANCELLED');
|
||||
gate.resume();
|
||||
await b;
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const {
|
||||
parseVersionFromFileName, compareVersions, parseSidecar, checkNewTag, buildReleaseBody
|
||||
} = require('../src/features/releaseAssist.js');
|
||||
|
||||
const HASH = '5ca7f0c680b6d73a92eadb5e90f8164f7f3fa52071ffaac54803b0e5ec95f35d';
|
||||
|
||||
test('Version aus dem Dateinamen', () => {
|
||||
assert.strictEqual(parseVersionFromFileName('Git-Manager-GUI-Setup-2.2.1.exe'), '2.2.1');
|
||||
assert.strictEqual(parseVersionFromFileName('Git Manager GUI Setup 2.1.10.exe'), '2.1.10');
|
||||
assert.strictEqual(parseVersionFromFileName('setup.exe'), null);
|
||||
assert.strictEqual(parseVersionFromFileName(null), null);
|
||||
});
|
||||
|
||||
test('Versionen werden numerisch verglichen, nicht als Text', () => {
|
||||
assert.strictEqual(compareVersions('2.2.1', '2.2.0'), 1);
|
||||
assert.strictEqual(compareVersions('v2.10.0', 'v2.9.9'), 1);
|
||||
assert.strictEqual(compareVersions('2.2.1', 'v2.2.1'), 0);
|
||||
assert.strictEqual(compareVersions('2.1.9', '2.2.0'), -1);
|
||||
assert.deepStrictEqual(['v2.10.0', 'v2.9.0', 'v2.2.1'].sort(compareVersions), ['v2.2.1', 'v2.9.0', 'v2.10.0']);
|
||||
});
|
||||
|
||||
test('Sidecar-Datei: Hash wird gefunden, auch mit Großbuchstaben', () => {
|
||||
assert.strictEqual(parseSidecar(`${HASH} Git-Manager-GUI-Setup-2.2.1.exe\n`), HASH);
|
||||
assert.strictEqual(parseSidecar(HASH.toUpperCase()), HASH);
|
||||
assert.strictEqual(parseSidecar('kein Hash'), null);
|
||||
assert.strictEqual(parseSidecar('abc123'), null);
|
||||
});
|
||||
|
||||
test('Neuer Tag: Format, Duplikat und Reihenfolge werden geprüft', () => {
|
||||
const releases = [{ tag_name: 'v2.2.0' }, { tag_name: 'v2.1.9' }];
|
||||
assert.strictEqual(checkNewTag('v2.2.1', releases).ok, true);
|
||||
assert.strictEqual(checkNewTag('2.2.1', releases).ok, true);
|
||||
assert.strictEqual(checkNewTag('v2.10.0', releases).ok, true);
|
||||
|
||||
assert.match(checkNewTag('release-2', releases).reason, /Version wie v2\.2\.1/);
|
||||
assert.match(checkNewTag('v2.2.0', releases).reason, /gibt es schon/);
|
||||
assert.match(checkNewTag('V2.2.0', releases).reason, /gibt es schon/);
|
||||
assert.match(checkNewTag('v2.1.5', releases).reason, /nicht neuer/);
|
||||
});
|
||||
|
||||
test('Neuer Tag: ohne vorhandene Releases ist jede Version erlaubt', () => {
|
||||
assert.strictEqual(checkNewTag('v1.0.0', []).ok, true);
|
||||
assert.strictEqual(checkNewTag('v1.0.0', null).ok, true);
|
||||
assert.strictEqual(checkNewTag('v1.0.0', [{ tag_name: 'nightly' }]).ok, true);
|
||||
});
|
||||
|
||||
test('Release-Text: Prüfsummen im Format, das der Updater liest', () => {
|
||||
const body = buildReleaseBody('## Neu\n\n- Abbrechen\n', [{ name: 'Git-Manager-GUI-Setup-2.2.1.exe', sha256: HASH.toUpperCase() }]);
|
||||
assert.match(body, /^## Neu/);
|
||||
assert.ok(body.includes(`- Git-Manager-GUI-Setup-2.2.1.exe: ${HASH}`));
|
||||
|
||||
// genau so liest updater.js die Zeile
|
||||
const line = body.split('\n').find((l) => l.toLowerCase().includes('git-manager-gui-setup-2.2.1.exe') && /\b[a-f0-9]{64}\b/i.test(l));
|
||||
assert.ok(line);
|
||||
assert.strictEqual(line.match(/\b([a-fA-F0-9]{64})\b/)[1], HASH);
|
||||
});
|
||||
|
||||
test('Release-Text: ungültige Prüfsummen und leere Notizen', () => {
|
||||
assert.strictEqual(buildReleaseBody('Nur Text', [{ name: 'a.exe', sha256: 'zu kurz' }]), 'Nur Text');
|
||||
assert.strictEqual(buildReleaseBody('', []), '');
|
||||
assert.ok(buildReleaseBody('', [{ name: 'a.exe', sha256: HASH }]).startsWith('### Prüfsummen'));
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { remainingMs, centeredBounds, MIN_SPIN_MS } = require('../src/features/splash.js');
|
||||
|
||||
test('Startanimation: wurde das Logo nie gezeigt, gibt es keine Wartezeit', () => {
|
||||
assert.strictEqual(remainingMs(0, 5000, 1600), 0);
|
||||
});
|
||||
|
||||
test('Startanimation: das Logo dreht sich mindestens minMs', () => {
|
||||
assert.strictEqual(remainingMs(1000, 1300, 1600), 1300);
|
||||
assert.strictEqual(remainingMs(1000, 1000, 1600), 1600);
|
||||
});
|
||||
|
||||
test('Startanimation: nach Ablauf von minMs wird nicht mehr gewartet', () => {
|
||||
assert.strictEqual(remainingMs(1000, 2600, 1600), 0);
|
||||
assert.strictEqual(remainingMs(1000, 9000, 1600), 0);
|
||||
});
|
||||
|
||||
test('Startanimation: Mindestdauer ist wie bei AeroMC 1,6 s', () => {
|
||||
assert.strictEqual(MIN_SPIN_MS, 1600);
|
||||
});
|
||||
|
||||
test('Startanimation: Fenster wird im Arbeitsbereich zentriert', () => {
|
||||
const b = centeredBounds({ x: 0, y: 0, width: 1920, height: 1040 }, 1200, 820);
|
||||
assert.deepStrictEqual(b, { x: 360, y: 110, width: 1200, height: 820 });
|
||||
});
|
||||
|
||||
test('Startanimation: Arbeitsbereich mit Versatz und mehrere Monitore', () => {
|
||||
const b = centeredBounds({ x: -1920, y: 40, width: 1920, height: 1000 }, 1200, 820);
|
||||
assert.deepStrictEqual(b, { x: -1560, y: 130, width: 1200, height: 820 });
|
||||
});
|
||||
|
||||
test('Startanimation: auf kleinen Bildschirmen wird das Fenster begrenzt', () => {
|
||||
const b = centeredBounds({ x: 0, y: 0, width: 1024, height: 700 }, 1200, 820);
|
||||
assert.deepStrictEqual(b, { x: 0, y: 0, width: 1024, height: 700 });
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { describeTransfer } = require('../src/features/transferNotice.js');
|
||||
|
||||
test('Erfolg nennt Repo und Dateizahl', () => {
|
||||
assert.deepStrictEqual(describeTransfer({ status: 'ok', repo: 'a/b', files: 3 }), { title: 'Upload fertig', body: 'a/b: 3 Dateien hochgeladen.' });
|
||||
assert.match(describeTransfer({ status: 'ok', repo: 'a/b', files: 1 }).body, /1 Datei hochgeladen/);
|
||||
});
|
||||
|
||||
test('Erfolg ohne bekannte Dateizahl bleibt lesbar', () => {
|
||||
assert.strictEqual(describeTransfer({ status: 'ok', repo: 'a/b', files: null }).body, 'a/b: Upload hochgeladen.');
|
||||
});
|
||||
|
||||
test('Abgebrochene Uploads melden nichts', () => {
|
||||
assert.strictEqual(describeTransfer({ status: 'abgebrochen', repo: 'a/b' }), null);
|
||||
assert.strictEqual(describeTransfer(null), null);
|
||||
});
|
||||
|
||||
test('Fehler zeigt fehlgeschlagene Dateien oder den Fehlertext', () => {
|
||||
assert.strictEqual(describeTransfer({ status: 'fehler', repo: 'a/b', failed: 2 }).title, 'Upload fehlgeschlagen');
|
||||
assert.match(describeTransfer({ status: 'fehler', repo: 'a/b', failed: 2 }).body, /2 Dateien fehlgeschlagen/);
|
||||
assert.match(describeTransfer({ status: 'fehler', repo: 'a/b', error: 'Server nicht erreichbar' }).body, /Server nicht erreichbar/);
|
||||
assert.match(describeTransfer({ status: 'fehler', repo: '' }).body, /Unbekannter Fehler/);
|
||||
});
|
||||
|
||||
test('Langer Fehlertext wird gekürzt', () => {
|
||||
assert.ok(describeTransfer({ status: 'fehler', repo: 'a/b', error: 'x'.repeat(500) }).body.length < 200);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { buildTrayTemplate, trayTooltip } = require('../src/features/trayMenu.js');
|
||||
|
||||
const noop = () => {};
|
||||
const base = { version: '2.2.1', onOpen: noop, onHistory: noop, onCancel: noop, onAutostart: noop, onQuit: noop };
|
||||
const labels = (tpl) => tpl.filter(i => i.label).map(i => i.label);
|
||||
const find = (tpl, label) => tpl.find(i => i.label === label);
|
||||
|
||||
test('Tooltip nennt Version und zeigt eine laufende Übertragung', () => {
|
||||
assert.strictEqual(trayTooltip('2.2.1', 0), 'Git Manager Explorer Pro v2.2.1');
|
||||
assert.match(trayTooltip('2.2.1', 2), /Übertragung läuft/);
|
||||
assert.match(trayTooltip('2.2.1', 1, true), /Übertragung pausiert/);
|
||||
assert.strictEqual(trayTooltip('2.2.1', 0, true), 'Git Manager Explorer Pro v2.2.1');
|
||||
});
|
||||
|
||||
test('Menü im Ruhezustand: Kopfzeile, Status, Aktionen; Abbrechen gesperrt', () => {
|
||||
const tpl = buildTrayTemplate(base);
|
||||
assert.deepStrictEqual(labels(tpl), [
|
||||
'Git Manager Explorer Pro v2.2.1',
|
||||
'Keine laufende Übertragung',
|
||||
'Fenster öffnen',
|
||||
'Upload-Verlauf',
|
||||
'Übertragung pausieren',
|
||||
'Laufende Übertragung abbrechen',
|
||||
'Mit Windows starten',
|
||||
'Beenden'
|
||||
]);
|
||||
assert.strictEqual(find(tpl, 'Laufende Übertragung abbrechen').enabled, false);
|
||||
assert.strictEqual(tpl[0].enabled, false);
|
||||
});
|
||||
|
||||
test('Menü bei laufender Übertragung: Zähler, Abbrechen freigeschaltet', () => {
|
||||
const one = buildTrayTemplate({ ...base, active: 1 });
|
||||
assert.ok(labels(one).includes('1 Übertragung läuft'));
|
||||
assert.strictEqual(find(one, 'Laufende Übertragung abbrechen').enabled, true);
|
||||
assert.ok(labels(buildTrayTemplate({ ...base, active: 3 })).includes('3 Übertragungen laufen'));
|
||||
});
|
||||
|
||||
test('Retry-Queue erscheint nur, wenn etwas wartet', () => {
|
||||
assert.ok(!labels(buildTrayTemplate(base)).some(l => l.startsWith('Retry-Queue')));
|
||||
assert.ok(labels(buildTrayTemplate({ ...base, queued: 4 })).includes('Retry-Queue: 4 wartend'));
|
||||
});
|
||||
|
||||
test('Autostart ist ein Häkchen und spiegelt den Zustand', () => {
|
||||
assert.strictEqual(find(buildTrayTemplate({ ...base, autostart: true }), 'Mit Windows starten').checked, true);
|
||||
const off = find(buildTrayTemplate(base), 'Mit Windows starten');
|
||||
assert.strictEqual(off.type, 'checkbox');
|
||||
assert.strictEqual(off.checked, false);
|
||||
});
|
||||
|
||||
test('Einträge rufen die übergebenen Aktionen auf', () => {
|
||||
const calls = [];
|
||||
const tpl = buildTrayTemplate({
|
||||
...base,
|
||||
active: 1,
|
||||
onOpen: () => calls.push('open'),
|
||||
onHistory: () => calls.push('history'),
|
||||
onCancel: () => calls.push('cancel'),
|
||||
onQuit: () => calls.push('quit')
|
||||
});
|
||||
for (const l of ['Fenster öffnen', 'Upload-Verlauf', 'Laufende Übertragung abbrechen', 'Beenden']) find(tpl, l).click();
|
||||
assert.deepStrictEqual(calls, ['open', 'history', 'cancel', 'quit']);
|
||||
});
|
||||
|
||||
const { serializeTemplate, runAction } = require('../src/features/trayMenu.js');
|
||||
const { computePosition } = require('../src/features/trayPopup.js');
|
||||
|
||||
test('serializeTemplate: nur Daten, keine Funktionen, Trenner bleiben erhalten', () => {
|
||||
const plain = serializeTemplate(buildTrayTemplate({ ...base, active: 1, autostart: true }));
|
||||
assert.ok(plain.every(i => Object.values(i).every(v => typeof v !== 'function')));
|
||||
assert.strictEqual(plain.filter(i => i.type === 'separator').length, 3);
|
||||
const cancel = plain.find(i => i.id === 'cancel');
|
||||
assert.deepStrictEqual(cancel, { id: 'cancel', label: 'Laufende Übertragung abbrechen', type: 'normal', enabled: true, checked: false });
|
||||
assert.strictEqual(plain.find(i => i.id === 'autostart').checked, true);
|
||||
assert.doesNotThrow(() => JSON.stringify(plain));
|
||||
});
|
||||
|
||||
test('runAction: führt Aktionen per id aus, Häkchen wird umgeschaltet', () => {
|
||||
const calls = [];
|
||||
const tpl = buildTrayTemplate({
|
||||
...base,
|
||||
active: 1,
|
||||
autostart: false,
|
||||
onOpen: () => calls.push('open'),
|
||||
onAutostart: (item) => calls.push(`autostart:${item.checked}`)
|
||||
});
|
||||
assert.strictEqual(runAction(tpl, 'open'), true);
|
||||
assert.strictEqual(runAction(tpl, 'autostart'), true);
|
||||
assert.deepStrictEqual(calls, ['open', 'autostart:true']);
|
||||
});
|
||||
|
||||
test('runAction: gesperrte oder unbekannte Einträge tun nichts', () => {
|
||||
let cancelled = false;
|
||||
const tpl = buildTrayTemplate({ ...base, active: 0, onCancel: () => { cancelled = true; } });
|
||||
assert.strictEqual(runAction(tpl, 'cancel'), false);
|
||||
assert.strictEqual(runAction(tpl, 'gibt-es-nicht'), false);
|
||||
assert.strictEqual(runAction(tpl, 'header'), false);
|
||||
assert.strictEqual(cancelled, false);
|
||||
});
|
||||
|
||||
test('computePosition: Menü bleibt im sichtbaren Bereich', () => {
|
||||
const wa = { x: 0, y: 0, width: 1920, height: 1040 };
|
||||
// Zeiger in der Taskleiste unten rechts: Menü sitzt direkt über der Taskleiste, nicht außerhalb rechts
|
||||
const bottomRight = computePosition({ x: 1900, y: 1060 }, wa, 286, 300);
|
||||
assert.strictEqual(bottomRight.y, 1040 - 300 - 8);
|
||||
assert.ok(bottomRight.x + 286 <= 1920 - 8);
|
||||
// Zeiger weit links: nicht links aus dem Bildschirm
|
||||
assert.ok(computePosition({ x: 2, y: 1060 }, wa, 286, 300).x >= 8);
|
||||
// Taskleiste oben
|
||||
assert.strictEqual(computePosition({ x: 500, y: 10 }, { ...wa, y: 40, height: 1000 }, 286, 300).y, 48);
|
||||
});
|
||||
|
||||
test('Pause: Eintrag wechselt zwischen pausieren und fortsetzen, nur bei laufender Übertragung wählbar', () => {
|
||||
const idle = buildTrayTemplate(base);
|
||||
assert.strictEqual(find(idle, 'Übertragung pausieren').enabled, false);
|
||||
|
||||
const running = buildTrayTemplate({ ...base, active: 1 });
|
||||
assert.strictEqual(find(running, 'Übertragung pausieren').enabled, true);
|
||||
|
||||
const paused = buildTrayTemplate({ ...base, active: 2, paused: true });
|
||||
assert.ok(labels(paused).includes('Übertragung fortsetzen'));
|
||||
assert.ok(labels(paused).includes('2 Übertragungen pausiert'));
|
||||
assert.ok(!labels(paused).includes('Übertragung pausieren'));
|
||||
assert.ok(labels(buildTrayTemplate({ ...base, active: 1, paused: true })).includes('1 Übertragung pausiert'));
|
||||
});
|
||||
|
||||
test('Pause-Eintrag ruft onPause auf', () => {
|
||||
const calls = [];
|
||||
const tpl = buildTrayTemplate({ ...base, active: 1, onPause: () => calls.push('pause') });
|
||||
assert.strictEqual(runAction(tpl, 'pause'), true);
|
||||
assert.deepStrictEqual(calls, ['pause']);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { blockedReason, partitionBlocked } = require('../src/features/uploadBlocklist.js');
|
||||
|
||||
test('Sperre: Dateien mit Zugangsdaten werden erkannt', () => {
|
||||
for (const name of ['credentials.json', 'data/credentials.json', 'C:\\proj\\.env', '.env.production', 'id_rsa', 'release.keystore', 'server.pem', 'secrets.json', '.npmrc']) {
|
||||
assert.ok(blockedReason(name, {}), `${name} sollte gesperrt sein`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Sperre: normale Dateien und Vorlagen bleiben erlaubt', () => {
|
||||
for (const name of ['main.js', 'package.json', 'README.md', '.env.example', 'config.sample.json', 'id_rsa.pub', 'credentials.md', 'src/credentials/index.js']) {
|
||||
assert.strictEqual(blockedReason(name, {}), null, `${name} darf hochgeladen werden`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Sperre: Einstellung kann sie abschalten, Standard ist an', () => {
|
||||
assert.ok(blockedReason('credentials.json', undefined));
|
||||
assert.ok(blockedReason('credentials.json', { uploadBlockSecrets: true }));
|
||||
assert.strictEqual(blockedReason('credentials.json', { uploadBlockSecrets: false }), null);
|
||||
});
|
||||
|
||||
test('partitionBlocked: trennt Einträge und behält die Reihenfolge', () => {
|
||||
const items = [
|
||||
{ localFile: 'a/main.js', targetPath: 'a/main.js' },
|
||||
{ localFile: 'a/data/credentials.json', targetPath: 'a/data/credentials.json' },
|
||||
{ localFile: 'a/.env', targetPath: 'a/.env' },
|
||||
{ localFile: 'a/README.md', targetPath: 'a/README.md' }
|
||||
];
|
||||
const { allowed, blocked } = partitionBlocked(items, {});
|
||||
assert.deepStrictEqual(allowed.map(i => i.localFile), ['a/main.js', 'a/README.md']);
|
||||
assert.deepStrictEqual(blocked.map(b => b.path), ['a/data/credentials.json', 'a/.env']);
|
||||
assert.ok(blocked.every(b => b.reason));
|
||||
assert.strictEqual(partitionBlocked(items, { uploadBlockSecrets: false }).blocked.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
uploadItemsViaGiteaApi,
|
||||
createUploadCancelledError,
|
||||
isUploadCancelledError,
|
||||
throwIfUploadCancelled
|
||||
} = require('../src/git/apiHandler.js');
|
||||
|
||||
test('Abbruchfehler: Erkennung und throwIfUploadCancelled', () => {
|
||||
assert.strictEqual(isUploadCancelledError(createUploadCancelledError()), true);
|
||||
assert.strictEqual(isUploadCancelledError(new Error('anderer Fehler')), false);
|
||||
assert.strictEqual(isUploadCancelledError(null), false);
|
||||
|
||||
assert.doesNotThrow(() => throwIfUploadCancelled(undefined));
|
||||
assert.doesNotThrow(() => throwIfUploadCancelled(new AbortController().signal));
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
assert.throws(() => throwIfUploadCancelled(c.signal), (e) => isUploadCancelledError(e));
|
||||
});
|
||||
|
||||
function makeTempFile() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-cancel-'));
|
||||
const file = path.join(dir, 'a.txt');
|
||||
fs.writeFileSync(file, 'hallo');
|
||||
return { dir, file };
|
||||
}
|
||||
|
||||
test('API-Upload: bereits abgebrochenes Signal sendet nichts', async () => {
|
||||
const { dir, file } = makeTempFile();
|
||||
try {
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
await assert.rejects(
|
||||
uploadItemsViaGiteaApi({
|
||||
token: 't', url: 'http://127.0.0.1:1', owner: 'o', repo: 'r', branch: 'main',
|
||||
items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm', signal: c.signal
|
||||
}),
|
||||
(e) => isUploadCancelledError(e)
|
||||
);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('API-Upload: Abbruch während der Übertragung wirft Abbruchfehler und landet nicht in "failed"', async () => {
|
||||
const { dir, file } = makeTempFile();
|
||||
let postSeen = false;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
postSeen = true;
|
||||
setTimeout(() => { res.writeHead(201, { 'content-type': 'application/json' }); res.end('{}'); }, 3000);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ default_branch: 'main', tree: [], entries: [] }));
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const c = new AbortController();
|
||||
const started = Date.now();
|
||||
setTimeout(() => c.abort(), 400);
|
||||
await assert.rejects(
|
||||
uploadItemsViaGiteaApi({
|
||||
token: 't', url: `http://127.0.0.1:${port}`, owner: 'o', repo: 'r', branch: 'main',
|
||||
items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm', signal: c.signal
|
||||
}),
|
||||
(e) => isUploadCancelledError(e)
|
||||
);
|
||||
assert.ok(postSeen, 'Der POST muss gestartet sein, sonst prüft der Test nichts');
|
||||
assert.ok(Date.now() - started < 2500, 'Abbruch muss sofort greifen, nicht erst nach der Serverantwort');
|
||||
} finally {
|
||||
server.closeAllConnections?.();
|
||||
await new Promise(r => server.close(r));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const { createPauseGate } = require('../src/features/pauseGate.js');
|
||||
|
||||
function gitea(onPost) {
|
||||
return http.createServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
onPost();
|
||||
res.writeHead(201, { 'content-type': 'application/json' });
|
||||
res.end('{}');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ default_branch: 'main', tree: [], entries: [] }));
|
||||
});
|
||||
}
|
||||
|
||||
test('API-Upload: während der Pause wird nichts gesendet, nach resume() schon', async () => {
|
||||
const { dir, file } = makeTempFile();
|
||||
let posts = 0;
|
||||
const server = gitea(() => { posts++; });
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const { port } = server.address();
|
||||
const gate = createPauseGate();
|
||||
try {
|
||||
gate.pause();
|
||||
const upload = uploadItemsViaGiteaApi({
|
||||
token: 't', url: `http://127.0.0.1:${port}`, owner: 'o', repo: 'r', branch: 'main',
|
||||
items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm',
|
||||
waitIfPaused: (s) => gate.wait(s)
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
assert.strictEqual(posts, 0, 'Während der Pause darf kein Paket gesendet werden');
|
||||
gate.resume();
|
||||
const result = await upload;
|
||||
assert.strictEqual(posts, 1);
|
||||
assert.deepStrictEqual(result.uploaded, ['a.txt']);
|
||||
} finally {
|
||||
await new Promise(r => server.close(r));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('API-Upload: Abbruch während der Pause beendet den Upload sofort', async () => {
|
||||
const { dir, file } = makeTempFile();
|
||||
let posts = 0;
|
||||
const server = gitea(() => { posts++; });
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const { port } = server.address();
|
||||
const gate = createPauseGate();
|
||||
try {
|
||||
gate.pause();
|
||||
const c = new AbortController();
|
||||
const upload = uploadItemsViaGiteaApi({
|
||||
token: 't', url: `http://127.0.0.1:${port}`, owner: 'o', repo: 'r', branch: 'main',
|
||||
items: [{ localFile: file, targetPath: 'a.txt' }], message: 'm',
|
||||
signal: c.signal, waitIfPaused: (s) => gate.wait(s)
|
||||
});
|
||||
setTimeout(() => c.abort(), 400);
|
||||
await assert.rejects(upload, (e) => isUploadCancelledError(e));
|
||||
assert.strictEqual(posts, 0);
|
||||
assert.strictEqual(gate.waitingCount(), 0);
|
||||
} finally {
|
||||
await new Promise(r => server.close(r));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { buildEntry, filterEntries, createUploadHistory } = require('../src/features/uploadHistory.js');
|
||||
|
||||
function memoryFs(initial) {
|
||||
const files = new Map(initial ? [['h.json', initial]] : []);
|
||||
return {
|
||||
files,
|
||||
readFileSync: (f) => { if (!files.has(f)) throw new Error('ENOENT'); return files.get(f); },
|
||||
writeFileSync: (f, c) => { files.set(f, c); }
|
||||
};
|
||||
}
|
||||
|
||||
test('buildEntry: erfolgreicher Upload', () => {
|
||||
const e = buildEntry('Drag & Drop', { owner: 'a', repo: 'b' }, { ok: true, results: [{}, {}, {}] }, 1000, null, 4000);
|
||||
assert.strictEqual(e.status, 'ok');
|
||||
assert.strictEqual(e.repo, 'a/b');
|
||||
assert.strictEqual(e.files, 3);
|
||||
assert.strictEqual(e.durationMs, 3000);
|
||||
assert.strictEqual(e.error, '');
|
||||
});
|
||||
|
||||
test('buildEntry: abgebrochen ist kein Fehler und trägt keinen Fehlertext', () => {
|
||||
const e = buildEntry('Ordner', { owner: 'a', repo: 'b' }, { ok: false, cancelled: true, error: 'Upload abgebrochen' }, 0, null, 10);
|
||||
assert.strictEqual(e.status, 'abgebrochen');
|
||||
assert.strictEqual(e.error, '');
|
||||
});
|
||||
|
||||
test('buildEntry: Fehler und geworfene Ausnahmen', () => {
|
||||
const failed = buildEntry('Dateien', { owner: 'a', repo: 'b' }, { ok: false, error: 'kaputt', failedCount: 2 }, 0, null, 10);
|
||||
assert.strictEqual(failed.status, 'fehler');
|
||||
assert.strictEqual(failed.error, 'kaputt');
|
||||
assert.strictEqual(failed.failed, 2);
|
||||
|
||||
const thrown = buildEntry('Dateien', {}, null, 0, new Error('boom'), 10);
|
||||
assert.strictEqual(thrown.status, 'fehler');
|
||||
assert.strictEqual(thrown.error, 'boom');
|
||||
assert.strictEqual(thrown.repo, '');
|
||||
});
|
||||
|
||||
test('buildEntry: Einzeldatei zählt als eine Datei, lange Fehler werden gekürzt', () => {
|
||||
assert.strictEqual(buildEntry('x', {}, { ok: true, singleFile: true }, 0, null, 1).files, 1);
|
||||
assert.strictEqual(buildEntry('x', {}, { ok: false, error: 'e'.repeat(1000) }, 0, null, 1).error.length, 300);
|
||||
});
|
||||
|
||||
test('filterEntries: nach Status und Repo-Suche', () => {
|
||||
const list = [
|
||||
{ repo: 'a/alpha', status: 'ok' },
|
||||
{ repo: 'a/beta', status: 'fehler' },
|
||||
{ repo: 'b/Alpha-2', status: 'abgebrochen' }
|
||||
];
|
||||
assert.strictEqual(filterEntries(list).length, 3);
|
||||
assert.deepStrictEqual(filterEntries(list, { status: 'fehler' }).map(e => e.repo), ['a/beta']);
|
||||
assert.deepStrictEqual(filterEntries(list, { query: 'ALPHA' }).map(e => e.repo), ['a/alpha', 'b/Alpha-2']);
|
||||
assert.strictEqual(filterEntries(list, { status: 'ok', query: 'beta' }).length, 0);
|
||||
});
|
||||
|
||||
test('Verlauf: neueste zuerst, auf max Einträge begrenzt', () => {
|
||||
const history = createUploadHistory({ file: 'h.json', max: 3, fs: memoryFs() });
|
||||
for (let i = 1; i <= 5; i++) history.record({ n: i });
|
||||
assert.deepStrictEqual(history.read().map(e => e.n), [5, 4, 3]);
|
||||
});
|
||||
|
||||
test('Verlauf: kaputte oder fehlende Datei ergibt eine leere Liste', () => {
|
||||
assert.deepStrictEqual(createUploadHistory({ file: 'h.json', fs: memoryFs() }).read(), []);
|
||||
assert.deepStrictEqual(createUploadHistory({ file: 'h.json', fs: memoryFs('{kaputt') }).read(), []);
|
||||
assert.deepStrictEqual(createUploadHistory({ file: 'h.json', fs: memoryFs('{"a":1}') }).read(), []);
|
||||
});
|
||||
|
||||
test('Verlauf: leeren', () => {
|
||||
const history = createUploadHistory({ file: 'h.json', fs: memoryFs() });
|
||||
history.record({ n: 1 });
|
||||
history.clear();
|
||||
assert.deepStrictEqual(history.read(), []);
|
||||
});
|
||||
|
||||
test('Verlauf: Schreibfehler werfen nicht', () => {
|
||||
const broken = { readFileSync: () => '[]', writeFileSync: () => { throw new Error('Platte voll'); } };
|
||||
assert.strictEqual(createUploadHistory({ file: 'h.json', fs: broken }).record({ n: 1 }), false);
|
||||
});
|
||||
|
||||
const { toCsv, formatDate } = require('../src/features/uploadHistory.js');
|
||||
|
||||
test('formatDate: TT.MM.JJJJ HH:MM:SS, ungültige Eingabe ergibt leeren Text', () => {
|
||||
assert.match(formatDate('2026-10-22T07:11:53.080Z'), /^\d{2}\.\d{2}\.2026 \d{2}:\d{2}:\d{2}$/);
|
||||
assert.strictEqual(formatDate('kein Datum'), '');
|
||||
});
|
||||
|
||||
test('toCsv: BOM, Semikolon, Windows-Zeilenenden, Kopfzeile', () => {
|
||||
const csv = toCsv([{ at: '2026-10-22T07:11:53.080Z', repo: 'a/b', kind: 'Drag & Drop', files: 3, failed: 0, status: 'ok', durationMs: 2500, error: '' }]);
|
||||
assert.strictEqual(csv.charCodeAt(0), 0xFEFF);
|
||||
const lines = csv.slice(1).split('\r\n');
|
||||
assert.strictEqual(lines[0], 'Datum;Repo;Art;Dateien;Fehlgeschlagen;Ergebnis;Dauer (s);Fehler');
|
||||
assert.match(lines[1], /;a\/b;Drag & Drop;3;0;Erfolgreich;2\.5;$/);
|
||||
assert.strictEqual(lines[2], '');
|
||||
});
|
||||
|
||||
test('toCsv: Anführungszeichen, Semikolon und Zeilenumbrüche werden maskiert', () => {
|
||||
const csv = toCsv([{ at: '2026-10-22T07:11:53Z', repo: 'a/b', kind: 'x', files: null, failed: 1, status: 'fehler', durationMs: 100, error: 'sagt "nein"; und\nmehr' }]);
|
||||
assert.ok(csv.includes('"sagt ""nein""; und\nmehr"'));
|
||||
assert.match(csv, /;Fehler;0\.1;/);
|
||||
});
|
||||
|
||||
test('toCsv: Excel-Formeln werden entschärft', () => {
|
||||
const csv = toCsv([{ at: '2026-10-22T07:11:53Z', repo: '=cmd|calc', kind: '+x', files: 1, failed: 0, status: 'ok', durationMs: 0, error: '@SUMME(A1)' }]);
|
||||
assert.ok(csv.includes(";'=cmd|calc;"));
|
||||
assert.ok(csv.includes(";'+x;"));
|
||||
assert.ok(csv.includes(";'@SUMME(A1)"));
|
||||
});
|
||||
|
||||
test('toCsv: leere Liste liefert nur die Kopfzeile', () => {
|
||||
assert.strictEqual(toCsv([]).slice(1).split('\r\n').filter(Boolean).length, 1);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// main.js — Main-Process with concurrent folder upload/download, progress events, and temp-dir cleanup
|
||||
const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage } = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage, Notification, screen } = require('electron');
|
||||
// Das Setup startet die frisch installierte EXE einmal mit diesem Schalter, damit Windows Defender
|
||||
// sie schon während der Installation prüft (installer/Installer.cs → Vorwaermen). Sofort beenden.
|
||||
if (process.argv.includes('--gm-vorwaermen')) process.exit(0);
|
||||
@@ -23,6 +23,7 @@ applyPrefsToProcess(appPrefs.get());
|
||||
appPrefs.onChange((p) => {
|
||||
applyPrefsToProcess(p);
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
if (trayPopup && w === trayPopup.getWindow()) continue;
|
||||
try { w.webContents.setZoomFactor(p.zoom / 100); } catch (_) {}
|
||||
}
|
||||
});
|
||||
@@ -414,7 +415,8 @@ async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, item
|
||||
console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`);
|
||||
|
||||
const result = await uploadItemsViaGiteaApi({
|
||||
token, url, owner, repo, branch, items, message, onProgress, signal
|
||||
token, url, owner, repo, branch, items, message, onProgress, signal,
|
||||
waitIfPaused: (s) => transferGate.wait(s)
|
||||
});
|
||||
|
||||
console.log(`[${label}] API-Upload fertig:`, {
|
||||
@@ -427,6 +429,16 @@ async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, item
|
||||
return { ok: result.failed.length === 0, ...result };
|
||||
}
|
||||
|
||||
// Fehler für einen teilweise fehlgeschlagenen API-Upload. Trägt die betroffenen Einträge,
|
||||
// damit der Renderer nur diese wiederholen kann.
|
||||
function buildPartialUploadError(gitErr, apiResult, items) {
|
||||
const failedPaths = new Set((apiResult.failed || []).map(f => f.path));
|
||||
const err = new Error(describeApiFallbackFailure(gitErr, apiResult));
|
||||
err.failedItems = items.filter(it => failedPaths.has(it.targetPath));
|
||||
err.uploadedCount = (apiResult.uploaded || []).length;
|
||||
return err;
|
||||
}
|
||||
|
||||
// Fasst die Fehler eines API-Fallbacks zu einer Meldung zusammen.
|
||||
function describeApiFallbackFailure(gitErr, apiResult) {
|
||||
const gitPart = `Git-Transport fehlgeschlagen: ${gitErr && gitErr.message ? gitErr.message : String(gitErr)}`;
|
||||
@@ -716,6 +728,7 @@ function broadcastRetryQueueUpdate(extra = {}) {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
try { win.webContents.send('retry-queue-updated', payload); } catch (_) {}
|
||||
}
|
||||
try { refreshTray(); } catch (_) {}
|
||||
}
|
||||
|
||||
function isRetryableNetworkError(errorLike) {
|
||||
@@ -899,6 +912,7 @@ app.commandLine.appendSwitch('enable-memory-coordination');
|
||||
app / window
|
||||
----------------------------- */
|
||||
let tray = null;
|
||||
let trayPopup = null;
|
||||
|
||||
function ensureUsableDirectory(dirPath) {
|
||||
try {
|
||||
@@ -969,17 +983,82 @@ function initializeAppStoragePaths() {
|
||||
|
||||
initializeAppStoragePaths();
|
||||
|
||||
const trayMenu = require('./src/features/trayMenu.js');
|
||||
const { createTrayPopup } = require('./src/features/trayPopup.js');
|
||||
const startAnimation = require('./src/features/splash.js');
|
||||
|
||||
function showMainWindow(win) {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
// Tooltip des Tray-Symbols und offenes Menü: zeigen, ob gerade etwas übertragen wird.
|
||||
// Wird beim Start und Ende jeder abbrechbaren Übertragung und bei Änderungen der Retry-Queue aufgerufen.
|
||||
function refreshTray() {
|
||||
if (!tray || tray.isDestroyed()) return;
|
||||
tray.setToolTip(trayMenu.trayTooltip(app.getVersion(), activeTransferAborts.size, transferGate.isPaused()));
|
||||
if (trayPopup) trayPopup.refresh();
|
||||
}
|
||||
|
||||
// Beenden über das Tray-Menü: läuft noch ein Upload oder Download, vorher nachfragen.
|
||||
async function quitFromTray(win) {
|
||||
if (activeTransferAborts.size > 0) {
|
||||
showMainWindow(win);
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: 'warning',
|
||||
buttons: ['Beenden und abbrechen', 'Weiter laufen lassen'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: 'Übertragung läuft',
|
||||
message: 'Es läuft noch ein Upload oder Download.',
|
||||
detail: 'Wenn du jetzt beendest, wird er abgebrochen.'
|
||||
});
|
||||
if (response !== 0) return;
|
||||
}
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// Menüvorlage mit dem aktuellen Status; wird bei jedem Öffnen und Aktualisieren neu gebaut.
|
||||
function trayTemplate(win) {
|
||||
return trayMenu.buildTrayTemplate({
|
||||
version: app.getVersion(),
|
||||
active: activeTransferAborts.size,
|
||||
queued: Array.isArray(retryQueue) ? retryQueue.length : 0,
|
||||
paused: transferGate.isPaused(),
|
||||
autostart: app.getLoginItemSettings({ args: ['--hidden'] }).openAtLogin,
|
||||
onPause: () => { if (transferGate.isPaused()) transferGate.resume(); else transferGate.pause(); },
|
||||
onOpen: () => showMainWindow(win),
|
||||
onHistory: () => {
|
||||
showMainWindow(win);
|
||||
try { win.webContents.send('open-upload-history'); } catch (_) {}
|
||||
},
|
||||
onCancel: () => {
|
||||
for (const controller of activeTransferAborts) controller.abort();
|
||||
refreshTray();
|
||||
},
|
||||
onAutostart: (item) => app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true, args: ['--hidden'] }),
|
||||
onQuit: () => { quitFromTray(win); }
|
||||
});
|
||||
}
|
||||
|
||||
function createTray(win) {
|
||||
const iconPath = ppath.join(__dirname, 'renderer', 'icon.png');
|
||||
tray = new Tray(nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }));
|
||||
tray.setToolTip('Git Manager Explorer Pro');
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: 'Öffnen', click: () => { win.show(); win.focus(); } },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Beenden', click: () => { app.quit(); } }
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
tray.on('double-click', () => { win.show(); win.focus(); });
|
||||
// Eigenes Menüfenster statt des nativen Menüs: Letzteres zeichnet unter Windows keine Hover-Markierung.
|
||||
trayPopup = createTrayPopup({
|
||||
htmlPath: ppath.join(__dirname, 'renderer', 'tray-menu.html'),
|
||||
preloadPath: ppath.join(__dirname, 'tray-menu-preload.js'),
|
||||
getModel: () => trayMenu.serializeTemplate(trayTemplate(win)),
|
||||
onAction: (id) => trayMenu.runAction(trayTemplate(win), id)
|
||||
});
|
||||
refreshTray();
|
||||
// Menüfenster kurz nach dem Start im Hintergrund vorladen, damit der erste Rechtsklick sofort reagiert.
|
||||
setTimeout(() => { try { trayPopup.warmUp(); } catch (e) { console.warn('[Tray] Vorladen fehlgeschlagen:', e.message); } }, 2000);
|
||||
tray.on('right-click', () => trayPopup.show());
|
||||
tray.on('click', () => showMainWindow(win));
|
||||
tray.on('double-click', () => showMainWindow(win));
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
@@ -988,11 +1067,23 @@ function createWindow() {
|
||||
|
||||
const startHidden = process.argv.includes('--hidden');
|
||||
|
||||
// Startanimation: drehendes Logo, danach baut sich das Fenster von der Mitte aus auf. Das Hauptfenster
|
||||
// bleibt bis dahin verborgen. Beim versteckten Start (Windows-Autostart im Tray) gibt es sie nie.
|
||||
const useSplash = !startHidden && appPrefs.get().showSplash;
|
||||
|
||||
// Start- und Hauptfenster bekommen dieselbe Position und Größe. Das Startfenster entsteht zuerst,
|
||||
// damit das Logo so früh wie möglich erscheint.
|
||||
let splashHandle = null;
|
||||
let windowBounds = { width: 1200, height: 820 };
|
||||
if (useSplash) {
|
||||
windowBounds = startAnimation.centeredBounds(screen.getPrimaryDisplay().workArea, 1200, 820);
|
||||
splashHandle = startAnimation.begin({ htmlPath: ppath.join(__dirname, 'renderer', 'splash.html'), bounds: windowBounds });
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 820,
|
||||
...windowBounds,
|
||||
frame: false,
|
||||
show: !startHidden,
|
||||
show: !startHidden && !useSplash,
|
||||
webPreferences: {
|
||||
preload: ppath.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -1027,6 +1118,14 @@ function createWindow() {
|
||||
});
|
||||
|
||||
win.loadFile(ppath.join(__dirname, 'renderer', 'index.html'));
|
||||
if (splashHandle) {
|
||||
// Die Animation zeigt das Hauptfenster selbst, sobald die Oberfläche geladen ist (oder das Laden scheitert).
|
||||
const uiReady = new Promise((resolve) => {
|
||||
win.webContents.once('did-finish-load', resolve);
|
||||
win.webContents.once('did-fail-load', resolve);
|
||||
});
|
||||
startAnimation.play(win, uiReady, splashHandle);
|
||||
}
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
try { win.webContents.setZoomFactor(appPrefs.get().zoom / 100); } catch (_) {}
|
||||
});
|
||||
@@ -1047,6 +1146,9 @@ function createWindow() {
|
||||
win.hide();
|
||||
}
|
||||
});
|
||||
mainWin = win;
|
||||
// Echtes Schließen: das versteckte Tray-Menüfenster mitschließen, sonst bliebe die App im Hintergrund.
|
||||
win.on('closed', () => { try { if (trayPopup) trayPopup.destroy(); } catch (_) {} });
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
@@ -1069,6 +1171,7 @@ app.whenReady().then(() => {
|
||||
});
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
||||
app.on('before-quit', () => {
|
||||
try { if (trayPopup) trayPopup.destroy(); } catch (_) {}
|
||||
if (retryQueueTimer) {
|
||||
clearInterval(retryQueueTimer);
|
||||
retryQueueTimer = null;
|
||||
@@ -1264,6 +1367,7 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress =
|
||||
async function worker() {
|
||||
while (true) {
|
||||
if (signal && signal.aborted) return;
|
||||
try { await transferGate.wait(signal); } catch (_) { return; }
|
||||
const i = index++;
|
||||
if (i >= tasks.length) return;
|
||||
try {
|
||||
@@ -1292,8 +1396,11 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress =
|
||||
Jeder Upload-Handler wird beim Registrieren umhüllt und schreibt Datum, Repo,
|
||||
Dateizahl und Ergebnis in upload-history.json (die letzten 100 Einträge).
|
||||
----------------------------- */
|
||||
const UPLOAD_HISTORY_FILE = ppath.join(app.getPath('userData'), 'upload-history.json');
|
||||
const UPLOAD_HISTORY_MAX = 100;
|
||||
const uploadHistory = require('./src/features/uploadHistory.js').createUploadHistory({
|
||||
file: ppath.join(app.getPath('userData'), 'upload-history.json'),
|
||||
max: 100
|
||||
});
|
||||
const { buildEntry: buildUploadHistoryEntry, filterEntries: filterUploadHistory, toCsv: uploadHistoryToCsv } = require('./src/features/uploadHistory.js');
|
||||
const UPLOAD_HISTORY_CHANNELS = {
|
||||
'upload-paths-to-gitea': 'Drag & Drop',
|
||||
'upload-local-folder-to-gitea': 'Ordner',
|
||||
@@ -1301,41 +1408,27 @@ const UPLOAD_HISTORY_CHANNELS = {
|
||||
'upload-and-push': 'Ordner/Datei'
|
||||
};
|
||||
|
||||
function readUploadHistory() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(UPLOAD_HISTORY_FILE, 'utf8'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Windows-Meldung zu einem beendeten Upload, wenn die App nicht im Vordergrund ist
|
||||
// (im Tray versteckt, minimiert oder von einem anderen Fenster verdeckt).
|
||||
const transferNotice = require('./src/features/transferNotice.js');
|
||||
let mainWin = null;
|
||||
|
||||
function recordUploadHistory(entry) {
|
||||
function maybeNotifyTransfer(entry) {
|
||||
try {
|
||||
const history = [entry, ...readUploadHistory()].slice(0, UPLOAD_HISTORY_MAX);
|
||||
fs.writeFileSync(UPLOAD_HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8');
|
||||
if (appPrefs.get().notifyTransfers === 'never') return;
|
||||
const win = mainWin;
|
||||
const inFront = win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused();
|
||||
if (inFront) return;
|
||||
const notice = transferNotice.describeTransfer(entry);
|
||||
if (!notice || !Notification.isSupported()) return;
|
||||
const n = new Notification({ title: notice.title, body: notice.body, icon: ppath.join(__dirname, 'assets', 'icon.png') });
|
||||
n.on('click', () => showMainWindow(win));
|
||||
n.show();
|
||||
} catch (e) {
|
||||
console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message);
|
||||
console.warn('Benachrichtigung:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUploadHistoryEntry(kind, data, res, startedAt, thrown) {
|
||||
const results = res && Array.isArray(res.results) ? res.results : null;
|
||||
let status = 'ok';
|
||||
if (thrown || !res || !res.ok) status = res && res.cancelled ? 'abgebrochen' : 'fehler';
|
||||
const error = thrown ? String(thrown.message || thrown) : (res && !res.ok && !res.cancelled ? String(res.error || '') : '');
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
kind,
|
||||
repo: data && data.owner && data.repo ? `${data.owner}/${data.repo}` : '',
|
||||
files: results ? results.length : (res && res.singleFile ? 1 : null),
|
||||
failed: res && typeof res.failedCount === 'number' ? res.failedCount : 0,
|
||||
status,
|
||||
error: error.slice(0, 300),
|
||||
durationMs: Date.now() - startedAt
|
||||
};
|
||||
}
|
||||
|
||||
const rawIpcHandle = ipcMain.handle.bind(ipcMain);
|
||||
ipcMain.handle = (channel, handler) => {
|
||||
const kind = UPLOAD_HISTORY_CHANNELS[channel];
|
||||
@@ -1346,17 +1439,40 @@ ipcMain.handle = (channel, handler) => {
|
||||
try {
|
||||
res = await handler(event, data);
|
||||
} catch (e) {
|
||||
recordUploadHistory(buildUploadHistoryEntry(kind, data, null, startedAt, e));
|
||||
const failedEntry = buildUploadHistoryEntry(kind, data, null, startedAt, e);
|
||||
uploadHistory.record(failedEntry);
|
||||
maybeNotifyTransfer(failedEntry);
|
||||
throw e;
|
||||
}
|
||||
recordUploadHistory(buildUploadHistoryEntry(kind, data, res, startedAt, null));
|
||||
const entry = buildUploadHistoryEntry(kind, data, res, startedAt, null);
|
||||
uploadHistory.record(entry);
|
||||
maybeNotifyTransfer(entry);
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
ipcMain.handle('get-upload-history', () => ({ ok: true, history: readUploadHistory() }));
|
||||
ipcMain.handle('get-upload-history', (_event, data) => ({ ok: true, history: filterUploadHistory(uploadHistory.read(), data || {}) }));
|
||||
// CSV-Export des (gefilterten) Verlaufs, für deutsches Excel (Semikolon, UTF-8 mit BOM).
|
||||
ipcMain.handle('export-upload-history-csv', async (event, data) => {
|
||||
try {
|
||||
const entries = filterUploadHistory(uploadHistory.read(), data || {});
|
||||
if (entries.length === 0) return { ok: false, error: 'Keine Einträge zum Exportieren.' };
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const save = await dialog.showSaveDialog(win, {
|
||||
defaultPath: ppath.join(app.getPath('documents'), `upload-verlauf-${stamp}.csv`),
|
||||
filters: [{ name: 'CSV-Datei', extensions: ['csv'] }]
|
||||
});
|
||||
if (save.canceled || !save.filePath) return { ok: false, canceled: true };
|
||||
fs.writeFileSync(save.filePath, uploadHistoryToCsv(entries), 'utf8');
|
||||
return { ok: true, savedTo: save.filePath, count: entries.length };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e && e.message || e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-upload-history', () => {
|
||||
try { fs.writeFileSync(UPLOAD_HISTORY_FILE, '[]', 'utf8'); return { ok: true }; }
|
||||
try { uploadHistory.clear(); return { ok: true }; }
|
||||
catch (e) { return { ok: false, error: String(e && e.message || e) }; }
|
||||
});
|
||||
|
||||
@@ -2653,6 +2769,11 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
||||
let stat;
|
||||
try { stat = fs.statSync(localFile); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); continue; }
|
||||
if (!stat.isFile()) { results.push({ file: localFile, ok: false, error: stat.isDirectory() ? 'path-is-directory' : 'path-is-not-file' }); continue; }
|
||||
const blockedReason = blockedSecretReason(localFile);
|
||||
if (blockedReason) {
|
||||
results.push({ file: localFile, ok: false, blocked: true, error: `Gesperrt (Zugangsdaten): ${blockedReason}` });
|
||||
continue;
|
||||
}
|
||||
validFiles.push(localFile);
|
||||
}
|
||||
|
||||
@@ -2696,6 +2817,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
||||
}
|
||||
|
||||
throwIfUploadCancelled(signal);
|
||||
await transferGate.wait(signal);
|
||||
await stageUploadedFiles(repoGit);
|
||||
|
||||
let hasChanges = true;
|
||||
@@ -2886,16 +3008,45 @@ function sumFileSizes(items) {
|
||||
// (es gibt nur einen Fortschrittsbalken mit einem Abbrechen-Knopf).
|
||||
const activeTransferAborts = new Set();
|
||||
|
||||
// Pause/Fortsetzen: Übertragungen halten an den Kontrollpunkten an (zwischen Paketen, Dateien und
|
||||
// Git-Schritten), solange pausiert ist. Ein laufender Clone oder Push wird erst fertig.
|
||||
const transferGate = require('./src/features/pauseGate.js').createPauseGate({
|
||||
onChange: (paused) => {
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
try { if (!w.isDestroyed()) w.webContents.send('transfer-pause-state', { paused }); } catch (_) {}
|
||||
}
|
||||
refreshTray();
|
||||
}
|
||||
});
|
||||
|
||||
function beginCancellableUpload() {
|
||||
// Eine vergessene Pause eines früheren Vorgangs darf den neuen nicht blockieren.
|
||||
if (activeTransferAborts.size === 0) transferGate.resume();
|
||||
const controller = new AbortController();
|
||||
activeTransferAborts.add(controller);
|
||||
refreshTray();
|
||||
return controller;
|
||||
}
|
||||
|
||||
function endCancellableUpload(controller) {
|
||||
activeTransferAborts.delete(controller);
|
||||
if (activeTransferAborts.size === 0) transferGate.resume();
|
||||
refreshTray();
|
||||
}
|
||||
|
||||
ipcMain.handle('pause-upload', () => {
|
||||
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
|
||||
transferGate.pause();
|
||||
return { ok: true, paused: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('resume-upload', () => {
|
||||
transferGate.resume();
|
||||
return { ok: true, paused: false };
|
||||
});
|
||||
|
||||
ipcMain.handle('get-pause-state', () => ({ ok: true, paused: transferGate.isPaused() }));
|
||||
|
||||
ipcMain.handle('cancel-upload', () => {
|
||||
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
|
||||
for (const controller of activeTransferAborts) controller.abort();
|
||||
@@ -2926,8 +3077,13 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
// Exclude-Liste: .git, node_modules etc. überspringen
|
||||
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
|
||||
|
||||
// Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt den Ordner neu einzulesen.
|
||||
const retryItems = Array.isArray(data.items)
|
||||
? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile))
|
||||
: null;
|
||||
const isIgnored = await createUploadIgnore(localFolder);
|
||||
(function walk(dir) {
|
||||
if (retryItems && retryItems.length) items.push(...retryItems);
|
||||
else (function walk(dir) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (excludeList.includes(entry)) continue;
|
||||
@@ -2950,8 +3106,11 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
}
|
||||
})(localFolder);
|
||||
|
||||
// Dateien mit Zugangsdaten bleiben außen vor.
|
||||
const blockedFiles = removeBlockedInPlace(items);
|
||||
|
||||
const total = items.length;
|
||||
if (total === 0) return { ok: true, results: [] };
|
||||
if (total === 0) return { ok: true, results: [], blockedFiles };
|
||||
const totalBytes = sumFileSizes(items);
|
||||
|
||||
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
|
||||
@@ -3001,6 +3160,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
|
||||
// git add + commit + push
|
||||
throwIfUploadCancelled(signal);
|
||||
await transferGate.wait(signal);
|
||||
await stageUploadedFiles(repoGit);
|
||||
|
||||
let hasChanges = true;
|
||||
@@ -3036,7 +3196,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
|
||||
|
||||
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
|
||||
return { ok: true, results: successResults, failedCount: 0 };
|
||||
return { ok: true, results: successResults, failedCount: 0, blockedFiles };
|
||||
|
||||
} catch (gitErr) {
|
||||
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
|
||||
@@ -3064,16 +3224,21 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
});
|
||||
|
||||
if (!apiResult.ok) {
|
||||
throw new Error(describeApiFallbackFailure(gitErr, apiResult));
|
||||
throw buildPartialUploadError(gitErr, apiResult, items);
|
||||
}
|
||||
|
||||
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
|
||||
return { ok: true, results: successResults, failedCount: 0, viaApi: true };
|
||||
return { ok: true, results: successResults, failedCount: 0, viaApi: true, blockedFiles };
|
||||
}
|
||||
} catch (e) {
|
||||
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen' };
|
||||
console.error('upload-local-folder-to-gitea error', e);
|
||||
return { ok: false, error: String(e) };
|
||||
return {
|
||||
ok: false,
|
||||
error: String(e),
|
||||
retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined,
|
||||
uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined
|
||||
};
|
||||
} finally {
|
||||
endCancellableUpload(uploadAbort);
|
||||
}
|
||||
@@ -3142,6 +3307,21 @@ async function createUploadIgnore(target, cache = new Map()) {
|
||||
|
||||
const GIT_UPLOAD_EXCLUDE = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
|
||||
|
||||
// Feste Sperre für Dateien mit Zugangsdaten (Einstellung "Zugangsdaten nie hochladen").
|
||||
const uploadBlocklist = require('./src/features/uploadBlocklist.js');
|
||||
|
||||
// Grund, warum eine Datei nicht hochgeladen werden darf, oder null.
|
||||
function blockedSecretReason(filePath) {
|
||||
return uploadBlocklist.blockedReason(filePath, appPrefs.get());
|
||||
}
|
||||
|
||||
// Entfernt gesperrte Dateien aus der Liste (an Ort und Stelle) und liefert sie zurück.
|
||||
function removeBlockedInPlace(items) {
|
||||
const { allowed, blocked } = uploadBlocklist.partitionBlocked(items, appPrefs.get());
|
||||
items.splice(0, items.length, ...allowed);
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// Wandelt gedroppte Pfade (Dateien und/oder Ordner) in eine flache Liste
|
||||
// { localFile, targetPath } um. Dateien -> destPath/<name>, Ordner ->
|
||||
// destPath/<ordnername>/<rel> (identisch zum bisherigen Verhalten).
|
||||
@@ -3183,7 +3363,8 @@ async function buildItemsFromPaths(paths, destPath) {
|
||||
skipped.push({ path: p, error: 'unsupported' });
|
||||
}
|
||||
}
|
||||
return { items, skipped, ignored };
|
||||
const blocked = removeBlockedInPlace(items);
|
||||
return { items, skipped, ignored, blocked };
|
||||
}
|
||||
|
||||
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
|
||||
@@ -3218,6 +3399,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
|
||||
if (onProgress) onProgress(60);
|
||||
|
||||
throwIfUploadCancelled(signal);
|
||||
await transferGate.wait(signal);
|
||||
await stageUploadedFiles(repoGit);
|
||||
|
||||
let hasChanges = true;
|
||||
@@ -3254,12 +3436,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
|
||||
});
|
||||
|
||||
if (!apiResult.ok) {
|
||||
// Die fehlgeschlagenen Dateien mitgeben, damit der Renderer nur diese wiederholen kann.
|
||||
const failedPaths = new Set((apiResult.failed || []).map(f => f.path));
|
||||
const err = new Error(describeApiFallbackFailure(gitErr, apiResult));
|
||||
err.failedItems = items.filter(it => failedPaths.has(it.targetPath));
|
||||
err.uploadedCount = (apiResult.uploaded || []).length;
|
||||
throw err;
|
||||
throw buildPartialUploadError(gitErr, apiResult, items);
|
||||
}
|
||||
|
||||
return { hasChanges: apiResult.uploaded.length > 0, viaApi: true };
|
||||
@@ -3296,13 +3473,22 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
|
||||
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
|
||||
|
||||
const built = retryItems && retryItems.length
|
||||
? { items: retryItems, skipped: [], ignored: [] }
|
||||
? { items: retryItems, skipped: [], ignored: [], blocked: removeBlockedInPlace(retryItems) }
|
||||
: await buildItemsFromPaths(paths, destPath);
|
||||
const skipped = built.skipped;
|
||||
const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []);
|
||||
const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items;
|
||||
if (items.length === 0) {
|
||||
return { ok: false, error: 'Keine Dateien zum Hochladen gefunden', skipped, debugId: uploadDebugId };
|
||||
const onlyBlocked = Array.isArray(built.blocked) && built.blocked.length > 0;
|
||||
return {
|
||||
ok: false,
|
||||
error: onlyBlocked
|
||||
? 'Es blieb nichts zum Hochladen übrig: Alle Dateien enthalten vermutlich Zugangsdaten und sind gesperrt.'
|
||||
: 'Keine Dateien zum Hochladen gefunden',
|
||||
skipped,
|
||||
blockedFiles: built.blocked,
|
||||
debugId: uploadDebugId
|
||||
};
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
@@ -3322,7 +3508,7 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
|
||||
console.log('[UploadPaths] done', { uploadDebugId, total });
|
||||
|
||||
const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath }));
|
||||
return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId };
|
||||
return { ok: true, results, failedCount: 0, skipped, blockedFiles: built.blocked, debugId: uploadDebugId };
|
||||
} catch (e) {
|
||||
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
|
||||
console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) });
|
||||
@@ -3643,6 +3829,15 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
|
||||
// --- FALL 1: Einzelne Datei ---
|
||||
if (isFile) {
|
||||
const singleBlocked = blockedSecretReason(data.localFolder);
|
||||
if (singleBlocked) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Gesperrt: „${ppath.basename(data.localFolder)}“ enthält vermutlich Zugangsdaten (${singleBlocked}). Die Sperre lässt sich unter Einstellungen → Uploads abschalten.`,
|
||||
blockedFiles: [{ path: data.localFolder, reason: singleBlocked }],
|
||||
debugId: uploadDebugId
|
||||
};
|
||||
}
|
||||
console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId });
|
||||
const raw = fs.readFileSync(data.localFolder);
|
||||
const base64 = raw.toString('base64');
|
||||
@@ -3754,6 +3949,8 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
const folderName = ppath.basename(data.localFolder);
|
||||
// Dateien/Ordner, die niemals hochgeladen werden sollen
|
||||
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
|
||||
// Dateien mit Zugangsdaten, die der Git-Weg beim Kopieren ausgelassen hat
|
||||
const blockedInCopy = [];
|
||||
|
||||
// === PRIMÄRWEG: git push =========================================
|
||||
// Ein einziger komprimierter Transfer + genau EIN Commit statt
|
||||
@@ -3794,6 +3991,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
destDirInRepo = ppath.join(destDirInRepo, folderName);
|
||||
ensureDir(destDirInRepo);
|
||||
|
||||
await transferGate.wait(signal);
|
||||
// Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen
|
||||
const isIgnoredLocal = await createUploadIgnore(data.localFolder);
|
||||
fs.cpSync(data.localFolder, destDirInRepo, {
|
||||
@@ -3802,6 +4000,10 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
if (excludeList.includes(ppath.basename(src))) return false;
|
||||
let isDir = false;
|
||||
try { isDir = fs.statSync(src).isDirectory(); } catch (_) {}
|
||||
if (!isDir) {
|
||||
const reason = blockedSecretReason(src);
|
||||
if (reason) { blockedInCopy.push({ path: src, reason }); return false; }
|
||||
}
|
||||
return !isIgnoredLocal(src, isDir);
|
||||
}
|
||||
});
|
||||
@@ -3814,7 +4016,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
if (!/nothing to commit/i.test(String(commitErr))) throw commitErr;
|
||||
console.log('[UPLOAD_DEBUG][main] directory-upload:git-nothing-to-commit', { uploadDebugId });
|
||||
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
|
||||
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
|
||||
return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
|
||||
}
|
||||
|
||||
let pushBranch = branch;
|
||||
@@ -3828,7 +4030,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
|
||||
console.log('[UPLOAD_DEBUG][main] directory-upload:git-push-done', { uploadDebugId, pushBranch });
|
||||
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
|
||||
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId };
|
||||
return { ok: true, usedGit: true, results: [], blockedFiles: blockedInCopy, debugId: uploadDebugId };
|
||||
} catch (gitErr) {
|
||||
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
throw gitErr;
|
||||
@@ -3874,9 +4076,12 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
}
|
||||
})(data.localFolder);
|
||||
|
||||
// Dateien mit Zugangsdaten bleiben außen vor.
|
||||
const blockedFiles = removeBlockedInPlace(items);
|
||||
|
||||
const total = items.length;
|
||||
const concurrency = data.concurrency || DEFAULT_CONCURRENCY;
|
||||
if (total === 0) return { ok: true, usedGit: false, results: [], debugId: uploadDebugId };
|
||||
if (total === 0) return { ok: true, usedGit: false, results: [], blockedFiles, debugId: uploadDebugId };
|
||||
|
||||
console.log('[UPLOAD_DEBUG][main] api-fallback:start', { uploadDebugId, total, concurrency, branch, destPath });
|
||||
|
||||
@@ -3902,7 +4107,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal);
|
||||
const failedCount = uploadResults.filter(r => !r.ok).length;
|
||||
console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length });
|
||||
return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount };
|
||||
return { ok: failedCount === 0, usedGit: false, results: uploadResults, blockedFiles, debugId: uploadDebugId, failedCount };
|
||||
} catch (e) {
|
||||
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
|
||||
console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) });
|
||||
@@ -3966,6 +4171,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
|
||||
let cancelled = false;
|
||||
for (let i = 0; i < repos.length; i++) {
|
||||
if (signal.aborted) { cancelled = true; break; }
|
||||
try { await transferGate.wait(signal); } catch (e) { if (isUploadCancelledError(e)) { cancelled = true; break; } throw e; }
|
||||
const item = repos[i];
|
||||
const progressBase = { index: i + 1, total: repos.length, repo: item.id, action };
|
||||
sendProgress({ ...progressBase, status: 'running' });
|
||||
@@ -4405,6 +4611,69 @@ ipcMain.handle('get-release', async (event, data) => {
|
||||
});
|
||||
|
||||
// Create a new release
|
||||
// Release für den Updater: Datei prüfen und Release vorbereiten (Tag, Prüfsummen im Text)
|
||||
const releaseAssist = require('./src/features/releaseAssist.js');
|
||||
|
||||
function sha256OfFile(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
// Liefert Name, Größe, SHA-256 und Version einer Release-Datei und gleicht sie mit "<datei>.sha256" ab.
|
||||
ipcMain.handle('inspect-release-file', async (_event, data) => {
|
||||
try {
|
||||
const filePath = String((data && data.filePath) || '');
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) return { ok: false, error: 'Das ist keine Datei.' };
|
||||
const name = ppath.basename(filePath);
|
||||
const sha256 = await sha256OfFile(filePath);
|
||||
const sidecarPath = `${filePath}.sha256`;
|
||||
const hasSidecar = fs.existsSync(sidecarPath);
|
||||
const sidecarSha256 = hasSidecar ? releaseAssist.parseSidecar(fs.readFileSync(sidecarPath, 'utf8')) : null;
|
||||
return {
|
||||
ok: true,
|
||||
name,
|
||||
size: stat.size,
|
||||
sha256,
|
||||
version: releaseAssist.parseVersionFromFileName(name),
|
||||
sidecarPath: hasSidecar ? sidecarPath : null,
|
||||
sidecarSha256,
|
||||
sidecarMatches: sidecarSha256 ? sidecarSha256 === sha256 : null
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e && e.message || e) };
|
||||
}
|
||||
});
|
||||
|
||||
// Prüft den Tag gegen die vorhandenen Releases und baut den Release-Text mit den Prüfsummen.
|
||||
ipcMain.handle('prepare-release', async (_event, data) => {
|
||||
try {
|
||||
const credentials = readCredentials();
|
||||
if (!credentials) return { ok: false, error: 'no-credentials' };
|
||||
let releases;
|
||||
try {
|
||||
if (data.platform === 'github') {
|
||||
if (!credentials.githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
|
||||
releases = await listGithubReleases({ token: credentials.githubToken, owner: data.owner, repo: data.repo });
|
||||
} else {
|
||||
releases = await listGiteaReleases({ token: credentials.giteaToken, url: credentials.giteaURL, owner: data.owner, repo: data.repo });
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: `Vorhandene Releases konnten nicht geprüft werden: ${String(e && e.message || e)}` };
|
||||
}
|
||||
const check = releaseAssist.checkNewTag(data.tag, releases);
|
||||
if (!check.ok) return { ok: false, error: check.reason };
|
||||
return { ok: true, body: releaseAssist.buildReleaseBody(data.notes, data.assets), newest: check.newest };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e && e.message || e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('create-release', async (event, data) => {
|
||||
try {
|
||||
const credentials = readCredentials();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "git-manager-gui",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "git-manager-gui",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.0",
|
||||
"axios": "^1.13.5",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git-manager-gui",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"description": "Git Manager GUI - Verwaltung von Git Repositories",
|
||||
"author": "M_Viper",
|
||||
"main": "main.js",
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "electron .",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||
"test": "node --test __tests__/*.test.js",
|
||||
"pdf": "electron generate-pdf.js"
|
||||
"pdf": "electron generate-pdf.js",
|
||||
"test:ui": "node scripts/ui-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.0",
|
||||
|
||||
+18
-1
@@ -40,8 +40,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data),
|
||||
uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data),
|
||||
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
|
||||
getUploadHistory: () => ipcRenderer.invoke('get-upload-history'),
|
||||
pauseUpload: () => ipcRenderer.invoke('pause-upload'),
|
||||
resumeUpload: () => ipcRenderer.invoke('resume-upload'),
|
||||
getPauseState: () => ipcRenderer.invoke('get-pause-state'),
|
||||
onTransferPauseState: (cb) => {
|
||||
const listener = (_event, payload) => cb(payload || {});
|
||||
ipcRenderer.on('transfer-pause-state', listener);
|
||||
return () => ipcRenderer.removeListener('transfer-pause-state', listener);
|
||||
},
|
||||
getUploadHistory: (data) => ipcRenderer.invoke('get-upload-history', data),
|
||||
clearUploadHistory: () => ipcRenderer.invoke('clear-upload-history'),
|
||||
exportUploadHistoryCsv: (data) => ipcRenderer.invoke('export-upload-history-csv', data),
|
||||
getGitignoreTemplates: () => ipcRenderer.invoke('get-gitignore-templates'),
|
||||
buildGitignore: (data) => ipcRenderer.invoke('build-gitignore', data),
|
||||
|
||||
@@ -96,6 +105,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
editRelease: (data) => ipcRenderer.invoke('edit-release', data),
|
||||
deleteRelease: (data) => ipcRenderer.invoke('delete-release', data),
|
||||
uploadReleaseAsset: (data) => ipcRenderer.invoke('upload-release-asset', data),
|
||||
inspectReleaseFile: (data) => ipcRenderer.invoke('inspect-release-file', data),
|
||||
prepareRelease: (data) => ipcRenderer.invoke('prepare-release', data),
|
||||
deleteReleaseAsset: (data) => ipcRenderer.invoke('delete-release-asset', data),
|
||||
downloadReleaseArchive: (data) => ipcRenderer.invoke('download-release-archive', data),
|
||||
|
||||
@@ -186,6 +197,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('repo-backup-progress', listener);
|
||||
return () => ipcRenderer.removeListener('repo-backup-progress', listener);
|
||||
},
|
||||
// Tray-Menü → "Upload-Verlauf": Renderer soll den Verlauf-Dialog öffnen.
|
||||
onOpenUploadHistory: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('open-upload-history', listener);
|
||||
return () => ipcRenderer.removeListener('open-upload-history', listener);
|
||||
},
|
||||
onFolderUploadProgress: (cb) => {
|
||||
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
|
||||
ipcRenderer.on('folder-upload-progress', listener);
|
||||
|
||||
+17
-1
@@ -121,7 +121,8 @@
|
||||
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) {
|
||||
const blocked = res.blocked || [];
|
||||
if (res.previewMode === 'warnings' && secrets.length === 0 && blocked.length === 0 && files.length > 0 && files.length <= 1000) {
|
||||
return { proceed: true, excludeFiles: [] };
|
||||
}
|
||||
|
||||
@@ -142,6 +143,21 @@
|
||||
m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: 'Es bleibt keine Datei zum Hochladen übrig.' }));
|
||||
}
|
||||
|
||||
if (blocked.length) {
|
||||
const list = el('div', { class: 'fx-list' });
|
||||
for (const b of blocked.slice(0, 25)) {
|
||||
list.append(el('div', { class: 'fx-list-row' }, [
|
||||
el('span', { class: 'fx-mono', text: b.targetPath || b.path }),
|
||||
el('span', { class: 'fx-muted', text: b.reason })
|
||||
]));
|
||||
}
|
||||
m.body.append(el('div', { class: 'fx-alert fx-alert--danger' }, [
|
||||
el('strong', { text: `🛡️ ${blocked.length} Datei(en) gesperrt und werden nicht hochgeladen` }),
|
||||
el('div', { text: 'Dateien mit Zugangsdaten werden nie hochgeladen. Die Sperre lässt sich unter Einstellungen → Uploads abschalten.' }),
|
||||
list
|
||||
]));
|
||||
}
|
||||
|
||||
const secretChecks = [];
|
||||
if (secrets.length) {
|
||||
const list = el('div', { class: 'fx-list' });
|
||||
|
||||
+28
-1
@@ -95,7 +95,7 @@
|
||||
<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="btnGitignoreTemplate" class="hidden" title="Standard-.gitignore (Node, .NET, Java, Python …) 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>
|
||||
@@ -394,6 +394,16 @@
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="settings-toggle-row" for="prefBlockSecrets">
|
||||
<span class="settings-toggle-info">
|
||||
<span class="settings-toggle-title">🛡️ Zugangsdaten nie hochladen</span>
|
||||
<span class="settings-toggle-desc">Dateien wie credentials.json, .env oder private Schlüssel werden auf allen Upload-Wegen ausgelassen, auch wenn sie in der Vorschau nicht abgewählt wurden. Empfohlen bei öffentlichen Repos.</span>
|
||||
</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="prefBlockSecrets" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-backup-card">
|
||||
<div class="input-group">
|
||||
@@ -403,6 +413,13 @@
|
||||
<option value="warnings">Nur bei Zugangsdaten oder sehr vielen Dateien</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="prefNotifyTransfers">Windows-Meldung bei Upload-Ende</label>
|
||||
<select id="prefNotifyTransfers">
|
||||
<option value="hidden">Nur wenn die App nicht im Vordergrund ist</option>
|
||||
<option value="never">Nie</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. *.log dist/ .idea/ *.psd"></textarea>
|
||||
@@ -471,6 +488,16 @@
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="settings-toggle-row" for="prefShowSplash">
|
||||
<span class="settings-toggle-info">
|
||||
<span class="settings-toggle-title">✨ Startanimation</span>
|
||||
<span class="settings-toggle-desc">Beim Öffnen dreht sich das Logo, danach baut sich das Fenster von der Mitte aus auf. Beim automatischen Start mit Windows (im Tray) gibt es sie nie.</span>
|
||||
</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="prefShowSplash" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-backup-card">
|
||||
<div class="input-group">
|
||||
|
||||
+342
-55
@@ -862,6 +862,46 @@ function showChipContextMenu(ev, entry, type) {
|
||||
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
|
||||
}
|
||||
|
||||
// Teilfehler eines Uploads: fragt nach und sendet nur die fehlgeschlagenen Dateien erneut
|
||||
// (höchstens 3 Wiederholungen). `retryCall(failedItems)` führt den Upload erneut aus.
|
||||
async function retryFailedUploads(res, retryCall) {
|
||||
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 retryCall(failedItems);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Hinweis, wenn die feste Sperre Dateien mit Zugangsdaten vom Upload ausgenommen hat.
|
||||
function warnBlockedFiles(res) {
|
||||
const blocked = Array.isArray(res?.blockedFiles) ? res.blockedFiles : [];
|
||||
if (blocked.length === 0) return;
|
||||
const names = blocked.slice(0, 3).map(b => ppathBasename(b.path)).join(', ');
|
||||
showWarning(`🛡️ ${blocked.length} Datei(en) mit Zugangsdaten wurden nicht hochgeladen: ${names}${blocked.length > 3 ? ' …' : ''}`);
|
||||
}
|
||||
|
||||
// Ordner-Upload mit Wiederholung der fehlgeschlagenen Dateien.
|
||||
async function uploadFolderWithRetry(params) {
|
||||
const res = await window.electronAPI.uploadLocalFolderToGitea(params);
|
||||
warnBlockedFiles(res);
|
||||
return retryFailedUploads(res, (failedItems) => window.electronAPI.uploadLocalFolderToGitea({
|
||||
...params,
|
||||
items: failedItems,
|
||||
messagePrefix: `${params.messagePrefix || 'Upload folder via GUI'} (Wiederholung)`
|
||||
}));
|
||||
}
|
||||
|
||||
async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl = null, branch = 'HEAD' }) {
|
||||
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:start', { owner, repo, destPath, branch, pathCount: Array.isArray(paths) ? paths.length : 0 }); } catch (_) {}
|
||||
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:start', {
|
||||
@@ -913,30 +953,16 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl
|
||||
excludeFiles,
|
||||
messagePrefix: 'Upload via GUI'
|
||||
}), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout
|
||||
warnBlockedFiles(res);
|
||||
|
||||
// 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');
|
||||
}
|
||||
res = await retryFailedUploads(res, (failedItems) => withTimeout(window.electronAPI.uploadPathsToGitea({
|
||||
items: failedItems,
|
||||
owner,
|
||||
repo,
|
||||
destPath,
|
||||
branch,
|
||||
messagePrefix: 'Upload via GUI (Wiederholung)'
|
||||
}), 900000, 'upload-paths-to-gitea'));
|
||||
|
||||
if (res?.cancelled) {
|
||||
hideProgress();
|
||||
@@ -2712,7 +2738,24 @@ function ensureProgressUI() {
|
||||
cancel.textContent = 'Wird abgebrochen …';
|
||||
try { await window.electronAPI.cancelUpload(); } catch (_) {}
|
||||
});
|
||||
container.appendChild(cancel);
|
||||
// Pause-Knopf: hält die Übertragung an den nächsten Kontrollpunkten an und setzt sie fort.
|
||||
const pause = document.createElement('button');
|
||||
pause.id = 'folderProgressPause';
|
||||
pause.textContent = PAUSE_LABEL;
|
||||
pause.style.cssText = cancel.style.cssText;
|
||||
pause.addEventListener('click', async () => {
|
||||
pause.disabled = true;
|
||||
try {
|
||||
if (transferPaused) await window.electronAPI.resumeUpload();
|
||||
else await window.electronAPI.pauseUpload();
|
||||
} catch (_) {}
|
||||
pause.disabled = false;
|
||||
});
|
||||
|
||||
const buttonRow = document.createElement('div');
|
||||
buttonRow.style.cssText = 'display:flex;gap:8px;';
|
||||
buttonRow.append(pause, cancel);
|
||||
container.appendChild(buttonRow);
|
||||
|
||||
// Esc bricht die laufende Übertragung ab, solange kein Dialog offen ist.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -2726,6 +2769,22 @@ function ensureProgressUI() {
|
||||
}
|
||||
|
||||
const CANCEL_LABEL = 'Abbrechen (Esc)';
|
||||
const PAUSE_LABEL = 'Pausieren';
|
||||
const RESUME_LABEL = 'Fortsetzen';
|
||||
const PAUSED_SUFFIX = ' · ⏸ pausiert';
|
||||
let transferPaused = false;
|
||||
|
||||
// Zeigt den Pausen-Zustand im Fortschrittsbalken (Knopfbeschriftung und Hinweis im Text).
|
||||
function applyPauseState(paused) {
|
||||
transferPaused = !!paused;
|
||||
const pause = $('folderProgressPause');
|
||||
const txt = $('folderProgressText');
|
||||
if (pause) pause.textContent = transferPaused ? RESUME_LABEL : PAUSE_LABEL;
|
||||
if (txt) {
|
||||
const base = txt.innerText.replace(PAUSED_SUFFIX, '');
|
||||
txt.innerText = transferPaused ? base + PAUSED_SUFFIX : base;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTransferBytes(bytes) {
|
||||
if (!(bytes > 0)) return '0 KB';
|
||||
@@ -2768,8 +2827,10 @@ function showProgress(percent, text, { cancellable = false } = {}) {
|
||||
const bar = $('folderProgressBar');
|
||||
const txt = $('folderProgressText');
|
||||
const cancel = $('folderProgressCancel');
|
||||
if (txt) txt.innerText = text || '';
|
||||
const pause = $('folderProgressPause');
|
||||
if (txt) txt.innerText = (text || '') + (transferPaused && cancellable ? PAUSED_SUFFIX : '');
|
||||
if (bar) bar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
|
||||
if (pause) pause.style.display = cancellable ? 'inline-block' : 'none';
|
||||
if (cancel) {
|
||||
cancel.style.display = cancellable ? 'inline-block' : 'none';
|
||||
// Ein neuer Upload startet wieder mit aktivem Knopf; bei laufendem Abbruch bleibt er gesperrt.
|
||||
@@ -2786,6 +2847,10 @@ function hideProgress() {
|
||||
// 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'; }
|
||||
const pause = $('folderProgressPause');
|
||||
if (pause) { pause.disabled = false; pause.style.display = 'none'; }
|
||||
transferPaused = false;
|
||||
if (pause) pause.textContent = PAUSE_LABEL;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
@@ -4922,7 +4987,7 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
|
||||
const sel = await window.electronAPI.selectFolder();
|
||||
if (sel) {
|
||||
showProgress(0, 'Upload...', { cancellable: true });
|
||||
const res = await window.electronAPI.uploadLocalFolderToGitea({
|
||||
const res = await uploadFolderWithRetry({
|
||||
localFolder: sel,
|
||||
owner,
|
||||
repo: repoName,
|
||||
@@ -5618,30 +5683,148 @@ async function createGitignoreForRepo(owner, repo) {
|
||||
}
|
||||
}
|
||||
|
||||
// Zeigt die zuletzt aufgezeichneten Uploads; der Bestätigen-Knopf leert den Verlauf.
|
||||
// Zeigt die zuletzt aufgezeichneten Uploads, filterbar nach Ergebnis und Repo-Name.
|
||||
// Ein Klick auf einen Eintrag öffnet das Repo, „CSV exportieren“ speichert die gefilterte Liste.
|
||||
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();
|
||||
|
||||
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';
|
||||
card.style.cssText = 'width:min(760px,92vw);max-width:92vw;';
|
||||
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.className = 'confirm-modal-title';
|
||||
titleEl.textContent = '🕘 Upload-Verlauf';
|
||||
|
||||
const filterRow = document.createElement('div');
|
||||
filterRow.style.cssText = 'display:flex;gap:8px;margin:10px 0;flex-wrap:wrap;';
|
||||
const statusSel = document.createElement('select');
|
||||
statusSel.style.cssText = 'width:auto;flex:0 0 160px;';
|
||||
for (const [value, label] of [['alle', 'Alle'], ['ok', 'Erfolgreich'], ['abgebrochen', 'Abgebrochen'], ['fehler', 'Fehler']]) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
statusSel.appendChild(opt);
|
||||
}
|
||||
const search = document.createElement('input');
|
||||
search.type = 'search';
|
||||
search.placeholder = 'Repo suchen …';
|
||||
search.style.cssText = 'width:auto;flex:1;min-width:160px;';
|
||||
filterRow.appendChild(statusSel);
|
||||
filterRow.appendChild(search);
|
||||
|
||||
const listEl = document.createElement('div');
|
||||
listEl.className = 'confirm-modal-message';
|
||||
listEl.style.cssText = 'max-height:50vh;overflow:auto;font-size:12px;line-height:1.6;';
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'font-size:11px;opacity:0.7;margin-top:6px;';
|
||||
hint.textContent = 'Ein Klick auf einen Eintrag öffnet das Repo.';
|
||||
|
||||
const btnWrap = document.createElement('div');
|
||||
btnWrap.className = 'modal-buttons confirm-modal-buttons';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'confirm-modal-btn confirm-modal-btn--primary';
|
||||
closeBtn.textContent = 'Schließen';
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary';
|
||||
exportBtn.textContent = 'CSV exportieren';
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary';
|
||||
clearBtn.textContent = 'Verlauf leeren';
|
||||
btnWrap.append(closeBtn, exportBtn, clearBtn);
|
||||
|
||||
card.append(titleEl, filterRow, listEl, hint, btnWrap);
|
||||
modal.appendChild(card);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const currentFilter = () => ({ status: statusSel.value, query: search.value });
|
||||
|
||||
const render = async () => {
|
||||
const res = await window.electronAPI.getUploadHistory(currentFilter());
|
||||
const history = (res && res.history) || [];
|
||||
listEl.textContent = '';
|
||||
exportBtn.disabled = history.length === 0;
|
||||
if (history.length === 0) {
|
||||
listEl.textContent = statusSel.value === 'alle' && !search.value.trim()
|
||||
? 'Noch keine Uploads aufgezeichnet.'
|
||||
: 'Keine Einträge für diesen Filter.';
|
||||
return;
|
||||
}
|
||||
for (const e of history.slice(0, 40)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'upload-history-row';
|
||||
const [owner, repo] = String(e.repo || '').split('/');
|
||||
const canOpen = !!(owner && repo);
|
||||
if (canOpen) row.title = `${e.repo} öffnen`;
|
||||
else row.style.cursor = 'default';
|
||||
|
||||
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 main = document.createElement('div');
|
||||
main.textContent = `${icons[e.status] || '•'} ${when} · ${e.repo || '?'} · ${files}${failed} · ${e.kind}${took}`;
|
||||
row.appendChild(main);
|
||||
if (e.error) {
|
||||
const err = document.createElement('div');
|
||||
err.className = 'upload-history-err';
|
||||
err.textContent = e.error;
|
||||
row.appendChild(err);
|
||||
}
|
||||
if (canOpen) {
|
||||
row.addEventListener('click', () => {
|
||||
close();
|
||||
loadRepoContents(owner, repo, '');
|
||||
});
|
||||
}
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
if (history.length > 40) {
|
||||
const more = document.createElement('div');
|
||||
more.style.cssText = 'opacity:0.7;padding:6px 8px;';
|
||||
more.textContent = `… und ${history.length - 40} ältere Einträge (im CSV-Export enthalten)`;
|
||||
listEl.appendChild(more);
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||||
function close() {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
modal.remove();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
closeBtn.onclick = close;
|
||||
|
||||
exportBtn.onclick = async () => {
|
||||
exportBtn.disabled = true;
|
||||
try {
|
||||
const res = await window.electronAPI.exportUploadHistoryCsv(currentFilter());
|
||||
if (res && res.ok) showSuccess(`Verlauf exportiert: ${res.count} Einträge → ${res.savedTo}`);
|
||||
else if (res && !res.canceled) showError('Export fehlgeschlagen: ' + (res.error || 'Unbekannter Fehler'));
|
||||
} finally {
|
||||
exportBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
clearBtn.onclick = async () => {
|
||||
const ok = await showActionConfirmModal({
|
||||
title: 'Verlauf leeren',
|
||||
message: 'Alle aufgezeichneten Uploads werden gelöscht.',
|
||||
confirmText: 'Leeren',
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
await window.electronAPI.clearUploadHistory();
|
||||
render();
|
||||
};
|
||||
statusSel.onchange = render;
|
||||
let searchTimer = null;
|
||||
search.oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(render, 150); };
|
||||
render();
|
||||
}
|
||||
|
||||
async function cleanupGitignoredFiles(owner, repo, repoPath) {
|
||||
@@ -5875,7 +6058,7 @@ function setupBackgroundContextMenu() {
|
||||
showProgress(0, `Lade "${clipboard.item.name}" hoch...`, { cancellable: true });
|
||||
try {
|
||||
const res = clipboard.item.isDirectory
|
||||
? await window.electronAPI.uploadLocalFolderToGitea({
|
||||
? await uploadFolderWithRetry({
|
||||
localFolder: clipboard.item.path,
|
||||
owner,
|
||||
repo,
|
||||
@@ -5890,6 +6073,7 @@ function setupBackgroundContextMenu() {
|
||||
destPath: currentPath,
|
||||
branch: getDefaultBranch(owner, repo)
|
||||
});
|
||||
warnBlockedFiles(res);
|
||||
if (res?.cancelled) { setStatus('Upload abgebrochen'); return; }
|
||||
if (!res?.ok) throw new Error(res?.error || 'Upload fehlgeschlagen');
|
||||
showSuccess(`"${clipboard.item.name}" nach Gitea kopiert`);
|
||||
@@ -6918,6 +7102,16 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Pause/Fortsetzen (auch vom Tray-Menü ausgelöst)
|
||||
if (window.electronAPI.onTransferPauseState) {
|
||||
window.electronAPI.onTransferPauseState(({ paused }) => applyPauseState(paused));
|
||||
}
|
||||
|
||||
// Tray-Menü "Upload-Verlauf"
|
||||
if (window.electronAPI.onOpenUploadHistory) {
|
||||
window.electronAPI.onOpenUploadHistory(() => { showUploadHistory(); });
|
||||
}
|
||||
|
||||
// Progress listeners
|
||||
window.electronAPI.onFolderUploadProgress(p => {
|
||||
showProgress(p.percent, describeTransferProgress('Upload', p), { cancellable: true });
|
||||
@@ -7438,6 +7632,12 @@ function showCreateReleaseModal(owner, repo) {
|
||||
assetGroup.appendChild(assetRow);
|
||||
card.appendChild(assetGroup);
|
||||
|
||||
// Ergebnis der Prüfung des gewählten Installers (Prüfsumme, Abgleich mit der .sha256-Datei)
|
||||
const assetInfoBox = document.createElement('div');
|
||||
assetInfoBox.id = 'releaseAssetInfo';
|
||||
assetInfoBox.style.cssText = 'display:none;margin:-4px 0 12px;padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.5;border:1px solid rgba(255,255,255,0.12);';
|
||||
card.appendChild(assetInfoBox);
|
||||
|
||||
const optionsGroup = document.createElement('div');
|
||||
optionsGroup.className = 'input-group';
|
||||
optionsGroup.style.cssText = 'display: flex; gap: 20px;';
|
||||
@@ -7480,17 +7680,67 @@ function showCreateReleaseModal(owner, repo) {
|
||||
|
||||
// Variable zum Speichern des gewählten Dateipfads
|
||||
let selectedAssetPath = null;
|
||||
// Prüfergebnis zum gewählten Asset (Name, SHA-256, Version, .sha256-Datei) oder null
|
||||
let assetInfo = null;
|
||||
|
||||
const showAssetInfo = (kind, lines) => {
|
||||
const colors = {
|
||||
ok: ['rgba(74,222,128,0.12)', 'rgba(74,222,128,0.4)'],
|
||||
warn: ['rgba(251,191,36,0.12)', 'rgba(251,191,36,0.4)'],
|
||||
error: ['rgba(248,113,113,0.14)', 'rgba(248,113,113,0.5)']
|
||||
}[kind];
|
||||
assetInfoBox.style.display = 'block';
|
||||
assetInfoBox.style.background = colors[0];
|
||||
assetInfoBox.style.borderColor = colors[1];
|
||||
assetInfoBox.textContent = '';
|
||||
for (const line of lines) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = line;
|
||||
assetInfoBox.appendChild(div);
|
||||
}
|
||||
};
|
||||
|
||||
// Prüft die gewählte Datei (Prüfsumme, Version, .sha256-Datei) und füllt Tag und Name vor.
|
||||
const applyAsset = async (filePath) => {
|
||||
selectedAssetPath = filePath;
|
||||
assetInfo = null;
|
||||
const fileName = filePath.split(/[\\/]/).pop();
|
||||
$('releaseAssetInput').value = fileName;
|
||||
$('releaseAssetInput').style.color = 'var(--text-primary)';
|
||||
$('releaseAssetInput').style.borderColor = 'var(--accent-primary)';
|
||||
showAssetInfo('warn', ['Prüfe Datei und berechne SHA-256 …']);
|
||||
|
||||
const info = await window.electronAPI.inspectReleaseFile({ filePath });
|
||||
if (!info || !info.ok) {
|
||||
showAssetInfo('error', [`Datei konnte nicht geprüft werden: ${info?.error || 'Unbekannter Fehler'}`]);
|
||||
return;
|
||||
}
|
||||
assetInfo = info;
|
||||
|
||||
if (info.version) {
|
||||
if (!$('releaseTag').value.trim()) $('releaseTag').value = `v${info.version}`;
|
||||
if (!$('releaseName').value.trim()) $('releaseName').value = `Version ${info.version}`;
|
||||
}
|
||||
|
||||
const lines = [`${info.name} · ${formatTransferBytes(info.size)}`, `SHA-256: ${info.sha256}`];
|
||||
if (info.sidecarMatches === true) {
|
||||
lines.push('✓ Stimmt mit der Datei .sha256 überein. Beide werden hochgeladen.');
|
||||
showAssetInfo('ok', lines);
|
||||
} else if (info.sidecarMatches === false) {
|
||||
lines.push(`✗ Die Datei .sha256 nennt eine andere Prüfsumme (${info.sidecarSha256}). Der Installer wurde nach dem Bauen verändert oder die Prüfsumme gehört zu einer anderen Version. Das Release wird so nicht erstellt.`);
|
||||
showAssetInfo('error', lines);
|
||||
} else {
|
||||
lines.push('Keine .sha256-Datei neben dem Installer gefunden. Die Prüfsumme wird nur in den Release-Text eingetragen.');
|
||||
showAssetInfo('warn', lines);
|
||||
}
|
||||
};
|
||||
|
||||
// Event Listener: Datei auswählen
|
||||
$('btnSelectReleaseAsset').onclick = async () => {
|
||||
try {
|
||||
const res = await window.electronAPI.selectFile();
|
||||
if (res.ok && res.files && res.files.length > 0) {
|
||||
selectedAssetPath = res.files[0];
|
||||
const fileName = selectedAssetPath.split(/[\\/]/).pop();
|
||||
$('releaseAssetInput').value = fileName;
|
||||
$('releaseAssetInput').style.color = 'var(--text-primary)';
|
||||
$('releaseAssetInput').style.borderColor = 'var(--accent-primary)';
|
||||
await applyAsset(res.files[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Auswählen der Datei:', error);
|
||||
@@ -7512,6 +7762,29 @@ function showCreateReleaseModal(owner, repo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mit gewähltem Installer: Prüfsumme abgleichen, Tag gegen vorhandene Releases prüfen und die
|
||||
// Prüfsumme in den Release-Text schreiben, damit der Updater das Release findet und verifiziert.
|
||||
let releaseBody = body;
|
||||
if (assetInfo) {
|
||||
if (assetInfo.sidecarMatches === false) {
|
||||
await showInfoModal('Prüfsumme stimmt nicht', 'Die Datei .sha256 passt nicht zum Installer. Bitte neu bauen (npm run build) und den Installer erneut wählen.', true);
|
||||
return;
|
||||
}
|
||||
const prep = await window.electronAPI.prepareRelease({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
notes: body,
|
||||
assets: [{ name: assetInfo.name, sha256: assetInfo.sha256 }],
|
||||
platform: currentState.platform
|
||||
});
|
||||
if (!prep || !prep.ok) {
|
||||
await showInfoModal('Release nicht möglich', prep?.error || 'Unbekannter Fehler', true);
|
||||
return;
|
||||
}
|
||||
releaseBody = prep.body;
|
||||
}
|
||||
|
||||
setStatus('Creating release...');
|
||||
|
||||
try {
|
||||
@@ -7521,7 +7794,7 @@ function showCreateReleaseModal(owner, repo) {
|
||||
repo,
|
||||
tag_name: tag,
|
||||
name,
|
||||
body,
|
||||
body: releaseBody,
|
||||
target_commitish: target,
|
||||
prerelease,
|
||||
draft,
|
||||
@@ -7545,6 +7818,20 @@ function showCreateReleaseModal(owner, repo) {
|
||||
});
|
||||
|
||||
if (uploadRes.ok) {
|
||||
// Prüfsummen-Datei neben den Installer legen, so sucht der Updater sie.
|
||||
if (assetInfo && assetInfo.sidecarPath) {
|
||||
showProgress(80, 'Lade Prüfsummen-Datei hoch…');
|
||||
const sidecarRes = await window.electronAPI.uploadReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
releaseId: res.release.id,
|
||||
filePath: assetInfo.sidecarPath,
|
||||
fileName: assetInfo.sidecarPath.split(/[\\/]/).pop()
|
||||
});
|
||||
if (!sidecarRes || !sidecarRes.ok) {
|
||||
showWarning('Release erstellt, aber die .sha256-Datei konnte nicht hochgeladen werden. Die Prüfsumme steht im Release-Text.');
|
||||
}
|
||||
}
|
||||
setStatus(`Release "${tag}" und Asset erstellt!`);
|
||||
} else {
|
||||
console.error('Asset Upload fehlgeschlagen:', uploadRes.error);
|
||||
|
||||
@@ -38,10 +38,13 @@
|
||||
set('prefZoom', String(p.zoom));
|
||||
if ($('prefZoom') && !$('prefZoom').value) $('prefZoom').value = '100';
|
||||
set('prefAutoUpdate', p.autoUpdateCheck);
|
||||
set('prefShowSplash', p.showSplash);
|
||||
set('prefCloseAction', p.closeAction);
|
||||
set('prefCloneDir', p.defaultCloneDir);
|
||||
set('prefSecretScan', p.uploadSecretScan);
|
||||
set('prefBlockSecrets', p.uploadBlockSecrets);
|
||||
set('prefUploadPreview', p.uploadPreview);
|
||||
set('prefNotifyTransfers', p.notifyTransfers);
|
||||
set('prefUploadExcludes', p.uploadExtraExcludes);
|
||||
}
|
||||
|
||||
@@ -54,10 +57,13 @@
|
||||
theme: val('prefTheme'),
|
||||
zoom: Number(val('prefZoom')),
|
||||
autoUpdateCheck: chk('prefAutoUpdate'),
|
||||
showSplash: chk('prefShowSplash'),
|
||||
closeAction: val('prefCloseAction'),
|
||||
defaultCloneDir: val('prefCloneDir'),
|
||||
uploadSecretScan: chk('prefSecretScan'),
|
||||
uploadBlockSecrets: chk('prefBlockSecrets'),
|
||||
uploadPreview: val('prefUploadPreview'),
|
||||
notifyTransfers: val('prefNotifyTransfers'),
|
||||
uploadExtraExcludes: val('prefUploadExcludes')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Preload für das Tray-Menüfenster (renderer/tray-menu.html): nur die vier nötigen Kanäle.
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('trayApi', {
|
||||
onState: (cb) => ipcRenderer.on('tray-menu-state', (_event, state) => cb(state)),
|
||||
action: (id) => ipcRenderer.send('tray-menu:action', id),
|
||||
resize: (height) => ipcRenderer.send('tray-menu:resize', height),
|
||||
close: () => ipcRenderer.send('tray-menu:close')
|
||||
});
|
||||
Reference in New Issue
Block a user