65 Commits
Author SHA1 Message Date
M_Viper 6e1cee9a59 Delete 16 files via Git Manager GUI 2026-10-22 09:14:12 +00:00
M_Viper 2e26aaa41c Delete 100 files via Git Manager GUI 2026-10-22 09:14:11 +00:00
M_Viper eb13bd071b Delete 100 files via Git Manager GUI 2026-10-22 09:14:10 +00:00
M_Viper 156b8e3041 Delete 100 files via Git Manager GUI 2026-10-22 09:14:08 +00:00
M_Viper 9d3472dff0 Delete 100 files via Git Manager GUI 2026-10-22 09:14:06 +00:00
M_Viper 0029668033 Delete 100 files via Git Manager GUI 2026-10-22 09:14:05 +00:00
M_Viper a809efd91a Delete 100 files via Git Manager GUI 2026-10-22 09:14:03 +00:00
M_Viper a902a0c5d7 Delete 100 files via Git Manager GUI 2026-10-22 09:14:02 +00:00
M_Viper 7fa0db02b5 Delete 100 files via Git Manager GUI 2026-10-22 09:14:00 +00:00
M_Viper 673affe1d1 Delete 100 files via Git Manager GUI 2026-10-22 09:13:57 +00:00
M_Viper 65dd67cef5 Delete 100 files via Git Manager GUI 2026-10-22 09:13:53 +00:00
M_Viper ef68a305ce Delete 100 files via Git Manager GUI 2026-10-22 09:13:49 +00:00
M_Viper e154cc507e Delete 100 files via Git Manager GUI 2026-10-22 09:13:46 +00:00
M_Viper 56b5cb17d0 Delete 100 files via Git Manager GUI 2026-10-22 09:13:44 +00:00
M_Viper e178605833 Delete 100 files via Git Manager GUI 2026-10-22 09:13:42 +00:00
M_Viper f4c16b173f Upload via GUI (85 Dateien) 2026-10-22 09:13:21 +00:00
M_Viper 2ad8777311 Upload via GUI (85 Dateien) 2026-10-22 09:13:16 +00:00
M_Viper ce5a655758 Delete 13 files via Git Manager GUI 2026-10-22 07:39:41 +00:00
M_Viper 7660b5de1a Delete data/credentials.json via Git Manager GUI 2026-10-22 07:39:34 +00:00
M_Viper 50705d1197 Delete .claude/settings.local.json via Git Manager GUI 2026-10-22 07:39:18 +00:00
M_Viper 1f635d33cc Upload via GUI (60 Dateien) 2026-10-22 07:22:51 +00:00
M_Viper 38a75bc720 Upload via GUI (60 Dateien) 2026-10-22 07:22:49 +00:00
M_Viper ead123c62b Upload via GUI (12 Dateien) 2026-10-22 07:16:16 +00:00
M_Viper 9b3aed6f0a Upload via GUI (28 Dateien) 2026-10-22 07:15:59 +00:00
M_Viper 99d9629e25 Upload via GUI (10 Dateien) 2026-10-22 07:15:41 +00:00
M_Viper 85a222a9fb Upload via GUI (7543 Dateien) 2026-10-22 07:03:23 +00:00
M_Viper dedb09b9c6 Upload via GUI (7543 Dateien) 2026-10-22 07:03:19 +00:00
M_Viper c45ad3ba32 Upload via GUI (7543 Dateien) 2026-10-22 07:03:16 +00:00
M_Viper 937c72ce4a Upload via GUI (7543 Dateien) 2026-10-22 07:03:12 +00:00
M_Viper 61d6d11a2f Upload via GUI (7543 Dateien) 2026-10-22 07:03:09 +00:00
M_Viper ef911afb06 Upload via GUI (7543 Dateien) 2026-10-22 07:03:04 +00:00
M_Viper 4f9494ae7b Upload via GUI (7543 Dateien) 2026-10-22 07:03:00 +00:00
M_Viper 37820af6dd Upload via GUI (7543 Dateien) 2026-10-22 07:02:57 +00:00
M_Viper 9c6a69cc53 Upload via GUI (7543 Dateien) 2026-10-22 07:02:52 +00:00
M_Viper eb549d3975 Upload via GUI (7543 Dateien) 2026-10-22 07:02:42 +00:00
M_Viper b2314f6fe6 Upload via GUI (7543 Dateien) 2026-10-22 07:02:28 +00:00
M_Viper 42fb8dfe9b Upload via GUI (7543 Dateien) 2026-10-22 07:02:04 +00:00
M_Viper 54baf9b874 Upload via GUI (7543 Dateien) 2026-10-22 07:01:41 +00:00
M_Viper 8d29040b57 Upload via GUI (7543 Dateien) 2026-10-22 07:01:11 +00:00
M_Viper 8c7b2ddf47 Upload via GUI (7543 Dateien) 2026-10-22 07:00:36 +00:00
M_Viper 03f4fa6fbc Upload via GUI (7543 Dateien) 2026-10-22 06:59:59 +00:00
M_Viper a57786fe54 Upload via GUI (7543 Dateien) 2026-10-22 06:59:38 +00:00
M_Viper 6f73a87314 Upload via GUI (7543 Dateien) 2026-10-22 06:59:13 +00:00
M_Viper 7ef5522258 Upload via GUI (7543 Dateien) 2026-10-22 06:58:47 +00:00
M_Viper 860c714301 Upload via GUI (7543 Dateien) 2026-10-22 06:58:16 +00:00
M_Viper 68133f4d0f Upload via GUI (7543 Dateien) 2026-10-22 06:57:52 +00:00
M_Viper 75cc88a011 Upload via GUI (7543 Dateien) 2026-10-22 06:57:20 +00:00
M_Viper bac355eabb Upload via GUI (7543 Dateien) 2026-10-22 06:56:47 +00:00
M_Viper d7658c873f Upload via GUI (7543 Dateien) 2026-10-22 06:56:30 +00:00
M_Viper 8830aac76c Upload via GUI (7543 Dateien) 2026-10-22 06:56:23 +00:00
M_Viper 336d9e600b Upload via GUI (7543 Dateien) 2026-10-22 06:56:21 +00:00
M_Viper d6518698b1 Upload via GUI (7543 Dateien) 2026-10-22 06:56:19 +00:00
M_Viper e4585e50ee Upload via GUI (7543 Dateien) 2026-10-22 06:56:17 +00:00
M_Viper 12a2f5faa9 Upload via GUI (7543 Dateien) 2026-10-22 06:56:16 +00:00
M_Viper 8aede65bd7 Upload via GUI (7543 Dateien) 2026-10-22 06:56:15 +00:00
M_Viper ae68900363 Upload via GUI (7543 Dateien) 2026-10-22 06:55:39 +00:00
M_Viper 81c541a0b5 Upload via GUI (7543 Dateien) 2026-10-22 06:55:33 +00:00
M_Viper 089257d740 Upload via GUI (7543 Dateien) 2026-10-22 06:55:01 +00:00
M_Viper 2ecc57c761 Upload via GUI (7543 Dateien) 2026-10-22 06:54:55 +00:00
M_Viper 4654eed12c Upload via GUI (7543 Dateien) 2026-10-22 06:54:26 +00:00
M_Viper 434a4e1c7b Upload via GUI (7543 Dateien) 2026-10-22 06:54:18 +00:00
M_Viper a0546c562d Upload via GUI (7543 Dateien) 2026-10-22 06:54:05 +00:00
M_Viper 264f1b7e45 Upload via GUI (7543 Dateien) 2026-10-22 06:54:02 +00:00
M_Viper 3b31c5893c Upload via GUI (7543 Dateien) 2026-10-22 06:53:49 +00:00
M_Viper 047196c4d0 Upload via GUI (7543 Dateien) 2026-10-22 06:53:45 +00:00
112 changed files with 19184 additions and 7753 deletions
+5
View File
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.png binary
*.ico binary
*.exe binary
*.blockmap binary
+58
View File
@@ -0,0 +1,58 @@
# ===== Zugangsdaten / Secrets (NIE hochladen!) =====
/data/
credentials.json
*.env
.env*
*.pem
*.key
*.pfx
*.p12
# ===== Abhängigkeiten =====
node_modules/
.npm/
.cache/
# ===== Build-Ausgaben =====
dist/
out/
release/
*.exe
*.blockmap
*.sha256
*.sha256sum
# ===== .NET Installer (Visual Studio / dotnet) =====
installer/bin/
installer/obj/
**/bin/
**/obj/
.vs/
*.user
*.suo
# ===== Große gebündelte Binaries (MinGit, ca. 93 MB) =====
vendor/mingit/
# ===== Laufzeitdaten / Backups / Repos =====
/repos/
/backup/
*.zip
*.rar
*.7z
# ===== Logs =====
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ===== Editor / OS =====
.vscode/
.idea/
.DS_Store
Thumbs.db
desktop.ini
# ===== Claude Code (lokale Einstellungen) =====
.claude/settings.local.json
+50 -1
View File
@@ -282,9 +282,58 @@ npm run build
# Output: dist/git-manager-gui-Setup-2.x.x.exe # Output: dist/git-manager-gui-Setup-2.x.x.exe
``` ```
### Abbrechbare Vorgänge
Uploads, Ordner-Downloads und Batch-Clones laufen über **ein** gemeinsames Abbruch-System:
- `main.js`: `beginCancellableUpload()` legt einen `AbortController` an, `endCancellableUpload()` räumt auf (immer im `finally`). Der IPC-Kanal `cancel-upload` bricht alle laufenden Vorgänge ab.
- Das `signal` wird durchgereicht an `simple-git` (`abort`), `execGit`/`runGitAsync` (`signal`), `runLimited` und `uploadItemsViaGiteaApi` (axios `signal`).
- Ein Abbruch ist **kein Fehler**: Er läuft als `UPLOAD_CANCELLED` durch die Kette (`createUploadCancelledError`, `isUploadCancelledError`, `throwIfUploadCancelled` in `src/git/apiHandler.js`), löst keinen API-Ausweichweg aus und kommt als `{ ok: false, cancelled: true }` im Renderer an.
- Neuer abbrechbarer Handler: `beginCancellableUpload()` am Anfang, `signal` weiterreichen, im `catch` `isUploadCancelledError(e)` abfangen, im `finally` `endCancellableUpload()`. Im Renderer `showProgress(..., { cancellable: true })`.
- Unter Windows beendet `spawn(signal)` nur `git.exe`; `execGit` beendet deshalb den ganzen Prozessbaum (`taskkill /T /F`) und kehrt sofort zurück.
### Pause, Sperre, Benachrichtigung, Release-Assistent
- **Pause:** `src/features/pauseGate.js` (`createPauseGate`). In `main.js` gibt es genau ein `transferGate`. Kontrollpunkte rufen `await transferGate.wait(signal)` auf (in `runLimited`, vor dem Staging der Git-Wege, zwischen den Paketen von `uploadItemsViaGiteaApi` über `waitIfPaused`, vor jedem Batch-Clone). Ein Abbruch löst Wartende sofort aus. Neue langlaufende Schritte brauchen einen eigenen Kontrollpunkt.
- **Zugangsdaten-Sperre:** `src/features/uploadBlocklist.js` nutzt die Dateinamen-Regeln aus `secretScan.js`. `removeBlockedInPlace()` und `blockedSecretReason()` in `main.js` sind in allen Upload-Handlern eingebaut, die Ergebnisse tragen `blockedFiles`. Neuer Upload-Weg: dort ebenfalls aufrufen. Einstellung `uploadBlockSecrets`.
- **Benachrichtigung:** `src/features/transferNotice.js` baut den Text aus dem Verlaufseintrag, `maybeNotifyTransfer()` in `main.js` zeigt ihn nur, wenn das Hauptfenster nicht vorn ist (Einstellung `notifyTransfers`).
- **Release-Assistent:** `src/features/releaseAssist.js` (Version aus Dateiname, Versionsvergleich, Tag-Prüfung, Release-Text mit Prüfsummen). IPC `inspect-release-file` und `prepare-release`, Oberfläche in `showCreateReleaseModal()`. Das Prüfsummen-Format in `buildReleaseBody` muss zu `extractChecksumFromReleaseBody` in `updater.js` passen.
### Startanimation
Nach dem Vorbild des AeroMC-Launchers (`AeroMC-Launcher/src/startanimation.js`):
1. `createWindow()` berechnet Position und Größe (`centeredBounds`) und legt über `begin()` zuerst ein randloses, durchsichtiges Fenster mit `renderer/splash.html` an: das Logo dreht sich (CSS `rotateY`), darunter der Schriftzug.
2. Das Hauptfenster entsteht danach mit denselben Maßen, verborgen, und lädt.
3. `play()` wartet auf `did-finish-load` (höchstens 12 s) und mindestens 1,6 s Drehzeit. Dann zeigt es das Hauptfenster unsichtbar (Deckkraft 0, Mausereignisse aus), wartet auf die gezeichnete Oberfläche (Repo-Liste oder höchstens 1,5 s), fotografiert es mit `capturePage()` und lässt `aufbauen()` in `splash.js` das Bild von der Mitte aus aufziehen.
4. Am Ende übernimmt das echte Fenster an derselben Stelle. Fehler und Zeitüberschreitungen zeigen das Hauptfenster immer trotzdem.
**Stolperstein:** `setIgnoreMouseEvents(true)` am Startfenster darf erst nach dem Zeigen und leicht verzögert aufgerufen werden (`IGNORE_MOUSE_DELAY_MS`). Davor aufgerufen wird das durchsichtige Fenster unter Windows weiß. `scripts/ui-smoke-splash.js` prüft das über eine Bildschirmaufnahme (Farbe an einer leeren Stelle muss der des Desktops entsprechen) und darf deshalb die Hardware-Beschleunigung nicht abschalten.
Beim Start mit `--hidden` und bei abgeschalteter Einstellung `showSplash` gibt es die Animation nicht. `scripts/ui-smoke-splash.js` prüft sie mit einem Ersatz-Hauptfenster. Im Entwicklungsmodus (`electron .`) startet die App wegen der vielen `node_modules`-Dateien deutlich langsamer als das gepackte Programm; die Animation selbst wird dadurch nicht langsamer.
### Oberflächen-Testlauf
`npm run test:ui` (`scripts/ui-smoke.js`) startet die App mit leerem eigenen `APPDATA`, steuert sie über den Debug-Port und prüft Toolbar-Layout bei drei Breiten, Fortschrittsbalken, Pause, Verlauf, Sperre, Release-Prüfung und Vorlagen. `scripts/ui-smoke-tray.js` prüft das Tray-Menüfenster in echtem Electron. Der Lauf berührt weder echte Zugangsdaten noch einen Server und dauert gut eine Minute.
### Tray-Menü
Das Tray-Symbol öffnet kein natives Menü, sondern ein eigenes kleines Fenster, denn das native Electron-Menü zeichnet unter Windows keine Hover-Markierung:
- `src/features/trayMenu.js`: Inhalt (`buildTrayTemplate`, `serializeTemplate`, `runAction`, `trayTooltip`), ohne Electron und damit testbar. Neuer Eintrag: dort mit `id` ergänzen, die Aktion in `trayTemplate()` in `main.js` verdrahten.
- `src/features/trayPopup.js`: das Fenster (frameless, transparent, schließt bei Fokusverlust und Esc). Es wird 2 Sekunden nach dem Start vorgeladen, weil der erste Renderer-Start mehrere Sekunden dauert.
- `renderer/tray-menu.html`, `renderer/tray-menu.js`, `tray-menu-preload.js`: Darstellung (hell/dunkel nach Windows-Design, Pfeiltasten und Enter) und die vier IPC-Kanäle.
- Das Fenster ist von der Zoom-Einstellung ausgenommen und wird beim Beenden zerstört, sonst hielte das versteckte Fenster die App am Leben.
### Upload-Verlauf und Wiederholen
- `src/features/uploadHistory.js` (`buildEntry`, `filterEntries`, `createUploadHistory`): speichert die letzten 100 Uploads in `upload-history.json` im userData-Ordner. `main.js` umhüllt dafür `ipcMain.handle` für die Kanäle in `UPLOAD_HISTORY_CHANNELS`, neue Upload-Kanäle dort eintragen.
- Teilfehler: `buildPartialUploadError` hängt die fehlgeschlagenen Einträge an den Fehler (`failedItems`). Die Handler geben sie als `retryItems` zurück, der Renderer sendet sie über den Parameter `items` erneut (`retryFailedUploads`).
- `.gitignore`-Vorlagen: `src/features/gitignoreTemplates.js`.
### Version erhöhen ### Version erhöhen
1. Version in `package.json` updaten: `"version": "2.1.4"` 1. Version in `package.json` updaten: `"version": "2.2.1"`
2. Changelog aktualisieren (optional) 2. Changelog aktualisieren (optional)
3. Build ausführen 3. Build ausführen
4. GitHub Release erstellen mit der .exe 4. GitHub Release erstellen mit der .exe
+1462 -1394
View File
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -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 Upload** — ziehe Dateien ins App-Fenster zum hochladen
- 📥 **Drag & Drop Download** — ziehe Repos raus zum herunterladen - 📥 **Drag & Drop Download** — ziehe Repos raus zum herunterladen
- 🔄 **Rename & Move** — Dateien verwalten direkt im Editor - 🔄 **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 ### 📊 Versionscontrol
- 📊 **Commit-History** mit Filter nach Autor und Nachricht - 📊 **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>K</kbd> | Repo-Suche fokussieren |
| <kbd>Ctrl</kbd>+<kbd>S</kbd> | Datei im Editor speichern | | <kbd>Ctrl</kbd>+<kbd>S</kbd> | Datei im Editor speichern |
| <kbd>Ctrl</kbd>+<kbd>F</kbd> | Im Editor suchen | | <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>Rechtsklick</kbd> | Kontextmenü (Repos & Dateien) |
| <kbd>Drag</kbd> raus | Datei/Repo herunterladen | | <kbd>Drag</kbd> raus | Datei/Repo herunterladen |
| <kbd>Drag</kbd> rein | Datei hochladen | | <kbd>Drag</kbd> rein | Datei hochladen |
@@ -176,6 +185,19 @@ Fehler gefunden oder Idee für neue Features?
## 📝 Changelog ## 📝 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) ### v2.1.3 (13. Mai 2026)
- ✨ Projektname wird korrekt ausgeblendet bei Wechsel zur Übersicht - ✨ Projektname wird korrekt ausgeblendet bei Wechsel zur Übersicht
- 🐛 Bug-Fix: Zurück-Button setzt Gravur korrekt zurück - 🐛 Bug-Fix: Zurück-Button setzt Gravur korrekt zurück
+116
View File
@@ -0,0 +1,116 @@
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');
require('../src/git/gitBinary.js').useBundledGit();
const { registerFeatureIpc } = require('../src/features/featuresIpc.js');
// Drei Repos; das ZIP von "langsam" kommt in kleinen Stücken über ~3 s, damit ein Abbruch mitten hineinfällt
function fakeGitea() {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
if (u.pathname === '/api/v1/user/repos') {
if (u.searchParams.get('page') !== '1') return send(200, []);
const port = server.address().port;
return send(200, ['eins', 'langsam', 'drei'].map(name => ({
name, owner: { login: 'M_Viper' }, default_branch: 'main', empty: false,
clone_url: `http://127.0.0.1:${port}/M_Viper/${name}.git`
})));
}
const m = u.pathname.match(/^\/api\/v1\/repos\/M_Viper\/(\w+)\/archive\/main\.zip$/);
if (m) {
const total = 64 * 1024;
res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': String(total) });
if (m[1] !== 'langsam') return res.end(Buffer.alloc(total, 1));
let sent = 0;
const t = setInterval(() => {
if (res.destroyed) return clearInterval(t);
res.write(Buffer.alloc(2048, 1));
sent += 2048;
if (sent >= total) { clearInterval(t); res.end(); }
}, 100);
return;
}
send(404, { message: 'not found' });
});
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, url: `http://127.0.0.1:${server.address().port}` })));
}
function setup(url) {
const handlers = {};
const events = [];
registerFeatureIpc({
ipcMain: { handle: (name, fn) => { handlers[name] = fn; } },
app: { getPath: () => fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ud-')) },
readCredentials: () => ({ giteaURL: url, giteaToken: 't' }),
buildItemsFromPaths: async () => ({ items: [], skipped: [], ignored: [] }),
clearGitTransportBlock: () => false,
broadcast: (channel, p) => { if (channel === 'repo-backup-progress') events.push(p); }
});
return { call: (name, data) => handlers[name]({ sender: { send() {} } }, data), events };
}
test('Backup meldet Fortschritt: Phasen, aktuelles Repo, Bytes und Ergebnis je Repo', async () => {
const g = await fakeGitea();
try {
const { call, events } = setup(g.url);
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-bk-'));
await call('repo-backup-set-config', { targetDir: target });
const r = await call('repo-backup-run-now', {});
assert.strictEqual(r.summary.ok, 3, JSON.stringify(r));
const phases = [...new Set(events.map(e => e.phase))];
assert.deepStrictEqual(phases, ['start', 'list', 'running', 'done']);
assert.deepStrictEqual(events.find(e => e.phase === 'running').repos, ['M_Viper/eins', 'M_Viper/langsam', 'M_Viper/drei']);
assert.ok(events.some(e => e.repo === 'M_Viper/langsam' && e.bytes > 0 && e.bytesTotal === 64 * 1024), 'Download-Fortschritt fehlt');
const last = events[events.length - 1];
assert.strictEqual(last.results.length, 3);
assert.ok(last.results.every(x => x.ok && x.method === 'ZIP'));
assert.ok(!events.some(e => 'abort' in e), 'interne Felder dürfen nicht nach außen gehen');
// Während kein Backup läuft, gibt es auch keinen Zwischenstand
const state = await call('repo-backup-get-config', {});
assert.strictEqual(state.running, false);
assert.strictEqual(state.progress, null);
} finally {
g.server.close();
}
});
test('Abbrechen beendet den laufenden ZIP-Download sofort und überspringt den Rest', async () => {
const g = await fakeGitea();
try {
const { call, events } = setup(g.url);
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-bk-'));
await call('repo-backup-set-config', { targetDir: target });
const started = Date.now();
const running = call('repo-backup-run-now', {});
// warten, bis der langsame Download läuft
while (!events.some(e => e.repo === 'M_Viper/langsam' && e.bytes > 0)) {
if (Date.now() - started > 10000) throw new Error('Download startete nicht');
await new Promise(r => setTimeout(r, 50));
}
const state = await call('repo-backup-get-config', {});
assert.strictEqual(state.running, true);
assert.strictEqual(state.progress.repo, 'M_Viper/langsam');
assert.strictEqual((await call('repo-backup-cancel', {})).ok, true);
const r = await running;
assert.ok(Date.now() - started < 2500, 'Abbruch muss den Download sofort beenden');
assert.strictEqual(r.ok, false);
assert.strictEqual(r.summary.cancelled, true);
assert.strictEqual(r.summary.ok, 1);
assert.strictEqual(r.summary.skipped, 2);
// keine halbe Datei zurücklassen
const archiv = path.join(target, 'M_Viper', 'langsam-archiv');
assert.deepStrictEqual(fs.existsSync(archiv) ? fs.readdirSync(archiv) : [], []);
assert.strictEqual((await call('repo-backup-cancel', {})).ok, false);
} finally {
g.server.close();
}
});
+60
View File
@@ -0,0 +1,60 @@
const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const { deleteGiteaPathsBatched } = require('../src/git/apiHandler.js');
// Minimaler Gitea-Nachbau: Repo-Info, Trees-API (seitenweise) und Mehrfach-Commit-Endpunkt.
function fakeGitea(paths, { perPage = 1000, failPath = null } = {}) {
const files = new Map(paths.map((p, i) => [p, `sha${i}`]));
const commits = [];
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
if (req.method === 'GET' && u.pathname === '/api/v1/repos/o/r') return send(200, { default_branch: 'main' });
if (req.method === 'GET' && u.pathname === '/api/v1/repos/o/r/git/trees/main') {
const page = Number(u.searchParams.get('page') || 1);
const all = [...files.keys()].map(p => ({ path: p, type: 'blob', sha: files.get(p) }));
const slice = all.slice((page - 1) * perPage, page * perPage);
return send(200, { tree: slice, truncated: page * perPage < all.length, total_count: all.length, page });
}
if (req.method === 'POST' && u.pathname === '/api/v1/repos/o/r/contents') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
const { files: ops } = JSON.parse(body);
if (failPath && ops.some(o => o.path === failPath)) return send(422, { message: 'kaputt' });
for (const o of ops) {
assert.strictEqual(o.operation, 'delete');
assert.strictEqual(o.sha, files.get(o.path));
files.delete(o.path);
}
commits.push(ops.length);
send(201, {});
});
return;
}
send(404, { message: 'object does not exist' });
});
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, files, commits, url: `http://127.0.0.1:${server.address().port}` })));
}
test('Ordner wird paketweise gelöscht, Nachbarn bleiben', async () => {
const paths = [...Array.from({ length: 2500 }, (_, i) => `mobile/build/f${i}.dex`), 'mobile/build.gradle.kts', 'mobile/src/A.kt'];
const g = await fakeGitea(paths, { perPage: 1000 });
try {
const r = await deleteGiteaPathsBatched({ token: 't', url: g.url, owner: 'o', repo: 'r', prefix: 'mobile/build' });
assert.strictEqual(r.deleted, 2500);
assert.deepStrictEqual(g.commits, Array(25).fill(100));
assert.deepStrictEqual([...g.files.keys()].sort(), ['mobile/build.gradle.kts', 'mobile/src/A.kt']);
} finally { g.server.close(); }
});
test('eine fehlerhafte Datei blockiert nicht das ganze Paket', async () => {
const paths = Array.from({ length: 10 }, (_, i) => `x/f${i}`);
const g = await fakeGitea(paths, { failPath: 'x/f3' });
try {
const r = await deleteGiteaPathsBatched({ token: 't', url: g.url, owner: 'o', repo: 'r', paths });
assert.strictEqual(r.deleted, 9);
assert.deepStrictEqual(r.failed.map(f => f.path), ['x/f3']);
} finally { g.server.close(); }
});
+162
View File
@@ -0,0 +1,162 @@
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 { spawnSync } = require('node:child_process');
require('../src/git/gitBinary.js').useBundledGit();
const { registerFeatureIpc, parsePorcelainZ } = require('../src/features/featuresIpc.js');
const { scanForSecrets, checkName } = require('../src/features/secretScan.js');
// Minimaler Gitea-Nachbau für Diagnose, PRs/Issues und Backup
function fakeGitea() {
const calls = [];
const comments = [];
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
calls.push(`${req.method} ${u.pathname}`);
const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
const p = u.pathname;
if (p === '/api/v1/version') return send(200, { version: '1.24.0' });
if (p === '/api/v1/user') return send(200, { login: 'M_Viper' });
if (p === '/api/v1/user/repos') {
if (u.searchParams.get('page') && u.searchParams.get('page') !== '1') return send(200, []);
return send(200, [{ name: 'deskhub', owner: { login: 'M_Viper' }, default_branch: 'main', empty: false,
clone_url: `http://127.0.0.1:${server.address().port}/M_Viper/deskhub.git` }]);
}
if (p === '/api/v1/repos/M_Viper/deskhub/pulls') return send(200, [{ number: 3, title: 'Fix', state: 'open', user: { login: 'a' }, labels: [], head: { label: 'fix' }, base: { label: 'main' }, mergeable: true }]);
if (p === '/api/v1/repos/M_Viper/deskhub/issues' && req.method === 'GET') return send(200, [{ number: 5, title: 'Bug', state: 'open', labels: [{ name: 'bug', color: 'ff0000' }] }]);
if (p === '/api/v1/repos/M_Viper/deskhub/issues' && req.method === 'POST') return send(201, { number: 6, ...JSON.parse(body), state: 'open', labels: [] });
if (p === '/api/v1/repos/M_Viper/deskhub/issues/5/comments' && req.method === 'POST') { comments.push(JSON.parse(body).body); return send(201, {}); }
if (p === '/api/v1/repos/M_Viper/deskhub/issues/5/comments') return send(200, comments.map((b, i) => ({ id: i, body: b, user: { login: 'M_Viper' } })));
if (p === '/api/v1/repos/M_Viper/deskhub/issues/5') return send(200, { number: 5, title: 'Bug', state: 'open', labels: [] });
if (p === '/api/v1/repos/M_Viper/deskhub/pulls/3/merge') return send(200, {});
if (p === '/api/v1/repos/M_Viper/deskhub/archive/main.zip') {
res.statusCode = 200; res.setHeader('Content-Type', 'application/zip'); return res.end(Buffer.from('PK\u0003\u0004fake'));
}
send(404, { message: 'not found' });
});
});
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, calls, comments, url: `http://127.0.0.1:${server.address().port}` })));
}
function setup(url) {
const handlers = {};
const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ud-'));
registerFeatureIpc({
ipcMain: { handle: (name, fn) => { handlers[name] = fn; } },
app: { getPath: () => userData },
readCredentials: () => ({ giteaURL: url, giteaToken: 'geheim123' }),
buildItemsFromPaths: async (paths) => ({
items: paths.map(p => ({ localFile: p, targetPath: path.basename(p) })), skipped: [], ignored: []
}),
clearGitTransportBlock: () => false
});
const call = (name, data) => handlers[name]({ sender: { send() {} } }, data);
return { call, userData };
}
test('Geheimnisse werden an Name und Inhalt erkannt, Vorlagen nicht', async () => {
assert.ok(checkName('google-services.json'));
assert.ok(checkName('.env'));
assert.ok(checkName('release.keystore'));
assert.strictEqual(checkName('.env.example'), null);
assert.strictEqual(checkName('README.md'), null);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-sec-'));
const bot = path.join(dir, 'bot.js');
fs.writeFileSync(bot, 'const TOKEN = "123456789:AA' + 'b'.repeat(33) + '";');
const clean = path.join(dir, 'app.js');
fs.writeFileSync(clean, 'console.log("hallo")');
const found = await scanForSecrets([{ localFile: bot, size: fs.statSync(bot).size }, { localFile: clean, size: fs.statSync(clean).size }]);
assert.match(found.get(bot), /Telegram/);
assert.strictEqual(found.has(clean), false);
fs.rmSync(dir, { recursive: true, force: true });
});
test('git status -z wird korrekt gelesen (inkl. Umbenennung)', () => {
const files = parsePorcelainZ(' M src/a.js\0?? neu.txt\0R neu/name.js\0alt/name.js\0D weg.txt\0');
assert.deepStrictEqual(files.map(f => [f.code, f.path, f.from]), [['M', 'src/a.js', null], ['?', 'neu.txt', null], ['R', 'neu/name.js', 'alt/name.js'], ['D', 'weg.txt', null]]);
});
test('Lokale Änderungen: Status, Diff und Commit nur der ausgewählten Dateien', async () => {
const { call } = setup('http://127.0.0.1:1');
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-local-'));
const git = (...a) => spawnSync('git', ['-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { encoding: 'utf8' });
git('init', '-q');
fs.writeFileSync(path.join(repo, 'a.txt'), 'eins\n');
git('add', '.'); git('commit', '-qm', 'start');
fs.writeFileSync(path.join(repo, 'a.txt'), 'zwei\n');
fs.writeFileSync(path.join(repo, 'b.txt'), 'neu\n');
const st = await call('local-git', { action: 'status', folder: repo });
assert.strictEqual(st.ok, true);
assert.deepStrictEqual(st.files.map(f => f.path).sort(), ['a.txt', 'b.txt']);
const diff = await call('local-git', { action: 'diff', folder: repo, file: 'a.txt', code: 'M' });
assert.match(diff.diff, /-eins/);
assert.match(diff.diff, /\+zwei/);
const c = await call('local-git', { action: 'commit', folder: repo, files: ['a.txt'], message: 'nur a' });
assert.strictEqual(c.ok, true, c.error);
const after = await call('local-git', { action: 'status', folder: repo });
assert.deepStrictEqual(after.files.map(f => f.path), ['b.txt']);
const notRepo = await call('local-git', { action: 'status', folder: os.tmpdir() + path.sep + 'gm-gibtsnicht-' + Date.now() });
assert.strictEqual(notRepo.ok, false);
fs.rmSync(repo, { recursive: true, force: true });
});
test('Upload-Vorschau markiert Geheimnisse', async () => {
const { call } = setup('http://127.0.0.1:1');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-prev-'));
const a = path.join(dir, 'main.kt'); fs.writeFileSync(a, 'fun main() {}');
const g = path.join(dir, 'google-services.json'); fs.writeFileSync(g, '{}');
const r = await call('preview-upload-paths', { paths: [a, g], destPath: '' });
assert.strictEqual(r.ok, true);
assert.strictEqual(r.secretCount, 1);
assert.ok(r.files.find(f => f.localFile === g).secret);
fs.rmSync(dir, { recursive: true, force: true });
});
test('Diagnose, PRs/Issues und Backup gegen einen nachgebauten Server', async () => {
const g = await fakeGitea();
const { call } = setup(g.url);
try {
const d = await call('run-server-diagnosis', {});
const byLabel = Object.fromEntries(d.checks.map(c => [c.label, c.status]));
assert.strictEqual(byLabel['Gitea-API'], 'ok');
assert.strictEqual(byLabel['Token'], 'ok');
assert.strictEqual(byLabel['Erreichbar über IPv4'], 'ok');
assert.ok(!JSON.stringify(d).includes('geheim123'), 'Token darf nicht in der Diagnose stehen');
const pulls = await call('gitea-collab', { action: 'listPulls', owner: 'M_Viper', repo: 'deskhub' });
assert.strictEqual(pulls.items[0].head, 'fix');
const issues = await call('gitea-collab', { action: 'listIssues', owner: 'M_Viper', repo: 'deskhub' });
assert.strictEqual(issues.items[0].labels[0].name, 'bug');
assert.strictEqual((await call('gitea-collab', { action: 'comment', owner: 'M_Viper', repo: 'deskhub', index: 5, body: 'Danke' })).ok, true);
const detail = await call('gitea-collab', { action: 'get', owner: 'M_Viper', repo: 'deskhub', index: 5 });
assert.strictEqual(detail.comments[0].body, 'Danke');
assert.strictEqual((await call('gitea-collab', { action: 'merge', owner: 'M_Viper', repo: 'deskhub', index: 3, style: 'squash' })).ok, true);
assert.strictEqual((await call('gitea-collab', { action: 'createIssue', owner: 'M_Viper', repo: 'deskhub', title: 'Neu' })).item.number, 6);
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-bk-'));
await call('repo-backup-set-config', { targetDir: target, interval: 'daily', keep: 2 });
for (let i = 0; i < 3; i++) {
const r = await call('repo-backup-run-now', {});
assert.strictEqual(r.summary.ok, 1, JSON.stringify(r));
await new Promise(res => setTimeout(res, 61000 / 60)); // eindeutige Zeitstempel
}
const zips = fs.readdirSync(path.join(target, 'M_Viper', 'deskhub-archiv'));
assert.ok(zips.length <= 3 && zips.length >= 1);
const state = await call('repo-backup-get-config', {});
assert.strictEqual(state.config.lastResult.total, 1);
fs.rmSync(target, { recursive: true, force: true });
} finally {
g.server.close();
}
});
+33
View File
@@ -0,0 +1,33 @@
const test = require('node:test');
const assert = require('node:assert');
const { listTemplates, buildGitignore } = require('../src/features/gitignoreTemplates.js');
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', () => {
const out = buildGitignore([]);
assert.match(out, /\.env/);
assert.doesNotMatch(out, /node_modules/);
});
test('buildGitignore fügt gewählte Vorlagen genau einmal hinzu', () => {
const out = buildGitignore(['node', 'node', 'electron']);
assert.strictEqual(out.split('node_modules/').length - 1, 1);
assert.match(out, /dist\//);
assert.doesNotMatch(out, /\.vs\//);
});
test('buildGitignore ignoriert unbekannte Schlüssel', () => {
assert.doesNotThrow(() => buildGitignore(['gibt-es-nicht', '__proto__', null]));
});
+41
View File
@@ -0,0 +1,41 @@
const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const { bareHostname } = require('../src/utils/helpers.js');
const { checkGiteaConnection } = require('../src/git/apiHandler.js');
function listen(host) {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ version: '1.22.0' }));
});
server.once('error', reject);
server.listen(0, host, () => resolve(server));
});
}
test('bareHostname entfernt Klammern nur bei IPv6-Literalen', () => {
assert.strictEqual(bareHostname(new URL('http://[2001:db8::1]:3000').hostname), '2001:db8::1');
assert.strictEqual(bareHostname('[::1]'), '::1');
assert.strictEqual(bareHostname('127.0.0.1'), '127.0.0.1');
assert.strictEqual(bareHostname('git.example.com'), 'git.example.com');
assert.strictEqual(bareHostname(undefined), '');
});
test('Gitea-Verbindung über IPv4', async () => {
const server = await listen('127.0.0.1');
try {
const r = await checkGiteaConnection({ url: `http://127.0.0.1:${server.address().port}` });
assert.strictEqual(r.ok, true);
} finally { server.close(); }
});
test('Gitea-Verbindung über IPv6', async (t) => {
let server;
try { server = await listen('::1'); } catch { return t.skip('IPv6-Loopback nicht verfügbar'); }
try {
const r = await checkGiteaConnection({ url: `http://[::1]:${server.address().port}` });
assert.strictEqual(r.ok, true);
} finally { server.close(); }
});
+77
View File
@@ -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;
});
+76
View File
@@ -0,0 +1,76 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createPrefs, DEFAULTS } = require('../src/features/appPrefs.js');
const { useBundledGit, applyGitIdentity, commitIdentity } = require('../src/git/gitBinary.js');
const { createRepoBackup, excludeMatcher } = require('../src/backup/GiteaRepoBackup.js');
const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p));
test('Einstellungen: Standardwerte, Bereinigung und ungültige E-Mail', () => {
const file = path.join(tmp('gm-prefs-'), 'app-settings.json');
const prefs = createPrefs(file);
assert.deepStrictEqual(prefs.get(), DEFAULTS);
const r = prefs.set({ zoom: 400, theme: 'pink', closeAction: 'tray', gitName: 'Max <x>\n', unbekannt: 1, uploadSecretScan: 0 });
assert.strictEqual(r.prefs.zoom, 150);
assert.strictEqual(r.prefs.theme, 'dark');
assert.strictEqual(r.prefs.closeAction, 'tray');
assert.strictEqual(r.prefs.gitName, 'Max x');
assert.strictEqual(r.prefs.uploadSecretScan, false);
assert.ok(!('unbekannt' in r.prefs));
const bad = prefs.set({ gitEmail: 'keine-mail' });
assert.deepStrictEqual(bad.rejected, ['gitEmail']);
assert.strictEqual(bad.prefs.gitEmail, '');
// nach dem Neuladen noch da
assert.strictEqual(createPrefs(file).get().closeAction, 'tray');
});
test('Git-Identität greift nur als Rückfallwert und verschwindet beim Leeren', () => {
useBundledGit();
const userData = tmp('gm-ud-');
const home = tmp('gm-home-');
const env = { ...process.env, HOME: home, USERPROFILE: home };
const ident = () => spawnSync('git', ['var', 'GIT_AUTHOR_IDENT'], { encoding: 'utf8', env: { ...env, GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM || '' } }).stdout;
assert.strictEqual(applyGitIdentity({ name: 'Test Name', email: 'test@example.com' }, userData), true);
assert.match(ident(), /^Test Name <test@example\.com>/);
// Eine globale Einstellung hat Vorrang
fs.writeFileSync(path.join(home, '.gitconfig'), '[user]\n\tname = Global\n\temail = global@example.com\n');
assert.match(ident(), /^Global <global@example\.com>/);
applyGitIdentity({ name: '', email: '' }, userData);
assert.strictEqual(process.env.GIT_CONFIG_SYSTEM, undefined);
assert.ok(!fs.existsSync(path.join(userData, 'git-system.gitconfig')));
assert.deepStrictEqual(commitIdentity({ gitName: '', gitEmail: '' }), { name: 'Git Manager GUI', email: 'gui@gitmanager.local' });
});
test('Backup: Ausschlussmuster und Zeitpunkt für automatische Backups', () => {
const m = excludeMatcher('test-*\nM_Viper/Alt?\n# Kommentar\nmy.repo');
assert.strictEqual(m('M_Viper/test-1'), true);
assert.strictEqual(m('M_Viper/Alt1'), true);
assert.strictEqual(m('M_Viper/Alt12'), false);
assert.strictEqual(m('x/my.repo'), true);
assert.strictEqual(m('x/myXrepo'), false);
const configPath = path.join(tmp('gm-bk-'), 'repo-backup.json');
const backup = createRepoBackup({ configPath, getContext() {}, giteaCall() {}, runGit() {}, isPackFailure() {}, redact: (s) => s });
backup.setConfig({ targetDir: 'X:/irgendwo', interval: 'daily', time: '03:00' });
const at = (s) => new Date(s);
// noch nie gelaufen: vor 03:00 nicht, danach schon
assert.strictEqual(backup.isDue(at('2026-10-22T02:59:00')), true, 'Slot von gestern 03:00 ist offen');
fs.writeFileSync(configPath, JSON.stringify({ ...JSON.parse(fs.readFileSync(configPath, 'utf8')), lastRun: '2026-10-22T03:05:00' }));
assert.strictEqual(backup.isDue(at('2026-10-22T20:00:00')), false, 'heute schon gelaufen');
assert.strictEqual(backup.isDue(at('2026-10-23T02:59:00')), false, 'vor 03:00 noch nicht');
assert.strictEqual(backup.isDue(at('2026-10-23T03:00:00')), true, 'ab 03:00 wieder fällig');
backup.setConfig({ time: '25:99' });
assert.strictEqual(backup.getState().config.time, '');
});
+64
View File
@@ -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'));
});
+201
View File
@@ -0,0 +1,201 @@
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 { spawnSync } = require('node:child_process');
const archiver = require('archiver');
require('../src/git/gitBinary.js').useBundledGit();
const { registerFeatureIpc } = require('../src/features/featuresIpc.js');
const git = (cwd, ...a) => spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, encoding: 'utf8' });
const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p));
function makeZip(file, entries) {
return new Promise((resolve, reject) => {
const out = fs.createWriteStream(file);
const zip = archiver('zip');
out.on('close', resolve);
zip.on('error', reject);
zip.pipe(out);
for (const [name, content] of entries) zip.append(content, { name });
zip.finalize();
});
}
// Backup-Ordner wie von GiteaRepoBackup angelegt: Spiegel + ZIP-Stände
async function makeBackupDir() {
const work = tmp('gm-src-');
git(work, 'init', '-q', '-b', 'main');
fs.writeFileSync(path.join(work, 'a.txt'), 'hallo');
git(work, 'add', '.'); git(work, 'commit', '-qm', 'eins');
git(work, 'branch', 'feature'); git(work, 'tag', 'v1.0');
const dir = tmp('gm-bk-');
fs.mkdirSync(path.join(dir, 'M_Viper'));
git(dir, 'clone', '-q', '--mirror', work, path.join(dir, 'M_Viper', 'deskhub.git'));
const zipDir = path.join(dir, 'M_Viper', 'deskhub-archiv');
fs.mkdirSync(zipDir);
await makeZip(path.join(zipDir, 'deskhub-2026-10-01_10-00.zip'), [['deskhub/a.txt', 'alt'], ['deskhub/src/b.kt', 'fun b() {}']]);
await makeZip(path.join(zipDir, 'deskhub-2026-10-20_10-00.zip'), [['deskhub/a.txt', 'neu'], ['deskhub/src/b.kt', 'fun b() {}']]);
fs.utimesSync(path.join(zipDir, 'deskhub-2026-10-01_10-00.zip'), new Date('2026-10-01'), new Date('2026-10-01'));
fs.writeFileSync(path.join(dir, 'backup-protokoll.json'), '{}');
return { dir, work };
}
// Gitea-Nachbau: neue Repos zeigen auf echte lokale Bare-Repos, damit der Push wirklich läuft
function fakeGitea() {
const created = [];
const patches = [];
const repoRoot = tmp('gm-remote-');
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
if (u.pathname === '/api/v1/user') return send(200, { login: 'M_Viper' });
if (u.pathname === '/api/v1/user/repos' && req.method === 'POST') {
const b = JSON.parse(body);
if (b.name === 'deskhub') return send(409, { message: 'repository already exists' });
const bare = path.join(repoRoot, `${b.name}.git`);
spawnSync('git', ['init', '-q', '--bare', '-b', 'main', bare]);
created.push(b);
return send(201, { name: b.name, full_name: `M_Viper/${b.name}`, owner: { login: 'M_Viper' }, clone_url: bare, html_url: `http://gitea/M_Viper/${b.name}`, default_branch: 'main' });
}
if (u.pathname.startsWith('/api/v1/repos/M_Viper/') && req.method === 'PATCH') { patches.push(JSON.parse(body)); return send(200, {}); }
send(404, { message: 'not found' });
});
});
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, created, patches, repoRoot, url: `http://127.0.0.1:${server.address().port}` })));
}
function setup(url) {
const handlers = {};
const userData = tmp('gm-ud-');
registerFeatureIpc({
ipcMain: { handle: (name, fn) => { handlers[name] = fn; } },
app: { getPath: () => userData },
readCredentials: () => ({ giteaURL: url, giteaToken: 'geheim123' }),
buildItemsFromPaths: async () => ({ items: [], skipped: [], ignored: [] }),
clearGitTransportBlock: () => false
});
return (name, data) => handlers[name]({ sender: { send() {} } }, data);
}
test('Sicherungen werden gefunden: Spiegel und ZIP-Stände, neuester zuerst', async () => {
const { dir } = await makeBackupDir();
const call = setup('http://127.0.0.1:1');
const r = await call('repo-backup-list', { dir });
assert.strictEqual(r.ok, true, r.error);
assert.strictEqual(r.backups.length, 1);
const b = r.backups[0];
assert.deepStrictEqual([b.owner, b.repo], ['M_Viper', 'deskhub']);
assert.ok(b.mirror);
assert.deepStrictEqual(b.zips.map(z => z.file), ['deskhub-2026-10-20_10-00.zip', 'deskhub-2026-10-01_10-00.zip']);
});
test('Lokal: Spiegel wird zur Arbeitskopie mit allen Branches, ZIP ohne Oberordner entpackt', async () => {
const { dir } = await makeBackupDir();
const call = setup('http://127.0.0.1:1');
const { backups: [b] } = await call('repo-backup-list', { dir });
const dest1 = tmp('gm-dst-');
const m = await call('repo-restore-local', { kind: 'mirror', source: b.mirror.path, destParent: dest1, repo: 'deskhub' });
assert.strictEqual(m.ok, true, m.error);
assert.strictEqual(fs.readFileSync(path.join(dest1, 'deskhub', 'a.txt'), 'utf8'), 'hallo');
assert.match(git(path.join(dest1, 'deskhub'), 'branch', '-r').stdout, /feature/);
// Zweiter Versuch in denselben Ordner darf nichts überschreiben
const again = await call('repo-restore-local', { kind: 'mirror', source: b.mirror.path, destParent: dest1, repo: 'deskhub' });
assert.strictEqual(again.ok, false);
assert.match(again.error, /existiert bereits/);
const dest2 = tmp('gm-dst-');
const z = await call('repo-restore-local', { kind: 'zip', source: b.zips[0].path, destParent: dest2, repo: 'deskhub' });
assert.strictEqual(z.ok, true, z.error);
assert.strictEqual(z.files, 2);
assert.strictEqual(fs.readFileSync(path.join(dest2, 'deskhub', 'a.txt'), 'utf8'), 'neu');
assert.ok(fs.existsSync(path.join(dest2, 'deskhub', 'src', 'b.kt')));
});
// ZIP von Hand bauen: archiver entschärft "../" schon beim Erzeugen, ein echter Angreifer nicht.
function rawZip(file, entries) {
const zlib = require('node:zlib');
const locals = [];
const centrals = [];
let offset = 0;
for (const [name, content] of entries) {
const n = Buffer.from(name);
const data = Buffer.from(content);
const crc = zlib.crc32(data);
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(n.length, 26);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6); ch.writeUInt32LE(crc, 16);
ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24); ch.writeUInt16LE(n.length, 28); ch.writeUInt32LE(offset, 42);
locals.push(lh, n, data);
centrals.push(ch, n);
offset += lh.length + n.length + data.length;
}
const cd = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
fs.writeFileSync(file, Buffer.concat([...locals, cd, end]));
}
test('Archive mit Pfaden außerhalb des Ziels werden abgewiesen', async () => {
const d = tmp('gm-evil-');
const zip = path.join(d, 'evil.zip');
rawZip(zip, [['deskhub/ok.txt', 'ok'], ['deskhub/../../boese.txt', 'x']]);
const unzipper = require('unzipper');
const names = (await unzipper.Open.file(zip)).files.map(f => f.path);
assert.ok(names.includes('deskhub/../../boese.txt'), 'Testarchiv muss den gefährlichen Pfad wirklich enthalten');
const call = setup('http://127.0.0.1:1');
const destParent = tmp('gm-dst-');
const r = await call('repo-restore-local', { kind: 'zip', source: zip, destParent, repo: 'x' });
assert.strictEqual(r.ok, false);
assert.match(r.error, /Unsicherer Pfad/);
assert.ok(!fs.existsSync(path.join(destParent, 'x', 'ok.txt')), 'auch harmlose Dateien dürfen dann nicht entpackt werden');
assert.ok(!fs.existsSync(path.join(destParent, 'boese.txt')));
});
test('Gitea: Spiegel und ZIP als neues Repo, Namenskonflikt und ZIP-Überschreiben werden abgefangen', async () => {
const { dir } = await makeBackupDir();
const g = await fakeGitea();
try {
const call = setup(g.url);
const { backups: [b] } = await call('repo-backup-list', { dir });
const m = await call('repo-restore-gitea', { kind: 'mirror', source: b.mirror.path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'deskhub-wieder', isPrivate: true });
assert.strictEqual(m.ok, true, m.error);
const bare = path.join(g.repoRoot, 'deskhub-wieder.git');
const refs = git(bare, 'for-each-ref', '--format=%(refname)').stdout;
assert.match(refs, /refs\/heads\/main/);
assert.match(refs, /refs\/heads\/feature/);
assert.match(refs, /refs\/tags\/v1\.0/);
assert.strictEqual(g.created[0].private, true);
const z = await call('repo-restore-gitea', { kind: 'zip', source: b.zips[0].path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'deskhub-zip' });
assert.strictEqual(z.ok, true, z.error);
assert.strictEqual(z.method, 'Git');
const files = git(path.join(g.repoRoot, 'deskhub-zip.git'), 'ls-tree', '-r', '--name-only', 'main').stdout.trim().split('\n');
assert.deepStrictEqual(files.sort(), ['a.txt', 'src/b.kt']);
const conflict = await call('repo-restore-gitea', { kind: 'mirror', source: b.mirror.path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'deskhub' });
assert.strictEqual(conflict.ok, false);
assert.match(conflict.error, /existiert bereits/);
const zipOverwrite = await call('repo-restore-gitea', { kind: 'zip', source: b.zips[0].path, owner: 'M_Viper', repo: 'deskhub', mode: 'overwrite' });
assert.strictEqual(zipOverwrite.ok, false);
assert.match(zipOverwrite.error, /nur mit einem Git-Spiegel/);
const badName = await call('repo-restore-gitea', { kind: 'zip', source: b.zips[0].path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'mit leerzeichen' });
assert.strictEqual(badName.ok, false);
} finally {
g.server.close();
}
});
+36
View File
@@ -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 });
});
+28
View File
@@ -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);
});
+133
View File
@@ -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']);
});
+35
View File
@@ -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);
});
+146
View File
@@ -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 });
}
});
+113
View File
@@ -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);
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
<Application x:Class="GitManagerSetup.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- [0] wird je nach Hell/Dunkel ersetzt -->
<ResourceDictionary Source="Themes/Colors.Light.xaml"/>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- Akzent wie in der App (Cyan → Blau) -->
<SolidColorBrush x:Key="AccentBrush" Color="#2E7FE0"/>
<SolidColorBrush x:Key="AccentHoverBrush" Color="#4590EA"/>
<SolidColorBrush x:Key="OnAccentBrush" Color="White"/>
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#222E7FE0"/>
<SolidColorBrush x:Key="AccentSubtleStrokeBrush" Color="#552E7FE0"/>
</ResourceDictionary>
</Application.Resources>
</Application>
+99
View File
@@ -0,0 +1,99 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using Microsoft.Win32;
namespace GitManagerSetup;
public partial class App : Application
{
private static Mutex? _single;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
bool has(string arg) => e.Args.Any(a => a.Equals(arg, StringComparison.OrdinalIgnoreCase));
// Das Deinstallationsprogramm ist das Setup ohne angehängte App → ohne Anhang immer Deinstallation
bool uninstall = has("--uninstall") || !Payload.Exists;
bool silent = has("/S");
bool update = has("--update");
bool launch = has("/launch");
var dirArg = e.Args.FirstOrDefault(a => a.StartsWith("/D=", StringComparison.OrdinalIgnoreCase))?.Substring(3);
_single = new Mutex(true, "GitManagerGUI-Setup", out bool first);
if (!first)
{
Shutdown(0);
return;
}
// Stille Installation/Deinstallation für Skripte:
// Git-Manager-GUI-Setup.exe /S [/D=Ordner] [/launch] bzw. --uninstall /S
if (silent)
{
var progress = new Progress<(double, string)>(_ => { });
try
{
if (uninstall) Installer.Uninstall(removeData: false, progress);
else
{
var dir = dirArg ?? Installer.InstalledDir ?? Installer.DefaultDir;
Installer.Install(dir, desktopLink: Installer.InstalledDir is null || Installer.DesktopLinkExists,
progress, waitForExit: true);
if (launch) System.Diagnostics.Process.Start(Path.Combine(dir, Installer.ExeName));
}
Shutdown(0);
}
catch (Exception ex)
{
// ohne Oberfläche: Fehler für Skripte nachvollziehbar ablegen
try { File.WriteAllText(Path.Combine(Path.GetTempPath(), "Git-Manager-GUI-Setup.log"), $"[{DateTime.Now}] {ex}"); } catch { }
Shutdown(1);
}
return;
}
ApplyTheme();
var mode = uninstall ? SetupMode.Uninstall : update ? SetupMode.Update : SetupMode.Install;
new MainWindow(mode).Show();
}
protected override void OnExit(ExitEventArgs e)
{
try { _single?.ReleaseMutex(); } catch { }
base.OnExit(e);
}
/// <summary>Hell/Dunkel wie in Windows eingestellt.</summary>
private void ApplyTheme()
{
bool light = ReadDword(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme") != 0;
Resources.MergedDictionaries[0] = new ResourceDictionary
{
Source = new Uri($"pack://application:,,,/Themes/Colors.{(light ? "Light" : "Dark")}.xaml"),
};
if (!light)
{
// im Dunkeln das Cyan der App, damit Text und Fortschrittsbalken gut lesbar bleiben
var a = Color.FromRgb(0x4C, 0xB8, 0xF5);
Resources["AccentBrush"] = new SolidColorBrush(a);
Resources["AccentHoverBrush"] = new SolidColorBrush(Color.FromRgb(0x67, 0xC6, 0xF8));
Resources["OnAccentBrush"] = new SolidColorBrush(Color.FromRgb(0x04, 0x10, 0x1F));
Resources["AccentSubtleBrush"] = new SolidColorBrush(Color.FromArgb(0x30, a.R, a.G, a.B));
Resources["AccentSubtleStrokeBrush"] = new SolidColorBrush(Color.FromArgb(0x60, a.R, a.G, a.B));
}
}
private static int ReadDword(string key, string name)
{
try
{
using var k = Registry.CurrentUser.OpenSubKey(key);
return k?.GetValue(name) is int i ? i : -1;
}
catch { return -1; }
}
}
public enum SetupMode { Install, Update, Uninstall }
+46
View File
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- .NET Framework 4.8 ist in jedem Windows 10/11 enthalten → der Installer läuft ohne Zusatzinstallation -->
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>AnyCPU</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<AssemblyName>Git-Manager-GUI-Setup</AssemblyName>
<RootNamespace>GitManagerSetup</RootNamespace>
<ApplicationIcon>..\assets\icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Product>Git Manager GUI Setup</Product>
<AssemblyTitle>Git Manager GUI Setup</AssemblyTitle>
<Description>Installiert Git Manager GUI</Description>
<Company>M_Viper</Company>
<Authors>M_Viper</Authors>
<Copyright>Copyright (c) M_Viper</Copyright>
<NeutralLanguage>de-DE</NeutralLanguage>
<!-- Version kommt beim Bauen aus package.json (scripts/build-setup.js) -->
<Version Condition="'$(Version)' == ''">0.0.0</Version>
</PropertyGroup>
<ItemGroup>
<!-- WinForms nur für den Ordner-Dialog; nicht global, sonst Namenskonflikte mit WPF -->
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
<Reference Include="Microsoft.VisualBasic" />
<Reference Include="System.IO.Compression" />
</ItemGroup>
<ItemGroup>
<!-- Themes\*.xaml bindet das SDK automatisch als Page ein -->
<Resource Include="..\assets\icon.ico" Link="app.ico" />
<Resource Include="..\assets\icon.png" Link="app.png" />
</ItemGroup>
<!--
Die eigentliche App (entpackter Electron-Ordner als ZIP) wird NICHT hier eingebettet,
sondern nach dem Bauen hinten an die .exe angehängt (scripts/build-setup.js, siehe Payload.cs).
So muss der Compiler keine ~150 MB als Ressource verarbeiten, und das Deinstallationsprogramm
ist einfach der vordere Teil der Datei ohne Anhang.
-->
</Project>
+501
View File
@@ -0,0 +1,501 @@
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.Win32;
namespace GitManagerSetup;
/// <summary>Installation/Deinstallation – nur für den aktuellen Benutzer, ohne Administratorrechte.</summary>
public static class Installer
{
public const string AppName = "Git Manager GUI";
public const string Publisher = "M_Viper";
// productName aus package.json → so heißt die EXE im entpackten Electron-Ordner
public const string ExeName = "Git Manager GUI.exe";
private const string ProcessName = "Git Manager GUI";
// Gleicher Name wie beim früheren NSIS-Setup (electron-builder), damit eine alte Installation sauber ersetzt wird
public const string UninstallerName = "Uninstall Git Manager GUI.exe";
// wie "appId" in package.json – gleiche Taskleisten-/Benachrichtigungs-Zuordnung wie bisher
public const string AppUserModelId = "com.viper.gitmanager";
// Schlüssel vom alten electron-builder-Setup (Version 2.1.7) übernommen → in "Apps" bleibt es ein einziger Eintrag
private const string KeyId = "4156cc7f-a3e7-5b1d-9238-0af3e0f54ecd";
private const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\" + KeyId;
private const string LegacyAppKey = @"Software\" + KeyId;
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string FileListName = "install-files.txt";
public static string Version
{
get
{
var info = typeof(Installer).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrEmpty(info)) return info!.Split('+')[0];
var v = typeof(Installer).Assembly.GetName().Version!;
return $"{v.Major}.{v.Minor}.{v.Build}";
}
}
/// <summary>Pfad dieses Setups (bzw. des Deinstallationsprogramms).</summary>
public static string SelfPath => Assembly.GetExecutingAssembly().Location;
public static string DefaultDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName);
/// <summary>App-Daten: Zugangsdaten (verschlüsselt), Einstellungen, Favoriten, Verlauf, Backup-Einstellung.</summary>
public static string DataDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "git-manager-gui");
private static string StartMenuLink =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk");
private static string DesktopLink =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk");
// ---------- vorhandene Installation ----------
private static string? RegValue(string name)
{
using var k = Registry.CurrentUser.OpenSubKey(UninstallKey);
return k?.GetValue(name) as string;
}
/// <summary>Ordner einer vorhandenen Installation (dieses Setups oder des alten NSIS-Setups), oder null.</summary>
public static string? InstalledDir
{
get
{
var dir = RegValue("InstallLocation");
if (string.IsNullOrWhiteSpace(dir))
{
// altes NSIS-Setup trägt keinen InstallLocation ein → aus dem Deinstallationsbefehl ablesen
var m = Regex.Match(RegValue("UninstallString") ?? "", "^\"([^\"]+)\"");
dir = m.Success ? Path.GetDirectoryName(m.Groups[1].Value) : null;
}
return dir is not null && File.Exists(Path.Combine(dir, ExeName)) ? dir : null;
}
}
/// <summary>Stammt die vorhandene Installation noch vom alten NSIS-Setup?</summary>
public static bool IsLegacyInstall =>
InstalledDir is not null && (RegValue("UninstallString") ?? "").IndexOf("--uninstall", StringComparison.OrdinalIgnoreCase) < 0;
public static string? InstalledVersion => RegValue("DisplayVersion");
public static bool DesktopLinkExists => File.Exists(DesktopLink);
public static bool IsRunning(string? dir) => AppProcesses(dir, InstalledDir).Any();
// ---------- Installieren ----------
/// <param name="waitForExit">nach einem Update aus der App: ihr erst Zeit geben, sich selbst zu beenden</param>
public static void Install(string dir, bool desktopLink, IProgress<(double, string)> progress, bool waitForExit = false)
{
if (!Environment.Is64BitOperatingSystem)
throw new InvalidOperationException("Git Manager GUI benötigt ein 64-Bit-Windows (Windows 10 oder 11).");
var oldDir = InstalledDir;
progress.Report((0.02, "Git Manager GUI wird beendet …"));
CloseRunningApp(waitForExit ? 20000 : 0, dir, oldDir);
using var zip = Payload.OpenArchive();
var topLevel = TopLevelNames(zip);
progress.Report((0.05, "Alte Version wird entfernt …"));
Directory.CreateDirectory(dir);
RemoveAppFiles(dir, topLevel);
if (oldDir is not null && !SamePath(oldDir, dir))
{
// Installation wurde in einen anderen Ordner verlegt → alten Ordner aufräumen
var oldExe = Path.Combine(oldDir, ExeName);
DeleteIfPointsTo(StartMenuLink, oldExe);
DeleteIfPointsTo(DesktopLink, oldExe);
RemoveAppFiles(oldDir, topLevel);
try { Directory.Delete(oldDir); } catch { } // nur wenn leer
}
Extract(zip, dir, p => progress.Report((0.08 + p * 0.8, "Dateien werden kopiert …")));
progress.Report((0.9, "Deinstallationsprogramm wird angelegt …"));
var exe = Path.Combine(dir, ExeName);
var uninstaller = Path.Combine(dir, UninstallerName);
if (!SamePath(SelfPath, uninstaller))
{
MoveAside(uninstaller);
CopyStub(uninstaller);
}
File.WriteAllLines(Path.Combine(dir, FileListName), topLevel.Concat([UninstallerName, FileListName]));
progress.Report((0.94, "Verknüpfungen werden angelegt …"));
Shortcut.Create(StartMenuLink, exe, "Repositories auf Gitea und GitHub verwalten", AppUserModelId);
if (desktopLink) Shortcut.Create(DesktopLink, exe, "Repositories auf Gitea und GitHub verwalten", AppUserModelId);
else DeleteIfPointsTo(DesktopLink, exe);
progress.Report((0.97, "Windows wird eingerichtet …"));
Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, throwOnMissingSubKey: false); // Reste des alten Setups
Registry.CurrentUser.DeleteSubKeyTree(LegacyAppKey, throwOnMissingSubKey: false);
using (var k = Registry.CurrentUser.CreateSubKey(UninstallKey))
{
long size = DirSize(dir) / 1024;
k.SetValue("DisplayName", AppName);
k.SetValue("DisplayVersion", Version);
k.SetValue("Publisher", Publisher);
k.SetValue("DisplayIcon", $"{exe},0");
k.SetValue("InstallLocation", dir);
k.SetValue("UninstallString", $"\"{uninstaller}\" --uninstall");
k.SetValue("QuietUninstallString", $"\"{uninstaller}\" --uninstall /S");
k.SetValue("URLInfoAbout", "https://git.viper.ipv64.net/M_Viper/Git-Manager-Gui");
k.SetValue("InstallDate", DateTime.Now.ToString("yyyyMMdd"));
k.SetValue("EstimatedSize", (int)Math.Min(int.MaxValue, size), RegistryValueKind.DWord);
k.SetValue("NoModify", 1, RegistryValueKind.DWord);
k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
}
progress.Report((0.98, "Installation wird abgeschlossen …"));
Vorwaermen(exe);
progress.Report((1.0, "Fertig"));
}
/// <summary>
/// Startet die App einmal kurz mit "--gm-vorwaermen" – sie beendet sich
/// damit sofort wieder (siehe main.js). Windows Defender prüft eine neue,
/// noch unbekannte EXE beim ersten Start mehrere Sekunden lang (gemessen: ~8 s);
/// das passiert so während des Fortschrittsbalkens und nicht erst beim Klick
/// auf "Fertigstellen". Das Ergebnis merkt sich Windows, der echte Start ist
/// danach sofort da.
/// </summary>
private static void Vorwaermen(string exe)
{
try
{
using var p = Process.Start(new ProcessStartInfo(exe, "--gm-vorwaermen")
{
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(exe)!,
});
if (p != null && !p.WaitForExit(30000)) { try { p.Kill(); } catch { } }
}
catch { /* nur eine Beschleunigung – Fehler sind egal */ }
}
private static List<string> TopLevelNames(ZipArchive zip) =>
zip.Entries
.Select(e => e.FullName.Replace('\\', '/').Split('/')[0])
.Where(n => n.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static void Extract(ZipArchive zip, string dir, Action<double> report)
{
var root = Path.GetFullPath(dir).TrimEnd('\\') + "\\";
long total = Math.Max(1, zip.Entries.Sum(e => e.Length)), done = 0;
var buffer = new byte[1 << 20];
int lastPercent = -1;
foreach (var entry in zip.Entries)
{
var target = Path.GetFullPath(Path.Combine(dir, entry.FullName));
if (!target.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; // nie außerhalb des Ordners schreiben
if (entry.Name.Length == 0)
{
Directory.CreateDirectory(target);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
long before = done;
Retry(target, () =>
{
done = before;
MoveAside(target);
using var src = entry.Open();
using var dst = File.Create(target);
int n;
while ((n = src.Read(buffer, 0, buffer.Length)) > 0)
{
dst.Write(buffer, 0, n);
done += n;
int percent = (int)(done * 100 / total);
if (percent != lastPercent)
{
lastPercent = percent;
report(Math.Min(1.0, (double)done / total));
}
}
});
}
report(1.0);
}
/// <summary>Setup ohne angehängte App als Deinstallationsprogramm ablegen (nur ein paar hundert KB).</summary>
private static void CopyStub(string path)
{
long length = Payload.StubLength;
using var src = File.OpenRead(SelfPath);
using var dst = File.Create(path);
var buffer = new byte[81920];
long left = length;
while (left > 0)
{
int n = src.Read(buffer, 0, (int)Math.Min(buffer.Length, left));
if (n <= 0) break;
dst.Write(buffer, 0, n);
left -= n;
}
}
// ---------- Deinstallieren ----------
public static void Uninstall(bool removeData, IProgress<(double, string)> progress)
{
// Nur im eingetragenen Installationsordner löschen – nie dort, wo das Setup zufällig liegt (z. B. Downloads)
var dir = InstalledDir;
var exe = dir is null ? "" : Path.Combine(dir, ExeName);
progress.Report((0.1, "Git Manager GUI wird beendet …"));
CloseRunningApp(0, dir);
progress.Report((0.3, "Verknüpfungen werden entfernt …"));
DeleteIfPointsTo(StartMenuLink, exe);
DeleteIfPointsTo(DesktopLink, exe);
progress.Report((0.45, "Windows-Einträge werden entfernt …"));
RemoveAutostart(exe);
Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, throwOnMissingSubKey: false);
Registry.CurrentUser.DeleteSubKeyTree(LegacyAppKey, throwOnMissingSubKey: false);
// Name/Logo für Windows-Benachrichtigungen (legt Electron selbst an)
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\AppUserModelId\" + AppUserModelId, throwOnMissingSubKey: false);
if (removeData && Directory.Exists(DataDir))
{
progress.Report((0.6, "Zugangsdaten und Einstellungen werden in den Papierkorb gelegt …"));
try
{
Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(DataDir,
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
}
catch { }
}
progress.Report((0.8, "Dateien werden entfernt …"));
if (dir is null) { progress.Report((1.0, "Fertig")); return; }
RemoveAppFiles(dir, null);
// Das laufende Deinstallationsprogramm kann sich nicht selbst löschen → kurz nach dem Beenden per cmd.
// rmdir ohne /s entfernt den Ordner nur, wenn er danach leer ist.
var self = SelfPath;
if (IsUnder(self, dir))
{
Process.Start(new ProcessStartInfo("cmd.exe",
$"/c ping 127.0.0.1 -n 3 > nul & del /f /q \"{self}\" & del /f /q \"{Path.Combine(dir, "*.old")}\" & rmdir \"{dir}\"")
{
CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden,
});
}
else
{
TryDelete(Path.Combine(dir, UninstallerName));
try { Directory.Delete(dir); } catch { } // nur wenn leer
}
progress.Report((1.0, "Fertig"));
}
/// <summary>Autostart-Einträge entfernen, die die App selbst gesetzt hat (Name vergibt Electron).</summary>
private static void RemoveAutostart(string exe)
{
if (exe.Length == 0) return;
using var run = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
if (run is null) return;
foreach (var name in run.GetValueNames())
{
if (run.GetValue(name) is string cmd && cmd.IndexOf(exe, StringComparison.OrdinalIgnoreCase) >= 0)
run.DeleteValue(name, throwOnMissingValue: false);
}
}
// ---------- Dateien ----------
/// <summary>
/// Entfernt die App-Dateien aus einem Installationsordner. Gelöscht wird nur, was nachweislich zur App gehört:
/// Einträge aus der Dateiliste der letzten Installation bzw. die Namen aus dem neuen Paket –
/// Ordner nur, wenn sie eindeutig von Electron stammen. Fremde Dateien im Ordner bleiben unangetastet.
/// </summary>
private static void RemoveAppFiles(string dir, IEnumerable<string>? extraNames)
{
if (!Directory.Exists(dir)) return;
CleanupOldFiles(dir);
var listFile = Path.Combine(dir, FileListName);
var fromList = File.Exists(listFile) ? File.ReadAllLines(listFile).Where(l => l.Trim().Length > 0).ToList() : [];
var names = fromList.Concat(extraNames ?? []).Concat([UninstallerName, FileListName])
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var name in names)
{
if (name.IndexOfAny(['\\', '/', ':']) >= 0 || name is "." or "..") continue;
var path = Path.Combine(dir, name);
if (SamePath(path, SelfPath)) continue; // das laufende Deinstallationsprogramm räumt cmd weg
if (Directory.Exists(path))
{
bool ours = fromList.Contains(name, StringComparer.OrdinalIgnoreCase)
|| (name.Equals("resources", StringComparison.OrdinalIgnoreCase) && File.Exists(Path.Combine(path, "app.asar")))
|| (name.Equals("locales", StringComparison.OrdinalIgnoreCase) && File.Exists(Path.Combine(path, "en-US.pak")));
if (ours) Retry(path, () => Directory.Delete(path, recursive: true));
}
else if (File.Exists(path))
{
Retry(path, () => MoveAside(path));
}
}
}
/// <summary>
/// Längster Pfad im Paket. Windows erlaubt (ohne Sonderfreigabe) nur 259 Zeichen pro Pfad –
/// der Installationsordner darf deshalb höchstens <see cref="MaxDirLength"/> Zeichen lang sein.
/// </summary>
private static int? _longestEntry;
public static int MaxDirLength
{
get
{
if (_longestEntry is null)
{
try
{
using var zip = Payload.OpenArchive();
_longestEntry = zip.Entries.Count == 0 ? 0 : zip.Entries.Max(e => e.FullName.Length);
}
catch { _longestEntry = 0; }
}
return 259 - 1 - _longestEntry.Value;
}
}
private static void Retry(string path, Action action)
{
for (int attempt = 1; ; attempt++)
{
try
{
action();
return;
}
catch (PathTooLongException ex)
{
throw new IOException($"Der Installationsordner ist zu lang. Bitte einen kürzeren Ordner wählen (höchstens {MaxDirLength} Zeichen).", ex);
}
catch (Exception ex) when ((ex is IOException || ex is UnauthorizedAccessException) && attempt < 10)
{
Thread.Sleep(500);
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
throw new IOException($"„{Path.GetFileName(path)}“ wird noch verwendet. Bitte Git Manager GUI beenden " +
"(auch das Symbol im Infobereich neben der Uhr) und erneut versuchen.", ex);
}
}
}
/// <summary>
/// Vorhandene Datei löschen oder – falls sie noch geöffnet ist (Explorer, Virenscanner) – beiseite umbenennen.
/// Umbenennen klappt unter Windows auch bei geöffneten Dateien, überschreiben nicht.
/// </summary>
private static void MoveAside(string path)
{
if (!File.Exists(path)) return;
try { File.Delete(path); return; } catch { }
File.Move(path, $"{path}.{Guid.NewGuid():N}.old");
}
private static void CleanupOldFiles(string dir)
{
if (!Directory.Exists(dir)) return;
foreach (var f in Directory.GetFiles(dir, "*.old", SearchOption.AllDirectories))
try { File.Delete(f); } catch { }
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
private static void DeleteIfPointsTo(string link, string target)
{
if (!File.Exists(link) || target.Length == 0) return;
if (!SamePath(Shortcut.Target(link) ?? "", target)) return;
// Explorer/Indexdienst halten Verknüpfungen manchmal kurz fest → mehrmals versuchen
for (int i = 0; i < 10 && File.Exists(link); i++)
{
try { File.Delete(link); }
catch { Thread.Sleep(300); }
}
}
// ---------- Prozesse ----------
private static IEnumerable<Process> AppProcesses(params string?[] dirs)
{
var roots = dirs.Where(d => !string.IsNullOrEmpty(d)).Select(d => d!).ToList();
if (roots.Count == 0) yield break;
// die App (mehrere Electron-Prozesse) und das mitgelieferte Git, falls gerade ein Upload läuft
foreach (var p in Process.GetProcessesByName(ProcessName).Concat(Process.GetProcessesByName("git")))
{
string? file = null;
try { file = p.MainModule?.FileName; } catch { }
if (file is not null && roots.Any(r => IsUnder(file, r))) yield return p;
}
}
private static void CloseRunningApp(int waitMs, params string?[] dirs)
{
var until = DateTime.Now.AddMilliseconds(waitMs);
while (waitMs > 0 && DateTime.Now < until && AppProcesses(dirs).Any())
Thread.Sleep(300);
foreach (var p in AppProcesses(dirs).ToList())
{
try
{
p.Kill();
p.WaitForExit(10000);
}
catch { }
}
}
// ---------- Helfer ----------
private static bool SamePath(string a, string b)
{
try { return string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); }
catch { return false; }
}
private static bool IsUnder(string path, string dir) =>
Path.GetFullPath(path).StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
private static long DirSize(string dir)
{
long sum = 0;
try
{
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
try { sum += new FileInfo(f).Length; } catch { }
}
catch { }
return sum;
}
public static long DataSize() => Directory.Exists(DataDir) ? DirSize(DataDir) : 0;
public static string FormatSize(long bytes) => bytes switch
{
< 1024 * 1024 => $"{Math.Max(1, bytes / 1024)} KB",
< 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:0.#} MB",
_ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB",
};
}
+352
View File
@@ -0,0 +1,352 @@
<Window x:Class="GitManagerSetup.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Git Manager GUI Setup" Icon="app.ico" Width="780" Height="520" ResizeMode="CanMinimize"
Background="{DynamicResource LayerBrush}" Foreground="{DynamicResource TextBrush}"
FontFamily="{DynamicResource UiFont}" FontSize="14"
TextOptions.TextFormattingMode="Display" UseLayoutRounding="True"
WindowStartupLocation="CenterScreen">
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="36" ResizeBorderThickness="0" GlassFrameThickness="0,0,0,1"
CornerRadius="0" UseAeroCaptionButtons="False"/>
</WindowChrome.WindowChrome>
<Window.Resources>
<!-- Markenfarben der Seitenleiste (in Hell und Dunkel gleich, wie die App) -->
<SolidColorBrush x:Key="SideText" Color="#F5F8FF"/>
<SolidColorBrush x:Key="SideTextSecondary" Color="#AEBDD8"/>
<SolidColorBrush x:Key="SideTextMuted" Color="#7083A4"/>
<LinearGradientBrush x:Key="BrandGradient" StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#58D5FF" Offset="0"/>
<GradientStop Color="#5C87FF" Offset="1"/>
</LinearGradientBrush>
<Style x:Key="PageTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource DisplayFont}"/>
<Setter Property="FontSize" Value="26"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<Style x:Key="Body" TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="LineHeight" Value="21"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
</Style>
<Style x:Key="StepDot" TargetType="Border">
<Setter Property="Width" Value="26"/>
<Setter Property="Height" Value="26"/>
<Setter Property="CornerRadius" Value="13"/>
<Setter Property="BorderThickness" Value="1.5"/>
</Style>
<Style x:Key="StepNum" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style x:Key="StepLabel" TargetType="TextBlock">
<Setter Property="Margin" Value="12,0,0,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="FontSize" Value="13.5"/>
</Style>
<Style x:Key="StepLine" TargetType="Border">
<Setter Property="Width" Value="2"/>
<Setter Property="Height" Value="18"/>
<Setter Property="Margin" Value="12,3,0,3"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="CornerRadius" Value="1"/>
</Style>
<Style x:Key="FeatureIcon" TargetType="Border">
<Setter Property="Width" Value="38"/>
<Setter Property="Height" Value="38"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Background" Value="{DynamicResource AccentSubtleBrush}"/>
<Setter Property="VerticalAlignment" Value="Top"/>
</Style>
<Style x:Key="FeatureGlyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="17"/>
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style x:Key="FeatureTitle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="13.5"/>
</Style>
<Style x:Key="FeatureText" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Margin" Value="0,2,0,0"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
</Style>
<Style x:Key="Card" TargetType="Border">
<Setter Property="Padding" Value="16,14"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Background" Value="{DynamicResource ControlFillBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="36"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- ===== Seitenleiste im Look der App ===== -->
<Border Grid.RowSpan="3" ClipToBounds="True">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0.6,1">
<GradientStop Color="#07111F" Offset="0"/>
<GradientStop Color="#0D1729" Offset="0.5"/>
<GradientStop Color="#13305C" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid>
<!-- Lichtschein hinter dem Logo und unten -->
<Ellipse Width="320" Height="320" Margin="-120,-110,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" IsHitTestVisible="False">
<Ellipse.Fill>
<RadialGradientBrush>
<GradientStop Color="#5558D5FF" Offset="0"/>
<GradientStop Color="#0058D5FF" Offset="1"/>
</RadialGradientBrush>
</Ellipse.Fill>
</Ellipse>
<Ellipse Width="300" Height="300" Margin="0,0,-150,-150" HorizontalAlignment="Right" VerticalAlignment="Bottom" IsHitTestVisible="False">
<Ellipse.Fill>
<RadialGradientBrush>
<GradientStop Color="#445C87FF" Offset="0"/>
<GradientStop Color="#005C87FF" Offset="1"/>
</RadialGradientBrush>
</Ellipse.Fill>
</Ellipse>
<StackPanel Margin="30,64,24,0">
<Image Source="app.png" Width="68" Height="68" HorizontalAlignment="Left" RenderOptions.BitmapScalingMode="HighQuality">
<Image.Effect>
<DropShadowEffect Color="#58D5FF" BlurRadius="28" ShadowDepth="0" Opacity="0.6"/>
</Image.Effect>
</Image>
<TextBlock Text="Git Manager" FontFamily="{DynamicResource DisplayFont}" FontSize="26" FontWeight="SemiBold"
Foreground="{StaticResource SideText}" Margin="0,16,0,0"/>
<TextBlock x:Name="VersionText" Foreground="{StaticResource SideTextSecondary}" FontSize="12" Margin="0,2,0,0"/>
<StackPanel x:Name="Steps" Margin="0,38,0,0">
<StackPanel Orientation="Horizontal">
<Border x:Name="Dot0" Style="{StaticResource StepDot}"><TextBlock x:Name="Num0" Text="1" Style="{StaticResource StepNum}"/></Border>
<TextBlock x:Name="Label0" Text="Willkommen" Style="{StaticResource StepLabel}"/>
</StackPanel>
<Border x:Name="Line0" Style="{StaticResource StepLine}"/>
<StackPanel Orientation="Horizontal">
<Border x:Name="Dot1" Style="{StaticResource StepDot}"><TextBlock x:Name="Num1" Text="2" Style="{StaticResource StepNum}"/></Border>
<TextBlock x:Name="Label1" Text="Optionen" Style="{StaticResource StepLabel}"/>
</StackPanel>
<Border x:Name="Line1" Style="{StaticResource StepLine}"/>
<StackPanel Orientation="Horizontal">
<Border x:Name="Dot2" Style="{StaticResource StepDot}"><TextBlock x:Name="Num2" Text="3" Style="{StaticResource StepNum}"/></Border>
<TextBlock x:Name="Label2" Text="Installation" Style="{StaticResource StepLabel}"/>
</StackPanel>
</StackPanel>
</StackPanel>
<TextBlock Text="© M_Viper" FontSize="11" Foreground="{StaticResource SideTextMuted}"
VerticalAlignment="Bottom" Margin="30,0,0,20"/>
</Grid>
</Border>
<!-- Titelleiste -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="14,0,0,0">
<Image Source="app.ico" Width="16" Height="16" RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock x:Name="WindowTitle" Text="Git Manager GUI Setup" FontSize="12" Margin="10,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource SideTextSecondary}"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Top">
<Button Style="{StaticResource CaptionButton}" Content="&#xE921;" Click="Minimize_Click"
WindowChrome.IsHitTestVisibleInChrome="True"/>
<Button x:Name="CloseButton" Style="{StaticResource CloseCaptionButton}" Content="&#xE8BB;" Click="Cancel_Click"
WindowChrome.IsHitTestVisibleInChrome="True"/>
</StackPanel>
<!-- ===== Inhalt ===== -->
<Grid Grid.Row="1" Grid.Column="1" Margin="40,18,40,0">
<!-- 0: Willkommen -->
<StackPanel x:Name="PageWelcome">
<TextBlock Text="Willkommen beim Git Manager" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Body}"
Text="Der Assistent richtet Git Manager GUI in wenigen Schritten ein – nur für dein Benutzerkonto und ohne Administratorrechte."/>
<Grid Margin="0,24,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="18"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<DockPanel>
<Border Style="{StaticResource FeatureIcon}" DockPanel.Dock="Left"><TextBlock Text="&#xE753;" Style="{StaticResource FeatureGlyph}"/></Border>
<StackPanel Margin="12,0,0,0">
<TextBlock Text="Gitea &amp; GitHub" Style="{StaticResource FeatureTitle}"/>
<TextBlock Text="Repos, Dateien, Releases und Commits an einem Ort" Style="{StaticResource FeatureText}"/>
</StackPanel>
</DockPanel>
<DockPanel Grid.Column="2">
<Border Style="{StaticResource FeatureIcon}" DockPanel.Dock="Left"><TextBlock Text="&#xE943;" Style="{StaticResource FeatureGlyph}"/></Border>
<StackPanel Margin="12,0,0,0">
<TextBlock Text="Git eingebaut" Style="{StaticResource FeatureTitle}"/>
<TextBlock Text="Kein zusätzliches Programm nötig" Style="{StaticResource FeatureText}"/>
</StackPanel>
</DockPanel>
<DockPanel Grid.Row="2">
<Border Style="{StaticResource FeatureIcon}" DockPanel.Dock="Left"><TextBlock Text="&#xE8C8;" Style="{StaticResource FeatureGlyph}"/></Border>
<StackPanel Margin="12,0,0,0">
<TextBlock Text="PRs &amp; Issues" Style="{StaticResource FeatureTitle}"/>
<TextBlock Text="Ansehen, kommentieren und mergen" Style="{StaticResource FeatureText}"/>
</StackPanel>
</DockPanel>
<DockPanel Grid.Row="2" Grid.Column="2">
<Border Style="{StaticResource FeatureIcon}" DockPanel.Dock="Left"><TextBlock Text="&#xE777;" Style="{StaticResource FeatureGlyph}"/></Border>
<StackPanel Margin="12,0,0,0">
<TextBlock Text="Backup" Style="{StaticResource FeatureTitle}"/>
<TextBlock Text="Alle Repos sichern und wiederherstellen" Style="{StaticResource FeatureText}"/>
</StackPanel>
</DockPanel>
</Grid>
<Border x:Name="UpdateHint" Margin="0,26,0,0" Padding="14,10" CornerRadius="8" Visibility="Collapsed"
Background="{DynamicResource AccentSubtleBrush}" BorderBrush="{DynamicResource AccentSubtleStrokeBrush}" BorderThickness="1">
<DockPanel>
<TextBlock DockPanel.Dock="Left" Text="&#xE946;" FontFamily="{DynamicResource IconFont}" FontSize="16"
Foreground="{DynamicResource AccentBrush}" Margin="0,2,12,0" VerticalAlignment="Top"/>
<TextBlock x:Name="UpdateHintText" TextWrapping="Wrap" LineHeight="20" FontSize="13"/>
</DockPanel>
</Border>
<Border x:Name="RunningHint" Margin="0,10,0,0" Padding="14,10" CornerRadius="8" Visibility="Collapsed"
Background="{DynamicResource ControlFillBrush}" BorderBrush="{DynamicResource ControlStrokeBrush}" BorderThickness="1">
<DockPanel>
<TextBlock DockPanel.Dock="Left" Text="&#xE7BA;" FontFamily="{DynamicResource IconFont}" FontSize="16"
Foreground="{DynamicResource AccentBrush}" Margin="0,2,12,0" VerticalAlignment="Top"/>
<TextBlock TextWrapping="Wrap" LineHeight="20" FontSize="13"
Text="Git Manager GUI läuft gerade und wird für die Installation automatisch beendet. Laufende Uploads gehen dabei verloren."/>
</DockPanel>
</Border>
</StackPanel>
<!-- 1: Optionen -->
<StackPanel x:Name="PageOptions" Visibility="Collapsed">
<TextBlock Text="Optionen" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Body}" Text="Wohin soll Git Manager GUI installiert werden?"/>
<Border Style="{StaticResource Card}" Margin="0,18,0,0">
<StackPanel>
<TextBlock Text="Installationsordner" FontWeight="SemiBold" FontSize="13" Margin="0,0,0,10"/>
<DockPanel>
<Button DockPanel.Dock="Right" Content="Ändern …" Margin="8,0,0,0" Click="ChangeDir_Click"/>
<TextBox x:Name="DirBox"/>
</DockPanel>
<TextBlock Style="{StaticResource Body}" FontSize="12" LineHeight="18" Margin="0,10,0,0"
Text="Benötigt ca. 450 MB. Zugangsdaten, Einstellungen und Favoriten liegen getrennt davon und bleiben bei Updates erhalten."/>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}" Margin="0,12,0,0">
<StackPanel>
<CheckBox x:Name="DesktopLinkBox" Content="Verknüpfung auf dem Desktop" IsChecked="True"/>
</StackPanel>
</Border>
</StackPanel>
<!-- 2: Fortschritt / Fertig -->
<StackPanel x:Name="PageProgress" Visibility="Collapsed">
<Border x:Name="SuccessIcon" Width="64" Height="64" CornerRadius="32" HorizontalAlignment="Left" Margin="0,4,0,20"
Background="{StaticResource BrandGradient}" RenderTransformOrigin="0.5,0.5" Visibility="Collapsed">
<Border.RenderTransform><ScaleTransform x:Name="SuccessScale" ScaleX="1" ScaleY="1"/></Border.RenderTransform>
<Border.Effect><DropShadowEffect Color="#58D5FF" BlurRadius="24" ShadowDepth="0" Opacity="0.5"/></Border.Effect>
<TextBlock Text="&#xE73E;" FontFamily="{DynamicResource IconFont}" FontSize="26" Foreground="#04101F"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock x:Name="ProgressTitle" Text="Git Manager GUI wird installiert …" Style="{StaticResource PageTitle}"/>
<StackPanel x:Name="ProgressBarPanel" Margin="0,18,0,0">
<TextBlock x:Name="PercentText" Text="0 %" FontFamily="{DynamicResource DisplayFont}" FontSize="44" FontWeight="SemiBold"
Foreground="{DynamicResource AccentBrush}"/>
<Border x:Name="ProgressTrack" SizeChanged="ProgressTrack_SizeChanged" Height="8" CornerRadius="4" Margin="0,10,0,0" Background="{DynamicResource SubtleSelectedBrush}">
<Border x:Name="ProgressFill" HorizontalAlignment="Left" Width="0" CornerRadius="4" Background="{StaticResource BrandGradient}"/>
</Border>
</StackPanel>
<TextBlock x:Name="ProgressText" Style="{StaticResource Body}" Margin="0,12,0,0" Text="Einen Moment bitte."/>
<StackPanel x:Name="DonePanel" Visibility="Collapsed">
<CheckBox x:Name="LaunchBox" Content="Git Manager GUI jetzt starten" IsChecked="True" Margin="0,24,0,0"/>
</StackPanel>
</StackPanel>
<!-- Deinstallation -->
<StackPanel x:Name="PageUninstall" Visibility="Collapsed">
<TextBlock Text="Git Manager GUI deinstallieren" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Body}"
Text="Git Manager GUI wird von deinem PC entfernt: Programm, Verknüpfungen, Autostart und der Eintrag in den Windows-Einstellungen."/>
<Border Style="{StaticResource Card}" Margin="0,22,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE72E;" FontFamily="{DynamicResource IconFont}" FontSize="15" Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="Deine Zugangsdaten und Einstellungen bleiben erhalten" FontWeight="SemiBold" Margin="10,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="DataInfo" Style="{StaticResource Body}" FontSize="12.5" Margin="0,6,0,14"/>
<CheckBox x:Name="RemoveDataBox" Content="Auch Zugangsdaten, Einstellungen, Favoriten und Verlauf löschen"
Checked="RemoveDataBox_Click" Unchecked="RemoveDataBox_Click"/>
<TextBlock x:Name="RemoveDataWarning" Visibility="Collapsed" Margin="30,8,0,0" TextWrapping="Wrap" FontSize="12.5"
Foreground="{DynamicResource DangerBrush}"
Text="Die gespeicherten Tokens für Gitea und GitHub werden entfernt (sie landen im Papierkorb). Deine Repos auf dem Server, lokale Projektordner und Repo-Backups bleiben unangetastet."/>
</StackPanel>
</Border>
</StackPanel>
</Grid>
<!-- Fußleiste -->
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,40,24">
<Button x:Name="BackButton" Content="Zurück" Width="110" Margin="0,0,8,0" Click="Back_Click"/>
<Button x:Name="NextButton" Content="Weiter" Width="140" Margin="0,0,8,0" Style="{StaticResource AccentButton}" IsDefault="True" Click="Next_Click"/>
<Button x:Name="CancelButton" Content="Abbrechen" Width="110" Click="Cancel_Click"/>
</StackPanel>
<!-- Eigene Rückfrage im Setup-Look statt Windows-Meldungsfenster (siehe Frage()) -->
<Grid x:Name="DialogOverlay" Grid.RowSpan="3" Grid.ColumnSpan="2" Visibility="Collapsed" Background="#99000000">
<Border x:Name="DialogCard" Width="420" HorizontalAlignment="Center" VerticalAlignment="Center"
Background="{DynamicResource LayerBrush}" BorderBrush="{DynamicResource ControlStrokeBrush}" BorderThickness="1"
CornerRadius="10" Padding="26,22,22,18" Effect="{StaticResource DialogShadow}" RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform><ScaleTransform x:Name="DialogScale" ScaleX="1" ScaleY="1"/></Border.RenderTransform>
<StackPanel>
<StackPanel Orientation="Horizontal">
<Border Width="34" Height="34" CornerRadius="17" Background="{DynamicResource AccentSubtleBrush}" VerticalAlignment="Top">
<TextBlock x:Name="DialogIcon" Text="&#xE7BA;" FontFamily="{DynamicResource IconFont}" FontSize="16"
Foreground="{DynamicResource AccentBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock x:Name="DialogTitle" FontFamily="{DynamicResource DisplayFont}" FontSize="18" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="12,0,0,0" TextWrapping="Wrap" MaxWidth="320"/>
</StackPanel>
<TextBlock x:Name="DialogText" TextWrapping="Wrap" LineHeight="20" Margin="46,8,0,0"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,22,0,0">
<Button x:Name="DialogSecondary" MinWidth="120" Margin="0,0,8,0" Click="DialogSecondary_Click"/>
<Button x:Name="DialogPrimary" MinWidth="130" Style="{StaticResource AccentButton}" Click="DialogPrimary_Click"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Grid>
</Window>
+454
View File
@@ -0,0 +1,454 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
namespace GitManagerSetup;
public partial class MainWindow : Window
{
private readonly SetupMode _mode;
private int _page;
private bool _busy;
private bool _done;
private string? _installedExe;
private const int PageNumOptions = 1;
private const int PageNumProgress = 2;
public MainWindow(SetupMode mode)
{
InitializeComponent();
_mode = mode;
VersionText.Text = $"Version {Installer.Version}";
var installed = Installer.InstalledDir;
DirBox.Text = installed ?? Installer.DefaultDir;
DesktopLinkBox.IsChecked = installed is null || Installer.DesktopLinkExists;
if (installed is not null)
{
UpdateHint.Visibility = Visibility.Visible;
UpdateHintText.Text = Installer.InstalledVersion == Installer.Version
? $"Git Manager GUI {Installer.Version} ist bereits installiert und wird neu installiert. Zugangsdaten und Einstellungen bleiben erhalten."
: $"Die vorhandene Installation{(Installer.InstalledVersion is { } v ? $" (Version {v})" : "")} wird durch Version {Installer.Version} ersetzt. Zugangsdaten und Einstellungen bleiben erhalten.";
}
if (Installer.IsRunning(installed)) RunningHint.Visibility = Visibility.Visible;
switch (mode)
{
case SetupMode.Uninstall:
Title = WindowTitle.Text = "Git Manager GUI deinstallieren";
Steps.Visibility = Visibility.Collapsed;
ShowOnly(PageUninstall);
NextButton.Content = "Deinstallieren";
BackButton.Visibility = Visibility.Collapsed;
LoadDataInfo();
break;
case SetupMode.Update:
// aus der App heraus: ohne Rückfragen direkt aktualisieren und danach wieder starten
Title = WindowTitle.Text = "Git Manager GUI wird aktualisiert";
Steps.Visibility = Visibility.Collapsed;
Loaded += async (_, _) => await RunInstall(DirBox.Text, DesktopLinkBox.IsChecked == true, update: true);
ShowOnly(PageProgress);
ProgressTitle.Text = "Git Manager GUI wird aktualisiert …";
BackButton.Visibility = Visibility.Hidden;
break;
default:
ShowPage(0);
break;
}
}
// ---------- Seiten ----------
private UIElement? _shownPage;
/// <summary>Seite wechseln – mit kurzem Einblenden von rechts.</summary>
private void ShowOnly(UIElement page)
{
foreach (var p in new UIElement[] { PageWelcome, PageOptions, PageProgress, PageUninstall })
p.Visibility = p == page ? Visibility.Visible : Visibility.Collapsed;
if (page == _shownPage) return;
_shownPage = page;
var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
var slide = new TranslateTransform(18, 0);
page.RenderTransform = slide;
slide.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(18, 0, TimeSpan.FromMilliseconds(260)) { EasingFunction = ease });
page.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(220)));
}
// Farben der Schritt-Anzeige in der (immer dunklen) Seitenleiste – Cyan wie in der App
private static readonly Brush StepDoneFill = Frozen(Color.FromRgb(0x58, 0xD5, 0xFF));
private static readonly Brush StepDoneGlyph = Frozen(Color.FromRgb(0x04, 0x10, 0x1F));
private static readonly Brush StepCurrentFill = Brushes.White;
private static readonly Brush StepCurrentText = Frozen(Color.FromRgb(0x13, 0x30, 0x5C));
private static readonly Brush StepUpcoming = Frozen(Color.FromRgb(0x70, 0x83, 0xA4));
private static readonly Brush StepUpcomingStroke = Frozen(Color.FromRgb(0x3A, 0x4E, 0x72));
private static readonly Brush StepDoneText = Frozen(Color.FromRgb(0xAE, 0xBD, 0xD8));
private static readonly Brush StepLineIdle = Frozen(Color.FromRgb(0x24, 0x36, 0x58));
private static Brush Frozen(Color c)
{
var b = new SolidColorBrush(c);
b.Freeze();
return b;
}
private void UpdateSteps(int page)
{
var dots = new[] { Dot0, Dot1, Dot2 };
var nums = new[] { Num0, Num1, Num2 };
var labels = new[] { Label0, Label1, Label2 };
var lines = new[] { Line0, Line1 };
var iconFont = (FontFamily)FindResource("IconFont");
var uiFont = (FontFamily)FindResource("UiFont");
for (int i = 0; i < dots.Length; i++)
{
bool done = i < page || (_done && i == page);
bool current = i == page && !done;
dots[i].Background = done ? StepDoneFill : current ? StepCurrentFill : Brushes.Transparent;
dots[i].BorderBrush = done ? StepDoneFill : current ? StepCurrentFill : StepUpcomingStroke;
nums[i].Text = done ? "" : (i + 1).ToString();
nums[i].FontFamily = done ? iconFont : uiFont;
nums[i].FontSize = done ? 11 : 12;
nums[i].Foreground = done ? StepDoneGlyph : current ? StepCurrentText : StepUpcoming;
labels[i].Foreground = current ? Brushes.White : done ? StepDoneText : StepUpcoming;
labels[i].FontWeight = current ? FontWeights.SemiBold : FontWeights.Normal;
}
for (int i = 0; i < lines.Length; i++)
lines[i].Background = i < page ? StepDoneFill : StepLineIdle;
}
private void ShowPage(int page)
{
_page = page;
ShowOnly(page switch { 0 => PageWelcome, PageNumOptions => PageOptions, _ => PageProgress });
UpdateSteps(page);
BackButton.Visibility = page == PageNumOptions ? Visibility.Visible : Visibility.Hidden;
NextButton.Content = page switch
{
PageNumOptions => "Installieren",
PageNumProgress => "Fertigstellen",
_ => "Weiter",
};
}
private void Back_Click(object sender, RoutedEventArgs e)
{
if (!_busy && _page > 0) ShowPage(_page - 1);
}
private async void Next_Click(object sender, RoutedEventArgs e)
{
if (_busy) return;
if (_mode == SetupMode.Uninstall)
{
if (_done) { Close(); return; }
await RunUninstall();
return;
}
if (_mode == SetupMode.Update)
{
Close(); // nur nach einem Fehler erreichbar
return;
}
switch (_page)
{
case 0: ShowPage(PageNumOptions); break;
case PageNumOptions:
var dir = DirBox.Text.Trim();
if (!IsFullPath(dir) || dir.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
{
await Frage("Ungültiger Ordner", "Bitte einen vollständigen Ordner angeben, z. B. C:\\Programme\\Git Manager GUI.", "OK");
return;
}
if (dir.TrimEnd('\\', '/').Length > Installer.MaxDirLength)
{
await Frage("Ordnerpfad zu lang",
$"Der Pfad hat {dir.TrimEnd('\\', '/').Length} Zeichen, Windows erlaubt hier höchstens {Installer.MaxDirLength}. " +
"Bitte einen kürzeren Ordner wählen.", "OK");
return;
}
await RunInstall(dir, DesktopLinkBox.IsChecked == true, update: false);
break;
case PageNumProgress:
// Start im Hintergrund: Process.Start kann blockieren, solange Windows
// die EXE prüft – das Fenster darf dabei nie einfrieren
if (LaunchBox.IsChecked == true)
{
_busy = true;
NextButton.IsEnabled = false;
NextButton.Content = "Wird gestartet …";
await Task.Run(StartApp);
_busy = false;
}
Close();
break;
}
}
private void ChangeDir_Click(object sender, RoutedEventArgs e)
{
using var dlg = new System.Windows.Forms.FolderBrowserDialog
{
Description = "Installationsordner wählen",
SelectedPath = Directory.Exists(DirBox.Text) ? DirBox.Text : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
ShowNewFolderButton = true,
};
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
var chosen = dlg.SelectedPath;
// wie bei üblichen Installern: Unterordner "Git Manager GUI" anhängen
DirBox.Text = Path.GetFileName(chosen.TrimEnd('\\')).Equals(Installer.AppName, StringComparison.OrdinalIgnoreCase)
? chosen : Path.Combine(chosen, Installer.AppName);
}
// ---------- Installation ----------
private async Task RunInstall(string dir, bool desktop, bool update)
{
_busy = true;
if (!update) ShowPage(PageNumProgress);
SetButtons(false);
var progress = new Progress<(double Value, string Text)>(p => SetProgress(p.Value, p.Text));
try
{
await Task.Run(() => Installer.Install(dir, desktop, progress, waitForExit: update));
_installedExe = Path.Combine(dir, Installer.ExeName);
_done = true;
if (update)
{
ShowSuccess("Update abgeschlossen", $"Git Manager GUI {Installer.Version} wird gestartet …");
StartApp();
_busy = false;
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
timer.Tick += (_, _) => { timer.Stop(); Close(); };
timer.Start();
return;
}
ShowSuccess("Alles bereit!", "Git Manager GUI ist installiert. Du findest es im Startmenü" + (desktop ? " und auf dem Desktop." : "."));
DonePanel.Visibility = Visibility.Visible;
ShowPage(PageNumProgress);
}
catch (Exception ex)
{
ProgressTitle.Text = update ? "Update fehlgeschlagen" : "Installation fehlgeschlagen";
ProgressBarPanel.Visibility = Visibility.Collapsed;
ProgressText.Text = ex.Message;
ProgressText.Foreground = (Brush)FindResource("DangerBrush");
NextButton.Content = "Schließen";
_installedExe = null;
}
_busy = false;
NextButton.IsEnabled = true;
CloseButton.IsEnabled = true;
BackButton.Visibility = Visibility.Hidden;
CancelButton.Visibility = Visibility.Collapsed;
}
/// <summary>Fortschritt ausblenden und den Haken mit kurzem "Aufploppen" zeigen.</summary>
private void ShowSuccess(string title, string text)
{
ProgressBarPanel.Visibility = Visibility.Collapsed;
ProgressTitle.Text = title;
ProgressText.Text = text;
SuccessIcon.Visibility = Visibility.Visible;
var pop = new DoubleAnimation(0.4, 1, TimeSpan.FromMilliseconds(420)) { EasingFunction = new BackEase { EasingMode = EasingMode.EaseOut, Amplitude = 0.5 } };
SuccessScale.BeginAnimation(ScaleTransform.ScaleXProperty, pop);
SuccessScale.BeginAnimation(ScaleTransform.ScaleYProperty, pop);
SuccessIcon.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
}
private void StartApp()
{
if (_installedExe is null || !File.Exists(_installedExe)) return;
try
{
Process.Start(new ProcessStartInfo(_installedExe)
{
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(_installedExe)!,
});
}
catch { }
}
// ---------- Deinstallation ----------
private async void LoadDataInfo()
{
var root = Installer.DataDir;
if (!Directory.Exists(root))
{
DataInfo.Text = "Es wurden keine App-Daten gefunden.";
RemoveDataBox.IsEnabled = false;
return;
}
DataInfo.Text = $"Sie liegen in {root}. Größe wird berechnet …";
var size = await Task.Run(Installer.DataSize);
DataInfo.Text = $"Sie liegen in {root} ({Installer.FormatSize(size)}) und stehen wieder bereit, falls du Git Manager GUI später neu installierst.";
}
private void RemoveDataBox_Click(object sender, RoutedEventArgs e) =>
RemoveDataWarning.Visibility = RemoveDataBox.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
private async Task RunUninstall()
{
_busy = true;
bool removeData = RemoveDataBox.IsChecked == true;
ShowOnly(PageProgress);
ProgressTitle.Text = "Git Manager GUI wird entfernt …";
LaunchBox.Visibility = Visibility.Collapsed;
SetButtons(false);
var progress = new Progress<(double Value, string Text)>(p => SetProgress(p.Value, p.Text));
try
{
await Task.Run(() => Installer.Uninstall(removeData, progress));
ShowSuccess("Git Manager GUI wurde entfernt", removeData
? "Programm, Zugangsdaten und Einstellungen wurden entfernt (die Daten liegen im Papierkorb)."
: "Deine Zugangsdaten und Einstellungen liegen weiterhin in " + Installer.DataDir + " – falls du Git Manager GUI später wieder installierst.");
}
catch (Exception ex)
{
ProgressTitle.Text = "Deinstallation fehlgeschlagen";
ProgressBarPanel.Visibility = Visibility.Collapsed;
ProgressText.Text = ex.Message;
}
_done = true;
_busy = false;
CloseButton.IsEnabled = true;
NextButton.Content = "Schließen";
NextButton.IsEnabled = true;
CancelButton.Visibility = Visibility.Collapsed;
}
// ---------- Helfer ----------
private void SetButtons(bool enabled)
{
NextButton.IsEnabled = enabled;
BackButton.IsEnabled = enabled;
CancelButton.IsEnabled = enabled;
CloseButton.IsEnabled = enabled;
}
private void SetProgress(double value, string text)
{
if (_done) return;
ProgressText.Text = text;
PercentText.Text = $"{(int)Math.Round(value * 100)} %";
_progress = value;
AnimateProgress();
}
private double _progress;
private void AnimateProgress()
{
var target = Math.Max(0, ProgressTrack.ActualWidth * _progress);
ProgressFill.BeginAnimation(WidthProperty,
new DoubleAnimation(target, TimeSpan.FromMilliseconds(250)) { EasingFunction = new CubicEase() });
}
// Kommt der erste Wert, bevor die Seite fertig angeordnet ist (Breite 0), nachträglich richtig setzen
private void ProgressTrack_SizeChanged(object sender, SizeChangedEventArgs e) => AnimateProgress();
private static bool IsFullPath(string p) =>
p.Length >= 3 && char.IsLetter(p[0]) && p[1] == ':' && (p[2] == '\\' || p[2] == '/');
private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private async void Cancel_Click(object sender, RoutedEventArgs e)
{
if (_busy || _dialog is not null) return;
if (!_done && _mode == SetupMode.Install && _page > 0)
{
bool abbrechen = await Frage("Installation abbrechen?",
"Git Manager GUI wird nicht installiert. Du kannst das Setup später jederzeit erneut starten.",
"Ja, abbrechen", "Weiter installieren", "\uE897");
if (!abbrechen) return;
}
Close();
}
// ---------- eigene Rückfragen im Setup-Look ----------
private TaskCompletionSource<bool>? _dialog;
/// <summary>
/// Zeigt eine Rückfrage über dem Fenster. Mit <paramref name="zurueck"/>: zwei Knöpfe –
/// der hervorgehobene ist der sichere Weg zurück (liefert false), der andere bestätigt (true).
/// Ohne: nur ein Hinweis mit einem Knopf (liefert true).
/// </summary>
private Task<bool> Frage(string titel, string text, string bestaetigen, string? zurueck = null, string symbol = "\uE7BA")
{
_dialog?.TrySetResult(false);
_dialog = new TaskCompletionSource<bool>();
DialogTitle.Text = titel;
DialogText.Text = text;
DialogIcon.Text = symbol;
if (zurueck is null)
{
DialogSecondary.Visibility = Visibility.Collapsed;
DialogPrimary.Content = bestaetigen;
}
else
{
DialogSecondary.Visibility = Visibility.Visible;
DialogSecondary.Content = bestaetigen;
DialogPrimary.Content = zurueck;
}
DialogOverlay.Visibility = Visibility.Visible;
var ein = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(160));
DialogOverlay.BeginAnimation(OpacityProperty, ein);
var groesse = new DoubleAnimation(0.94, 1, TimeSpan.FromMilliseconds(220)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
DialogScale.BeginAnimation(ScaleTransform.ScaleXProperty, groesse);
DialogScale.BeginAnimation(ScaleTransform.ScaleYProperty, groesse);
DialogPrimary.Focus();
return _dialog.Task;
}
private void SchliesseFrage(bool ergebnis)
{
if (_dialog is null) return;
var tcs = _dialog;
_dialog = null;
DialogOverlay.Visibility = Visibility.Collapsed;
tcs.TrySetResult(ergebnis);
}
// hervorgehobener Knopf: bei Rückfragen "zurück" (false), beim reinen Hinweis "OK" (true)
private void DialogPrimary_Click(object sender, RoutedEventArgs e) =>
SchliesseFrage(DialogSecondary.Visibility != Visibility.Visible);
private void DialogSecondary_Click(object sender, RoutedEventArgs e) => SchliesseFrage(true);
protected override void OnPreviewKeyDown(System.Windows.Input.KeyEventArgs e)
{
// solange die Rückfrage offen ist: Enter = hervorgehobener Knopf, Esc = zurück
if (_dialog is not null)
{
if (e.Key == System.Windows.Input.Key.Escape) { SchliesseFrage(DialogSecondary.Visibility != Visibility.Visible); e.Handled = true; return; }
if (e.Key == System.Windows.Input.Key.Enter) { DialogPrimary_Click(this, new RoutedEventArgs()); e.Handled = true; return; }
}
base.OnPreviewKeyDown(e);
}
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (_busy) e.Cancel = true; // während der Installation nicht schließen
_dialog?.TrySetResult(false);
base.OnClosing(e);
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.IO;
using System.IO.Compression;
using System.Text;
namespace GitManagerSetup;
/// <summary>
/// Die App liegt als ZIP hinten an der Setup-.exe angehängt:
/// [Setup.exe][ZIP][8 Byte: ZIP-Länge][8 Byte: "GITMGRPK"]
/// Windows ignoriert angehängte Daten beim Starten einer .exe.
/// </summary>
public static class Payload
{
private static readonly byte[] Magic = Encoding.ASCII.GetBytes("GITMGRPK");
private const int FooterSize = 16;
private static (long Offset, long Length)? _info;
private static bool _checked;
/// <summary>Position und Länge des ZIPs in dieser .exe, oder null (z. B. beim Deinstallationsprogramm).</summary>
public static (long Offset, long Length)? Info
{
get
{
if (_checked) return _info;
_checked = true;
try
{
using var f = File.OpenRead(Installer.SelfPath);
if (f.Length < FooterSize) return null;
f.Seek(-FooterSize, SeekOrigin.End);
var footer = new byte[FooterSize];
if (f.Read(footer, 0, FooterSize) != FooterSize) return null;
for (int i = 0; i < Magic.Length; i++)
if (footer[8 + i] != Magic[i]) return null;
long length = BitConverter.ToInt64(footer, 0);
long offset = f.Length - FooterSize - length;
if (length <= 0 || offset <= 0) return null;
_info = (offset, length);
}
catch { _info = null; }
return _info;
}
}
public static bool Exists => Info is not null;
/// <summary>Länge des eigentlichen Setup-Programms ohne Anhang.</summary>
public static long StubLength => Info?.Offset ?? new FileInfo(Installer.SelfPath).Length;
public static ZipArchive OpenArchive()
{
var info = Info ?? throw new InvalidOperationException("Die Installationsdaten fehlen in dieser Datei. Bitte das Setup neu herunterladen.");
var file = File.OpenRead(Installer.SelfPath);
return new ZipArchive(new SubStream(file, info.Offset, info.Length), ZipArchiveMode.Read, leaveOpen: false);
}
/// <summary>Nur-Lese-Ausschnitt einer Datei, damit ZipArchive den Anhang wie eine eigene Datei sieht.</summary>
private sealed class SubStream(Stream inner, long start, long length) : Stream
{
private long _pos;
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => length;
public override long Position
{
get => _pos;
set => _pos = Math.Max(0, Math.Min(value, length));
}
public override int Read(byte[] buffer, int offset, int count)
{
long left = length - _pos;
if (left <= 0) return 0;
if (count > left) count = (int)left;
inner.Position = start + _pos;
int n = inner.Read(buffer, offset, count);
_pos += n;
return n;
}
public override long Seek(long offset, SeekOrigin origin)
{
Position = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => _pos + offset,
_ => length + offset,
};
return _pos;
}
public override void Flush() { }
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing) inner.Dispose();
base.Dispose(disposing);
}
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
namespace GitManagerSetup;
/// <summary>
/// Verknüpfungen (.lnk) über die Shell-Schnittstellen – inklusive AppUserModelID, damit
/// Taskleiste und Windows-Benachrichtigungen dem Git Manager wie bisher zuordnen (wie beim alten NSIS-Setup).
/// </summary>
public static class Shortcut
{
public static void Create(string link, string target, string description, string appUserModelId)
{
Directory.CreateDirectory(Path.GetDirectoryName(link)!);
var shellLink = (IShellLinkW)new CShellLink();
try
{
shellLink.SetPath(target);
shellLink.SetWorkingDirectory(Path.GetDirectoryName(target)!);
shellLink.SetIconLocation(target, 0);
shellLink.SetDescription(description);
var store = (IPropertyStore)shellLink;
var key = new PropertyKey(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 5); // PKEY_AppUserModel_ID
var value = new PropVariant { vt = 31 /* VT_LPWSTR */, p = Marshal.StringToCoTaskMemUni(appUserModelId) };
try
{
store.SetValue(ref key, ref value);
store.Commit();
}
finally { Marshal.FreeCoTaskMem(value.p); }
((IPersistFile)shellLink).Save(link, true);
}
finally { Marshal.ReleaseComObject(shellLink); }
}
/// <summary>Ziel einer vorhandenen Verknüpfung, oder null.</summary>
public static string? Target(string link)
{
if (!File.Exists(link)) return null;
var shellLink = (IShellLinkW)new CShellLink();
try
{
((IPersistFile)shellLink).Load(link, 0);
var sb = new StringBuilder(1024);
shellLink.GetPath(sb, sb.Capacity, IntPtr.Zero, 0);
return sb.ToString();
}
catch { return null; }
finally { Marshal.ReleaseComObject(shellLink); }
}
// ---------- COM ----------
[ComImport, Guid("00021401-0000-0000-C000-000000000046")]
private class CShellLink { }
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLinkW
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, uint fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved);
void Resolve(IntPtr hwnd, uint fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
private interface IPropertyStore
{
void GetCount(out uint cProps);
void GetAt(uint iProp, out PropertyKey pkey);
void GetValue(ref PropertyKey key, out PropVariant pv);
void SetValue(ref PropertyKey key, ref PropVariant pv);
void Commit();
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct PropertyKey(Guid fmtid, uint pid)
{
public Guid fmtid = fmtid;
public uint pid = pid;
}
// PROPVARIANT: 16 Byte (32 Bit) bzw. 24 Byte (64 Bit)
[StructLayout(LayoutKind.Sequential)]
private struct PropVariant
{
public ushort vt;
public ushort r1, r2, r3;
public IntPtr p;
public IntPtr p2;
}
}
+27
View File
@@ -0,0 +1,27 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="BaseBrush" Color="#202020"/>
<SolidColorBrush x:Key="LayerBrush" Color="#282828"/>
<SolidColorBrush x:Key="LayerStrokeBrush" Color="#1A1A1A"/>
<SolidColorBrush x:Key="SubtleHoverBrush" Color="#0FFFFFFF"/>
<SolidColorBrush x:Key="SubtlePressedBrush" Color="#0AFFFFFF"/>
<SolidColorBrush x:Key="SubtleSelectedBrush" Color="#14FFFFFF"/>
<SolidColorBrush x:Key="ControlFillBrush" Color="#2D2D2D"/>
<SolidColorBrush x:Key="ControlHoverBrush" Color="#323232"/>
<SolidColorBrush x:Key="ControlPressedBrush" Color="#272727"/>
<SolidColorBrush x:Key="ControlStrokeBrush" Color="#353535"/>
<SolidColorBrush x:Key="ControlStrongStrokeBrush" Color="#8A8A8A"/>
<SolidColorBrush x:Key="InputFocusBrush" Color="#1F1F1F"/>
<SolidColorBrush x:Key="TextBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#C8C8C8"/>
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#9A9A9A"/>
<SolidColorBrush x:Key="FlyoutBrush" Color="#2C2C2C"/>
<SolidColorBrush x:Key="FlyoutStrokeBrush" Color="#404040"/>
<SolidColorBrush x:Key="DialogFooterBrush" Color="#202020"/>
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#9F9F9F"/>
<SolidColorBrush x:Key="OverlayBrush" Color="#66000000"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#4CAF50"/>
<SolidColorBrush x:Key="DangerBrush" Color="#FF99A4"/>
<SolidColorBrush x:Key="FenceBrush" Color="#F21C1C1C"/>
<Color x:Key="ShadowColor">#000000</Color>
</ResourceDictionary>
+27
View File
@@ -0,0 +1,27 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="BaseBrush" Color="#F3F3F3"/>
<SolidColorBrush x:Key="LayerBrush" Color="#FAFAFA"/>
<SolidColorBrush x:Key="LayerStrokeBrush" Color="#E5E5E5"/>
<SolidColorBrush x:Key="SubtleHoverBrush" Color="#0A000000"/>
<SolidColorBrush x:Key="SubtlePressedBrush" Color="#06000000"/>
<SolidColorBrush x:Key="SubtleSelectedBrush" Color="#0F000000"/>
<SolidColorBrush x:Key="ControlFillBrush" Color="#FDFDFD"/>
<SolidColorBrush x:Key="ControlHoverBrush" Color="#F6F6F6"/>
<SolidColorBrush x:Key="ControlPressedBrush" Color="#F2F2F2"/>
<SolidColorBrush x:Key="ControlStrokeBrush" Color="#E3E3E3"/>
<SolidColorBrush x:Key="ControlStrongStrokeBrush" Color="#C4C4C4"/>
<SolidColorBrush x:Key="InputFocusBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TextBrush" Color="#1B1B1B"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#5D5D5D"/>
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#8B8B8B"/>
<SolidColorBrush x:Key="FlyoutBrush" Color="#F9F9F9"/>
<SolidColorBrush x:Key="FlyoutStrokeBrush" Color="#DCDCDC"/>
<SolidColorBrush x:Key="DialogFooterBrush" Color="#F3F3F3"/>
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#8A8A8A"/>
<SolidColorBrush x:Key="OverlayBrush" Color="#4D000000"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#0F7B0F"/>
<SolidColorBrush x:Key="DangerBrush" Color="#C42B1C"/>
<SolidColorBrush x:Key="FenceBrush" Color="#F5F7F7F7"/>
<Color x:Key="ShadowColor">#000000</Color>
</ResourceDictionary>
+651
View File
@@ -0,0 +1,651 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Schriften: Windows 11 nutzt "Segoe UI Variable" / "Segoe Fluent Icons", Windows 10 fällt auf Segoe UI / MDL2 zurück -->
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI</FontFamily>
<FontFamily x:Key="DisplayFont">Segoe UI Variable Display, Segoe UI</FontFamily>
<FontFamily x:Key="IconFont">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
<DropShadowEffect x:Key="FlyoutShadow" BlurRadius="24" ShadowDepth="6" Direction="270" Opacity="0.18"
Color="Black"/>
<DropShadowEffect x:Key="DialogShadow" BlurRadius="40" ShadowDepth="12" Direction="270" Opacity="0.28"
Color="Black"/>
<!-- ===== Buttons ===== -->
<Style TargetType="Button">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Background" Value="{DynamicResource ControlFillBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,5"/>
<Setter Property="MinHeight" Value="32"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" RecognizesAccessKey="True"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlPressedBrush}"/>
<Setter Property="Opacity" Value="0.85"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource TextBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="AccentButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}"/>
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="4" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" RecognizesAccessKey="True"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentHoverBrush}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.8"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource TextBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Transparenter Button (Symbolleisten, Sidebar) -->
<Style x:Key="SubtleButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="9,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtlePressedBrush}"/>
<Setter Property="Opacity" Value="0.75"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Titelleisten-Buttons -->
<Style x:Key="CaptionButton" TargetType="Button">
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtlePressedBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="CloseCaptionButton" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#C42B1C"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#C83C30"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== TextBox ===== -->
<Style TargetType="TextBox">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Background" Value="{DynamicResource ControlFillBrush}"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="MinHeight" Value="32"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Grid>
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4"
BorderBrush="{DynamicResource ControlStrokeBrush}" BorderThickness="1"/>
<!-- Unterstrich wie bei Windows 11 -->
<Border x:Name="Line" Height="1" VerticalAlignment="Bottom" Margin="3,0" CornerRadius="0,0,4,4"
Background="{DynamicResource ControlStrongStrokeBrush}"/>
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlHoverBrush}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource InputFocusBrush}"/>
<Setter TargetName="Line" Property="Height" Value="2"/>
<Setter TargetName="Line" Property="Margin" Value="1,0"/>
<Setter TargetName="Line" Property="Background" Value="{DynamicResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Scrollbar (schmal, wird beim Überfahren breiter) ===== -->
<Style x:Key="ScrollThumb" TargetType="Thumb">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{DynamicResource ScrollThumbBrush}" CornerRadius="3"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="12"/>
<Setter Property="MinWidth" Value="12"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb x:Name="Th" Style="{StaticResource ScrollThumb}" Width="3" HorizontalAlignment="Center" Opacity="0.7"/>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Th" Property="Width" Value="6"/>
<Setter TargetName="Th" Property="Opacity" Value="1"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="MinWidth" Value="0"/>
<Setter Property="Height" Value="12"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb x:Name="Th" Style="{StaticResource ScrollThumb}" Height="3" VerticalAlignment="Center" Opacity="0.7"/>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Th" Property="Height" Value="6"/>
<Setter TargetName="Th" Property="Opacity" Value="1"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
<!-- ===== ToolTip ===== -->
<Style TargetType="ToolTip">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="HasDropShadow" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Margin="8,4,8,12" Padding="9,6" CornerRadius="4" MaxWidth="320"
Background="{DynamicResource FlyoutBrush}" BorderBrush="{DynamicResource FlyoutStrokeBrush}" BorderThickness="1"
Effect="{StaticResource FlyoutShadow}">
<ContentPresenter>
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Kontextmenü ===== -->
<Style TargetType="ContextMenu">
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="HasDropShadow" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Margin="12,4,12,16" Padding="0,4" CornerRadius="8" MinWidth="200"
Background="{DynamicResource FlyoutBrush}" BorderBrush="{DynamicResource FlyoutStrokeBrush}" BorderThickness="1"
Effect="{StaticResource FlyoutShadow}">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Cycle"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="{x:Static MenuItem.SeparatorStyleKey}" TargetType="Separator">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Separator">
<Border Height="1" Margin="0,4" Background="{DynamicResource FlyoutStrokeBrush}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Margin="4,1" Padding="11,0" Height="34">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ContentPresenter ContentSource="Icon" VerticalAlignment="Center" HorizontalAlignment="Left"
TextElement.FontFamily="{DynamicResource IconFont}" TextElement.FontSize="16"/>
<ContentPresenter Grid.Column="1" ContentSource="Header" VerticalAlignment="Center" RecognizesAccessKey="True"/>
<TextBlock x:Name="Gesture" Grid.Column="2" Text="{TemplateBinding InputGestureText}" FontSize="12"
VerticalAlignment="Center" Margin="24,0,0,0" Foreground="{DynamicResource TextTertiaryBrush}"/>
<TextBlock x:Name="Arrow" Grid.Column="2" Text="&#xE76C;" FontFamily="{DynamicResource IconFont}" FontSize="11"
VerticalAlignment="Center" Margin="24,0,0,0" Visibility="Collapsed"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<Popup x:Name="PART_Popup" Placement="Right" HorizontalOffset="-4" VerticalOffset="-12"
IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}"
AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
<Border Margin="12,4,12,16" Padding="0,4" CornerRadius="8" MinWidth="180"
Background="{DynamicResource FlyoutBrush}" BorderBrush="{DynamicResource FlyoutStrokeBrush}"
BorderThickness="1" Effect="{StaticResource FlyoutShadow}">
<ScrollViewer MaxHeight="420" VerticalScrollBarVisibility="Auto">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Cycle"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="Role" Value="SubmenuHeader">
<Setter TargetName="Arrow" Property="Visibility" Value="Visible"/>
<Setter TargetName="Gesture" Property="Visibility" Value="Collapsed"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Listen ===== -->
<Style TargetType="ListBox">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBox">
<ScrollViewer Focusable="False" Padding="{TemplateBinding Padding}">
<ItemsPresenter/>
</ScrollViewer>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Navigationseintrag in der Seitenleiste -->
<Style x:Key="NavItem" TargetType="ListBoxItem">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Height" Value="38"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Grid Margin="4,1" Background="Transparent">
<Border x:Name="Bd" CornerRadius="4" BorderThickness="1" BorderBrush="Transparent" Background="Transparent"/>
<Rectangle x:Name="Pill" Width="3" Height="16" RadiusX="1.5" RadiusY="1.5" HorizontalAlignment="Left"
Fill="{DynamicResource AccentBrush}" Visibility="Collapsed"/>
<ContentPresenter Margin="14,0,12,0" VerticalAlignment="Center"/>
<!-- Einfügelinie beim Umsortieren -->
<Border x:Name="InsertTop" Height="2" CornerRadius="1" VerticalAlignment="Top" Margin="6,-2,6,0"
Background="{DynamicResource AccentBrush}" Visibility="Collapsed"/>
<Border x:Name="InsertBottom" Height="2" CornerRadius="1" VerticalAlignment="Bottom" Margin="6,0,6,-2"
Background="{DynamicResource AccentBrush}" Visibility="Collapsed"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleSelectedBrush}"/>
<Setter TargetName="Pill" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="Tag" Value="before">
<Setter TargetName="InsertTop" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="Tag" Value="after">
<Setter TargetName="InsertBottom" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="Tag" Value="drop">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentSubtleBrush}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Icon-Kachel -->
<Style x:Key="TileItem" TargetType="ListBoxItem">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Margin="2" CornerRadius="6" BorderThickness="1" BorderBrush="Transparent" Background="Transparent">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentSubtleBrush}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentSubtleStrokeBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Listenansicht-Zeile ===== -->
<Style x:Key="RowItem" TargetType="ListBoxItem">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Grid Margin="0,1">
<Border x:Name="Bd" CornerRadius="4" Background="Transparent"/>
<Rectangle x:Name="Pill" Width="3" Height="16" RadiusX="1.5" RadiusY="1.5" HorizontalAlignment="Left"
Fill="{DynamicResource AccentBrush}" Visibility="Collapsed"/>
<ContentPresenter/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentSubtleBrush}"/>
<Setter TargetName="Pill" Property="Visibility" Value="Visible"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Karte (Einstellungen) ===== -->
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource ControlFillBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="18,14"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style x:Key="SectionTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="2,24,0,8"/>
</Style>
<!-- ===== Kippschalter (Windows 11) ===== -->
<Style x:Key="ToggleSwitch" TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<TextBlock x:Name="State" Text="Aus" VerticalAlignment="Center" Margin="0,0,12,0" MinWidth="24" TextAlignment="Right"/>
<Grid Width="40" Height="20">
<Border x:Name="Track" CornerRadius="10" BorderThickness="1"
Background="{DynamicResource SubtleSelectedBrush}" BorderBrush="{DynamicResource ControlStrongStrokeBrush}"/>
<Ellipse x:Name="Knob" Width="12" Height="12" HorizontalAlignment="Left" Margin="4,0,0,0"
Fill="{DynamicResource TextSecondaryBrush}"/>
</Grid>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter TargetName="Knob" Property="Fill" Value="{DynamicResource OnAccentBrush}"/>
<Setter TargetName="Knob" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="Knob" Property="Margin" Value="0,0,4,0"/>
<Setter TargetName="State" Property="Text" Value="Ein"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Knob" Property="Width" Value="14"/>
<Setter TargetName="Knob" Property="Height" Value="14"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Kontrollkästchen ===== -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FontFamily" Value="{DynamicResource UiFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="Box" Width="20" Height="20" CornerRadius="4" BorderThickness="1" VerticalAlignment="Center"
Background="{DynamicResource ControlFillBrush}" BorderBrush="{DynamicResource ControlStrongStrokeBrush}">
<TextBlock x:Name="Check" Text="&#xE73E;" FontFamily="{DynamicResource IconFont}" FontSize="12"
Foreground="{DynamicResource OnAccentBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Border>
<ContentPresenter Margin="10,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
<Setter TargetName="Check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="Opacity" Value="0.85"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Segment-Auswahl (z. B. Hell | Dunkel | System) ===== -->
<Style x:Key="SegmentRadio" TargetType="RadioButton">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="Bd" CornerRadius="4" Padding="{TemplateBinding Padding}" Margin="2" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SegmentGroup" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource SubtlePressedBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="1"/>
</Style>
<!-- ===== Farb- und Symbolauswahl ===== -->
<Style x:Key="SwatchRadio" TargetType="RadioButton">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Grid Width="34" Height="34" Margin="0,0,6,6" Background="Transparent">
<Ellipse x:Name="Ring" Stroke="{DynamicResource TextBrush}" StrokeThickness="2" Visibility="Collapsed"/>
<Ellipse Margin="5" Fill="{TemplateBinding Background}" Stroke="{DynamicResource ControlStrokeBrush}"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Ring" Property="Visibility" Value="Visible"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GlyphRadio" TargetType="RadioButton">
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FontFamily" Value="{DynamicResource IconFont}"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="Bd" Width="36" Height="36" Margin="0,0,4,4" CornerRadius="4" BorderThickness="1"
BorderBrush="Transparent" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SubtleHoverBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentSubtleBrush}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="GitManager.Setup"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- Installation nur für den aktuellen Benutzer → keine Administratorrechte -->
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")]
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "D722851FC43B542A95C365A4D89BC7E17AC3C32D"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace GitManagerSetup {
/// <summary>
/// App
/// </summary>
public partial class App : System.Windows.Application {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Git-Manager-GUI-Setup;V0.0.0.0;component/app.xaml", System.UriKind.Relative);
#line 1 "..\..\..\App.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
/// <summary>
/// Application Entry Point.
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
public static void Main() {
GitManagerSetup.App app = new GitManagerSetup.App();
app.InitializeComponent();
app.Run();
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
</configuration>
@@ -0,0 +1,20 @@
Git-Manager-GUI-Setup
0.0.0.0
winexe
C#
.cs
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Debug\net48\
GitManagerSetup
none
false
TRACE;DEBUG;NETFRAMEWORK;NET48;NET30_OR_GREATER;NET35_OR_GREATER;NET40_OR_GREATER;NET45_OR_GREATER;NET451_OR_GREATER;NET452_OR_GREATER;NET46_OR_GREATER;NET461_OR_GREATER;NET462_OR_GREATER;NET47_OR_GREATER;NET471_OR_GREATER;NET472_OR_GREATER;NET48_OR_GREATER
C:\Users\Viper\Desktop\git-manager-gui\installer\App.xaml
41549299691
8-482891414
23-602304815
MainWindow.xaml;Themes\Colors.Dark.xaml;Themes\Colors.Light.xaml;Themes\Controls.xaml;
False
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("M_Viper")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert Git Manager GUI")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("0.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("0.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("Git Manager GUI Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("Git Manager GUI Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("0.0.0.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -0,0 +1 @@
9c3aaae1d47c807c810117538379b6f2752577cee8cc71a0a6017da12833e788
@@ -0,0 +1,14 @@
is_global = true
build_property.ApplicationManifest = app.manifest
build_property.StartupObject =
build_property.ApplicationDefaultFont =
build_property.ApplicationHighDpiMode =
build_property.ApplicationUseCompatibleTextRendering =
build_property.ApplicationVisualStyles =
build_property.RootNamespace = GitManagerSetup
build_property.ProjectDir = C:\Users\Viper\Desktop\git-manager-gui\installer\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle =
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
Binary file not shown.
+676
View File
@@ -0,0 +1,676 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "248163D303017091F6C4A571FD0B5DF4D5DBF414"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace GitManagerSetup {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : System.Windows.Window, System.Windows.Markup.IComponentConnector {
#line 140 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock VersionText;
#line default
#line hidden
#line 142 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel Steps;
#line default
#line hidden
#line 144 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot0;
#line default
#line hidden
#line 144 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num0;
#line default
#line hidden
#line 145 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label0;
#line default
#line hidden
#line 147 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line0;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot1;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num1;
#line default
#line hidden
#line 150 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label1;
#line default
#line hidden
#line 152 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line1;
#line default
#line hidden
#line 154 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot2;
#line default
#line hidden
#line 154 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num2;
#line default
#line hidden
#line 155 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label2;
#line default
#line hidden
#line 168 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock WindowTitle;
#line default
#line hidden
#line 174 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button CloseButton;
#line default
#line hidden
#line 182 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageWelcome;
#line default
#line hidden
#line 229 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border UpdateHint;
#line default
#line hidden
#line 234 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock UpdateHintText;
#line default
#line hidden
#line 237 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border RunningHint;
#line default
#line hidden
#line 249 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageOptions;
#line default
#line hidden
#line 258 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBox DirBox;
#line default
#line hidden
#line 267 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox DesktopLinkBox;
#line default
#line hidden
#line 273 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageProgress;
#line default
#line hidden
#line 274 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border SuccessIcon;
#line default
#line hidden
#line 276 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Media.ScaleTransform SuccessScale;
#line default
#line hidden
#line 281 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock ProgressTitle;
#line default
#line hidden
#line 283 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel ProgressBarPanel;
#line default
#line hidden
#line 284 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock PercentText;
#line default
#line hidden
#line 286 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border ProgressTrack;
#line default
#line hidden
#line 287 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border ProgressFill;
#line default
#line hidden
#line 290 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock ProgressText;
#line default
#line hidden
#line 292 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel DonePanel;
#line default
#line hidden
#line 293 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox LaunchBox;
#line default
#line hidden
#line 298 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageUninstall;
#line default
#line hidden
#line 309 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DataInfo;
#line default
#line hidden
#line 310 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox RemoveDataBox;
#line default
#line hidden
#line 312 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock RemoveDataWarning;
#line default
#line hidden
#line 322 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BackButton;
#line default
#line hidden
#line 323 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button NextButton;
#line default
#line hidden
#line 324 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button CancelButton;
#line default
#line hidden
#line 328 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Grid DialogOverlay;
#line default
#line hidden
#line 329 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border DialogCard;
#line default
#line hidden
#line 332 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Media.ScaleTransform DialogScale;
#line default
#line hidden
#line 336 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogIcon;
#line default
#line hidden
#line 339 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogTitle;
#line default
#line hidden
#line 342 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogText;
#line default
#line hidden
#line 345 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button DialogSecondary;
#line default
#line hidden
#line 346 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button DialogPrimary;
#line default
#line hidden
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Git-Manager-GUI-Setup;V0.0.0.0;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
switch (connectionId)
{
case 1:
this.VersionText = ((System.Windows.Controls.TextBlock)(target));
return;
case 2:
this.Steps = ((System.Windows.Controls.StackPanel)(target));
return;
case 3:
this.Dot0 = ((System.Windows.Controls.Border)(target));
return;
case 4:
this.Num0 = ((System.Windows.Controls.TextBlock)(target));
return;
case 5:
this.Label0 = ((System.Windows.Controls.TextBlock)(target));
return;
case 6:
this.Line0 = ((System.Windows.Controls.Border)(target));
return;
case 7:
this.Dot1 = ((System.Windows.Controls.Border)(target));
return;
case 8:
this.Num1 = ((System.Windows.Controls.TextBlock)(target));
return;
case 9:
this.Label1 = ((System.Windows.Controls.TextBlock)(target));
return;
case 10:
this.Line1 = ((System.Windows.Controls.Border)(target));
return;
case 11:
this.Dot2 = ((System.Windows.Controls.Border)(target));
return;
case 12:
this.Num2 = ((System.Windows.Controls.TextBlock)(target));
return;
case 13:
this.Label2 = ((System.Windows.Controls.TextBlock)(target));
return;
case 14:
this.WindowTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 15:
#line 172 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.Minimize_Click);
#line default
#line hidden
return;
case 16:
this.CloseButton = ((System.Windows.Controls.Button)(target));
#line 174 "..\..\..\MainWindow.xaml"
this.CloseButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
#line default
#line hidden
return;
case 17:
this.PageWelcome = ((System.Windows.Controls.StackPanel)(target));
return;
case 18:
this.UpdateHint = ((System.Windows.Controls.Border)(target));
return;
case 19:
this.UpdateHintText = ((System.Windows.Controls.TextBlock)(target));
return;
case 20:
this.RunningHint = ((System.Windows.Controls.Border)(target));
return;
case 21:
this.PageOptions = ((System.Windows.Controls.StackPanel)(target));
return;
case 22:
#line 257 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.ChangeDir_Click);
#line default
#line hidden
return;
case 23:
this.DirBox = ((System.Windows.Controls.TextBox)(target));
return;
case 24:
this.DesktopLinkBox = ((System.Windows.Controls.CheckBox)(target));
return;
case 25:
this.PageProgress = ((System.Windows.Controls.StackPanel)(target));
return;
case 26:
this.SuccessIcon = ((System.Windows.Controls.Border)(target));
return;
case 27:
this.SuccessScale = ((System.Windows.Media.ScaleTransform)(target));
return;
case 28:
this.ProgressTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 29:
this.ProgressBarPanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 30:
this.PercentText = ((System.Windows.Controls.TextBlock)(target));
return;
case 31:
this.ProgressTrack = ((System.Windows.Controls.Border)(target));
#line 286 "..\..\..\MainWindow.xaml"
this.ProgressTrack.SizeChanged += new System.Windows.SizeChangedEventHandler(this.ProgressTrack_SizeChanged);
#line default
#line hidden
return;
case 32:
this.ProgressFill = ((System.Windows.Controls.Border)(target));
return;
case 33:
this.ProgressText = ((System.Windows.Controls.TextBlock)(target));
return;
case 34:
this.DonePanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 35:
this.LaunchBox = ((System.Windows.Controls.CheckBox)(target));
return;
case 36:
this.PageUninstall = ((System.Windows.Controls.StackPanel)(target));
return;
case 37:
this.DataInfo = ((System.Windows.Controls.TextBlock)(target));
return;
case 38:
this.RemoveDataBox = ((System.Windows.Controls.CheckBox)(target));
#line 311 "..\..\..\MainWindow.xaml"
this.RemoveDataBox.Checked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
#line default
#line hidden
#line 311 "..\..\..\MainWindow.xaml"
this.RemoveDataBox.Unchecked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
#line default
#line hidden
return;
case 39:
this.RemoveDataWarning = ((System.Windows.Controls.TextBlock)(target));
return;
case 40:
this.BackButton = ((System.Windows.Controls.Button)(target));
#line 322 "..\..\..\MainWindow.xaml"
this.BackButton.Click += new System.Windows.RoutedEventHandler(this.Back_Click);
#line default
#line hidden
return;
case 41:
this.NextButton = ((System.Windows.Controls.Button)(target));
#line 323 "..\..\..\MainWindow.xaml"
this.NextButton.Click += new System.Windows.RoutedEventHandler(this.Next_Click);
#line default
#line hidden
return;
case 42:
this.CancelButton = ((System.Windows.Controls.Button)(target));
#line 324 "..\..\..\MainWindow.xaml"
this.CancelButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
#line default
#line hidden
return;
case 43:
this.DialogOverlay = ((System.Windows.Controls.Grid)(target));
return;
case 44:
this.DialogCard = ((System.Windows.Controls.Border)(target));
return;
case 45:
this.DialogScale = ((System.Windows.Media.ScaleTransform)(target));
return;
case 46:
this.DialogIcon = ((System.Windows.Controls.TextBlock)(target));
return;
case 47:
this.DialogTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 48:
this.DialogText = ((System.Windows.Controls.TextBlock)(target));
return;
case 49:
this.DialogSecondary = ((System.Windows.Controls.Button)(target));
#line 345 "..\..\..\MainWindow.xaml"
this.DialogSecondary.Click += new System.Windows.RoutedEventHandler(this.DialogSecondary_Click);
#line default
#line hidden
return;
case 50:
this.DialogPrimary = ((System.Windows.Controls.Button)(target));
#line 346 "..\..\..\MainWindow.xaml"
this.DialogPrimary.Click += new System.Windows.RoutedEventHandler(this.DialogPrimary_Click);
#line default
#line hidden
return;
}
this._contentLoaded = true;
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
{
"format": 1,
"restore": {
"C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj": {}
},
"projects": {
"C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj": {
"version": "0.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj",
"projectName": "Git-Manager-GUI-Setup",
"projectPath": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj",
"packagesPath": "C:\\Users\\Viper\\.nuget\\packages\\",
"outputPath": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\obj\\",
"projectStyle": "PackageReference",
"configFilePaths": [
"C:\\Users\\Viper\\AppData\\Roaming\\NuGet\\NuGet.Config"
],
"originalTargetFrameworks": [
"net48"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net48": {
"targetAlias": "net48",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net48": {
"targetAlias": "net48",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies": {
"suppressParent": "All",
"target": "Package",
"version": "[1.0.3, )",
"autoReferenced": true
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.113\\RuntimeIdentifierGraph.json"
}
}
}
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\Viper\.nuget\packages\</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\Viper\.nuget\packages\" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net48\1.0.3\build\Microsoft.NETFramework.ReferenceAssemblies.net48.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net48\1.0.3\build\Microsoft.NETFramework.ReferenceAssemblies.net48.targets')" />
</ImportGroup>
</Project>
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")]
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "D722851FC43B542A95C365A4D89BC7E17AC3C32D"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace GitManagerSetup {
/// <summary>
/// App
/// </summary>
public partial class App : System.Windows.Application {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Git-Manager-GUI-Setup;component/app.xaml", System.UriKind.Relative);
#line 1 "..\..\..\App.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
/// <summary>
/// Application Entry Point.
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
public static void Main() {
GitManagerSetup.App app = new GitManagerSetup.App();
app.InitializeComponent();
app.Run();
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
</configuration>
@@ -0,0 +1,20 @@
Git-Manager-GUI-Setup
winexe
C#
.cs
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\
GitManagerSetup
none
false
TRACE;RELEASE;NETFRAMEWORK;NET48;NET30_OR_GREATER;NET35_OR_GREATER;NET40_OR_GREATER;NET45_OR_GREATER;NET451_OR_GREATER;NET452_OR_GREATER;NET46_OR_GREATER;NET461_OR_GREATER;NET462_OR_GREATER;NET47_OR_GREATER;NET471_OR_GREATER;NET472_OR_GREATER;NET48_OR_GREATER
C:\Users\Viper\Desktop\git-manager-gui\installer\App.xaml
41549299691
6-1705775420
23-602304815
MainWindow.xaml;Themes\Colors.Dark.xaml;Themes\Colors.Light.xaml;Themes\Controls.xaml;
False
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("M_Viper")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert Git Manager GUI")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("2.2.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("2.2.0")]
[assembly: System.Reflection.AssemblyProductAttribute("Git Manager GUI Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("Git Manager GUI Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("2.2.0.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -0,0 +1 @@
62f4b9ac7b1430e3a52ef9dfe2e8422ccd7cc5a7e41145550122532f56db4abe
@@ -0,0 +1,14 @@
is_global = true
build_property.ApplicationManifest = app.manifest
build_property.StartupObject =
build_property.ApplicationDefaultFont =
build_property.ApplicationHighDpiMode =
build_property.ApplicationUseCompatibleTextRendering =
build_property.ApplicationVisualStyles =
build_property.RootNamespace = GitManagerSetup
build_property.ProjectDir = C:\Users\Viper\Desktop\git-manager-gui\installer\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle =
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1 @@
4e41dc5e237f26559d3c4bd8208c813ba09bdba804615d78813773d6b0be3d1d
@@ -0,0 +1,19 @@
C:\Users\Viper\AppData\Local\Temp\claude\G--\bba0d154-b599-43ca-80e6-538e01f4f870\scratchpad\setupbuild\Git-Manager-GUI-Setup.exe.config
C:\Users\Viper\AppData\Local\Temp\claude\G--\bba0d154-b599-43ca-80e6-538e01f4f870\scratchpad\setupbuild\Git-Manager-GUI-Setup.exe
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\GitManagerSetup.csproj.AssemblyReference.cache
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\MainWindow.baml
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\Themes\Colors.Dark.baml
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\Themes\Colors.Light.baml
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\Themes\Controls.baml
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\App.baml
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\MainWindow.g.cs
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\App.g.cs
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\Git-Manager-GUI-Setup_MarkupCompile.cache
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\Git-Manager-GUI-Setup.g.resources
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\GitManagerSetup.GeneratedMSBuildEditorConfig.editorconfig
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\GitManagerSetup.AssemblyInfoInputs.cache
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\GitManagerSetup.AssemblyInfo.cs
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\GitManagerSetup.csproj.CoreCompileInputs.cache
C:\Users\Viper\Desktop\git-manager-gui\installer\obj\Release\net48\Git-Manager-GUI-Setup.exe
C:\Users\Viper\Desktop\git-manager-gui\dist\.setup\stub\Git-Manager-GUI-Setup.exe.config
C:\Users\Viper\Desktop\git-manager-gui\dist\.setup\stub\Git-Manager-GUI-Setup.exe
Binary file not shown.
+676
View File
@@ -0,0 +1,676 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "248163D303017091F6C4A571FD0B5DF4D5DBF414"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace GitManagerSetup {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : System.Windows.Window, System.Windows.Markup.IComponentConnector {
#line 140 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock VersionText;
#line default
#line hidden
#line 142 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel Steps;
#line default
#line hidden
#line 144 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot0;
#line default
#line hidden
#line 144 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num0;
#line default
#line hidden
#line 145 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label0;
#line default
#line hidden
#line 147 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line0;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot1;
#line default
#line hidden
#line 149 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num1;
#line default
#line hidden
#line 150 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label1;
#line default
#line hidden
#line 152 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Line1;
#line default
#line hidden
#line 154 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border Dot2;
#line default
#line hidden
#line 154 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Num2;
#line default
#line hidden
#line 155 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock Label2;
#line default
#line hidden
#line 168 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock WindowTitle;
#line default
#line hidden
#line 174 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button CloseButton;
#line default
#line hidden
#line 182 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageWelcome;
#line default
#line hidden
#line 229 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border UpdateHint;
#line default
#line hidden
#line 234 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock UpdateHintText;
#line default
#line hidden
#line 237 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border RunningHint;
#line default
#line hidden
#line 249 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageOptions;
#line default
#line hidden
#line 258 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBox DirBox;
#line default
#line hidden
#line 267 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox DesktopLinkBox;
#line default
#line hidden
#line 273 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageProgress;
#line default
#line hidden
#line 274 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border SuccessIcon;
#line default
#line hidden
#line 276 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Media.ScaleTransform SuccessScale;
#line default
#line hidden
#line 281 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock ProgressTitle;
#line default
#line hidden
#line 283 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel ProgressBarPanel;
#line default
#line hidden
#line 284 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock PercentText;
#line default
#line hidden
#line 286 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border ProgressTrack;
#line default
#line hidden
#line 287 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border ProgressFill;
#line default
#line hidden
#line 290 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock ProgressText;
#line default
#line hidden
#line 292 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel DonePanel;
#line default
#line hidden
#line 293 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox LaunchBox;
#line default
#line hidden
#line 298 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.StackPanel PageUninstall;
#line default
#line hidden
#line 309 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DataInfo;
#line default
#line hidden
#line 310 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.CheckBox RemoveDataBox;
#line default
#line hidden
#line 312 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock RemoveDataWarning;
#line default
#line hidden
#line 322 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button BackButton;
#line default
#line hidden
#line 323 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button NextButton;
#line default
#line hidden
#line 324 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button CancelButton;
#line default
#line hidden
#line 328 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Grid DialogOverlay;
#line default
#line hidden
#line 329 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Border DialogCard;
#line default
#line hidden
#line 332 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Media.ScaleTransform DialogScale;
#line default
#line hidden
#line 336 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogIcon;
#line default
#line hidden
#line 339 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogTitle;
#line default
#line hidden
#line 342 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.TextBlock DialogText;
#line default
#line hidden
#line 345 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button DialogSecondary;
#line default
#line hidden
#line 346 "..\..\..\MainWindow.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.Button DialogPrimary;
#line default
#line hidden
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Git-Manager-GUI-Setup;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.13.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
switch (connectionId)
{
case 1:
this.VersionText = ((System.Windows.Controls.TextBlock)(target));
return;
case 2:
this.Steps = ((System.Windows.Controls.StackPanel)(target));
return;
case 3:
this.Dot0 = ((System.Windows.Controls.Border)(target));
return;
case 4:
this.Num0 = ((System.Windows.Controls.TextBlock)(target));
return;
case 5:
this.Label0 = ((System.Windows.Controls.TextBlock)(target));
return;
case 6:
this.Line0 = ((System.Windows.Controls.Border)(target));
return;
case 7:
this.Dot1 = ((System.Windows.Controls.Border)(target));
return;
case 8:
this.Num1 = ((System.Windows.Controls.TextBlock)(target));
return;
case 9:
this.Label1 = ((System.Windows.Controls.TextBlock)(target));
return;
case 10:
this.Line1 = ((System.Windows.Controls.Border)(target));
return;
case 11:
this.Dot2 = ((System.Windows.Controls.Border)(target));
return;
case 12:
this.Num2 = ((System.Windows.Controls.TextBlock)(target));
return;
case 13:
this.Label2 = ((System.Windows.Controls.TextBlock)(target));
return;
case 14:
this.WindowTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 15:
#line 172 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.Minimize_Click);
#line default
#line hidden
return;
case 16:
this.CloseButton = ((System.Windows.Controls.Button)(target));
#line 174 "..\..\..\MainWindow.xaml"
this.CloseButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
#line default
#line hidden
return;
case 17:
this.PageWelcome = ((System.Windows.Controls.StackPanel)(target));
return;
case 18:
this.UpdateHint = ((System.Windows.Controls.Border)(target));
return;
case 19:
this.UpdateHintText = ((System.Windows.Controls.TextBlock)(target));
return;
case 20:
this.RunningHint = ((System.Windows.Controls.Border)(target));
return;
case 21:
this.PageOptions = ((System.Windows.Controls.StackPanel)(target));
return;
case 22:
#line 257 "..\..\..\MainWindow.xaml"
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.ChangeDir_Click);
#line default
#line hidden
return;
case 23:
this.DirBox = ((System.Windows.Controls.TextBox)(target));
return;
case 24:
this.DesktopLinkBox = ((System.Windows.Controls.CheckBox)(target));
return;
case 25:
this.PageProgress = ((System.Windows.Controls.StackPanel)(target));
return;
case 26:
this.SuccessIcon = ((System.Windows.Controls.Border)(target));
return;
case 27:
this.SuccessScale = ((System.Windows.Media.ScaleTransform)(target));
return;
case 28:
this.ProgressTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 29:
this.ProgressBarPanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 30:
this.PercentText = ((System.Windows.Controls.TextBlock)(target));
return;
case 31:
this.ProgressTrack = ((System.Windows.Controls.Border)(target));
#line 286 "..\..\..\MainWindow.xaml"
this.ProgressTrack.SizeChanged += new System.Windows.SizeChangedEventHandler(this.ProgressTrack_SizeChanged);
#line default
#line hidden
return;
case 32:
this.ProgressFill = ((System.Windows.Controls.Border)(target));
return;
case 33:
this.ProgressText = ((System.Windows.Controls.TextBlock)(target));
return;
case 34:
this.DonePanel = ((System.Windows.Controls.StackPanel)(target));
return;
case 35:
this.LaunchBox = ((System.Windows.Controls.CheckBox)(target));
return;
case 36:
this.PageUninstall = ((System.Windows.Controls.StackPanel)(target));
return;
case 37:
this.DataInfo = ((System.Windows.Controls.TextBlock)(target));
return;
case 38:
this.RemoveDataBox = ((System.Windows.Controls.CheckBox)(target));
#line 311 "..\..\..\MainWindow.xaml"
this.RemoveDataBox.Checked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
#line default
#line hidden
#line 311 "..\..\..\MainWindow.xaml"
this.RemoveDataBox.Unchecked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
#line default
#line hidden
return;
case 39:
this.RemoveDataWarning = ((System.Windows.Controls.TextBlock)(target));
return;
case 40:
this.BackButton = ((System.Windows.Controls.Button)(target));
#line 322 "..\..\..\MainWindow.xaml"
this.BackButton.Click += new System.Windows.RoutedEventHandler(this.Back_Click);
#line default
#line hidden
return;
case 41:
this.NextButton = ((System.Windows.Controls.Button)(target));
#line 323 "..\..\..\MainWindow.xaml"
this.NextButton.Click += new System.Windows.RoutedEventHandler(this.Next_Click);
#line default
#line hidden
return;
case 42:
this.CancelButton = ((System.Windows.Controls.Button)(target));
#line 324 "..\..\..\MainWindow.xaml"
this.CancelButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
#line default
#line hidden
return;
case 43:
this.DialogOverlay = ((System.Windows.Controls.Grid)(target));
return;
case 44:
this.DialogCard = ((System.Windows.Controls.Border)(target));
return;
case 45:
this.DialogScale = ((System.Windows.Media.ScaleTransform)(target));
return;
case 46:
this.DialogIcon = ((System.Windows.Controls.TextBlock)(target));
return;
case 47:
this.DialogTitle = ((System.Windows.Controls.TextBlock)(target));
return;
case 48:
this.DialogText = ((System.Windows.Controls.TextBlock)(target));
return;
case 49:
this.DialogSecondary = ((System.Windows.Controls.Button)(target));
#line 345 "..\..\..\MainWindow.xaml"
this.DialogSecondary.Click += new System.Windows.RoutedEventHandler(this.DialogSecondary_Click);
#line default
#line hidden
return;
case 50:
this.DialogPrimary = ((System.Windows.Controls.Button)(target));
#line 346 "..\..\..\MainWindow.xaml"
this.DialogPrimary.Click += new System.Windows.RoutedEventHandler(this.DialogPrimary_Click);
#line default
#line hidden
return;
}
this._contentLoaded = true;
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+471
View File
@@ -0,0 +1,471 @@
{
"version": 3,
"targets": {
".NETFramework,Version=v4.8": {
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
"type": "package",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies.net48": "1.0.3"
}
},
"Microsoft.NETFramework.ReferenceAssemblies.net48/1.0.3": {
"type": "package",
"build": {
"build/Microsoft.NETFramework.ReferenceAssemblies.net48.targets": {}
}
}
}
},
"libraries": {
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
"sha512": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==",
"type": "package",
"path": "microsoft.netframework.referenceassemblies/1.0.3",
"files": [
".nupkg.metadata",
".signature.p7s",
"microsoft.netframework.referenceassemblies.1.0.3.nupkg.sha512",
"microsoft.netframework.referenceassemblies.nuspec"
]
},
"Microsoft.NETFramework.ReferenceAssemblies.net48/1.0.3": {
"sha512": "zMk4D+9zyiEWByyQ7oPImPN/Jhpj166Ky0Nlla4eXlNL8hI/BtSJsgR8Inldd4NNpIAH3oh8yym0W2DrhXdSLQ==",
"type": "package",
"path": "microsoft.netframework.referenceassemblies.net48/1.0.3",
"files": [
".nupkg.metadata",
".signature.p7s",
"build/.NETFramework/v4.8/Accessibility.dll",
"build/.NETFramework/v4.8/Accessibility.xml",
"build/.NETFramework/v4.8/CustomMarshalers.dll",
"build/.NETFramework/v4.8/CustomMarshalers.xml",
"build/.NETFramework/v4.8/Facades/Microsoft.Win32.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.AppContext.dll",
"build/.NETFramework/v4.8/Facades/System.Collections.Concurrent.dll",
"build/.NETFramework/v4.8/Facades/System.Collections.NonGeneric.dll",
"build/.NETFramework/v4.8/Facades/System.Collections.Specialized.dll",
"build/.NETFramework/v4.8/Facades/System.Collections.dll",
"build/.NETFramework/v4.8/Facades/System.ComponentModel.Annotations.dll",
"build/.NETFramework/v4.8/Facades/System.ComponentModel.EventBasedAsync.dll",
"build/.NETFramework/v4.8/Facades/System.ComponentModel.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.ComponentModel.TypeConverter.dll",
"build/.NETFramework/v4.8/Facades/System.ComponentModel.dll",
"build/.NETFramework/v4.8/Facades/System.Console.dll",
"build/.NETFramework/v4.8/Facades/System.Data.Common.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.Contracts.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.Debug.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.FileVersionInfo.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.Process.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.StackTrace.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.TextWriterTraceListener.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.Tools.dll",
"build/.NETFramework/v4.8/Facades/System.Diagnostics.TraceSource.dll",
"build/.NETFramework/v4.8/Facades/System.Drawing.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.Dynamic.Runtime.dll",
"build/.NETFramework/v4.8/Facades/System.Globalization.Calendars.dll",
"build/.NETFramework/v4.8/Facades/System.Globalization.Extensions.dll",
"build/.NETFramework/v4.8/Facades/System.Globalization.dll",
"build/.NETFramework/v4.8/Facades/System.IO.Compression.ZipFile.dll",
"build/.NETFramework/v4.8/Facades/System.IO.FileSystem.DriveInfo.dll",
"build/.NETFramework/v4.8/Facades/System.IO.FileSystem.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.IO.FileSystem.Watcher.dll",
"build/.NETFramework/v4.8/Facades/System.IO.FileSystem.dll",
"build/.NETFramework/v4.8/Facades/System.IO.IsolatedStorage.dll",
"build/.NETFramework/v4.8/Facades/System.IO.MemoryMappedFiles.dll",
"build/.NETFramework/v4.8/Facades/System.IO.Pipes.dll",
"build/.NETFramework/v4.8/Facades/System.IO.UnmanagedMemoryStream.dll",
"build/.NETFramework/v4.8/Facades/System.IO.dll",
"build/.NETFramework/v4.8/Facades/System.Linq.Expressions.dll",
"build/.NETFramework/v4.8/Facades/System.Linq.Parallel.dll",
"build/.NETFramework/v4.8/Facades/System.Linq.Queryable.dll",
"build/.NETFramework/v4.8/Facades/System.Linq.dll",
"build/.NETFramework/v4.8/Facades/System.Net.Http.Rtc.dll",
"build/.NETFramework/v4.8/Facades/System.Net.NameResolution.dll",
"build/.NETFramework/v4.8/Facades/System.Net.NetworkInformation.dll",
"build/.NETFramework/v4.8/Facades/System.Net.Ping.dll",
"build/.NETFramework/v4.8/Facades/System.Net.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.Net.Requests.dll",
"build/.NETFramework/v4.8/Facades/System.Net.Security.dll",
"build/.NETFramework/v4.8/Facades/System.Net.Sockets.dll",
"build/.NETFramework/v4.8/Facades/System.Net.WebHeaderCollection.dll",
"build/.NETFramework/v4.8/Facades/System.Net.WebSockets.Client.dll",
"build/.NETFramework/v4.8/Facades/System.Net.WebSockets.dll",
"build/.NETFramework/v4.8/Facades/System.ObjectModel.dll",
"build/.NETFramework/v4.8/Facades/System.Reflection.Emit.ILGeneration.dll",
"build/.NETFramework/v4.8/Facades/System.Reflection.Emit.Lightweight.dll",
"build/.NETFramework/v4.8/Facades/System.Reflection.Emit.dll",
"build/.NETFramework/v4.8/Facades/System.Reflection.Extensions.dll",
"build/.NETFramework/v4.8/Facades/System.Reflection.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.Reflection.dll",
"build/.NETFramework/v4.8/Facades/System.Resources.Reader.dll",
"build/.NETFramework/v4.8/Facades/System.Resources.ResourceManager.dll",
"build/.NETFramework/v4.8/Facades/System.Resources.Writer.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.CompilerServices.VisualC.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Extensions.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Handles.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.InteropServices.RuntimeInformation.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.InteropServices.WindowsRuntime.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.InteropServices.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Numerics.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Serialization.Formatters.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Serialization.Json.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Serialization.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.Serialization.Xml.dll",
"build/.NETFramework/v4.8/Facades/System.Runtime.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Claims.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Cryptography.Algorithms.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Cryptography.Csp.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Cryptography.Encoding.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Cryptography.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Cryptography.X509Certificates.dll",
"build/.NETFramework/v4.8/Facades/System.Security.Principal.dll",
"build/.NETFramework/v4.8/Facades/System.Security.SecureString.dll",
"build/.NETFramework/v4.8/Facades/System.ServiceModel.Duplex.dll",
"build/.NETFramework/v4.8/Facades/System.ServiceModel.Http.dll",
"build/.NETFramework/v4.8/Facades/System.ServiceModel.NetTcp.dll",
"build/.NETFramework/v4.8/Facades/System.ServiceModel.Primitives.dll",
"build/.NETFramework/v4.8/Facades/System.ServiceModel.Security.dll",
"build/.NETFramework/v4.8/Facades/System.Text.Encoding.Extensions.dll",
"build/.NETFramework/v4.8/Facades/System.Text.Encoding.dll",
"build/.NETFramework/v4.8/Facades/System.Text.RegularExpressions.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.Overlapped.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.Tasks.Parallel.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.Tasks.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.Thread.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.ThreadPool.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.Timer.dll",
"build/.NETFramework/v4.8/Facades/System.Threading.dll",
"build/.NETFramework/v4.8/Facades/System.ValueTuple.dll",
"build/.NETFramework/v4.8/Facades/System.Xml.ReaderWriter.dll",
"build/.NETFramework/v4.8/Facades/System.Xml.XDocument.dll",
"build/.NETFramework/v4.8/Facades/System.Xml.XPath.XDocument.dll",
"build/.NETFramework/v4.8/Facades/System.Xml.XPath.dll",
"build/.NETFramework/v4.8/Facades/System.Xml.XmlDocument.dll",
"build/.NETFramework/v4.8/Facades/System.Xml.XmlSerializer.dll",
"build/.NETFramework/v4.8/Facades/netstandard.dll",
"build/.NETFramework/v4.8/ISymWrapper.dll",
"build/.NETFramework/v4.8/ISymWrapper.xml",
"build/.NETFramework/v4.8/Microsoft.Activities.Build.dll",
"build/.NETFramework/v4.8/Microsoft.Activities.Build.xml",
"build/.NETFramework/v4.8/Microsoft.Build.Conversion.v4.0.dll",
"build/.NETFramework/v4.8/Microsoft.Build.Conversion.v4.0.xml",
"build/.NETFramework/v4.8/Microsoft.Build.Engine.dll",
"build/.NETFramework/v4.8/Microsoft.Build.Engine.xml",
"build/.NETFramework/v4.8/Microsoft.Build.Framework.dll",
"build/.NETFramework/v4.8/Microsoft.Build.Framework.xml",
"build/.NETFramework/v4.8/Microsoft.Build.Tasks.v4.0.dll",
"build/.NETFramework/v4.8/Microsoft.Build.Tasks.v4.0.xml",
"build/.NETFramework/v4.8/Microsoft.Build.Utilities.v4.0.dll",
"build/.NETFramework/v4.8/Microsoft.Build.Utilities.v4.0.xml",
"build/.NETFramework/v4.8/Microsoft.Build.dll",
"build/.NETFramework/v4.8/Microsoft.Build.xml",
"build/.NETFramework/v4.8/Microsoft.CSharp.dll",
"build/.NETFramework/v4.8/Microsoft.CSharp.xml",
"build/.NETFramework/v4.8/Microsoft.JScript.dll",
"build/.NETFramework/v4.8/Microsoft.JScript.xml",
"build/.NETFramework/v4.8/Microsoft.VisualBasic.Compatibility.Data.dll",
"build/.NETFramework/v4.8/Microsoft.VisualBasic.Compatibility.Data.xml",
"build/.NETFramework/v4.8/Microsoft.VisualBasic.Compatibility.dll",
"build/.NETFramework/v4.8/Microsoft.VisualBasic.Compatibility.xml",
"build/.NETFramework/v4.8/Microsoft.VisualBasic.dll",
"build/.NETFramework/v4.8/Microsoft.VisualBasic.xml",
"build/.NETFramework/v4.8/Microsoft.VisualC.STLCLR.dll",
"build/.NETFramework/v4.8/Microsoft.VisualC.STLCLR.xml",
"build/.NETFramework/v4.8/Microsoft.VisualC.dll",
"build/.NETFramework/v4.8/Microsoft.VisualC.xml",
"build/.NETFramework/v4.8/PermissionSets/FullTrust.xml",
"build/.NETFramework/v4.8/PermissionSets/Internet.xml",
"build/.NETFramework/v4.8/PermissionSets/LocalIntranet.xml",
"build/.NETFramework/v4.8/PresentationBuildTasks.dll",
"build/.NETFramework/v4.8/PresentationBuildTasks.xml",
"build/.NETFramework/v4.8/PresentationCore.dll",
"build/.NETFramework/v4.8/PresentationCore.xml",
"build/.NETFramework/v4.8/PresentationFramework.Aero.dll",
"build/.NETFramework/v4.8/PresentationFramework.Aero.xml",
"build/.NETFramework/v4.8/PresentationFramework.Aero2.dll",
"build/.NETFramework/v4.8/PresentationFramework.Aero2.xml",
"build/.NETFramework/v4.8/PresentationFramework.AeroLite.dll",
"build/.NETFramework/v4.8/PresentationFramework.AeroLite.xml",
"build/.NETFramework/v4.8/PresentationFramework.Classic.dll",
"build/.NETFramework/v4.8/PresentationFramework.Classic.xml",
"build/.NETFramework/v4.8/PresentationFramework.Luna.dll",
"build/.NETFramework/v4.8/PresentationFramework.Luna.xml",
"build/.NETFramework/v4.8/PresentationFramework.Royale.dll",
"build/.NETFramework/v4.8/PresentationFramework.Royale.xml",
"build/.NETFramework/v4.8/PresentationFramework.dll",
"build/.NETFramework/v4.8/PresentationFramework.xml",
"build/.NETFramework/v4.8/ReachFramework.dll",
"build/.NETFramework/v4.8/ReachFramework.xml",
"build/.NETFramework/v4.8/RedistList/FrameworkList.xml",
"build/.NETFramework/v4.8/System.Activities.Core.Presentation.dll",
"build/.NETFramework/v4.8/System.Activities.Core.Presentation.xml",
"build/.NETFramework/v4.8/System.Activities.DurableInstancing.dll",
"build/.NETFramework/v4.8/System.Activities.DurableInstancing.xml",
"build/.NETFramework/v4.8/System.Activities.Presentation.dll",
"build/.NETFramework/v4.8/System.Activities.Presentation.xml",
"build/.NETFramework/v4.8/System.Activities.dll",
"build/.NETFramework/v4.8/System.Activities.xml",
"build/.NETFramework/v4.8/System.AddIn.Contract.dll",
"build/.NETFramework/v4.8/System.AddIn.Contract.xml",
"build/.NETFramework/v4.8/System.AddIn.dll",
"build/.NETFramework/v4.8/System.AddIn.xml",
"build/.NETFramework/v4.8/System.ComponentModel.Composition.Registration.dll",
"build/.NETFramework/v4.8/System.ComponentModel.Composition.Registration.xml",
"build/.NETFramework/v4.8/System.ComponentModel.Composition.dll",
"build/.NETFramework/v4.8/System.ComponentModel.Composition.xml",
"build/.NETFramework/v4.8/System.ComponentModel.DataAnnotations.dll",
"build/.NETFramework/v4.8/System.ComponentModel.DataAnnotations.xml",
"build/.NETFramework/v4.8/System.Configuration.Install.dll",
"build/.NETFramework/v4.8/System.Configuration.Install.xml",
"build/.NETFramework/v4.8/System.Configuration.dll",
"build/.NETFramework/v4.8/System.Configuration.xml",
"build/.NETFramework/v4.8/System.Core.dll",
"build/.NETFramework/v4.8/System.Core.xml",
"build/.NETFramework/v4.8/System.Data.DataSetExtensions.dll",
"build/.NETFramework/v4.8/System.Data.DataSetExtensions.xml",
"build/.NETFramework/v4.8/System.Data.Entity.Design.dll",
"build/.NETFramework/v4.8/System.Data.Entity.Design.xml",
"build/.NETFramework/v4.8/System.Data.Entity.dll",
"build/.NETFramework/v4.8/System.Data.Entity.xml",
"build/.NETFramework/v4.8/System.Data.Linq.dll",
"build/.NETFramework/v4.8/System.Data.Linq.xml",
"build/.NETFramework/v4.8/System.Data.OracleClient.dll",
"build/.NETFramework/v4.8/System.Data.OracleClient.xml",
"build/.NETFramework/v4.8/System.Data.Services.Client.dll",
"build/.NETFramework/v4.8/System.Data.Services.Client.xml",
"build/.NETFramework/v4.8/System.Data.Services.Design.dll",
"build/.NETFramework/v4.8/System.Data.Services.Design.xml",
"build/.NETFramework/v4.8/System.Data.Services.dll",
"build/.NETFramework/v4.8/System.Data.Services.xml",
"build/.NETFramework/v4.8/System.Data.SqlXml.dll",
"build/.NETFramework/v4.8/System.Data.SqlXml.xml",
"build/.NETFramework/v4.8/System.Data.dll",
"build/.NETFramework/v4.8/System.Data.xml",
"build/.NETFramework/v4.8/System.Deployment.dll",
"build/.NETFramework/v4.8/System.Deployment.xml",
"build/.NETFramework/v4.8/System.Design.dll",
"build/.NETFramework/v4.8/System.Design.xml",
"build/.NETFramework/v4.8/System.Device.dll",
"build/.NETFramework/v4.8/System.Device.xml",
"build/.NETFramework/v4.8/System.Diagnostics.Tracing.dll",
"build/.NETFramework/v4.8/System.Diagnostics.Tracing.xml",
"build/.NETFramework/v4.8/System.DirectoryServices.AccountManagement.dll",
"build/.NETFramework/v4.8/System.DirectoryServices.AccountManagement.xml",
"build/.NETFramework/v4.8/System.DirectoryServices.Protocols.dll",
"build/.NETFramework/v4.8/System.DirectoryServices.Protocols.xml",
"build/.NETFramework/v4.8/System.DirectoryServices.dll",
"build/.NETFramework/v4.8/System.DirectoryServices.xml",
"build/.NETFramework/v4.8/System.Drawing.Design.dll",
"build/.NETFramework/v4.8/System.Drawing.Design.xml",
"build/.NETFramework/v4.8/System.Drawing.dll",
"build/.NETFramework/v4.8/System.Drawing.xml",
"build/.NETFramework/v4.8/System.Dynamic.dll",
"build/.NETFramework/v4.8/System.EnterpriseServices.Thunk.dll",
"build/.NETFramework/v4.8/System.EnterpriseServices.Wrapper.dll",
"build/.NETFramework/v4.8/System.EnterpriseServices.dll",
"build/.NETFramework/v4.8/System.EnterpriseServices.xml",
"build/.NETFramework/v4.8/System.IO.Compression.FileSystem.dll",
"build/.NETFramework/v4.8/System.IO.Compression.FileSystem.xml",
"build/.NETFramework/v4.8/System.IO.Compression.dll",
"build/.NETFramework/v4.8/System.IO.Compression.xml",
"build/.NETFramework/v4.8/System.IO.Log.dll",
"build/.NETFramework/v4.8/System.IO.Log.xml",
"build/.NETFramework/v4.8/System.IdentityModel.Selectors.dll",
"build/.NETFramework/v4.8/System.IdentityModel.Selectors.xml",
"build/.NETFramework/v4.8/System.IdentityModel.Services.dll",
"build/.NETFramework/v4.8/System.IdentityModel.Services.xml",
"build/.NETFramework/v4.8/System.IdentityModel.dll",
"build/.NETFramework/v4.8/System.IdentityModel.xml",
"build/.NETFramework/v4.8/System.Linq.xml",
"build/.NETFramework/v4.8/System.Management.Instrumentation.dll",
"build/.NETFramework/v4.8/System.Management.Instrumentation.xml",
"build/.NETFramework/v4.8/System.Management.dll",
"build/.NETFramework/v4.8/System.Management.xml",
"build/.NETFramework/v4.8/System.Messaging.dll",
"build/.NETFramework/v4.8/System.Messaging.xml",
"build/.NETFramework/v4.8/System.Net.Http.WebRequest.dll",
"build/.NETFramework/v4.8/System.Net.Http.WebRequest.xml",
"build/.NETFramework/v4.8/System.Net.Http.dll",
"build/.NETFramework/v4.8/System.Net.Http.xml",
"build/.NETFramework/v4.8/System.Net.dll",
"build/.NETFramework/v4.8/System.Net.xml",
"build/.NETFramework/v4.8/System.Numerics.dll",
"build/.NETFramework/v4.8/System.Numerics.xml",
"build/.NETFramework/v4.8/System.Printing.dll",
"build/.NETFramework/v4.8/System.Printing.xml",
"build/.NETFramework/v4.8/System.Reflection.Context.dll",
"build/.NETFramework/v4.8/System.Reflection.Context.xml",
"build/.NETFramework/v4.8/System.Runtime.Caching.dll",
"build/.NETFramework/v4.8/System.Runtime.Caching.xml",
"build/.NETFramework/v4.8/System.Runtime.DurableInstancing.dll",
"build/.NETFramework/v4.8/System.Runtime.DurableInstancing.xml",
"build/.NETFramework/v4.8/System.Runtime.Remoting.dll",
"build/.NETFramework/v4.8/System.Runtime.Remoting.xml",
"build/.NETFramework/v4.8/System.Runtime.Serialization.Formatters.Soap.dll",
"build/.NETFramework/v4.8/System.Runtime.Serialization.Formatters.Soap.xml",
"build/.NETFramework/v4.8/System.Runtime.Serialization.dll",
"build/.NETFramework/v4.8/System.Runtime.Serialization.xml",
"build/.NETFramework/v4.8/System.Security.dll",
"build/.NETFramework/v4.8/System.Security.xml",
"build/.NETFramework/v4.8/System.ServiceModel.Activation.dll",
"build/.NETFramework/v4.8/System.ServiceModel.Activation.xml",
"build/.NETFramework/v4.8/System.ServiceModel.Activities.dll",
"build/.NETFramework/v4.8/System.ServiceModel.Activities.xml",
"build/.NETFramework/v4.8/System.ServiceModel.Channels.dll",
"build/.NETFramework/v4.8/System.ServiceModel.Channels.xml",
"build/.NETFramework/v4.8/System.ServiceModel.Discovery.dll",
"build/.NETFramework/v4.8/System.ServiceModel.Discovery.xml",
"build/.NETFramework/v4.8/System.ServiceModel.Routing.dll",
"build/.NETFramework/v4.8/System.ServiceModel.Routing.xml",
"build/.NETFramework/v4.8/System.ServiceModel.Web.dll",
"build/.NETFramework/v4.8/System.ServiceModel.Web.xml",
"build/.NETFramework/v4.8/System.ServiceModel.dll",
"build/.NETFramework/v4.8/System.ServiceModel.xml",
"build/.NETFramework/v4.8/System.ServiceProcess.dll",
"build/.NETFramework/v4.8/System.ServiceProcess.xml",
"build/.NETFramework/v4.8/System.Speech.dll",
"build/.NETFramework/v4.8/System.Speech.xml",
"build/.NETFramework/v4.8/System.Threading.Tasks.Dataflow.xml",
"build/.NETFramework/v4.8/System.Transactions.dll",
"build/.NETFramework/v4.8/System.Transactions.xml",
"build/.NETFramework/v4.8/System.Web.Abstractions.dll",
"build/.NETFramework/v4.8/System.Web.ApplicationServices.dll",
"build/.NETFramework/v4.8/System.Web.ApplicationServices.xml",
"build/.NETFramework/v4.8/System.Web.DataVisualization.Design.dll",
"build/.NETFramework/v4.8/System.Web.DataVisualization.dll",
"build/.NETFramework/v4.8/System.Web.DataVisualization.xml",
"build/.NETFramework/v4.8/System.Web.DynamicData.Design.dll",
"build/.NETFramework/v4.8/System.Web.DynamicData.Design.xml",
"build/.NETFramework/v4.8/System.Web.DynamicData.dll",
"build/.NETFramework/v4.8/System.Web.DynamicData.xml",
"build/.NETFramework/v4.8/System.Web.Entity.Design.dll",
"build/.NETFramework/v4.8/System.Web.Entity.Design.xml",
"build/.NETFramework/v4.8/System.Web.Entity.dll",
"build/.NETFramework/v4.8/System.Web.Entity.xml",
"build/.NETFramework/v4.8/System.Web.Extensions.Design.dll",
"build/.NETFramework/v4.8/System.Web.Extensions.Design.xml",
"build/.NETFramework/v4.8/System.Web.Extensions.dll",
"build/.NETFramework/v4.8/System.Web.Extensions.xml",
"build/.NETFramework/v4.8/System.Web.Mobile.dll",
"build/.NETFramework/v4.8/System.Web.Mobile.xml",
"build/.NETFramework/v4.8/System.Web.RegularExpressions.dll",
"build/.NETFramework/v4.8/System.Web.RegularExpressions.xml",
"build/.NETFramework/v4.8/System.Web.Routing.dll",
"build/.NETFramework/v4.8/System.Web.Services.dll",
"build/.NETFramework/v4.8/System.Web.Services.xml",
"build/.NETFramework/v4.8/System.Web.dll",
"build/.NETFramework/v4.8/System.Web.xml",
"build/.NETFramework/v4.8/System.Windows.Controls.Ribbon.dll",
"build/.NETFramework/v4.8/System.Windows.Controls.Ribbon.xml",
"build/.NETFramework/v4.8/System.Windows.Forms.DataVisualization.Design.dll",
"build/.NETFramework/v4.8/System.Windows.Forms.DataVisualization.dll",
"build/.NETFramework/v4.8/System.Windows.Forms.DataVisualization.xml",
"build/.NETFramework/v4.8/System.Windows.Forms.dll",
"build/.NETFramework/v4.8/System.Windows.Forms.xml",
"build/.NETFramework/v4.8/System.Windows.Input.Manipulations.dll",
"build/.NETFramework/v4.8/System.Windows.Input.Manipulations.xml",
"build/.NETFramework/v4.8/System.Windows.Presentation.dll",
"build/.NETFramework/v4.8/System.Windows.Presentation.xml",
"build/.NETFramework/v4.8/System.Windows.dll",
"build/.NETFramework/v4.8/System.Workflow.Activities.dll",
"build/.NETFramework/v4.8/System.Workflow.Activities.xml",
"build/.NETFramework/v4.8/System.Workflow.ComponentModel.dll",
"build/.NETFramework/v4.8/System.Workflow.ComponentModel.xml",
"build/.NETFramework/v4.8/System.Workflow.Runtime.dll",
"build/.NETFramework/v4.8/System.Workflow.Runtime.xml",
"build/.NETFramework/v4.8/System.WorkflowServices.dll",
"build/.NETFramework/v4.8/System.WorkflowServices.xml",
"build/.NETFramework/v4.8/System.Xaml.dll",
"build/.NETFramework/v4.8/System.Xaml.xml",
"build/.NETFramework/v4.8/System.Xml.Linq.dll",
"build/.NETFramework/v4.8/System.Xml.Linq.xml",
"build/.NETFramework/v4.8/System.Xml.Serialization.dll",
"build/.NETFramework/v4.8/System.Xml.dll",
"build/.NETFramework/v4.8/System.Xml.xml",
"build/.NETFramework/v4.8/System.dll",
"build/.NETFramework/v4.8/System.xml",
"build/.NETFramework/v4.8/UIAutomationClient.dll",
"build/.NETFramework/v4.8/UIAutomationClient.xml",
"build/.NETFramework/v4.8/UIAutomationClientsideProviders.dll",
"build/.NETFramework/v4.8/UIAutomationClientsideProviders.xml",
"build/.NETFramework/v4.8/UIAutomationProvider.dll",
"build/.NETFramework/v4.8/UIAutomationProvider.xml",
"build/.NETFramework/v4.8/UIAutomationTypes.dll",
"build/.NETFramework/v4.8/UIAutomationTypes.xml",
"build/.NETFramework/v4.8/WindowsBase.dll",
"build/.NETFramework/v4.8/WindowsBase.xml",
"build/.NETFramework/v4.8/WindowsFormsIntegration.dll",
"build/.NETFramework/v4.8/WindowsFormsIntegration.xml",
"build/.NETFramework/v4.8/XamlBuildTask.dll",
"build/.NETFramework/v4.8/XamlBuildTask.xml",
"build/.NETFramework/v4.8/mscorlib.dll",
"build/.NETFramework/v4.8/mscorlib.xml",
"build/.NETFramework/v4.8/namespaces.xml",
"build/.NETFramework/v4.8/sysglobl.dll",
"build/.NETFramework/v4.8/sysglobl.xml",
"build/Microsoft.NETFramework.ReferenceAssemblies.net48.targets",
"microsoft.netframework.referenceassemblies.net48.1.0.3.nupkg.sha512",
"microsoft.netframework.referenceassemblies.net48.nuspec"
]
}
},
"projectFileDependencyGroups": {
".NETFramework,Version=v4.8": [
"Microsoft.NETFramework.ReferenceAssemblies >= 1.0.3"
]
},
"packageFolders": {
"C:\\Users\\Viper\\.nuget\\packages\\": {}
},
"project": {
"version": "0.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj",
"projectName": "Git-Manager-GUI-Setup",
"projectPath": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj",
"packagesPath": "C:\\Users\\Viper\\.nuget\\packages\\",
"outputPath": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\obj\\",
"projectStyle": "PackageReference",
"configFilePaths": [
"C:\\Users\\Viper\\AppData\\Roaming\\NuGet\\NuGet.Config"
],
"originalTargetFrameworks": [
"net48"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net48": {
"targetAlias": "net48",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net48": {
"targetAlias": "net48",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies": {
"suppressParent": "All",
"target": "Package",
"version": "[1.0.3, )",
"autoReferenced": true
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.113\\RuntimeIdentifierGraph.json"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": 2,
"dgSpecHash": "5on68f3wDDM=",
"success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\git-manager-gui\\installer\\GitManagerSetup.csproj",
"expectedPackageFiles": [
"C:\\Users\\Viper\\.nuget\\packages\\microsoft.netframework.referenceassemblies\\1.0.3\\microsoft.netframework.referenceassemblies.1.0.3.nupkg.sha512",
"C:\\Users\\Viper\\.nuget\\packages\\microsoft.netframework.referenceassemblies.net48\\1.0.3\\microsoft.netframework.referenceassemblies.net48.1.0.3.nupkg.sha512"
],
"logs": []
}
+665 -665
View File
File diff suppressed because it is too large Load Diff
+860 -111
View File
File diff suppressed because it is too large Load Diff
+3885 -5275
View File
File diff suppressed because it is too large Load Diff
+75 -73
View File
@@ -1,73 +1,75 @@
{ {
"name": "git-manager-gui", "name": "git-manager-gui",
"version": "2.1.7", "version": "2.2.1",
"description": "Git Manager GUI - Verwaltung von Git Repositories", "description": "Git Manager GUI - Verwaltung von Git Repositories",
"author": "M_Viper", "author": "M_Viper",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"build": "electron-builder", "build": "electron-builder --win dir && node scripts/build-setup.js",
"test": "node --test __tests__/*.test.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": { },
"axios": "^1.13.5", "dependencies": {
"form-data": "^4.0.5", "archiver": "^6.0.0",
"simple-git": "^3.32.3", "axios": "^1.13.5",
"archiver": "^6.0.0", "form-data": "^4.0.5",
"unzipper": "^0.11.0" "simple-git": "^3.32.3",
}, "unzipper": "^0.11.0"
"overrides": { },
"minimatch": "^3.1.4" "overrides": {
}, "minimatch": "^3.1.4"
"devDependencies": { },
"electron": "^41.0.4", "devDependencies": {
"electron-builder": "^26.8.1" "electron": "^44.6.0",
}, "electron-builder": "^26.17.0"
"build": { },
"asar": true, "build": {
"appId": "com.viper.gitmanager", "asar": true,
"productName": "Git Manager GUI", "appId": "com.viper.gitmanager",
"files": [ "productName": "Git Manager GUI",
"**/*", "files": [
"!node_modules/.cache", "**/*",
"!**/*.map", "!node_modules/.cache",
"!**/.git", "!**/*.map",
"!**/.git/**", "!**/.git",
"!repos/**", "!**/.git/**",
"!data/**", "!repos/**",
"!backup/**", "!data/**",
"!*.zip", "!backup/**",
"!*.rar", "!*.zip",
"!*.log" "!*.rar",
], "!*.log",
"extraResources": [ "!vendor/**",
"repos/", "!installer/**",
{ "!scripts/**",
"from": "data", "!__tests__/**",
"to": "data", "!assets/icon.ico"
"filter": [ ],
"**/*", "extraResources": [
"!credentials.json" "repos/",
] {
} "from": "data",
], "to": "data",
"directories": { "filter": [
"buildResources": "assets" "**/*",
}, "!credentials.json"
"win": { ]
"icon": "assets/icon.png", },
"target": [ {
"nsis" "from": "vendor/mingit",
] "to": "mingit"
}, }
"nsis": { ],
"oneClick": false, "directories": {
"perMachine": false, "buildResources": "assets"
"allowToChangeInstallationDirectory": true, },
"createDesktopShortcut": true, "win": {
"createStartMenuShortcut": true, "icon": "assets/icon.png",
"shortcutName": "Git Manager GUI" "target": [
} "dir"
} ]
} }
}
}
+52
View File
@@ -39,6 +39,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
downloadGiteaFile: (data) => ipcRenderer.invoke('download-gitea-file', data), downloadGiteaFile: (data) => ipcRenderer.invoke('download-gitea-file', data),
uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data), uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data),
uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data), uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data),
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
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),
// Repository & Git Management // Repository & Git Management
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data), saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),
@@ -91,6 +105,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
editRelease: (data) => ipcRenderer.invoke('edit-release', data), editRelease: (data) => ipcRenderer.invoke('edit-release', data),
deleteRelease: (data) => ipcRenderer.invoke('delete-release', data), deleteRelease: (data) => ipcRenderer.invoke('delete-release', data),
uploadReleaseAsset: (data) => ipcRenderer.invoke('upload-release-asset', 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), deleteReleaseAsset: (data) => ipcRenderer.invoke('delete-release-asset', data),
downloadReleaseArchive: (data) => ipcRenderer.invoke('download-release-archive', data), downloadReleaseArchive: (data) => ipcRenderer.invoke('download-release-archive', data),
@@ -104,6 +120,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Local Repository Commits // Local Repository Commits
getLocalCommits: (data) => ipcRenderer.invoke('get-local-commits', data), getLocalCommits: (data) => ipcRenderer.invoke('get-local-commits', data),
getLocalCommitDiff: (data) => ipcRenderer.invoke('get-local-commit-diff', data), getLocalCommitDiff: (data) => ipcRenderer.invoke('get-local-commit-diff', data),
findGitignoredInRepo: (data) => ipcRenderer.invoke('find-gitignored-in-repo', data),
deleteGiteaPaths: (data) => ipcRenderer.invoke('delete-gitea-paths', data),
getLocalCommitFiles: (data) => ipcRenderer.invoke('get-local-commit-files', data),
getLocalCommitDetails: (data) => ipcRenderer.invoke('get-local-commit-details', data), getLocalCommitDetails: (data) => ipcRenderer.invoke('get-local-commit-details', data),
// === FAVORITEN & ZULETZT GEÖFFNET === // === FAVORITEN & ZULETZT GEÖFFNET ===
@@ -151,6 +170,39 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('push-progress', listener); return () => ipcRenderer.removeListener('push-progress', listener);
}, },
// Neue Funktionen
prefsGet: () => ipcRenderer.invoke('prefs-get'),
prefsSet: (data) => ipcRenderer.invoke('prefs-set', data),
openKnownFolder: (which) => ipcRenderer.invoke('open-known-folder', { which }),
gitTransportState: () => ipcRenderer.invoke('git-transport-state'),
gitTransportClear: (host) => ipcRenderer.invoke('git-transport-clear', { host }),
previewUploadPaths: (data) => ipcRenderer.invoke('preview-upload-paths', data),
runServerDiagnosis: (data) => ipcRenderer.invoke('run-server-diagnosis', data),
localGit: (data) => ipcRenderer.invoke('local-git', data),
giteaCollab: (data) => ipcRenderer.invoke('gitea-collab', data),
repoBackupGetConfig: () => ipcRenderer.invoke('repo-backup-get-config'),
repoBackupSetConfig: (data) => ipcRenderer.invoke('repo-backup-set-config', data),
repoBackupRunNow: () => ipcRenderer.invoke('repo-backup-run-now'),
repoBackupCancel: () => ipcRenderer.invoke('repo-backup-cancel'),
repoBackupList: (data) => ipcRenderer.invoke('repo-backup-list', data),
repoRestoreLocal: (data) => ipcRenderer.invoke('repo-restore-local', data),
repoRestoreGitea: (data) => ipcRenderer.invoke('repo-restore-gitea', data),
onRepoRestoreProgress: (cb) => {
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
ipcRenderer.on('repo-restore-progress', listener);
return () => ipcRenderer.removeListener('repo-restore-progress', listener);
},
onRepoBackupProgress: (cb) => {
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
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) => { onFolderUploadProgress: (cb) => {
const listener = (event, payload) => { try { cb(payload); } catch (_) {} }; const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
ipcRenderer.on('folder-upload-progress', listener); ipcRenderer.on('folder-upload-progress', listener);
+918
View File
@@ -0,0 +1,918 @@
// renderer/features.js
// Oberfläche für: Upload-Vorschau mit Geheimnis-Warnung, lokale Änderungen, Pull Requests &
// Issues, Server-Diagnose und Repo-Backup. Wird nach renderer.js geladen und nutzt dessen
// globale Helfer ($, currentState, selectedFolder, showProgress, showSuccess, ...).
// Inhalte vom Server werden ausschließlich per textContent eingesetzt, nie als HTML.
(function () {
'use strict';
/* ---------- kleine Helfer ---------- */
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v == null) continue;
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
else if (k in node) node[k] = v;
else node.setAttribute(k, v);
}
for (const c of [].concat(children)) if (c != null) node.append(c);
return node;
}
function fmtBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
function fmtDate(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } catch (_) { return iso; }
}
function fxModal({ title, wide = false }) {
const titleEl = el('h2', { class: 'fx-title', text: title });
const closeBtn = el('button', { class: 'fx-close', type: 'button', title: 'Schließen', text: '✕' });
const body = el('div', { class: 'fx-body' });
const footer = el('div', { class: 'fx-footer' });
const card = el('div', { class: `modalContent card fx-card${wide ? ' fx-card--wide' : ''}` }, [
el('div', { class: 'fx-header' }, [titleEl, closeBtn]), body, footer
]);
const root = el('div', { class: 'modal fx-modal' }, [card]);
let onClose = null;
const close = (result) => {
root.remove();
document.removeEventListener('keydown', onKey);
if (onClose) onClose(result);
};
const onKey = (e) => {
if (e.key !== 'Escape') return;
// Nur den obersten Dialog schließen (z. B. eine Rückfrage über diesem Fenster)
if (document.querySelector('.confirm-modal') || root.nextElementSibling && root.nextElementSibling.classList.contains('fx-modal')) return;
close(undefined);
};
closeBtn.onclick = () => close(undefined);
root.addEventListener('mousedown', (e) => { if (e.target === root) close(undefined); });
document.addEventListener('keydown', onKey);
document.body.appendChild(root);
return {
root, body, footer, close,
setTitle: (t) => { titleEl.textContent = t; },
onClose: (fn) => { onClose = fn; }
};
}
function button(text, onclick, variant = 'secondary') {
return el('button', { type: 'button', class: `fx-btn fx-btn--${variant}`, text, onclick });
}
function busy(container, text) {
container.textContent = '';
container.append(el('div', { class: 'fx-busy', text }));
}
/* ---------- Knöpfe je nach Ansicht ---------- */
window.updateFeatureButtons = function (mode, owner, repo) {
const isGitea = currentState.platform !== 'github';
const btnPulls = $('btnPulls');
const btnIssues = $('btnIssues');
const btnLocal = $('btnLocalChanges');
const showRepo = mode === 'repo' && isGitea;
if (btnPulls) {
btnPulls.classList.toggle('hidden', !showRepo);
btnPulls.onclick = () => openCollab(owner, repo, 'pulls');
}
if (btnIssues) {
btnIssues.classList.toggle('hidden', !showRepo);
btnIssues.onclick = () => openCollab(owner, repo, 'issues');
}
if (btnLocal) {
btnLocal.classList.toggle('hidden', mode !== 'local');
btnLocal.onclick = () => openLocalChanges(selectedFolder);
}
};
/* ================================================================
1. Upload-Vorschau mit Geheimnis-Warnung
================================================================ */
window.uploadPreviewGate = async function ({ paths, destPath, repo }) {
showProgress(5, 'Prüfe Dateien vor dem Upload ...');
let res;
try {
res = await window.electronAPI.previewUploadPaths({ paths, destPath });
} catch (e) {
res = { ok: false, error: String(e) };
} finally {
hideProgress();
}
if (!res || !res.ok) {
// Die Vorschau darf einen Upload nie verhindern.
showWarning('Upload-Vorschau nicht möglich: ' + (res && res.error ? res.error : 'unbekannter Fehler'));
return { proceed: true, excludeFiles: [] };
}
const files = res.files || [];
const secrets = files.filter(f => f.secret);
const ignored = res.ignored || [];
const baseDir = paths.length ? paths[0].replace(/[\\/][^\\/]+$/, '') : '';
const relLocal = (p) => (baseDir && p.startsWith(baseDir) ? p.slice(baseDir.length + 1) : p).split('\\').join('/');
// Einstellung "Upload-Vorschau: nur bei Auffälligkeiten" – ohne Zugangsdaten und ohne Dateiflut direkt hochladen
const blocked = res.blocked || [];
if (res.previewMode === 'warnings' && secrets.length === 0 && blocked.length === 0 && files.length > 0 && files.length <= 1000) {
return { proceed: true, excludeFiles: [] };
}
return new Promise((resolve) => {
const m = fxModal({ title: '📤 Upload-Vorschau', wide: true });
let settled = false;
const finish = (result) => { if (!settled) { settled = true; m.close(); resolve(result); } };
m.onClose(() => { if (!settled) { settled = true; resolve({ proceed: false }); } });
const target = destPath ? `${repo}/${destPath}` : repo;
m.body.append(el('p', { class: 'fx-lead', text: `${files.length} Dateien (${fmtBytes(res.totalBytes || 0)}) werden nach „${target}“ hochgeladen.` }));
if (files.length > 1000) {
m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text:
`Das sind sehr viele Dateien. Prüfe, ob Build-Ordner (z. B. build/, dist/, node_modules/) in der .gitignore stehen.` }));
}
if (files.length === 0) {
m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: 'Es bleibt keine Datei zum Hochladen übrig.' }));
}
if (blocked.length) {
const list = el('div', { class: 'fx-list' });
for (const b of blocked.slice(0, 25)) {
list.append(el('div', { class: 'fx-list-row' }, [
el('span', { class: 'fx-mono', text: b.targetPath || b.path }),
el('span', { class: 'fx-muted', text: b.reason })
]));
}
m.body.append(el('div', { class: 'fx-alert fx-alert--danger' }, [
el('strong', { text: `🛡️ ${blocked.length} Datei(en) gesperrt und werden nicht hochgeladen` }),
el('div', { text: 'Dateien mit Zugangsdaten werden nie hochgeladen. Die Sperre lässt sich unter Einstellungen → Uploads abschalten.' }),
list
]));
}
const secretChecks = [];
if (secrets.length) {
const list = el('div', { class: 'fx-list' });
for (const f of secrets) {
const cb = el('input', { type: 'checkbox', checked: true });
secretChecks.push({ cb, file: f.localFile });
list.append(el('label', { class: 'fx-list-row fx-list-row--check' }, [
cb,
el('span', { class: 'fx-mono', text: f.targetPath }),
el('span', { class: 'fx-muted', text: f.secret })
]));
}
m.body.append(el('div', { class: 'fx-alert fx-alert--danger' }, [
el('strong', { text: `⚠️ ${secrets.length} Datei(en) enthalten vermutlich Zugangsdaten` }),
el('div', { text: 'Angehakte Dateien werden NICHT hochgeladen. Haken nur entfernen, wenn die Datei wirklich ins Repo gehört (z. B. bei einem privaten Repo bewusst gewollt).' }),
list,
el('div', { class: 'fx-muted', text: 'Hinweis: Dateien, die schon im Repo liegen, bleiben dort, bis du sie löschst. Ein geleakter Schlüssel sollte zusätzlich erneuert werden.' })
]));
}
if (ignored.length) {
const list = el('div', { class: 'fx-list fx-list--compact' });
ignored.slice(0, 25).forEach(i => list.append(el('div', { class: 'fx-list-row fx-mono', text: relLocal(i.path) + (i.isDir ? '/' : '') })));
if (ignored.length > 25) list.append(el('div', { class: 'fx-list-row fx-muted', text: `… und ${ignored.length - 25} weitere` }));
m.body.append(el('div', { class: 'fx-section' }, [
el('h3', { text: `🚫 Von .gitignore aussortiert (${ignored.length})` }),
list
]));
}
if (files.length) {
const list = el('div', { class: 'fx-list fx-list--compact' });
files.slice(0, 500).forEach(f => list.append(el('div', { class: 'fx-list-row' }, [
el('span', { class: 'fx-mono', text: f.targetPath }),
el('span', { class: 'fx-muted', text: fmtBytes(f.size) })
])));
if (files.length > 500) list.append(el('div', { class: 'fx-list-row fx-muted', text: `… und ${files.length - 500} weitere` }));
m.body.append(el('details', { class: 'fx-section' }, [
el('summary', { text: `Alle ${files.length} Dateien anzeigen` }),
list
]));
}
m.footer.append(
button('Abbrechen', () => finish({ proceed: false })),
button(files.length ? 'Hochladen' : 'Schließen', () => {
if (!files.length) return finish({ proceed: false });
const excludeFiles = secretChecks.filter(s => s.cb.checked).map(s => s.file);
if (excludeFiles.length >= files.length) return finish({ proceed: false });
finish({ proceed: true, excludeFiles });
}, 'primary')
);
});
};
/* ================================================================
2. Lokale Änderungen: Status, Diff, Commit, Push
================================================================ */
async function openLocalChanges(folder) {
if (!folder) { showWarning('Bitte zuerst einen lokalen Ordner öffnen.'); return; }
const m = fxModal({ title: '📝 Lokale Änderungen', wide: true });
const info = el('div', { class: 'fx-lead' });
const listPane = el('div', { class: 'fx-pane fx-pane--list' });
const diffPane = el('pre', { class: 'fx-pane fx-diff', text: 'Datei anklicken, um die Änderungen zu sehen.' });
m.body.append(info, el('div', { class: 'fx-split' }, [listPane, diffPane]));
const msg = el('input', { type: 'text', class: 'fx-input', placeholder: 'Commit-Nachricht, z. B. „Login-Fehler behoben“' });
const btnCommit = button('Commit', () => commit(false), 'primary');
const btnCommitPush = button('Commit & Push', () => commit(true), 'primary');
const btnRefresh = button('↻ Aktualisieren', () => load());
const btnPushOnly = button('⬆ Nur pushen', () => push());
m.footer.append(msg, btnRefresh, btnPushOnly, btnCommit, btnCommitPush);
let files = [];
const checks = new Map();
async function load() {
busy(listPane, 'Lade Status ...');
const res = await window.electronAPI.localGit({ action: 'status', folder });
if (!res.ok) {
listPane.textContent = '';
listPane.append(el('div', { class: 'fx-alert fx-alert--warn', text: res.notRepo
? 'Dieser Ordner ist noch kein Git-Repository. Über „Push“ in der Werkzeugleiste kannst du ihn als Repo anlegen und hochladen.'
: (res.error || 'Status nicht lesbar') }));
[btnCommit, btnCommitPush, btnPushOnly].forEach(b => { b.disabled = true; });
return;
}
files = res.files;
info.textContent = `Branch: ${res.branch}` + (res.ahead ? ` · ${res.ahead} Commit(s) noch nicht gepusht` : '') + ` · ${files.length} geänderte Datei(en)`;
btnPushOnly.disabled = !res.ahead;
listPane.textContent = '';
checks.clear();
if (!files.length) {
listPane.append(el('div', { class: 'fx-empty', text: '✅ Keine Änderungen. Alles ist committet.' }));
diffPane.textContent = '';
return;
}
const all = el('input', { type: 'checkbox', checked: true, onchange: () => { for (const cb of checks.values()) cb.checked = all.checked; } });
listPane.append(el('label', { class: 'fx-list-row fx-list-row--check fx-list-head' }, [all, el('span', { text: 'Alle auswählen' })]));
for (const f of files) {
const cb = el('input', { type: 'checkbox', checked: true });
checks.set(f.path, cb);
const name = el('button', { type: 'button', class: 'fx-linkish fx-mono', text: f.from ? `${f.from} → ${f.path}` : f.path, onclick: () => showDiff(f, row) });
const discard = el('button', { type: 'button', class: 'fx-icon-btn', title: 'Änderung verwerfen', text: '↺', onclick: () => discardFile(f) });
const row = el('div', { class: 'fx-list-row fx-list-row--check' }, [
cb, el('span', { class: `fx-badge fx-badge--${f.code === '?' ? 'new' : f.code}`, text: f.label }), name, discard
]);
listPane.append(row);
}
}
async function showDiff(f, row) {
listPane.querySelectorAll('.fx-list-row.is-active').forEach(r => r.classList.remove('is-active'));
row.classList.add('is-active');
diffPane.textContent = 'Lade Unterschiede ...';
const res = await window.electronAPI.localGit({ action: 'diff', folder, file: f.path, code: f.code });
diffPane.textContent = '';
if (!res.ok) { diffPane.textContent = res.error || 'Diff nicht verfügbar'; return; }
for (const line of String(res.diff).split('\n')) {
let cls = '';
if (line.startsWith('+') && !line.startsWith('+++')) cls = 'fx-diff-add';
else if (line.startsWith('-') && !line.startsWith('---')) cls = 'fx-diff-del';
else if (line.startsWith('@@')) cls = 'fx-diff-hunk';
diffPane.append(el('span', { class: cls, text: line + '\n' }));
}
}
async function discardFile(f) {
const ok = await showActionConfirmModal({
title: 'Änderung verwerfen',
message: f.code === '?'
? `Die neue Datei "${f.path}" wird gelöscht. Das lässt sich nicht rückgängig machen.`
: `Alle nicht committeten Änderungen an "${f.path}" gehen verloren. Das lässt sich nicht rückgängig machen.`,
confirmText: 'Verwerfen',
danger: true
});
if (!ok) return;
const res = await window.electronAPI.localGit({ action: 'discard', folder, file: f.path, code: f.code });
if (!res.ok) showError('Verwerfen fehlgeschlagen: ' + res.error);
await load();
}
async function commit(andPush) {
const selected = files.filter(f => checks.get(f.path) && checks.get(f.path).checked).map(f => f.path);
if (!selected.length) { showWarning('Keine Datei ausgewählt.'); return; }
if (!msg.value.trim()) { showWarning('Bitte eine Commit-Nachricht eingeben.'); msg.focus(); return; }
[btnCommit, btnCommitPush].forEach(b => { b.disabled = true; });
try {
const res = await window.electronAPI.localGit({ action: 'commit', folder, files: selected, message: msg.value });
if (!res.ok) { showError('Commit fehlgeschlagen: ' + res.error); return; }
showSuccess(`${selected.length} Datei(en) committet`);
msg.value = '';
if (andPush) await push();
} finally {
[btnCommit, btnCommitPush].forEach(b => { b.disabled = false; });
await load();
}
}
async function push() {
showProgress(30, 'Pushe ...');
const res = await window.electronAPI.localGit({ action: 'push', folder });
hideProgress();
if (res.ok) showSuccess('Push erfolgreich');
else await showInfoModal('Push fehlgeschlagen', (res.error || '') + (res.hint ? '\n\n' + res.hint : ''), true);
await load();
}
await load();
}
/* ================================================================
3. Pull Requests & Issues
================================================================ */
async function openCollab(owner, repo, kind) {
const isPulls = kind === 'pulls';
const m = fxModal({ title: `${isPulls ? '🔀 Pull Requests' : '🐞 Issues'} · ${repo}`, wide: true });
let state = 'open';
let page = 1;
function renderList() {
m.setTitle(`${isPulls ? '🔀 Pull Requests' : '🐞 Issues'} · ${repo}`);
m.body.textContent = '';
m.footer.textContent = '';
const tabs = el('div', { class: 'fx-tabs' }, [
['open', 'Offen'], ['closed', 'Geschlossen']
].map(([s, label]) => el('button', {
type: 'button', class: `fx-tab${s === state ? ' is-active' : ''}`, text: label,
onclick: () => { state = s; page = 1; renderList(); }
})));
const list = el('div', { class: 'fx-list fx-list--cards' });
m.body.append(tabs, list);
if (!isPulls) m.footer.append(button('+ Neues Issue', () => renderNewIssue(), 'primary'));
loadPage(list);
}
async function loadPage(list) {
const loading = el('div', { class: 'fx-busy', text: 'Lade ...' });
list.append(loading);
const res = await window.electronAPI.giteaCollab({ action: isPulls ? 'listPulls' : 'listIssues', owner, repo, state, page });
loading.remove();
if (!res.ok) { list.append(el('div', { class: 'fx-alert fx-alert--danger', text: res.error })); return; }
if (!res.items.length && page === 1) {
list.append(el('div', { class: 'fx-empty', text: state === 'open'
? (isPulls ? 'Keine offenen Pull Requests.' : 'Keine offenen Issues.')
: 'Nichts gefunden.' }));
return;
}
for (const it of res.items) {
const meta = [`#${it.number}`, it.user ? `von ${it.user.login}` : null, `aktualisiert ${fmtDate(it.updated)}`, it.comments ? `💬 ${it.comments}` : null]
.filter(Boolean).join(' · ');
const card = el('button', { type: 'button', class: 'fx-item', onclick: () => renderDetail(it.number) }, [
el('div', { class: 'fx-item-title', text: (it.merged ? '🟣 ' : it.state === 'open' ? '🟢 ' : '🔴 ') + it.title }),
el('div', { class: 'fx-muted', text: meta + (isPulls && it.head ? ` · ${it.head} → ${it.base}` : '') }),
it.labels.length ? el('div', { class: 'fx-labels' }, it.labels.map(l => {
const lab = el('span', { class: 'fx-label', text: l.name });
if (/^[0-9a-f]{6}$/i.test(String(l.color).replace('#', ''))) lab.style.borderColor = '#' + String(l.color).replace('#', '');
return lab;
})) : null
]);
list.append(card);
}
if (res.items.length === 30) {
const more = button('Mehr laden', () => { more.remove(); page++; loadPage(list); });
list.append(more);
}
}
async function renderDetail(number) {
m.body.textContent = '';
m.footer.textContent = '';
busy(m.body, 'Lade ...');
const res = await window.electronAPI.giteaCollab({ action: 'get', owner, repo, index: number, isPull: isPulls });
m.body.textContent = '';
if (!res.ok) { m.body.append(el('div', { class: 'fx-alert fx-alert--danger', text: res.error })); m.footer.append(button('← Zurück', renderList)); return; }
const it = res.item;
m.setTitle(`#${it.number} ${it.title}`);
const meta = [it.state === 'open' ? 'offen' : (it.merged ? 'gemergt' : 'geschlossen'), it.user ? `von ${it.user.login}` : null, `erstellt ${fmtDate(it.created)}`]
.filter(Boolean).join(' · ');
m.body.append(el('div', { class: 'fx-muted', text: meta + (isPulls && it.head ? ` · ${it.head} → ${it.base}` : '') }));
m.body.append(el('div', { class: 'fx-text', text: it.body || '(keine Beschreibung)' }));
const comments = el('div', { class: 'fx-comments' });
for (const c of res.comments) {
comments.append(el('div', { class: 'fx-comment' }, [
el('div', { class: 'fx-muted', text: `${c.user ? c.user.login : '?'} · ${fmtDate(c.created)}` }),
el('div', { class: 'fx-text', text: c.body })
]));
}
m.body.append(el('h3', { text: `Kommentare (${res.comments.length})` }), comments);
const input = el('textarea', { class: 'fx-input fx-textarea', placeholder: 'Kommentar schreiben ...' });
const send = button('Kommentieren', async () => {
if (!input.value.trim()) return;
send.disabled = true;
const r = await window.electronAPI.giteaCollab({ action: 'comment', owner, repo, index: number, body: input.value });
send.disabled = false;
if (r.ok) { showSuccess('Kommentar gespeichert'); renderDetail(number); } else showError('Kommentar fehlgeschlagen: ' + r.error);
}, 'primary');
m.body.append(input, el('div', { class: 'fx-row-end' }, [send]));
m.footer.append(button('← Zurück', renderList));
if (it.url) m.footer.append(button('Im Browser öffnen', () => window.electronAPI.openExternalUrl(it.url)));
if (isPulls && it.state === 'open' && !it.merged) {
const style = el('select', { class: 'fx-input fx-select' }, [
['merge', 'Merge-Commit'], ['squash', 'Squash'], ['rebase', 'Rebase'], ['rebase-merge', 'Rebase + Merge-Commit']
].map(([v, t]) => el('option', { value: v, text: t })));
const delBranch = el('input', { type: 'checkbox' });
const mergeBtn = button('Mergen', async () => {
const ok = await showActionConfirmModal({
title: 'Pull Request mergen',
message: `#${it.number} „${it.title}“ wird per ${style.options[style.selectedIndex].text} in ${it.base} übernommen.` + (delBranch.checked ? `\nDer Branch ${it.head} wird danach gelöscht.` : ''),
confirmText: 'Mergen'
});
if (!ok) return;
mergeBtn.disabled = true;
const r = await window.electronAPI.giteaCollab({ action: 'merge', owner, repo, index: number, style: style.value, deleteBranch: delBranch.checked });
mergeBtn.disabled = false;
if (r.ok) { showSuccess(`#${number} gemergt`); renderDetail(number); }
else await showInfoModal('Mergen fehlgeschlagen', r.error + (it.mergeable === false ? '\n\nDer Pull Request hat Konflikte und muss erst angepasst werden.' : ''), true);
}, 'primary');
if (it.mergeable === false) m.footer.append(el('span', { class: 'fx-muted', text: '⚠️ Konflikte' }));
m.footer.append(style, el('label', { class: 'fx-inline-check' }, [delBranch, el('span', { text: 'Branch löschen' })]), mergeBtn);
}
if (!it.merged) {
const toState = it.state === 'open' ? 'closed' : 'open';
m.footer.append(button(toState === 'closed' ? 'Schließen' : 'Wieder öffnen', async () => {
const r = await window.electronAPI.giteaCollab({ action: 'setState', owner, repo, index: number, state: toState });
if (r.ok) renderDetail(number); else showError(r.error);
}, toState === 'closed' ? 'danger' : 'secondary'));
}
}
function renderNewIssue() {
m.setTitle(`🐞 Neues Issue · ${repo}`);
m.body.textContent = '';
m.footer.textContent = '';
const title = el('input', { type: 'text', class: 'fx-input', placeholder: 'Titel' });
const body = el('textarea', { class: 'fx-input fx-textarea fx-textarea--tall', placeholder: 'Beschreibung (optional)' });
m.body.append(title, body);
m.footer.append(button('← Zurück', renderList), button('Issue anlegen', async () => {
if (!title.value.trim()) { title.focus(); return; }
const r = await window.electronAPI.giteaCollab({ action: 'createIssue', owner, repo, title: title.value, body: body.value });
if (r.ok) { showSuccess(`Issue #${r.item.number} angelegt`); renderDetail(r.item.number); } else showError(r.error);
}, 'primary'));
title.focus();
}
renderList();
}
/* ================================================================
4. Server-Diagnose
================================================================ */
async function openDiagnosis() {
const m = fxModal({ title: '🩺 Server-Diagnose', wide: true });
busy(m.body, 'Prüfe Server, Verbindung und Git-Transport ... (bis zu einer Minute)');
m.footer.append(button('Schließen', () => m.close()));
const inRepo = currentState.view === 'gitea-repo' && currentState.owner && currentState.repo;
const res = await window.electronAPI.runServerDiagnosis(inRepo ? { owner: currentState.owner, repo: currentState.repo } : {});
m.body.textContent = '';
if (!res || !res.ok) { m.body.append(el('div', { class: 'fx-alert fx-alert--danger', text: (res && res.error) || 'Diagnose fehlgeschlagen' })); return; }
m.body.append(el('div', { class: 'fx-muted', text: 'Verwendet die gespeicherten Zugangsdaten.' }));
const icons = { ok: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' };
for (const c of res.checks) {
m.body.append(el('div', { class: `fx-check fx-check--${c.status}` }, [
el('div', { class: 'fx-check-head' }, [el('span', { text: icons[c.status] || '•' }), el('strong', { text: c.label })]),
el('div', { class: 'fx-text', text: c.detail || '' }),
c.hint ? el('pre', { class: 'fx-hint', text: c.hint }) : null
]));
}
const bad = res.checks.filter(c => c.status === 'fail').length;
m.setTitle(bad ? `🩺 Server-Diagnose: ${bad} Problem(e)` : '🩺 Server-Diagnose: alles in Ordnung');
}
/* ================================================================
5. Repo-Backup (Einstellungen)
================================================================ */
/* ---------- Fortschrittsfenster für das Repo-Backup ---------- */
let backupView = null;
function fmtDuration(ms) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`;
}
function backupPercent(p) {
if (!p || !p.total) return 0;
if (p.phase === 'done') return 100;
// Laufendes Repo anteilig mitzählen, wenn die Größe des Downloads bekannt ist
const partial = p.bytesTotal ? Math.min(0.99, p.bytes / p.bytesTotal) : 0;
return Math.min(99, Math.floor(((p.done || 0) + partial) / p.total * 100));
}
function openBackupProgress(initial) {
if (backupView) { backupView.update(initial); return; }
const m = fxModal({ title: '⇣ Backup läuft', wide: true });
const percentEl = el('div', { class: 'fx-progress-percent', text: '0 %' });
const fill = el('div', { class: 'fx-progress-fill' });
const bar = el('div', { class: 'fx-progress-track' }, [fill]);
const currentEl = el('div', { class: 'fx-lead', text: 'Startet …' });
const stepEl = el('div', { class: 'fx-muted' });
const metaEl = el('div', { class: 'fx-progress-meta' });
const list = el('div', { class: 'fx-list fx-backup-list' });
const warn = el('div', { class: 'fx-alert fx-alert--warn hidden', text:
'Der Server liefert keine Git-Pakete. Die Repos werden deshalb als ZIP gesichert: nur der Standard-Branch, ohne Historie.' });
m.body.append(el('div', { class: 'fx-progress-head' }, [percentEl, el('div', { class: 'fx-progress-now' }, [currentEl, stepEl])]),
bar, metaEl, warn, list);
const cancelBtn = button('Abbrechen', async () => {
const ok = await showActionConfirmModal({
title: 'Backup abbrechen?',
message: 'Bereits gesicherte Repos bleiben erhalten. Ein laufender ZIP-Download wird sofort beendet, ein laufender Git-Spiegel erst nach dem aktuellen Repo.',
confirmText: 'Abbrechen',
danger: true
});
if (ok) { cancelBtn.disabled = true; await window.electronAPI.repoBackupCancel(); }
}, 'danger');
const closeBtn = button('Im Hintergrund weiterlaufen lassen', () => m.close());
m.footer.append(cancelBtn, closeBtn);
m.onClose(() => { backupView = null; clearInterval(timer); });
let last = null;
let rowsFor = '';
const rows = new Map();
// Verstrichene Zeit auch ohne neue Meldung weiterzählen
const timer = setInterval(() => { if (last && last.phase !== 'done') renderMeta(last); }, 1000);
function renderMeta(p) {
const elapsed = Date.now() - (p.startedAt || Date.now());
const parts = [`${p.done || 0} von ${p.total || '?'} Repos`, `Dauer ${fmtDuration(p.summary ? p.summary.durationMs : elapsed)}`];
if (p.phase === 'running' && p.done >= 1 && p.total > p.done) {
parts.push(`noch ca. ${fmtDuration(elapsed / p.done * (p.total - p.done))}`);
}
metaEl.textContent = parts.join(' · ');
}
function update(p) {
if (!p) return;
last = p;
const pct = backupPercent(p);
percentEl.textContent = `${pct} %`;
fill.style.width = `${pct}%`;
warn.classList.toggle('hidden', !p.mirrorBroken);
renderMeta(p);
if (p.phase === 'list' || p.phase === 'start') {
currentEl.textContent = p.step || 'Startet …';
stepEl.textContent = p.targetDir ? `Ziel: ${p.targetDir}` : '';
} else if (p.phase === 'running') {
currentEl.textContent = p.repo ? `${p.current}/${p.total}: ${p.repo}` : 'Läuft …';
let step = p.step || '';
if (p.bytes) step += ` ${fmtBytes(p.bytes)}${p.bytesTotal ? ` von ${fmtBytes(p.bytesTotal)}` : ''}`;
stepEl.textContent = step;
} else if (p.phase === 'done') {
const s = p.summary || {};
m.setTitle(s.cancelled ? '⇣ Backup abgebrochen' : s.failed ? '⇣ Backup mit Fehlern beendet' : '⇣ Backup fertig');
currentEl.textContent = `${s.ok} von ${s.total} Repos gesichert` + (s.failed ? `, ${s.failed} Fehler` : '') + (s.skipped ? `, ${s.skipped} übersprungen` : '');
stepEl.textContent = `${s.mirror} als Git-Spiegel, ${s.zip} als ZIP · Protokoll: backup-protokoll.json im Backup-Ordner`;
percentEl.textContent = s.cancelled ? '–' : '100 %';
fill.style.width = s.cancelled ? fill.style.width : '100%';
cancelBtn.classList.add('hidden');
closeBtn.textContent = 'Schließen';
} else if (p.phase === 'error') {
m.setTitle('⇣ Backup fehlgeschlagen');
currentEl.textContent = p.error || 'Unbekannter Fehler';
stepEl.textContent = '';
cancelBtn.classList.add('hidden');
closeBtn.textContent = 'Schließen';
}
// Liste aller Repos mit Zustand
const repos = p.repos || [];
if (repos.join('|') !== rowsFor) {
rowsFor = repos.join('|');
list.textContent = '';
rows.clear();
for (const name of repos) {
const icon = el('span', { class: 'fx-backup-icon', text: '○' });
const info = el('span', { class: 'fx-muted', text: 'wartet' });
rows.set(name, { row: el('div', { class: 'fx-list-row' }, [icon, el('span', { class: 'fx-mono', text: name }), info]), icon, info });
list.append(rows.get(name).row);
}
}
const byRepo = new Map((p.results || []).map(r => [r.repo, r]));
for (const [name, r] of rows) {
const res = byRepo.get(name);
r.row.classList.toggle('is-active', !res && name === p.repo && p.phase === 'running');
if (res) {
r.icon.textContent = res.ok ? '✅' : '❌';
r.info.textContent = res.ok
? (res.method === 'leer' ? 'leeres Repo' : res.method + (res.bytes ? ` · ${fmtBytes(res.bytes)}` : ''))
: res.error;
r.info.title = res.error || '';
} else if (name === p.repo && p.phase === 'running') {
r.icon.textContent = '⏳';
r.info.textContent = 'läuft …';
} else if (p.phase === 'done') {
r.icon.textContent = '–';
r.info.textContent = 'übersprungen';
}
}
const active = list.querySelector('.fx-list-row.is-active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
backupView = { update };
update(initial || { phase: 'start', step: 'Startet …', startedAt: Date.now(), results: [], repos: [] });
}
function renderBackupStatus(state) {
const statusEl = $('repoBackupStatus');
if (!statusEl) return;
const runBtn = $('btnRepoBackupRun');
if (runBtn) runBtn.textContent = state.running ? '⏳ Fortschritt anzeigen' : '▶️ Jetzt sichern';
if (state.running) {
const p = state.progress;
statusEl.textContent = p && p.total
? `⏳ Backup läuft: ${backupPercent(p)} % · ${p.current}/${p.total}: ${p.repo || ''}`
: '⏳ Backup läuft …';
return;
}
const r = state.config && state.config.lastResult;
if (!r) { statusEl.textContent = 'Noch kein Backup gelaufen.'; return; }
let text = `Letztes Backup: ${fmtDate(r.at)} · ${r.ok}/${r.total} gesichert (${r.mirror} als Git-Spiegel, ${r.zip} als ZIP)`;
if (r.cancelled) text += ' · abgebrochen';
if (r.durationMs) text += ` · Dauer ${fmtDuration(r.durationMs)}`;
if (r.failed) text += ` · ${r.failed} Fehler: ` + r.errors.map(e => `${e.repo} (${e.error})`).join('; ');
if (r.mirrorBroken) text += '\nDer Server liefert keine Git-Pakete, daher ZIP-Archive: nur der Standard-Branch, ohne Historie. Server-Diagnose zeigt die Ursache.';
statusEl.textContent = text;
}
async function loadBackupSettings() {
try {
const state = await window.electronAPI.repoBackupGetConfig();
if (!state || !state.ok) return;
const c = state.config;
if ($('repoBackupTarget')) $('repoBackupTarget').value = c.targetDir || '';
if ($('repoBackupInterval')) $('repoBackupInterval').value = c.interval || 'off';
if ($('repoBackupKeep')) $('repoBackupKeep').value = c.keep || 5;
if ($('repoBackupTime')) $('repoBackupTime').value = c.time || '';
if ($('repoBackupNotify')) $('repoBackupNotify').value = c.notify || 'errors';
if ($('repoBackupExclude')) $('repoBackupExclude').value = c.excludeRepos || '';
renderBackupStatus(state);
} catch (_) {}
}
async function saveBackupSettings(silent) {
const res = await window.electronAPI.repoBackupSetConfig({
targetDir: $('repoBackupTarget').value,
interval: $('repoBackupInterval').value,
keep: $('repoBackupKeep').value,
time: $('repoBackupTime') ? $('repoBackupTime').value : '',
notify: $('repoBackupNotify') ? $('repoBackupNotify').value : 'errors',
excludeRepos: $('repoBackupExclude') ? $('repoBackupExclude').value : ''
});
if (res && res.ok) {
renderBackupStatus(res);
if (!silent) showSuccess('Backup-Einstellung gespeichert');
} else if (!silent) showError('Speichern fehlgeschlagen: ' + (res && res.error));
return res;
}
/* ================================================================
6. Backup wiederherstellen
================================================================ */
async function openRestore() {
const m = fxModal({ title: '↺ Backup wiederherstellen', wide: true });
let dir = null;
async function renderList(customDir) {
m.setTitle('↺ Backup wiederherstellen');
m.footer.textContent = '';
busy(m.body, 'Suche Sicherungen ...');
const res = await window.electronAPI.repoBackupList(customDir ? { dir: customDir } : {});
m.body.textContent = '';
const pick = button('📂 Anderen Ordner wählen', async () => {
const folder = await window.electronAPI.selectFolder();
if (folder) renderList(folder);
});
m.footer.append(pick, button('Schließen', () => m.close()));
if (!res || !res.ok) {
m.body.append(el('div', { class: 'fx-alert fx-alert--warn', text: (res && res.error) || 'Backup-Ordner nicht lesbar' }));
return;
}
dir = res.dir;
m.body.append(el('div', { class: 'fx-muted', text: `Quelle: ${dir}` }));
if (!res.backups.length) {
m.body.append(el('div', { class: 'fx-empty', text: 'In diesem Ordner wurden keine Sicherungen gefunden.' }));
return;
}
const list = el('div', { class: 'fx-list fx-list--cards' });
for (const b of res.backups) {
const parts = [];
if (b.mirror) parts.push(`Git-Spiegel · Stand ${fmtDate(b.mirror.updated)}`);
if (b.zips.length) parts.push(`${b.zips.length} ZIP-Stand${b.zips.length > 1 ? 'e' : ''} · neuester ${fmtDate(b.zips[0].date)}`);
list.append(el('button', { type: 'button', class: 'fx-item', onclick: () => renderDetail(b) }, [
el('div', { class: 'fx-item-title', text: `${b.owner}/${b.repo}` }),
el('div', { class: 'fx-muted', text: parts.join(' · ') })
]));
}
m.body.append(list);
}
function renderDetail(b) {
m.setTitle(`↺ ${b.owner}/${b.repo} wiederherstellen`);
m.body.textContent = '';
m.footer.textContent = '';
// Welche Sicherung?
const sources = [];
if (b.mirror) sources.push({ kind: 'mirror', path: b.mirror.path, label: `Git-Spiegel – volle Historie, alle Branches und Tags (Stand ${fmtDate(b.mirror.updated)})`, date: b.mirror.updated });
for (const z of b.zips) sources.push({ kind: 'zip', path: z.path, label: `ZIP vom ${fmtDate(z.date)} – nur Standard-Branch, ohne Historie (${fmtBytes(z.size)})`, date: z.date });
let chosen = sources[0];
const srcList = el('div', { class: 'fx-list' });
sources.forEach((src, i) => {
const radio = el('input', { type: 'radio', name: 'fxRestoreSource', checked: i === 0, onchange: () => { chosen = src; updateMode(); } });
srcList.append(el('label', { class: 'fx-list-row fx-list-row--check' }, [radio, el('span', { text: src.label })]));
});
m.body.append(el('h3', { text: '1. Sicherung wählen' }), srcList);
// Lokal entpacken
const localBtn = button('📂 Zielordner wählen und entpacken', async () => {
const parent = await window.electronAPI.selectFolder();
if (!parent) return;
setBusy(true, chosen.kind === 'mirror' ? 'Klone Spiegel ...' : 'Entpacke Archiv ...');
const r = await window.electronAPI.repoRestoreLocal({ kind: chosen.kind, source: chosen.path, destParent: parent, repo: b.repo });
setBusy(false);
if (!r.ok) { await showInfoModal('Entpacken fehlgeschlagen', r.error, true); return; }
showSuccess(`Wiederhergestellt nach ${r.path}`);
showResult(`✅ Lokal wiederhergestellt: ${r.path}` + (r.branches ? ` (${r.branches} Branches)` : r.files ? ` (${r.files} Dateien)` : ''), [
button('In der App öffnen', () => openLocalInApp(r.path, m), 'primary')
]);
});
m.body.append(el('h3', { text: '2a. Lokal entpacken' }),
el('div', { class: 'fx-muted', text: `Legt den Ordner „${b.repo}“ im gewählten Verzeichnis an. Ein vorhandener, nicht leerer Ordner wird nie überschrieben.` }),
el('div', {}, [localBtn]));
// Auf Gitea zurückspielen
const modeNew = el('input', { type: 'radio', name: 'fxRestoreMode', checked: true, onchange: updateMode });
const modeOverwrite = el('input', { type: 'radio', name: 'fxRestoreMode', onchange: updateMode });
const nameInput = el('input', { type: 'text', class: 'fx-input', value: `${b.repo}-wiederhergestellt` });
const privateCb = el('input', { type: 'checkbox', checked: true });
const overwriteNote = el('div', { class: 'fx-muted' });
const giteaBtn = button('Auf Gitea zurückspielen', () => restoreGitea(), 'primary');
m.body.append(el('h3', { text: '2b. Auf Gitea zurückspielen' }),
el('label', { class: 'fx-list-row fx-list-row--check' }, [modeNew, el('span', { text: 'Als neues Repo anlegen' })]),
el('div', { class: 'fx-row fx-indent' }, [nameInput, el('label', { class: 'fx-inline-check' }, [privateCb, el('span', { text: 'Privat' })])]),
el('label', { class: 'fx-list-row fx-list-row--check' }, [modeOverwrite, el('span', { text: `Bestehendes Repo „${b.owner}/${b.repo}“ überschreiben` })]),
overwriteNote,
el('div', {}, [giteaBtn]));
const status = el('div', { class: 'fx-lead fx-restore-status' });
m.body.append(status);
m.footer.append(button('← Zurück', () => renderList(dir)));
let offProgress = null;
function setBusy(on, text) {
[localBtn, giteaBtn].forEach(btn => { btn.disabled = on; });
status.textContent = on ? `⏳ ${text}` : '';
if (on && !offProgress) offProgress = window.electronAPI.onRepoRestoreProgress(p => { if (p && p.text) status.textContent = `⏳ ${p.text}`; });
if (!on && offProgress) { offProgress(); offProgress = null; }
}
function showResult(text, actions = []) {
status.textContent = '';
status.append(el('div', { text }), el('div', { class: 'fx-row' }, actions));
}
function updateMode() {
const zip = chosen.kind === 'zip';
modeOverwrite.disabled = zip;
if (zip && modeOverwrite.checked) modeNew.checked = true;
nameInput.disabled = !modeNew.checked;
privateCb.disabled = !modeNew.checked;
overwriteNote.textContent = zip
? 'Überschreiben geht nur mit einem Git-Spiegel, weil ein ZIP-Stand keine Historie enthält.'
: 'Achtung: Setzt alle Branches und Tags auf den Stand der Sicherung. Neuere Commits auf Gitea gehen verloren.';
}
updateMode();
async function restoreGitea() {
const overwrite = modeOverwrite.checked;
const name = nameInput.value.trim();
if (!overwrite && !name) { nameInput.focus(); return; }
const ok = await showActionConfirmModal(overwrite ? {
title: 'Repo überschreiben',
message: `Alle Branches und Tags von „${b.owner}/${b.repo}“ werden auf den Stand vom ${fmtDate(chosen.date)} gesetzt.\n\nCommits, Branches und Tags, die danach auf Gitea entstanden sind, gehen verloren. Das lässt sich nur mit einer neueren Sicherung rückgängig machen.`,
confirmText: 'Überschreiben',
danger: true
} : {
title: 'Neues Repo anlegen',
message: `Das Repo „${b.owner}/${name}“ wird ${privateCb.checked ? 'privat' : 'öffentlich'} angelegt und mit der Sicherung vom ${fmtDate(chosen.date)} befüllt.`,
confirmText: 'Anlegen und befüllen'
});
if (!ok) return;
setBusy(true, 'Starte ...');
const r = await window.electronAPI.repoRestoreGitea({
kind: chosen.kind, source: chosen.path, owner: b.owner, repo: b.repo,
mode: overwrite ? 'overwrite' : 'new', newName: name, isPrivate: privateCb.checked
});
setBusy(false);
if (!r.ok) { await showInfoModal('Wiederherstellen fehlgeschlagen', r.error, true); return; }
showSuccess(`${r.repo} wiederhergestellt`);
showResult(`✅ Auf Gitea wiederhergestellt: ${r.repo} (über ${r.method}${r.files ? `, ${r.files} Dateien` : ''})`,
r.url ? [button('Im Browser öffnen', () => window.electronAPI.openExternalUrl(r.url))] : []);
}
}
renderList(null);
}
function openLocalInApp(folder, modal) {
modal.close();
$('settingsModal')?.classList.add('hidden');
selectedFolder = folder;
currentState.view = 'local';
setStatus('Local: ' + folder);
if (typeof updateNavigationUI === 'function') updateNavigationUI();
window.updateFeatureButtons('local');
if (typeof refreshLocalTree === 'function') refreshLocalTree(folder);
if (typeof loadBranches === 'function') loadBranches(folder);
}
// Bereiche in den Einstellungen (Navigation links). "general" bleibt als alter Name für "konten" gültig.
window.showSettingsTab = function (name) {
if (name === 'general') name = 'konten';
document.querySelectorAll('#settingsModal [data-settings-tab]').forEach(tab => {
const active = tab.dataset.settingsTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', String(active));
});
document.querySelectorAll('#settingsModal [data-settings-panel]').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== name);
});
if (name === 'backup') loadBackupSettings();
// Höhenabgleich der Karten braucht sichtbare Elemente
if (name === 'konten' && typeof syncSettingsPanelHeights === 'function') requestAnimationFrame(syncSettingsPanelHeights);
const pages = document.querySelector('#settingsModal .settings-pages');
if (pages) pages.scrollTop = 0;
};
// Für das gemeinsame "Speichern" in settings-extra.js
window.gmBackupSettings = { load: loadBackupSettings, save: saveBackupSettings };
function wireSettings() {
document.querySelectorAll('#settingsModal [data-settings-tab]').forEach(tab => {
tab.addEventListener('click', () => window.showSettingsTab(tab.dataset.settingsTab));
});
const restoreBtn = $('btnRepoRestore');
if (restoreBtn) restoreBtn.onclick = () => openRestore();
const diag = $('btnServerDiagnosis');
if (diag) diag.onclick = () => openDiagnosis();
const browse = $('btnRepoBackupBrowse');
if (browse) browse.onclick = async () => {
const folder = await window.electronAPI.selectFolder();
if (folder) $('repoBackupTarget').value = folder;
};
const save = $('btnRepoBackupSave');
if (save) save.onclick = () => saveBackupSettings(false);
const run = $('btnRepoBackupRun');
if (run) run.onclick = async () => {
const state = await window.electronAPI.repoBackupGetConfig();
// Läuft schon ein Backup (z. B. ein geplantes), nur den Fortschritt zeigen
if (state && state.running) { openBackupProgress(state.progress); return; }
if (!$('repoBackupTarget').value.trim()) { showWarning('Bitte zuerst ein Backup-Ziel wählen.'); return; }
await saveBackupSettings(true);
openBackupProgress(null);
const res = await window.electronAPI.repoBackupRunNow();
if (!res.ok && !res.summary && !res.running) showError('Backup fehlgeschlagen: ' + res.error);
};
// Fortschritt kommt für jedes Backup an alle Fenster – auch für geplante Backups
window.electronAPI.onRepoBackupProgress((p) => {
if (!p) return;
const finished = p.phase === 'done' || p.phase === 'error';
renderBackupStatus({ running: !finished, progress: p });
if (backupView) backupView.update(p);
if (finished) {
const s = p.summary;
if (p.phase === 'error') showError('Backup fehlgeschlagen: ' + p.error);
else if (s && s.cancelled) showWarning(`Backup abgebrochen: ${s.ok} von ${s.total} Repos gesichert`);
else if (s && s.failed) showWarning(`Backup mit ${s.failed} Fehler(n) beendet`);
else if (s) showSuccess(`Backup fertig: ${s.ok}/${s.total} Repos gesichert`);
setTimeout(loadBackupSettings, 300);
}
});
// Beim Öffnen der Einstellungen den aktuellen Stand anzeigen
const btnSettings = $('btnSettings');
if (btnSettings) btnSettings.addEventListener('click', () => { window.showSettingsTab('konten'); setTimeout(loadBackupSettings, 50); });
loadBackupSettings();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wireSettings);
else wireSettings();
})();
+528 -180
View File
@@ -52,16 +52,11 @@
<div class="tool-group tool-group--utility"> <div class="tool-group tool-group--utility">
<span class="tool-group-title">Steuerung</span> <span class="tool-group-title">Steuerung</span>
<button id="btnSettings" title="Einstellungen">⚙️ Settings</button> <button id="btnSettings" title="Einstellungen">⚙️ <span class="btn-label">Settings</span></button>
<button id="btnBatchActions" title="Batch-Aktionen">🧩 Batch</button> <button id="btnBatchActions" title="Batch-Aktionen">🧩 <span class="btn-label">Batch</span></button>
<button id="btnOpenActivityLog" title="Aktivitätsprotokoll">📝 Activity</button> <button id="btnOpenActivityLog" title="Aktivitätsprotokoll">📝 <span class="btn-label">Activity</span></button>
<button id="btnUploadHistory" title="Verlauf der letzten Uploads">🕘 <span class="btn-label">Verlauf</span></button>
<button id="btnRetryQueueNow" class="secondary" title="Retry-Queue jetzt verarbeiten">🔁 Queue (0)</button> <button id="btnRetryQueueNow" class="secondary" title="Retry-Queue jetzt verarbeiten">🔁 Queue (0)</button>
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
</div>
<div class="toolbar-status-wrap">
<span class="status-dot" aria-hidden="true"></span>
<span id="status" class="status">Bereit</span>
</div> </div>
</div> </div>
</div> </div>
@@ -73,15 +68,34 @@
<button id="btnLoadGiteaRepos" class="accent-btn" title="Projekte der gewählten Plattform laden">🌐 Load Projekte</button> <button id="btnLoadGiteaRepos" class="accent-btn" title="Projekte der gewählten Plattform laden">🌐 Load Projekte</button>
</div> </div>
<div class="tool-group tool-group--repo"> <div class="tool-group tool-group--platform">
<span class="tool-group-title">Repository</span>
<input id="platform" type="hidden" value="gitea"> <input id="platform" type="hidden" value="gitea">
<div class="platform-switch" role="tablist" aria-label="Plattform auswählen"> <div class="platform-switch" role="tablist" aria-label="Plattform auswählen">
<button type="button" class="platform-option active" data-platform="gitea" aria-pressed="true">Gitea</button> <button type="button" class="platform-option active" data-platform="gitea" aria-pressed="true">Gitea</button>
<button type="button" class="platform-option" data-platform="github" aria-pressed="false">GitHub</button> <button type="button" class="platform-option" data-platform="github" aria-pressed="false">GitHub</button>
</div> </div>
<div class="toolbar-status-wrap">
<span class="status-dot" aria-hidden="true"></span>
<span id="status" class="status">Bereit</span>
</div>
</div>
</div>
<!-- Nur in der Repo-Ansicht sichtbar: erscheint, sobald mindestens ein Knopf darin eingeblendet ist. -->
<div class="toolbar-row toolbar-row--context">
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
<div class="tool-group tool-group--repo-nav">
<span class="tool-group-title">Repository</span>
<button id="btnCommits" class="hidden" title="Commit History anzeigen">📊 Commits</button> <button id="btnCommits" class="hidden" title="Commit History anzeigen">📊 Commits</button>
<button id="btnReleases" class="hidden" title="Releases anzeigen">📦 Releases</button> <button id="btnReleases" class="hidden" title="Releases anzeigen">📦 Releases</button>
<button id="btnPulls" class="hidden" title="Pull Requests ansehen, kommentieren und mergen">🔀 PRs</button>
<button id="btnIssues" class="hidden" title="Issues ansehen und kommentieren">🐞 Issues</button>
<button id="btnLocalChanges" class="hidden" title="Lokale Änderungen ansehen und committen">📝 Änderungen</button>
</div>
<div class="tool-group tool-group--repo-tools">
<span class="tool-group-title">Werkzeuge</span>
<button id="btnCleanupIgnored" class="hidden" title="Dateien entfernen, die laut .gitignore nicht ins Repo gehören">🧹 Aufräumen</button>
<button id="btnGitignoreTemplate" class="hidden" title="Standard-.gitignore (Node, .NET, Java, Python …) im Repo anlegen">📄 .gitignore</button>
<button id="btnTrash" class="hidden" style="display:none" title=""><!-- removed --></button> <button id="btnTrash" class="hidden" style="display:none" title=""><!-- removed --></button>
<button id="btnPurgeTrash" class="hidden" style="display:none" title=""><!-- removed --></button> <button id="btnPurgeTrash" class="hidden" style="display:none" title=""><!-- removed --></button>
</div> </div>
@@ -123,197 +137,529 @@
<div> <div>
<div class="settings-eyebrow">Konfiguration</div> <div class="settings-eyebrow">Konfiguration</div>
<h2>⚙️ Einstellungen</h2> <h2>⚙️ Einstellungen</h2>
<p class="settings-subtitle">Alle wichtigen Optionen auf einer Seite: Zugangsdaten, Verbindungscheck, Darstellung und Updates.</p> <p class="settings-subtitle">Links den Bereich wählen. „Speichern“ übernimmt alle Änderungen aus allen Bereichen.</p>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-layout"> <div class="settings-shell">
<div class="settings-column settings-column--left"> <nav class="settings-nav" role="tablist" aria-label="Einstellungsbereiche" aria-orientation="vertical">
<section class="settings-panel settings-panel--credentials"> <button type="button" class="settings-tab is-active" role="tab" data-settings-tab="konten" aria-selected="true">
<div class="settings-panel-header"> <span class="settings-tab-icon" aria-hidden="true">🔑</span>
<div> <span class="settings-tab-text"><strong>Konten</strong><small>Gitea, GitHub, Verbindung</small></span>
<h3>Zugangsdaten</h3> </button>
<p>API-Zugriffe für GitHub und Gitea konfigurieren.</p> <button type="button" class="settings-tab" role="tab" data-settings-tab="darstellung" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">🎨</span>
<span class="settings-tab-text"><strong>Darstellung</strong><small>Übersicht und Explorer</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="uploads" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">📤</span>
<span class="settings-tab-text"><strong>Uploads</strong><small>Ausschlüsse, Prüfungen</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="system" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">🖥️</span>
<span class="settings-tab-text"><strong>System</strong><small>Autostart, Version, Updates</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="backup" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">💾</span>
<span class="settings-tab-text"><strong>Backup</strong><small>Sichern, wiederherstellen</small></span>
</button>
<button type="button" class="settings-tab" role="tab" data-settings-tab="diagnose" aria-selected="false">
<span class="settings-tab-icon" aria-hidden="true">🩺</span>
<span class="settings-tab-text"><strong>Diagnose</strong><small>Server prüfen, Fehlersuche</small></span>
</button>
</nav>
<div class="settings-pages">
<div class="settings-tabpanel" data-settings-panel="konten" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--credentials">
<div class="settings-panel-header">
<div>
<h3>Zugangsdaten</h3>
<p>API-Zugriffe für GitHub und Gitea konfigurieren.</p>
</div>
</div> </div>
</div>
<div class="settings-credentials-grid"> <div class="settings-credentials-grid">
<article class="settings-auth-card settings-auth-card--github"> <article class="settings-auth-card settings-auth-card--github">
<div class="settings-auth-card-header"> <div class="settings-auth-card-header">
<h4>GitHub</h4> <h4>GitHub</h4>
<button id="btnTestGithubConnection" class="secondary" type="button">🔌 Verbindung testen</button> <button id="btnTestGithubConnection" class="secondary" type="button">🔌 Verbindung testen</button>
</div> </div>
<div class="input-group settings-auth-input"> <div class="input-group settings-auth-input">
<label for="githubToken">GitHub Token</label> <label for="githubToken">GitHub Token</label>
<input id="githubToken" type="password" placeholder="ghp_..."> <input id="githubToken" type="password" placeholder="ghp_...">
</div> </div>
<div class="settings-auth-spacer" aria-hidden="true">GitHub benötigt keine Server-URL</div> <div class="settings-auth-spacer" aria-hidden="true">GitHub benötigt keine Server-URL</div>
<div id="githubTokenHint" class="settings-inline-hint">Hinweis: Der Token wird direkt über api.github.com geprüft.</div> <div id="githubTokenHint" class="settings-inline-hint">Hinweis: Der Token wird direkt über api.github.com geprüft.</div>
</article> </article>
<article class="settings-auth-card settings-auth-card--gitea"> <article class="settings-auth-card settings-auth-card--gitea">
<div class="settings-auth-card-header"> <div class="settings-auth-card-header">
<h4>Gitea</h4> <h4>Gitea</h4>
<button id="btnTestGiteaConnection" class="secondary" type="button">🔌 Verbindung testen</button> <button id="btnTestGiteaConnection" class="secondary" type="button">🔌 Verbindung testen</button>
</div> </div>
<div class="input-group settings-auth-input"> <div class="input-group settings-auth-input">
<label for="giteaToken">Gitea Token</label> <label for="giteaToken">Gitea Token</label>
<input id="giteaToken" type="password" placeholder="Token hier einfügen"> <input id="giteaToken" type="password" placeholder="Token hier einfügen">
</div> </div>
<div class="input-group settings-auth-input"> <div class="input-group settings-auth-input">
<label for="giteaURL">Gitea URL</label> <label for="giteaURL">Gitea URL</label>
<input id="giteaURL" type="text" placeholder="https://gitea.example.com"> <input id="giteaURL" type="text" placeholder="https://gitea.example.com">
</div> </div>
<div id="giteaUrlHint" class="settings-inline-hint">Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000</div> <div id="giteaUrlHint" class="settings-inline-hint">Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000</div>
</article> </article>
</div>
</section>
<section class="settings-panel settings-panel--display">
<div class="settings-panel-header">
<div>
<h3>Darstellung</h3>
<p>Übersicht und Explorer an deinen Arbeitsstil anpassen.</p>
</div> </div>
</div> </section>
<div class="settings-toggle-list"> <section class="settings-panel settings-panel--health">
<label class="settings-toggle-row" for="settingFavorites"> <div class="settings-panel-header">
<span class="settings-toggle-info"> <div>
<span class="settings-toggle-title">⭐ Favoriten-Bereich anzeigen</span> <h3>Verbindungsstatus</h3>
<span class="settings-toggle-desc">Pinnt wichtige Repositories und Ordner sichtbar im Kopfbereich.</span> <p>Direkt sehen, ob URL, API und Auth sauber antworten.</p>
</span> </div>
<span class="toggle-switch">
<input type="checkbox" id="settingFavorites" checked>
<span class="toggle-track"></span>
</span>
</label>
<label class="settings-toggle-row" for="settingRecent">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🕐 Zuletzt geöffnet anzeigen</span>
<span class="settings-toggle-desc">Zeigt deine letzten Projekte direkt in der Übersicht an.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingRecent" checked>
<span class="toggle-track"></span>
</span>
</label>
<label class="settings-toggle-row" for="settingCompact">
<span class="settings-toggle-info">
<span class="settings-toggle-title">⊞ Kompakt-Modus</span>
<span class="settings-toggle-desc">Verdichtet Karten und Abstände für kleinere Fenster.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingCompact">
<span class="toggle-track"></span>
</span>
</label>
<label class="settings-toggle-row" for="settingColoredIcons">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🎨 Farbige Datei-Icons</span>
<span class="settings-toggle-desc">Setzt stärkere Dateityp-Farben für schnellere Orientierung.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingColoredIcons" checked>
<span class="toggle-track"></span>
</span>
</label>
</div>
</section>
<section class="settings-panel settings-panel--diagnostics">
<div class="settings-panel-header">
<div>
<h3>Diagnose</h3>
<p>Erstellt ein Diagnosepaket mit Logs, Plattform und letzten Fehlern für schnelles Debugging.</p>
</div> </div>
</div>
<div class="settings-diagnostics-card">
<button id="btnCreateDiagnostics" class="settings-action-btn" type="button">🧪 Diagnose erstellen</button>
</div>
</section>
<div class="settings-health-box">
<div class="settings-health-row"><span>URL</span><strong id="healthUrl">Unbekannt</strong></div>
<div class="settings-health-row"><span>API</span><strong id="healthApi">Unbekannt</strong></div>
<div class="settings-health-row"><span>Auth</span><strong id="healthAuth">Unbekannt</strong></div>
<div class="settings-health-row"><span>Latenz</span><strong id="healthLatency">-</strong></div>
<div class="settings-health-row"><span>Server</span><strong id="healthVersion">-</strong></div>
<div class="settings-health-row"><span>Letzter Fehler</span><strong id="healthLastError">-</strong></div>
</div>
</section>
<section class="settings-panel settings-panel--profiles">
<div class="settings-panel-header">
<div>
<h3>Gitea-Server</h3>
<p>Mehrere Server oder Konten speichern und mit einem Klick wechseln. Tokens werden verschlüsselt gespeichert.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="fx-row">
<div class="input-group">
<label for="giteaProfileSelect">Gespeicherte Server</label>
<select id="giteaProfileSelect"></select>
</div>
<button id="btnGiteaProfileUse" class="settings-action-btn fx-browse-btn" type="button">⇄ Wechseln</button>
<button id="btnGiteaProfileDelete" class="settings-action-btn fx-browse-btn" type="button">🗑 Entfernen</button>
</div>
<div class="fx-row">
<div class="input-group">
<label for="giteaProfileName">Name für den aktuellen Server</label>
<input id="giteaProfileName" type="text" placeholder="z. B. Homelab oder Arbeit">
</div>
<button id="btnGiteaProfileSave" class="settings-action-btn fx-browse-btn" type="button">💾 Als Profil speichern</button>
</div>
<div id="giteaProfileHint" class="settings-inline-hint">Speichert URL und Token aus „Zugangsdaten“ unter diesem Namen.</div>
</div>
</section>
<section class="settings-panel settings-panel--identity">
<div class="settings-panel-header">
<div>
<h3>Git-Identität</h3>
<p>Name und E-Mail für Commits, die die App anlegt (Uploads, lokale Commits, Wiederherstellen).</p>
</div>
</div>
<div class="settings-backup-card">
<div class="fx-row">
<div class="input-group">
<label for="prefGitName">Name</label>
<input id="prefGitName" type="text" placeholder="z. B. M_Viper" autocomplete="off">
</div>
<div class="input-group">
<label for="prefGitEmail">E-Mail</label>
<input id="prefGitEmail" type="email" placeholder="z. B. name@example.com" autocomplete="off">
</div>
</div>
<div class="settings-inline-hint">Ist im Repo oder in deiner globalen Git-Konfiguration schon ein Name eingetragen, gilt dieser. Leer lassen = „Git Manager GUI“.</div>
</div>
</section>
</div>
</div> </div>
<div class="settings-column settings-column--right"> <div class="settings-tabpanel hidden" data-settings-panel="darstellung" role="tabpanel">
<section class="settings-panel settings-panel--health"> <div class="settings-column">
<div class="settings-panel-header"> <section class="settings-panel settings-panel--display">
<div> <div class="settings-panel-header">
<h3>Verbindungsstatus</h3> <div>
<p>Direkt sehen, ob URL, API und Auth sauber antworten.</p> <h3>Darstellung</h3>
<p>Übersicht und Explorer an deinen Arbeitsstil anpassen.</p>
</div>
</div> </div>
</div>
<div class="settings-health-box"> <div class="settings-toggle-list">
<div class="settings-health-row"><span>URL</span><strong id="healthUrl">Unbekannt</strong></div> <label class="settings-toggle-row" for="settingFavorites">
<div class="settings-health-row"><span>API</span><strong id="healthApi">Unbekannt</strong></div> <span class="settings-toggle-info">
<div class="settings-health-row"><span>Auth</span><strong id="healthAuth">Unbekannt</strong></div> <span class="settings-toggle-title">⭐ Favoriten-Bereich anzeigen</span>
<div class="settings-health-row"><span>Latenz</span><strong id="healthLatency">-</strong></div> <span class="settings-toggle-desc">Pinnt wichtige Repositories und Ordner sichtbar im Kopfbereich.</span>
<div class="settings-health-row"><span>Server</span><strong id="healthVersion">-</strong></div> </span>
<div class="settings-health-row"><span>Letzter Fehler</span><strong id="healthLastError">-</strong></div> <span class="toggle-switch">
</div> <input type="checkbox" id="settingFavorites" checked>
</section> <span class="toggle-track"></span>
</span>
</label>
<section class="settings-panel settings-panel--system"> <label class="settings-toggle-row" for="settingRecent">
<div class="settings-panel-header"> <span class="settings-toggle-info">
<div> <span class="settings-toggle-title">🕐 Zuletzt geöffnet anzeigen</span>
<h3>System</h3> <span class="settings-toggle-desc">Zeigt deine letzten Projekte direkt in der Übersicht an.</span>
<p>Verhalten beim Windows-Start steuern.</p> </span>
</div> <span class="toggle-switch">
</div> <input type="checkbox" id="settingRecent" checked>
<div class="settings-toggle-list"> <span class="toggle-track"></span>
<label class="settings-toggle-row" for="settingAutostart"> </span>
<span class="settings-toggle-info"> </label>
<span class="settings-toggle-title">🚀 Mit Windows starten</span>
<span class="settings-toggle-desc">Startet die App automatisch beim Anmelden, minimiert im System-Tray.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingAutostart">
<span class="toggle-track"></span>
</span>
</label>
</div>
</section>
<section class="settings-panel settings-panel--app"> <label class="settings-toggle-row" for="settingCompact">
<div class="settings-panel-header"> <span class="settings-toggle-info">
<div> <span class="settings-toggle-title">⊞ Kompakt-Modus</span>
<h3>App & Updates</h3> <span class="settings-toggle-desc">Verdichtet Karten und Abstände für kleinere Fenster.</span>
<p>Version prüfen und neue Releases direkt anstoßen.</p> </span>
</div> <span class="toggle-switch">
</div> <input type="checkbox" id="settingCompact">
<span class="toggle-track"></span>
</span>
</label>
<div class="settings-version-card"> <label class="settings-toggle-row" for="settingColoredIcons">
<div class="input-group input-group--wide settings-version-field"> <span class="settings-toggle-info">
<label for="appVersion">App Version</label> <span class="settings-toggle-title">🎨 Farbige Datei-Icons</span>
<input id="appVersion" class="settings-readonly-input" type="text" readonly> <span class="settings-toggle-desc">Setzt stärkere Dateityp-Farben für schnellere Orientierung.</span>
</div> </span>
<button id="btnCheckUpdates" class="settings-update-btn">🔄 Nach Updates suchen</button> <span class="toggle-switch">
</div> <input type="checkbox" id="settingColoredIcons" checked>
</section> <span class="toggle-track"></span>
</span>
</label>
<section class="settings-panel settings-panel--backup">
<div class="settings-panel-header">
<div>
<h3>Backup & Transfer</h3>
<p>Einstellungen, Favoriten und Verlauf exportieren oder auf einem anderen PC importieren.</p>
</div> </div>
</div> </section>
<div class="settings-backup-card">
<div class="settings-tools-grid"> <section class="settings-panel settings-panel--look">
<button id="btnExportSettingsBundle" class="settings-action-btn" type="button">📦 Backup exportieren</button> <div class="settings-panel-header">
<button id="btnImportSettingsBundle" class="settings-action-btn" type="button">📥 Backup importieren</button> <div>
<h3>Design &amp; Größe</h3>
<p>Hell oder dunkel und wie groß die Oberfläche angezeigt wird. Wird sofort angewendet.</p>
</div>
</div> </div>
<div class="settings-inline-hint"> <div class="settings-backup-card">
Export enthält Tokens nur verschlüsselt, nie als Klartext. Der Import ist für andere PCs geeignet. <div class="fx-row">
<div class="input-group">
<label for="prefTheme">Design</label>
<select id="prefTheme">
<option value="dark">Dunkel</option>
<option value="light">Hell</option>
<option value="system">Wie Windows</option>
</select>
</div>
<div class="input-group">
<label for="prefZoom">Größe</label>
<select id="prefZoom">
<option value="80">80 %</option>
<option value="90">90 %</option>
<option value="100">100 % (Standard)</option>
<option value="110">110 %</option>
<option value="125">125 %</option>
<option value="150">150 %</option>
</select>
</div>
</div>
</div> </div>
</div> </section>
</section> </div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="uploads" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--uploads">
<div class="settings-panel-header">
<div>
<h3>Upload-Regeln</h3>
<p>Was beim Hochladen geprüft und weggelassen wird.</p>
</div>
</div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="prefSecretScan">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🔐 Zugangsdaten erkennen</span>
<span class="settings-toggle-desc">Dateien wie .env, Keystores oder Bot-Tokens werden erkannt und vor dem Upload abgewählt.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="prefSecretScan" checked>
<span class="toggle-track"></span>
</span>
</label>
<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">
<label for="prefUploadPreview">Upload-Vorschau anzeigen</label>
<select id="prefUploadPreview">
<option value="always">Immer vor dem Hochladen</option>
<option value="warnings">Nur bei Zugangsdaten oder sehr vielen Dateien</option>
</select>
</div>
<div class="input-group">
<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.&#10;*.log&#10;dist/&#10;.idea/&#10;*.psd"></textarea>
</div>
<div class="settings-inline-hint">Gleiche Schreibweise wie in einer .gitignore. Gilt für alle Projekte. node_modules, .git, .vscode und .idea werden immer weggelassen.</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="system" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--system">
<div class="settings-panel-header">
<div>
<h3>System</h3>
<p>Verhalten beim Windows-Start steuern.</p>
</div>
</div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="settingAutostart">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🚀 Mit Windows starten</span>
<span class="settings-toggle-desc">Startet die App automatisch beim Anmelden, minimiert im System-Tray.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="settingAutostart">
<span class="toggle-track"></span>
</span>
</label>
</div>
</section>
<section class="settings-panel settings-panel--app">
<div class="settings-panel-header">
<div>
<h3>App & Updates</h3>
<p>Version prüfen und neue Releases direkt anstoßen.</p>
</div>
</div>
<div class="settings-version-card">
<div class="input-group input-group--wide settings-version-field">
<label for="appVersion">App Version</label>
<input id="appVersion" class="settings-readonly-input" type="text" readonly>
</div>
<button id="btnCheckUpdates" class="settings-update-btn">🔄 Nach Updates suchen</button>
</div>
</section>
<section class="settings-panel settings-panel--behaviour">
<div class="settings-panel-header">
<div>
<h3>Verhalten</h3>
<p>Updates, Schließen-Knopf und Standardordner.</p>
</div>
</div>
<div class="settings-toggle-list">
<label class="settings-toggle-row" for="prefAutoUpdate">
<span class="settings-toggle-info">
<span class="settings-toggle-title">🔄 Beim Start nach Updates suchen</span>
<span class="settings-toggle-desc">Prüft kurz nach dem Start, ob eine neue Version bereitsteht.</span>
</span>
<span class="toggle-switch">
<input type="checkbox" id="prefAutoUpdate" checked>
<span class="toggle-track"></span>
</span>
</label>
<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">
<label for="prefCloseAction">Beim Schließen des Fensters</label>
<select id="prefCloseAction">
<option value="auto">Automatisch: in den Tray, wenn die App mit Windows startet</option>
<option value="tray">Immer in den Tray (läuft im Hintergrund weiter)</option>
<option value="quit">Immer beenden</option>
</select>
</div>
<div class="input-group input-group--wide">
<label for="prefCloneDir">Standardordner zum Klonen</label>
<div class="fx-row">
<input id="prefCloneDir" type="text" placeholder="z. B. D:\Projekte">
<button id="btnPrefCloneDir" class="settings-action-btn fx-browse-btn" type="button">📂 Wählen</button>
</div>
</div>
<div class="settings-inline-hint">Wird beim Klonen mehrerer Repos (Batch) als Ziel vorgeschlagen.</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="backup" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--repobackup">
<div class="settings-panel-header">
<div>
<h3>Repo-Backup</h3>
<p>Alle Gitea-Repos automatisch in einen Ordner oder auf die NAS sichern.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="input-group input-group--wide">
<label for="repoBackupTarget">Backup-Ziel (Ordner oder Netzwerkfreigabe)</label>
<div class="fx-row">
<input id="repoBackupTarget" type="text" placeholder="z. B. \\NAS\backup\gitea oder D:\Backups\gitea">
<button id="btnRepoBackupBrowse" class="settings-action-btn fx-browse-btn" type="button">📂 Wählen</button>
</div>
</div>
<div class="fx-row">
<div class="input-group">
<label for="repoBackupInterval">Automatisch sichern</label>
<select id="repoBackupInterval">
<option value="off">Aus</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<div class="input-group">
<label for="repoBackupKeep">ZIP-Stände behalten</label>
<input id="repoBackupKeep" type="number" min="1" max="100" value="5">
</div>
</div>
<div class="fx-row">
<div class="input-group">
<label for="repoBackupTime">Frühestens um</label>
<input id="repoBackupTime" type="time">
</div>
<div class="input-group">
<label for="repoBackupNotify">Benachrichtigen</label>
<select id="repoBackupNotify">
<option value="errors">Nur bei Fehlern oder Abbruch</option>
<option value="always">Nach jedem Backup</option>
<option value="never">Nie</option>
</select>
</div>
</div>
<div class="input-group input-group--wide">
<label for="repoBackupExclude">Diese Repos nicht sichern</label>
<textarea id="repoBackupExclude" class="settings-textarea" rows="4" spellcheck="false" placeholder="Ein Muster pro Zeile, z. B.&#10;test-*&#10;M_Viper/alt-*&#10;*-archiv"></textarea>
</div>
<div class="settings-tools-grid">
<button id="btnRepoBackupRun" class="settings-action-btn" type="button">▶️ Jetzt sichern</button>
<button id="btnRepoBackupOpen" class="settings-action-btn" type="button">📂 Backup-Ordner öffnen</button>
</div>
<div id="repoBackupStatus" class="settings-inline-hint">Noch kein Backup gelaufen.</div>
<div class="settings-inline-hint">Automatische Backups laufen, solange die App geöffnet ist (auch im Tray).</div>
</div>
</section>
<section class="settings-panel settings-panel--restore">
<div class="settings-panel-header">
<div>
<h3>Wiederherstellen</h3>
<p>Ein gesichertes Repo zurück auf Gitea spielen oder lokal entpacken.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="settings-tools-grid">
<button id="btnRepoRestore" class="settings-action-btn" type="button">↺ Backup wiederherstellen</button>
</div>
<div class="settings-inline-hint">Zeigt alle Sicherungen im Backup-Ziel. Git-Spiegel enthalten die volle Historie, ZIP-Stände nur den Standard-Branch.</div>
</div>
</section>
<section class="settings-panel settings-panel--backup">
<div class="settings-panel-header">
<div>
<h3>Einstellungen sichern & übertragen</h3>
<p>Einstellungen, Favoriten und Verlauf exportieren oder auf einem anderen PC importieren.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="settings-tools-grid">
<button id="btnExportSettingsBundle" class="settings-action-btn" type="button">📦 Backup exportieren</button>
<button id="btnImportSettingsBundle" class="settings-action-btn" type="button">📥 Backup importieren</button>
</div>
<div class="settings-inline-hint">
Export enthält Tokens nur verschlüsselt, nie als Klartext. Der Import ist für andere PCs geeignet.
</div>
</div>
</section>
</div>
</div>
<div class="settings-tabpanel hidden" data-settings-panel="diagnose" role="tabpanel">
<div class="settings-column">
<section class="settings-panel settings-panel--diagnostics">
<div class="settings-panel-header">
<div>
<h3>Diagnose</h3>
<p>Diagnosepaket für die Fehlersuche erstellen oder die Verbindung zum Gitea-Server Schritt für Schritt prüfen.</p>
</div>
</div>
<div class="settings-diagnostics-card">
<button id="btnCreateDiagnostics" class="settings-action-btn" type="button">🧪 Diagnose erstellen</button>
<button id="btnServerDiagnosis" class="settings-action-btn" type="button">🩺 Server-Diagnose starten</button>
</div>
</section>
<section class="settings-panel settings-panel--transport">
<div class="settings-panel-header">
<div>
<h3>Schneller Git-Weg</h3>
<p>Bricht der Server beim Git-Transport ab, nutzt die App 24 Stunden lang die langsamere API. Hier siehst du den Stand.</p>
</div>
</div>
<div class="settings-backup-card">
<div id="gitTransportState" class="settings-inline-hint">Wird geprüft …</div>
<div class="settings-tools-grid">
<button id="btnGitTransportClear" class="settings-action-btn" type="button">🔓 Sperre aufheben</button>
</div>
</div>
</section>
<section class="settings-panel settings-panel--folders">
<div class="settings-panel-header">
<div>
<h3>Ordner</h3>
<p>Wo die App ihre Daten und Protokolle ablegt.</p>
</div>
</div>
<div class="settings-backup-card">
<div class="settings-tools-grid">
<button id="btnOpenAppData" class="settings-action-btn" type="button">📂 App-Daten öffnen</button>
<button id="btnOpenLogs" class="settings-action-btn" type="button">📄 Protokolle öffnen</button>
</div>
</div>
</section>
</div>
</div>
</div> </div>
</div> </div>
@@ -585,5 +931,7 @@
</div> </div>
</div> <script src="renderer.js"></script> </div> <script src="renderer.js"></script>
<script src="features.js"></script>
<script src="settings-extra.js"></script>
</body> </body>
</html> </html>
+716 -38
View File
@@ -364,6 +364,35 @@ function loadRepos() {
// Einmalig auf Modulebene definiert, damit die Funktion sofort ab Programmstart // Einmalig auf Modulebene definiert, damit die Funktion sofort ab Programmstart
// verfügbar ist (früher versehentlich in loadRepoContents definiert -> beim // verfügbar ist (früher versehentlich in loadRepoContents definiert -> beim
// Klick vor dem ersten Repo-Laden "resetProjectGravurTitle is not a function"). // Klick vor dem ersten Repo-Laden "resetProjectGravurTitle is not a function").
// Pfadanzeige "repo / ordner / unterordner". Jeder Teil ist klickbar und springt
// direkt in diesen Ordner; der aktuelle Ordner (letzter Teil) ist nicht klickbar.
function setProjectGravurTitle(repo, path, owner) {
const gravurTitle = document.getElementById('project-gravur-title');
if (!gravurTitle) return;
gravurTitle.textContent = '';
const parts = String(path || '').split('/').filter(Boolean);
const addCrumb = (text, className, targetPath) => {
const el = document.createElement('span');
el.className = className;
el.textContent = text;
if (owner && targetPath !== null) {
el.classList.add('gravur-link');
el.title = targetPath ? `Zu "${targetPath}" springen` : `Zum Hauptordner von ${repo}`;
el.onclick = () => loadRepoContents(owner, repo, targetPath);
}
gravurTitle.appendChild(el);
};
addCrumb(repo, 'gravur-repo', parts.length ? '' : null);
parts.forEach((part, i) => {
const sep = document.createElement('span');
sep.className = 'gravur-sub';
sep.textContent = ' / ';
gravurTitle.appendChild(sep);
const isLast = i === parts.length - 1;
addCrumb(part, 'gravur-sub', isLast ? null : parts.slice(0, i + 1).join('/'));
});
}
window.resetProjectGravurTitle = function() { window.resetProjectGravurTitle = function() {
const gravurTitle = document.getElementById('project-gravur-title'); const gravurTitle = document.getElementById('project-gravur-title');
if (gravurTitle) gravurTitle.textContent = ''; if (gravurTitle) gravurTitle.textContent = '';
@@ -388,6 +417,11 @@ async function loadGithubRepos(requestId = null) {
const btnReleases = $('btnReleases'); const btnReleases = $('btnReleases');
if (btnCommits) btnCommits.classList.add('hidden'); if (btnCommits) btnCommits.classList.add('hidden');
if (btnReleases) btnReleases.classList.add('hidden'); if (btnReleases) btnReleases.classList.add('hidden');
const btnCleanupIgnored = $('btnCleanupIgnored');
if (btnCleanupIgnored) btnCleanupIgnored.classList.add('hidden');
const btnGitignoreTemplate = $('btnGitignoreTemplate');
if (btnGitignoreTemplate) btnGitignoreTemplate.classList.add('hidden');
if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('list');
const grid = $('explorerGrid'); const grid = $('explorerGrid');
if (grid) grid.style.gridTemplateColumns = ''; if (grid) grid.style.gridTemplateColumns = '';
@@ -828,6 +862,46 @@ function showChipContextMenu(ev, entry, type) {
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10); 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' }) { 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 (_) {} 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', { console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:start', {
@@ -860,16 +934,40 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl
// viele Objekte gedroppt wurden. Das war die Ursache für die langen // viele Objekte gedroppt wurden. Das war die Ursache für die langen
// Wartezeiten und Zeitüberschreitungen (vorher: ein kompletter Clone+Push // Wartezeiten und Zeitüberschreitungen (vorher: ein kompletter Clone+Push
// pro einzelner Datei bzw. pro Ordner). // pro einzelner Datei bzw. pro Ordner).
showProgress(15, `Bereite Upload von ${safePaths.length} Objekt(en) vor ...`); // Vorschau: welche Dateien gehen hoch, was sortiert die .gitignore aus, sind Geheimnisse dabei?
let excludeFiles = [];
if (typeof window.uploadPreviewGate === 'function') {
const gate = await window.uploadPreviewGate({ paths: safePaths, destPath, repo });
if (!gate.proceed) return { ok: false, cancelled: true, error: 'Upload abgebrochen' };
excludeFiles = gate.excludeFiles || [];
}
showProgress(15, `Bereite Upload von ${safePaths.length} Objekt(en) vor ...`, { cancellable: true });
try { try {
const res = await withTimeout(window.electronAPI.uploadPathsToGitea({ let res = await withTimeout(window.electronAPI.uploadPathsToGitea({
paths: safePaths, paths: safePaths,
owner, owner,
repo, repo,
destPath, destPath,
branch, branch,
excludeFiles,
messagePrefix: 'Upload via GUI' messagePrefix: 'Upload via GUI'
}), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout }), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout
warnBlockedFiles(res);
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();
return { ok: false, cancelled: true, error: 'Upload abgebrochen' };
}
showProgress(100, 'Upload abgeschlossen'); showProgress(100, 'Upload abgeschlossen');
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:result', res); console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:result', res);
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:result', { count: safePaths.length, ok: res?.ok, failedCount: res?.failedCount }); } catch (_) {} try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:result', { count: safePaths.length, ok: res?.ok, failedCount: res?.failedCount }); } catch (_) {}
@@ -1378,6 +1476,9 @@ function openSettingsForFix(focusId = null) {
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
$('settingsWatermarkCard')?.classList.add('hidden'); $('settingsWatermarkCard')?.classList.add('hidden');
// Zugangsdaten liegen im Reiter "Allgemein"
if (typeof window.showSettingsTab === 'function') window.showSettingsTab('general');
if (window.gmPrefs) window.gmPrefs.takeSnapshot();
renderSettingsHealth(); renderSettingsHealth();
requestAnimationFrame(() => { requestAnimationFrame(() => {
syncSettingsPanelHeights(); syncSettingsPanelHeights();
@@ -2112,6 +2213,8 @@ function syncSettingsPanelHeights() {
if (window.matchMedia('(max-width: 1120px)').matches) return; if (window.matchMedia('(max-width: 1120px)').matches) return;
if (credentialsPanel && healthPanel) { if (credentialsPanel && healthPanel) {
// Nur angleichen, wenn beide Karten nebeneinander stehen (im Bereich "Konten" stehen sie untereinander)
if (Math.abs(credentialsPanel.getBoundingClientRect().top - healthPanel.getBoundingClientRect().top) > 4) return;
const topRightTargetHeight = Math.ceil(credentialsPanel.getBoundingClientRect().height); const topRightTargetHeight = Math.ceil(credentialsPanel.getBoundingClientRect().height);
if (topRightTargetHeight > 0) { if (topRightTargetHeight > 0) {
healthPanel.style.minHeight = `${topRightTargetHeight}px`; healthPanel.style.minHeight = `${topRightTargetHeight}px`;
@@ -2614,16 +2717,125 @@ function ensureProgressUI() {
barWrap.appendChild(bar); barWrap.appendChild(bar);
container.appendChild(barWrap); container.appendChild(barWrap);
// Abbrechen-Knopf: nur bei Uploads sichtbar (showProgress mit { cancellable: true }).
const cancel = document.createElement('button');
cancel.id = 'folderProgressCancel';
cancel.textContent = CANCEL_LABEL;
cancel.style.cssText = `
display: none;
margin-top: 10px;
padding: 5px 14px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.25);
background: transparent;
color: #fff;
cursor: pointer;
font-size: 12px;
`;
cancel.addEventListener('click', async () => {
cancel.disabled = true;
cancel.textContent = 'Wird abgebrochen …';
try { await window.electronAPI.cancelUpload(); } catch (_) {}
});
// 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) => {
if (e.key !== 'Escape') return;
if (container.style.display === 'none' || cancel.style.display === 'none' || cancel.disabled) return;
if (document.querySelector('.modal:not(.hidden)')) return;
cancel.click();
});
document.body.appendChild(container); document.body.appendChild(container);
} }
function showProgress(percent, text) { 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';
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2).replace('.', ',')} GB`;
}
function formatRemaining(seconds) {
const s = Math.round(seconds);
if (s < 60) return `${Math.max(1, s)} Sek.`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} Min. ${s % 60} Sek.`;
return `${Math.floor(m / 60)} Std. ${m % 60} Min.`;
}
// Text für den Fortschrittsbalken: Dateien, übertragene MB und geschätzte Restzeit.
// Die Restzeit ist eine Hochrechnung und erscheint erst, wenn genug Daten vorliegen.
let transferStartedAt = 0;
let transferLabel = '';
function describeTransferProgress(label, p) {
if (!transferStartedAt || transferLabel !== label || !p.percent) {
transferStartedAt = Date.now();
transferLabel = label;
}
let text = `${label}: ${p.processed}/${p.total} Dateien`;
if (p.totalBytes > 0) {
text += ` · ${formatTransferBytes(p.totalBytes * p.percent / 100)} / ${formatTransferBytes(p.totalBytes)}`;
}
const elapsed = (Date.now() - transferStartedAt) / 1000;
if (p.percent >= 5 && p.percent < 100 && elapsed >= 3) {
text += ` · noch ca. ${formatRemaining(elapsed * (100 - p.percent) / p.percent)}`;
}
return text;
}
function showProgress(percent, text, { cancellable = false } = {}) {
ensureProgressUI(); ensureProgressUI();
const container = $('folderProgressContainer'); const container = $('folderProgressContainer');
const bar = $('folderProgressBar'); const bar = $('folderProgressBar');
const txt = $('folderProgressText'); const txt = $('folderProgressText');
if (txt) txt.innerText = text || ''; const cancel = $('folderProgressCancel');
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 (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.
if (!cancellable) { cancel.disabled = false; cancel.textContent = CANCEL_LABEL; }
}
if (container) container.style.display = 'block'; if (container) container.style.display = 'block';
} }
@@ -2632,6 +2844,13 @@ function hideProgress() {
if (container) { if (container) {
setTimeout(() => { setTimeout(() => {
container.style.display = 'none'; container.style.display = 'none';
// Knopf für den nächsten Vorgang zurücksetzen (nach einem Abbruch ist er gesperrt).
const cancel = $('folderProgressCancel');
if (cancel) { cancel.disabled = false; cancel.textContent = CANCEL_LABEL; cancel.style.display = 'none'; }
const pause = $('folderProgressPause');
if (pause) { pause.disabled = false; pause.style.display = 'none'; }
transferPaused = false;
if (pause) pause.textContent = PAUSE_LABEL;
}, 500); }, 500);
} }
} }
@@ -3473,7 +3692,12 @@ async function loadGiteaRepos(preloadedData = null, requestId = null) {
const btnReleases = $('btnReleases'); const btnReleases = $('btnReleases');
if (btnCommits) btnCommits.classList.add('hidden'); if (btnCommits) btnCommits.classList.add('hidden');
if (btnReleases) btnReleases.classList.add('hidden'); if (btnReleases) btnReleases.classList.add('hidden');
const btnCleanupIgnored = $('btnCleanupIgnored');
if (btnCleanupIgnored) btnCleanupIgnored.classList.add('hidden');
const btnGitignoreTemplate = $('btnGitignoreTemplate');
if (btnGitignoreTemplate) btnGitignoreTemplate.classList.add('hidden');
if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('list');
// WICHTIG: Grid-Layout zurücksetzen // WICHTIG: Grid-Layout zurücksetzen
const grid = $('explorerGrid'); const grid = $('explorerGrid');
if (grid) { if (grid) {
@@ -3879,7 +4103,9 @@ async function loadGiteaRepos(preloadedData = null, requestId = null) {
branch: getDefaultBranch(owner, repoName) branch: getDefaultBranch(owner, repoName)
}); });
console.log('[UPLOAD_DEBUG][renderer] repoCard:dropResult', res); console.log('[UPLOAD_DEBUG][renderer] repoCard:dropResult', res);
if (!res.ok) { if (res.cancelled) {
setStatus('Upload abgebrochen');
} else if (!res.ok) {
showError('Fehler: ' + (res.error || 'Upload fehlgeschlagen')); showError('Fehler: ' + (res.error || 'Upload fehlgeschlagen'));
await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen'); await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen');
setStatus('Upload fehlgeschlagen'); setStatus('Upload fehlgeschlagen');
@@ -3916,11 +4142,9 @@ async function loadGiteaRepos(preloadedData = null, requestId = null) {
} }
async function loadRepoContents(owner, repo, path) { async function loadRepoContents(owner, repo, path) {
// Projekttitel als Gravur unterhalb der Toolbar setzen (nur Repo-Name) // Projekttitel als Gravur unterhalb der Toolbar setzen: Repo-Name bleibt
const gravurTitle = document.getElementById('project-gravur-title'); // sichtbar, ein geöffneter Unterordner wird als Pfad dahinter angezeigt.
if (gravurTitle) { setProjectGravurTitle(repo, path, owner);
gravurTitle.textContent = repo;
}
currentState.view = 'gitea-repo'; currentState.view = 'gitea-repo';
currentState.owner = owner; currentState.owner = owner;
currentState.repo = repo; currentState.repo = repo;
@@ -3940,6 +4164,20 @@ async function loadRepoContents(owner, repo, path) {
btnReleases.onclick = () => loadRepoReleases(owner, repo); btnReleases.onclick = () => loadRepoReleases(owner, repo);
} }
// Aufräumen nutzt die Gitea-API und ist daher nur dort verfügbar.
const btnCleanupIgnored = $('btnCleanupIgnored');
if (btnCleanupIgnored) {
const isGitea = currentState.platform !== 'github';
btnCleanupIgnored.classList.toggle('hidden', !isGitea);
btnCleanupIgnored.onclick = () => cleanupGitignoredFiles(owner, repo, currentState.path || '');
}
const btnGitignoreTemplate = $('btnGitignoreTemplate');
if (btnGitignoreTemplate) {
btnGitignoreTemplate.classList.remove('hidden');
btnGitignoreTemplate.onclick = () => createGitignoreForRepo(owner, repo);
}
if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('repo', owner, repo);
// WICHTIG: Grid-Layout zurücksetzen // WICHTIG: Grid-Layout zurücksetzen
const grid = $('explorerGrid'); const grid = $('explorerGrid');
if (grid) { if (grid) {
@@ -4063,7 +4301,9 @@ async function loadRepoContents(owner, repo, path) {
destPath: targetPath, destPath: targetPath,
branch: getDefaultBranch(owner, repo) branch: getDefaultBranch(owner, repo)
}); });
if (!res.ok) { if (res.cancelled) {
setStatus('Upload abgebrochen');
} else if (!res.ok) {
showError('Upload error: ' + (res.error || 'Unbekannter Fehler')); showError('Upload error: ' + (res.error || 'Unbekannter Fehler'));
await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen'); await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen');
} }
@@ -4123,6 +4363,7 @@ async function selectLocalFolder() {
setStatus('Local: ' + folder); setStatus('Local: ' + folder);
currentState.view = 'local'; currentState.view = 'local';
updateNavigationUI(); updateNavigationUI();
if (typeof window.updateFeatureButtons === 'function') window.updateFeatureButtons('local');
await refreshLocalTree(folder); await refreshLocalTree(folder);
await loadBranches(folder); await loadBranches(folder);
@@ -4745,15 +4986,20 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
try { try {
const sel = await window.electronAPI.selectFolder(); const sel = await window.electronAPI.selectFolder();
if (sel) { if (sel) {
showProgress(0, 'Upload...'); showProgress(0, 'Upload...', { cancellable: true });
const res = await window.electronAPI.uploadLocalFolderToGitea({ const res = await uploadFolderWithRetry({
localFolder: sel, localFolder: sel,
owner, owner,
repo: repoName, repo: repoName,
destPath: '', destPath: '',
branch: getDefaultBranch(owner, repoName), branch: getDefaultBranch(owner, repoName),
messagePrefix: 'Upload folder via GUI' messagePrefix: 'Upload folder via GUI'
}); });
if (res?.cancelled) {
hideProgress();
setStatus('Upload abgebrochen');
return;
}
if (!res?.ok) { if (!res?.ok) {
showError('Upload fehlgeschlagen: ' + (res?.error || 'Unbekannter Fehler')); showError('Upload fehlgeschlagen: ' + (res?.error || 'Unbekannter Fehler'));
await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen'); await showUploadFailureModal('Upload fehlgeschlagen', res, 'Upload fehlgeschlagen');
@@ -4915,10 +5161,11 @@ function showGiteaItemContextMenu(ev, item, owner, repo) {
}); });
} else { } else {
addItem('📥', 'Ordner herunterladen', async () => { addItem('📥', 'Ordner herunterladen', async () => {
showProgress(0, `Lade ${item.name}...`); showProgress(0, `Lade ${item.name}...`, { cancellable: true });
const res = await window.electronAPI.downloadGiteaFolder({ owner, repo, path: item.path }); const res = await window.electronAPI.downloadGiteaFolder({ owner, repo, path: item.path });
hideProgress(); hideProgress();
setStatus(res.ok ? `Gespeichert: ${res.savedTo}` : 'Download fehlgeschlagen'); if (res.cancelled) setStatus('Download abgebrochen');
else setStatus(res.ok ? `Gespeichert: ${res.savedTo}` : 'Download fehlgeschlagen');
}); });
} }
@@ -5340,6 +5587,311 @@ function showActionConfirmModal({ title, message, confirmText = 'Bestätigen', d
}); });
} }
// Entfernt Dateien aus dem Repo, die laut .gitignore des passenden lokalen Ordners
// nicht hineingehören (z. B. build/ oder .gradle/ aus früheren Uploads).
// Auswahldialog für die .gitignore-Vorlagen; liefert die gewählten Schlüssel oder null.
function pickGitignoreTemplates(templates) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal confirm-modal';
modal.style.zIndex = '99999';
const card = document.createElement('div');
card.className = 'modalContent card confirm-modal-content';
const titleEl = document.createElement('h2');
titleEl.className = 'confirm-modal-title';
titleEl.textContent = '📄 .gitignore anlegen';
const msgEl = document.createElement('p');
msgEl.className = 'confirm-modal-message';
msgEl.textContent = 'Der allgemeine Teil (Betriebssystem, Editor, .env, Logs) ist immer dabei. Zusätzlich wählen:';
const list = document.createElement('div');
list.style.cssText = 'display:flex;flex-direction:column;gap:6px;margin:10px 0 14px;';
for (const t of templates) {
const label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;';
const box = document.createElement('input');
box.type = 'checkbox';
box.value = t.key;
label.appendChild(box);
label.appendChild(document.createTextNode(t.label));
list.appendChild(label);
}
const btnWrap = document.createElement('div');
btnWrap.className = 'modal-buttons confirm-modal-buttons';
const okBtn = document.createElement('button');
okBtn.className = 'confirm-modal-btn confirm-modal-btn--primary';
okBtn.textContent = 'Anlegen';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary';
cancelBtn.textContent = 'Abbrechen';
btnWrap.appendChild(okBtn);
btnWrap.appendChild(cancelBtn);
card.appendChild(titleEl);
card.appendChild(msgEl);
card.appendChild(list);
card.appendChild(btnWrap);
modal.appendChild(card);
document.body.appendChild(modal);
const onKey = (e) => { if (e.key === 'Escape') close(null); };
function close(value) {
document.removeEventListener('keydown', onKey);
modal.remove();
resolve(value);
}
document.addEventListener('keydown', onKey);
okBtn.onclick = () => close([...list.querySelectorAll('input:checked')].map(i => i.value));
cancelBtn.onclick = () => close(null);
});
}
// Schreibt eine Standard-.gitignore aus den gewählten Vorlagen ins Repo.
async function createGitignoreForRepo(owner, repo) {
const tpl = await window.electronAPI.getGitignoreTemplates();
const keys = await pickGitignoreTemplates((tpl && tpl.templates) || []);
if (!keys) return;
const built = await window.electronAPI.buildGitignore({ keys });
const ref = getDefaultBranch(owner, repo);
const platform = currentState.platform;
const existing = await window.electronAPI.getGiteaFileContent({ owner, repo, path: '.gitignore', ref, platform });
if (existing && existing.ok) {
const overwrite = await showActionConfirmModal({
title: '.gitignore existiert bereits',
message: `In "${repo}" gibt es schon eine .gitignore. Sie wird ersetzt, der alte Stand bleibt in der Commit-Historie.`,
confirmText: 'Ersetzen',
danger: true
});
if (!overwrite) return;
}
showProgress(30, '.gitignore wird geschrieben...');
const res = await window.electronAPI.writeGiteaFile({
owner, repo, path: '.gitignore', content: built.content, ref, platform
});
hideProgress();
if (res && res.ok) {
showSuccess('.gitignore angelegt');
loadRepoContents(owner, repo, currentState.path);
} else {
showError('.gitignore konnte nicht angelegt werden: ' + (res?.error || 'Unbekannter Fehler'));
}
}
// Zeigt die zuletzt aufgezeichneten Uploads, filterbar nach Ergebnis und Repo-Name.
// Ein Klick auf einen Eintrag öffnet das Repo, „CSV exportieren“ speichert die gefilterte Liste.
async function showUploadHistory() {
const icons = { ok: '✅', abgebrochen: '⏹️', fehler: '❌' };
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) {
const where = repoPath ? `${repo}/${repoPath}` : repo;
const proceed = await showActionConfirmModal({
title: 'Repo aufräumen',
message: `Wähle im nächsten Schritt den lokalen Ordner, der zu "${where}" gehört. Dessen .gitignore entscheidet, welche Dateien im Repo nicht hingehören. Vor dem Löschen siehst du die Liste.`,
confirmText: 'Ordner wählen'
});
if (!proceed) return;
const localFolder = await window.electronAPI.selectFolder();
if (!localFolder) return;
showProgress(0, 'Suche Dateien, die laut .gitignore nicht ins Repo gehören...');
const found = await window.electronAPI.findGitignoredInRepo({
owner, repo, repoPath, localFolder, branch: getDefaultBranch(owner, repo)
});
hideProgress();
if (!found || !found.ok) {
await showInfoModal('Aufräumen fehlgeschlagen', found?.error || 'Unbekannter Fehler', true);
return;
}
if (found.files.length === 0) {
await showInfoModal('Nichts zu tun', `Alle ${found.scanned} Dateien in "${where}" sind laut .gitignore in Ordnung.`);
return;
}
// Übersicht nach oberster Ebene gruppieren, damit auch tausende Dateien lesbar bleiben.
const groups = new Map();
const prefixLen = repoPath ? repoPath.length + 1 : 0;
for (const p of found.files) {
const rel = p.slice(prefixLen);
const top = rel.includes('/') ? rel.slice(0, rel.indexOf('/') + 1) : rel;
groups.set(top, (groups.get(top) || 0) + 1);
}
const lines = [...groups.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([name, count]) => `• ${name} (${count} Dateien)`);
if (groups.size > 12) lines.push(`• … und ${groups.size - 12} weitere`);
const ok = await showActionConfirmModal({
title: `${found.files.length} Dateien entfernen?`,
message: `Diese Dateien liegen in "${where}", gehören laut .gitignore aber nicht ins Repo:\n\n${lines.join('\n')}\n\nSie werden nur auf dem Server gelöscht, lokal bleibt alles erhalten. Die Löschung ist über die Commit-Historie nachvollziehbar.`,
confirmText: 'Entfernen',
danger: true
});
if (!ok) return;
showProgress(0, `Entferne ${found.files.length} Dateien...`);
const res = await window.electronAPI.deleteGiteaPaths({
owner, repo, paths: found.files, branch: found.branch,
message: 'Remove files ignored by .gitignore via Git Manager GUI'
});
hideProgress();
if (res && res.ok) {
showSuccess(`${res.deleted} Dateien in ${res.commits} Commits entfernt`);
} else {
const detail = res?.failed?.length
? `${res.deleted} entfernt, ${res.failed.length} fehlgeschlagen:\n` + res.failed.slice(0, 5).map(f => `${f.path}: ${f.error}`).join('\n')
: (res?.error || 'Unbekannter Fehler');
await showInfoModal('Aufräumen unvollständig', detail, true);
}
loadRepoContents(owner, repo, currentState.path);
}
function showInfoModal(title, message, danger = false) { function showInfoModal(title, message, danger = false) {
return showActionConfirmModal({ return showActionConfirmModal({
title, title,
@@ -5503,10 +6055,10 @@ function setupBackgroundContextMenu() {
); );
} else if (clipboard.item.isLocal) { } else if (clipboard.item.isLocal) {
addItem('📋', `⬆️ Von Lokal einfügen: "${clipboard.item.name}"`, async () => { addItem('📋', `⬆️ Von Lokal einfügen: "${clipboard.item.name}"`, async () => {
showProgress(0, `Lade "${clipboard.item.name}" hoch...`); showProgress(0, `Lade "${clipboard.item.name}" hoch...`, { cancellable: true });
try { try {
const res = clipboard.item.isDirectory const res = clipboard.item.isDirectory
? await window.electronAPI.uploadLocalFolderToGitea({ ? await uploadFolderWithRetry({
localFolder: clipboard.item.path, localFolder: clipboard.item.path,
owner, owner,
repo, repo,
@@ -5521,6 +6073,8 @@ function setupBackgroundContextMenu() {
destPath: currentPath, destPath: currentPath,
branch: getDefaultBranch(owner, repo) branch: getDefaultBranch(owner, repo)
}); });
warnBlockedFiles(res);
if (res?.cancelled) { setStatus('Upload abgebrochen'); return; }
if (!res?.ok) throw new Error(res?.error || 'Upload fehlgeschlagen'); if (!res?.ok) throw new Error(res?.error || 'Upload fehlgeschlagen');
showSuccess(`"${clipboard.item.name}" nach Gitea kopiert`); showSuccess(`"${clipboard.item.name}" nach Gitea kopiert`);
loadRepoContents(owner, repo, currentState.path); loadRepoContents(owner, repo, currentState.path);
@@ -5548,14 +6102,15 @@ function setupBackgroundContextMenu() {
); );
} else if (clipboard.item.isGitea) { } else if (clipboard.item.isGitea) {
addItem('📋', `⬇️ Von Gitea einfügen: "${clipboard.item.name}"`, async () => { addItem('📋', `⬇️ Von Gitea einfügen: "${clipboard.item.name}"`, async () => {
showProgress(0, `Lade "${clipboard.item.name}" herunter...`); showProgress(0, `Lade "${clipboard.item.name}" herunter...`, { cancellable: true });
try { try {
await window.electronAPI.downloadGiteaFolder({ const dl = await window.electronAPI.downloadGiteaFolder({
owner: clipboard.item.owner, owner: clipboard.item.owner,
repo: clipboard.item.repo, repo: clipboard.item.repo,
giteaPath: clipboard.item.path, giteaPath: clipboard.item.path,
localPath: selectedFolder localPath: selectedFolder
}); });
if (dl?.cancelled) { setStatus('Download abgebrochen'); return; }
showSuccess(`"${clipboard.item.name}" nach Lokal kopiert`); showSuccess(`"${clipboard.item.name}" nach Lokal kopiert`);
refreshLocalTree(selectedFolder); refreshLocalTree(selectedFolder);
} catch(e) { showError('Cross-Paste fehlgeschlagen'); } } catch(e) { showError('Cross-Paste fehlgeschlagen'); }
@@ -5636,7 +6191,9 @@ function setupGlobalDropZone() {
destPath: targetPath, destPath: targetPath,
branch: getDefaultBranch(owner, repo) branch: getDefaultBranch(owner, repo)
}); });
if (!res.ok) { if (res.cancelled) {
setStatus('Upload abgebrochen');
} else if (!res.ok) {
console.error('Upload error:', res.error); console.error('Upload error:', res.error);
showError('Error: ' + (res.error || 'Upload failed')); showError('Error: ' + (res.error || 'Upload failed'));
} }
@@ -5876,6 +6433,10 @@ window.addEventListener('DOMContentLoaded', async () => {
}; };
} }
if ($('btnUploadHistory')) {
$('btnUploadHistory').onclick = () => { showUploadHistory(); };
}
if ($('btnOpenActivityLog')) { if ($('btnOpenActivityLog')) {
$('btnOpenActivityLog').onclick = () => { $('btnOpenActivityLog').onclick = () => {
$('activityLogModal')?.classList.remove('hidden'); $('activityLogModal')?.classList.remove('hidden');
@@ -5979,14 +6540,18 @@ window.addEventListener('DOMContentLoaded', async () => {
logActivity('info', `Batch gestartet: ${action} (${repos.length} Repos)`); logActivity('info', `Batch gestartet: ${action} (${repos.length} Repos)`);
try { try {
if (action === 'clone') showProgress(0, `Klone 0/${repos.length} Repos ...`, { cancellable: true });
const res = await window.electronAPI.runBatchRepoAction({ action, repos, options }); const res = await window.electronAPI.runBatchRepoAction({ action, repos, options });
if (action === 'clone') hideProgress();
if (!res.ok) { if (!res.ok) {
showError(res.error || 'Batch-Aktion fehlgeschlagen'); showError(res.error || 'Batch-Aktion fehlgeschlagen');
return; return;
} }
const summary = res.summary || { total: repos.length, success: 0, failed: 0 }; const summary = res.summary || { total: repos.length, success: 0, failed: 0 };
if (summary.failed > 0) { if (res.cancelled) {
showWarning(`Batch abgebrochen: ${summary.success} von ${summary.total} Repos erledigt.`);
} else if (summary.failed > 0) {
showWarning(`Batch beendet: ${summary.success}/${summary.total} erfolgreich, ${summary.failed} fehlgeschlagen.`); showWarning(`Batch beendet: ${summary.success}/${summary.total} erfolgreich, ${summary.failed} fehlgeschlagen.`);
} else { } else {
showSuccess(`Batch erfolgreich: ${summary.success}/${summary.total}`); showSuccess(`Batch erfolgreich: ${summary.success}/${summary.total}`);
@@ -6157,7 +6722,9 @@ window.addEventListener('DOMContentLoaded', async () => {
} }
if ($('btnCloseSettings')) { if ($('btnCloseSettings')) {
$('btnCloseSettings').onclick = () => { $('btnCloseSettings').onclick = async () => {
// Bei ungespeicherten Änderungen erst nachfragen (settings-extra.js)
if (typeof window.settingsCanClose === 'function' && !(await window.settingsCanClose())) return;
$('settingsWatermarkCard')?.classList.add('hidden'); $('settingsWatermarkCard')?.classList.add('hidden');
$('settingsModal').classList.add('hidden'); $('settingsModal').classList.add('hidden');
}; };
@@ -6535,13 +7102,23 @@ 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 // Progress listeners
window.electronAPI.onFolderUploadProgress(p => { window.electronAPI.onFolderUploadProgress(p => {
showProgress(p.percent, `Upload: ${p.processed}/${p.total}`); showProgress(p.percent, describeTransferProgress('Upload', p), { cancellable: true });
}); });
window.electronAPI.onFolderDownloadProgress(p => { window.electronAPI.onFolderDownloadProgress(p => {
showProgress(p.percent, `Download: ${p.processed}/${p.total}`); showProgress(p.percent, describeTransferProgress('Download', p), { cancellable: true });
}); });
if (window.electronAPI.onRetryQueueUpdated) { if (window.electronAPI.onRetryQueueUpdated) {
@@ -6566,6 +7143,9 @@ window.addEventListener('DOMContentLoaded', async () => {
if (!payload) return; if (!payload) return;
if (payload.status === 'running') { if (payload.status === 'running') {
logActivity('info', `Batch ${payload.action}: ${payload.repo} (${payload.index}/${payload.total})`); logActivity('info', `Batch ${payload.action}: ${payload.repo} (${payload.index}/${payload.total})`);
if (payload.action === 'clone') {
showProgress(Math.round(((payload.index - 1) / payload.total) * 100), `Klone ${payload.repo} (${payload.index}/${payload.total})`, { cancellable: true });
}
} else if (payload.status === 'error') { } else if (payload.status === 'error') {
logActivity('error', `Batch ${payload.action}: ${payload.repo} - ${payload.error || 'Fehler'}`); logActivity('error', `Batch ${payload.action}: ${payload.repo} - ${payload.error || 'Fehler'}`);
} }
@@ -7052,6 +7632,12 @@ function showCreateReleaseModal(owner, repo) {
assetGroup.appendChild(assetRow); assetGroup.appendChild(assetRow);
card.appendChild(assetGroup); 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'); const optionsGroup = document.createElement('div');
optionsGroup.className = 'input-group'; optionsGroup.className = 'input-group';
optionsGroup.style.cssText = 'display: flex; gap: 20px;'; optionsGroup.style.cssText = 'display: flex; gap: 20px;';
@@ -7094,17 +7680,67 @@ function showCreateReleaseModal(owner, repo) {
// Variable zum Speichern des gewählten Dateipfads // Variable zum Speichern des gewählten Dateipfads
let selectedAssetPath = null; 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 // Event Listener: Datei auswählen
$('btnSelectReleaseAsset').onclick = async () => { $('btnSelectReleaseAsset').onclick = async () => {
try { try {
const res = await window.electronAPI.selectFile(); const res = await window.electronAPI.selectFile();
if (res.ok && res.files && res.files.length > 0) { if (res.ok && res.files && res.files.length > 0) {
selectedAssetPath = res.files[0]; await applyAsset(res.files[0]);
const fileName = selectedAssetPath.split(/[\\/]/).pop();
$('releaseAssetInput').value = fileName;
$('releaseAssetInput').style.color = 'var(--text-primary)';
$('releaseAssetInput').style.borderColor = 'var(--accent-primary)';
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Auswählen der Datei:', error); console.error('Fehler beim Auswählen der Datei:', error);
@@ -7125,7 +7761,30 @@ function showCreateReleaseModal(owner, repo) {
await showInfoModal('Eingabe fehlt', 'Tag Version ist erforderlich!'); await showInfoModal('Eingabe fehlt', 'Tag Version ist erforderlich!');
return; 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...'); setStatus('Creating release...');
try { try {
@@ -7135,7 +7794,7 @@ function showCreateReleaseModal(owner, repo) {
repo, repo,
tag_name: tag, tag_name: tag,
name, name,
body, body: releaseBody,
target_commitish: target, target_commitish: target,
prerelease, prerelease,
draft, draft,
@@ -7159,6 +7818,20 @@ function showCreateReleaseModal(owner, repo) {
}); });
if (uploadRes.ok) { 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!`); setStatus(`Release "${tag}" und Asset erstellt!`);
} else { } else {
console.error('Asset Upload fehlgeschlagen:', uploadRes.error); console.error('Asset Upload fehlgeschlagen:', uploadRes.error);
@@ -8215,7 +8888,12 @@ document.addEventListener('DOMContentLoaded', () => {
// 2. AUTOMATISCHER UPDATE-CHECK BEIM START // 2. AUTOMATISCHER UPDATE-CHECK BEIM START
// Etwas spaeter, damit initiales UI/Repo-Laden nicht ausgebremst wird. // Etwas spaeter, damit initiales UI/Repo-Laden nicht ausgebremst wird.
setTimeout(() => { // Lässt sich unter Einstellungen → System abschalten.
setTimeout(async () => {
try {
const res = await window.electronAPI.prefsGet();
if (res && res.ok && res.prefs.autoUpdateCheck === false) return;
} catch (_) {}
window.electronAPI.checkForUpdates({ silent: true }); window.electronAPI.checkForUpdates({ silent: true });
}, 12000); }, 12000);
}); });
+285
View File
@@ -0,0 +1,285 @@
// renderer/settings-extra.js
// Ergänzungen der Einstellungen: App-Einstellungen (Identität, Uploads, Design, System),
// ein gemeinsames "Speichern" für alle Bereiche, Warnung bei ungespeicherten Änderungen,
// mehrere Gitea-Server, Token anzeigen/entfernen und Diagnose-Hilfen.
// Wird nach renderer.js und features.js geladen und nutzt deren globale Helfer.
(function () {
'use strict';
let savedPrefs = null;
/* ---------- Design ---------- */
const systemDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
function applyTheme(theme) {
const effective = theme === 'system' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme;
document.documentElement.dataset.theme = effective === 'light' ? 'light' : 'dark';
}
if (systemDark) {
systemDark.addEventListener('change', () => { if (savedPrefs && savedPrefs.theme === 'system') applyTheme('system'); });
}
/* ---------- Laden ---------- */
async function loadPrefs() {
const res = await window.electronAPI.prefsGet();
if (!res || !res.ok) return null;
savedPrefs = res.prefs;
applyTheme(savedPrefs.theme);
return savedPrefs;
}
function fillPrefFields(p) {
if (!p) return;
const set = (id, v) => { const el = $(id); if (!el) return; if (el.type === 'checkbox') el.checked = !!v; else el.value = v; };
set('prefGitName', p.gitName);
set('prefGitEmail', p.gitEmail);
set('prefTheme', p.theme);
set('prefZoom', String(p.zoom));
if ($('prefZoom') && !$('prefZoom').value) $('prefZoom').value = '100';
set('prefAutoUpdate', p.autoUpdateCheck);
set('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);
}
function collectPrefs() {
const val = (id) => ($(id) ? $(id).value : undefined);
const chk = (id) => ($(id) ? $(id).checked : undefined);
return {
gitName: val('prefGitName'),
gitEmail: val('prefGitEmail'),
theme: val('prefTheme'),
zoom: Number(val('prefZoom')),
autoUpdateCheck: chk('prefAutoUpdate'),
showSplash: chk('prefShowSplash'),
closeAction: val('prefCloseAction'),
defaultCloneDir: val('prefCloneDir'),
uploadSecretScan: chk('prefSecretScan'),
uploadBlockSecrets: chk('prefBlockSecrets'),
uploadPreview: val('prefUploadPreview'),
notifyTransfers: val('prefNotifyTransfers'),
uploadExtraExcludes: val('prefUploadExcludes')
};
}
/* ---------- Ungespeicherte Änderungen ---------- */
let snapshot = null;
function readFormState() {
const state = {};
document.querySelectorAll('#settingsModal input, #settingsModal select, #settingsModal textarea').forEach(el => {
if (!el.id || el.type === 'file') return;
state[el.id] = el.type === 'checkbox' ? el.checked : el.value;
});
return JSON.stringify(state);
}
async function takeSnapshot() {
// Erst nachdem alle Felder befüllt sind (Zugangsdaten, App-Einstellungen, Backup)
fillPrefFields(await loadPrefs());
if (window.gmBackupSettings) await window.gmBackupSettings.load();
await loadProfiles();
await refreshTransportState();
snapshot = readFormState();
}
// Wird vom "Abbrechen"-Knopf in renderer.js gefragt
window.settingsCanClose = async function () {
if (!snapshot || readFormState() === snapshot) return true;
const discard = await showActionConfirmModal({
title: 'Ungespeicherte Änderungen',
message: 'Du hast Einstellungen geändert, aber nicht gespeichert. Änderungen verwerfen?',
confirmText: 'Verwerfen',
danger: true
});
if (discard && savedPrefs) applyTheme(savedPrefs.theme); // Vorschau zurücknehmen
return discard;
};
/* ---------- Gemeinsames Speichern ---------- */
async function saveExtras() {
const problems = [];
const res = await window.electronAPI.prefsSet(collectPrefs());
if (res && res.prefs) { savedPrefs = res.prefs; applyTheme(savedPrefs.theme); fillPrefFields(savedPrefs); }
if (!res || !res.ok) problems.push(res && res.error ? res.error : 'App-Einstellungen nicht gespeichert');
if (window.gmBackupSettings) {
const b = await window.gmBackupSettings.save(true);
if (!b || !b.ok) problems.push('Backup-Einstellung nicht gespeichert' + (b && b.error ? ': ' + b.error : ''));
}
snapshot = null;
if (problems.length) showWarning(problems.join(' · '));
}
/* ---------- Mehrere Gitea-Server ---------- */
async function loadProfiles() {
const sel = $('giteaProfileSelect');
if (!sel) return;
const creds = (await window.electronAPI.loadCredentials()) || {};
const profiles = Array.isArray(creds.giteaProfiles) ? creds.giteaProfiles : [];
sel.textContent = '';
if (!profiles.length) {
sel.append(new Option('Noch keine Profile gespeichert', ''));
sel.disabled = true;
} else {
sel.disabled = false;
for (const p of profiles) {
const active = p.url === creds.giteaURL && p.token === creds.giteaToken;
sel.append(new Option(`${p.name} – ${p.url}${active ? ' (aktiv)' : ''}`, p.name));
}
}
$('btnGiteaProfileUse').disabled = $('btnGiteaProfileDelete').disabled = !profiles.length;
}
async function saveProfile() {
const name = $('giteaProfileName').value.trim();
const url = $('giteaURL').value.trim();
const token = $('giteaToken').value.trim();
if (!name) { showWarning('Bitte einen Namen für das Profil eingeben.'); $('giteaProfileName').focus(); return; }
if (!url || !token) { showWarning('Bitte zuerst Gitea-URL und Token unter „Zugangsdaten“ eintragen.'); return; }
const creds = (await window.electronAPI.loadCredentials()) || {};
const profiles = (Array.isArray(creds.giteaProfiles) ? creds.giteaProfiles : []).filter(p => p.name !== name);
profiles.push({ name, url, token });
profiles.sort((a, b) => a.name.localeCompare(b.name, 'de'));
await window.electronAPI.saveCredentials({ ...creds, giteaProfiles: profiles });
$('giteaProfileName').value = '';
await loadProfiles();
snapshot = readFormState();
showSuccess(`Profil „${name}“ gespeichert`);
}
async function useProfile() {
const name = $('giteaProfileSelect').value;
const creds = (await window.electronAPI.loadCredentials()) || {};
const p = (creds.giteaProfiles || []).find(x => x.name === name);
if (!p) return;
$('giteaURL').value = p.url;
$('giteaToken').value = p.token;
// Wie ein normales "Speichern": übernimmt den Server und lädt danach die Repos neu
$('btnSaveSettings').click();
setTimeout(() => { if (typeof loadRepos === 'function') loadRepos(); }, 600);
showInfo(`Wechsle zu „${p.name}“ …`);
}
async function deleteProfile() {
const name = $('giteaProfileSelect').value;
if (!name) return;
const ok = await showActionConfirmModal({
title: 'Profil entfernen',
message: `Das gespeicherte Profil „${name}“ wird entfernt. Der aktuell eingetragene Server bleibt unverändert.`,
confirmText: 'Entfernen',
danger: true
});
if (!ok) return;
const creds = (await window.electronAPI.loadCredentials()) || {};
await window.electronAPI.saveCredentials({ ...creds, giteaProfiles: (creds.giteaProfiles || []).filter(p => p.name !== name) });
await loadProfiles();
snapshot = readFormState();
}
/* ---------- Token anzeigen / entfernen ---------- */
function addTokenTools(inputId) {
const input = $(inputId);
if (!input || input.dataset.tools) return;
input.dataset.tools = '1';
const wrap = document.createElement('div');
wrap.className = 'token-field';
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
const eye = document.createElement('button');
eye.type = 'button';
eye.className = 'token-tool';
eye.title = 'Token anzeigen';
eye.textContent = '👁';
eye.onclick = () => {
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
eye.title = show ? 'Token verbergen' : 'Token anzeigen';
eye.classList.toggle('is-on', show);
};
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'token-tool';
clear.title = 'Token entfernen (wirksam nach „Speichern“)';
clear.textContent = '✕';
clear.onclick = () => { input.value = ''; input.focus(); };
wrap.append(eye, clear);
}
/* ---------- Diagnose ---------- */
async function refreshTransportState() {
const el = $('gitTransportState');
const btn = $('btnGitTransportClear');
if (!el) return;
const res = await window.electronAPI.gitTransportState();
const blocked = (res && res.blocked) || [];
if (!blocked.length) {
el.textContent = '✅ Nicht gesperrt: Uploads, Löschen und Backup nutzen den schnellen Git-Weg, sobald der Server ihn anbietet.';
if (btn) btn.disabled = true;
return;
}
el.textContent = blocked.map(b => `⛔ ${b.host} gesperrt seit ${new Date(b.since).toLocaleString('de-DE')} – die App nutzt dort die langsamere API.`).join('\n');
if (btn) { btn.disabled = false; btn.dataset.host = blocked[0].host; }
}
async function openFolder(which) {
const r = await window.electronAPI.openKnownFolder(which);
if (!r || !r.ok) showWarning((r && r.error) || 'Ordner konnte nicht geöffnet werden');
}
/* ---------- Verdrahtung ---------- */
function wire() {
addTokenTools('githubToken');
addTokenTools('giteaToken');
const on = (id, fn) => { const el = $(id); if (el) el.addEventListener('click', fn); };
on('btnGiteaProfileSave', saveProfile);
on('btnGiteaProfileUse', useProfile);
on('btnGiteaProfileDelete', deleteProfile);
on('btnPrefCloneDir', async () => { const d = await window.electronAPI.selectFolder(); if (d) $('prefCloneDir').value = d; });
on('btnRepoBackupOpen', () => openFolder('backup'));
on('btnOpenAppData', () => openFolder('appData'));
on('btnOpenLogs', () => openFolder('logs'));
on('btnGitTransportClear', async (e) => {
const r = await window.electronAPI.gitTransportClear(e.currentTarget.dataset.host || '');
if (r && r.ok) showSuccess('Sperre aufgehoben. Der nächste Upload probiert wieder den schnellen Git-Weg.');
refreshTransportState();
});
// Design als Vorschau sofort zeigen; "Abbrechen" nimmt sie zurück
const theme = $('prefTheme');
if (theme) theme.addEventListener('change', () => applyTheme(theme.value));
// Gemeinsames Speichern: läuft zusätzlich zum bisherigen Speichern der Zugangsdaten
on('btnSaveSettings', () => { saveExtras(); });
// Beim Öffnen der Einstellungen Stand merken (für die Warnung beim Abbrechen)
on('btnSettings', () => setTimeout(takeSnapshot, 80));
// Standardordner beim Klonen mehrerer Repos vorschlagen
on('btnBatchActions', () => setTimeout(() => {
const field = $('batchCloneTarget');
if (field && !field.value && savedPrefs && savedPrefs.defaultCloneDir) field.value = savedPrefs.defaultCloneDir;
}, 50));
// Diagnose-Bereich: Sperrstatus aktualisieren, wenn er geöffnet wird
const diagTab = document.querySelector('#settingsModal [data-settings-tab="diagnose"]');
if (diagTab) diagTab.addEventListener('click', refreshTransportState);
}
// Gemeinsamer Zugriff für features.js / renderer.js
window.gmPrefs = {
get: () => savedPrefs,
load: loadPrefs,
takeSnapshot
};
loadPrefs().catch(() => {});
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
})();
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'">
<title>Git Manager</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
body { font-family: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
/* Logo und Schriftzug in der Mitte */
.marke {
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
transition: opacity .28s ease, transform .32s ease;
}
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
.logo {
width: 132px; height: 132px; display: block; object-fit: contain;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
}
.name {
font-size: 34px; font-weight: 600; letter-spacing: .06em;
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
animation: schrift .5s ease .25s both;
}
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
/* das fertige Fenster als Bild, wächst von der Mitte nach außen */
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
body.aufbau .marke { opacity: 0; transform: scale(.82); }
</style>
</head>
<body>
<div class="marke">
<div class="buehne"><img class="logo" src="./icon.png" alt=""></div>
<div class="name">Git Manager</div>
</div>
<img id="fenster" alt="">
<script src="./splash.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
// Vom Hauptprozess aufgerufen: das Bild des fertigen Fensters von der Mitte aus aufziehen.
// Liefert true, wenn die Animation durchgelaufen ist, sonst false.
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
const bild = document.getElementById('fenster');
bild.onerror = () => fertig(false);
bild.onload = () => {
const v = Math.max(0, innerHeight / 2 - 66);
const h = Math.max(0, innerWidth / 2 - 66);
bild.style.visibility = 'visible';
document.body.classList.add('aufbau');
bild.animate([
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
{ opacity: 1, offset: 0.25 },
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 }
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
.finished.then(() => fertig(true), () => fertig(false));
};
bild.src = bildAdresse;
});
+631 -1
View File
@@ -21,6 +21,13 @@
transition: color 0.2s; transition: color 0.2s;
margin-bottom: 2px; margin-bottom: 2px;
} }
.project-gravur-title .gravur-repo {
color: rgba(174, 189, 216, 0.45);
font-weight: 600;
}
.project-gravur-title .gravur-sub {
color: rgba(174, 189, 216, 0.22);
}
.project-gravur-separator { .project-gravur-separator {
width: 100%; width: 100%;
height: 2px; height: 2px;
@@ -2437,6 +2444,10 @@ input[type="checkbox"] {
margin: 10px 0 0; margin: 10px 0 0;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
white-space: pre-line;
overflow-wrap: anywhere;
max-height: 60vh;
overflow-y: auto;
} }
.confirm-modal-buttons { .confirm-modal-buttons {
margin-top: 18px; margin-top: 18px;
@@ -3949,4 +3960,623 @@ body.compact-mode .file-type-badge {
.fav-chip[draggable="true"] { .fav-chip[draggable="true"] {
cursor: grab; cursor: grab;
} }
/* ===== Klickbarer Projektpfad ===== */
.project-gravur-title .gravur-link {
pointer-events: auto;
cursor: pointer;
border-radius: 4px;
transition: color var(--transition-fast);
}
.project-gravur-title .gravur-link:hover {
color: var(--accent-primary);
text-decoration: underline;
}
/* ===== Neue Dialoge (features.js) ===== */
.fx-modal { z-index: 99990; }
.fx-card {
width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.fx-card--wide { width: min(1000px, calc(100vw - 32px)); }
.fx-header { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-md); }
.fx-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
.fx-close {
background: transparent; border: 1px solid var(--outline); color: var(--text-secondary);
border-radius: var(--radius-sm); width: 32px; height: 32px; cursor: pointer; flex-shrink: 0;
}
.fx-close:hover { color: var(--text-primary); border-color: var(--outline-strong); }
.fx-body { overflow-y: auto; display: flex; flex-direction: column; gap: var(--spacing-md); min-height: 0; flex: 1; }
.fx-body h3 { margin: var(--spacing-sm) 0 0; font-size: 14px; color: var(--text-secondary); }
.fx-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--spacing-sm); }
.fx-lead { color: var(--text-primary); margin: 0; }
.fx-muted { color: var(--text-muted); font-size: 12px; }
.fx-mono { font-family: Consolas, "Courier New", monospace; font-size: 12.5px; overflow-wrap: anywhere; }
.fx-text { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-secondary); line-height: 1.5; }
.fx-empty, .fx-busy { color: var(--text-muted); padding: var(--spacing-xl); text-align: center; }
.fx-btn {
min-height: 36px; padding: 0 14px; border-radius: var(--radius-sm); cursor: pointer;
border: 1px solid var(--outline); background: var(--bg-tertiary); color: var(--text-primary);
}
.fx-btn:hover:not(:disabled) { border-color: var(--outline-strong); background: var(--surface-hover); }
.fx-btn:disabled { opacity: 0.5; cursor: default; }
.fx-btn--primary { background: var(--accent-gradient); color: #04101f; border-color: transparent; font-weight: 600; }
.fx-btn--primary:hover:not(:disabled) { background: var(--accent-gradient); filter: brightness(1.08); }
.fx-btn--danger { border-color: rgba(239, 68, 68, 0.5); color: #fca5a5; }
.fx-input {
width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--outline); background: var(--bg-tertiary); color: var(--text-primary);
}
.fx-footer .fx-input { flex: 1 1 260px; width: auto; }
.fx-select { width: auto; }
.fx-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
.fx-textarea--tall { min-height: 200px; }
.fx-row { display: flex; gap: var(--spacing-sm); align-items: flex-end; flex-wrap: wrap; }
.fx-row > .input-group, .fx-row > input { flex: 1 1 160px; }
.fx-row-end { display: flex; justify-content: flex-end; }
.fx-inline-check { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 13px; }
.fx-alert { border-radius: var(--radius-md); padding: var(--spacing-md); display: flex; flex-direction: column; gap: var(--spacing-sm); }
.fx-alert--warn { background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.4); color: #fcd34d; }
.fx-alert--danger { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.45); color: #fca5a5; }
.fx-alert .fx-list { background: rgba(0, 0, 0, 0.18); }
.fx-section summary { cursor: pointer; color: var(--text-secondary); }
.fx-list {
display: flex; flex-direction: column; border: 1px solid var(--outline);
border-radius: var(--radius-md); overflow: auto; max-height: 260px;
}
.fx-list--compact { max-height: 200px; }
.fx-list--cards { border: none; max-height: none; gap: var(--spacing-sm); overflow: visible; }
.fx-list-row {
display: flex; align-items: center; gap: var(--spacing-sm); padding: 6px 10px;
border-bottom: 1px solid var(--outline); color: var(--text-secondary); min-width: 0;
}
.fx-list-row:last-child { border-bottom: none; }
.fx-list-row > .fx-mono { flex: 1; min-width: 0; }
.fx-list-row--check { cursor: pointer; }
.fx-list-row.is-active { background: var(--surface-hover); }
.fx-list-head { font-weight: 600; color: var(--text-primary); }
.fx-linkish { background: none; border: none; color: var(--text-primary); text-align: left; cursor: pointer; padding: 0; flex: 1; min-width: 0; }
.fx-linkish:hover { color: var(--accent-primary); }
.fx-icon-btn { background: none; border: 1px solid transparent; color: var(--text-muted); cursor: pointer; border-radius: 6px; padding: 2px 6px; }
.fx-icon-btn:hover { border-color: var(--outline-strong); color: var(--text-primary); }
.fx-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--outline); color: var(--text-secondary); white-space: nowrap; }
.fx-badge--new, .fx-badge--A { border-color: rgba(34, 197, 94, 0.5); color: #86efac; }
.fx-badge--D { border-color: rgba(239, 68, 68, 0.5); color: #fca5a5; }
.fx-badge--M, .fx-badge--R { border-color: rgba(88, 213, 255, 0.45); color: var(--accent-primary); }
.fx-badge--U { border-color: rgba(245, 158, 11, 0.5); color: #fcd34d; }
.fx-split { display: grid; grid-template-columns: minmax(240px, 2fr) 3fr; gap: var(--spacing-md); min-height: 320px; }
.fx-pane { border: 1px solid var(--outline); border-radius: var(--radius-md); overflow: auto; max-height: 52vh; margin: 0; }
.fx-diff { padding: var(--spacing-md); font-family: Consolas, "Courier New", monospace; font-size: 12px; white-space: pre; color: var(--text-secondary); background: rgba(0, 0, 0, 0.2); }
.fx-diff-add { color: #86efac; background: rgba(34, 197, 94, 0.08); }
.fx-diff-del { color: #fca5a5; background: rgba(239, 68, 68, 0.08); }
.fx-diff-hunk { color: var(--accent-primary); }
.fx-tabs { display: flex; gap: var(--spacing-xs); }
.fx-tab { padding: 6px 14px; border-radius: 999px; border: 1px solid var(--outline); background: transparent; color: var(--text-secondary); cursor: pointer; }
.fx-tab.is-active { background: var(--surface-hover); color: var(--text-primary); border-color: var(--outline-strong); }
.fx-item {
display: flex; flex-direction: column; gap: 4px; text-align: left; cursor: pointer; width: 100%;
padding: var(--spacing-md); border-radius: var(--radius-md); border: 1px solid var(--outline);
background: var(--panel-soft); color: var(--text-primary);
}
.fx-item:hover { border-color: var(--outline-strong); background: var(--surface-hover); }
.fx-item-title { font-weight: 600; overflow-wrap: anywhere; }
.fx-labels { display: flex; flex-wrap: wrap; gap: 4px; }
.fx-label { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--outline-strong); color: var(--text-secondary); }
.fx-comments { display: flex; flex-direction: column; gap: var(--spacing-sm); }
.fx-comment { border-left: 2px solid var(--outline-strong); padding: 4px 0 4px var(--spacing-md); }
.fx-check { border: 1px solid var(--outline); border-radius: var(--radius-md); padding: var(--spacing-md); display: flex; flex-direction: column; gap: 6px; }
.fx-check--fail { border-color: rgba(239, 68, 68, 0.45); background: rgba(239, 68, 68, 0.06); }
.fx-check--warn { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.05); }
.fx-check-head { display: flex; align-items: center; gap: var(--spacing-sm); }
.fx-hint {
margin: 0; padding: var(--spacing-sm) var(--spacing-md); border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.25); color: var(--text-secondary); white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace; font-size: 12px; user-select: text;
}
#repoBackupStatus { white-space: pre-line; }
@media (max-width: 760px) {
.fx-split { grid-template-columns: 1fr; }
}
/* ===== Reiter in den Einstellungen ===== */
.settings-tabs {
display: flex;
gap: 6px;
margin: 0 0 14px;
padding: 4px;
width: fit-content;
max-width: 100%;
border: 1px solid var(--outline);
border-radius: 999px;
background: var(--panel-soft);
}
.settings-tab {
padding: 7px 18px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--text-secondary);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.settings-tab:hover { color: var(--text-primary); }
.settings-tab.is-active {
background: var(--surface-hover);
border-color: var(--outline-strong);
color: var(--text-primary);
}
.settings-tab:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.settings-layout--backup { grid-template-columns: minmax(0, 1.18fr) minmax(300px, 0.82fr); }
@media (max-width: 1120px) {
.settings-layout--backup { grid-template-columns: 1fr; }
}
.fx-browse-btn { flex: 0 0 auto; min-height: 44px; white-space: nowrap; }
.fx-row > input#repoBackupTarget { min-width: 0; }
.fx-indent { padding-left: 28px; }
.fx-restore-status:empty { display: none; }
.fx-restore-status { display: flex; flex-direction: column; gap: var(--spacing-sm); }
/* Radio und Checkbox in den neuen Dialogen nicht wie Textfelder strecken */
.fx-modal input[type="radio"],
.fx-modal input[type="checkbox"] {
width: auto;
min-width: 0;
height: auto;
flex: 0 0 auto;
margin: 0;
padding: 0;
box-shadow: none;
accent-color: var(--accent-primary);
}
.fx-modal .fx-list-row--check > span:not(.fx-badge):not(.fx-muted) { flex: 1; min-width: 0; }
/* ===== Einstellungen: Navigation links, ein Bereich rechts ===== */
.settings-shell {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border: 1px solid var(--outline);
border-radius: var(--radius-lg);
background: var(--panel-soft);
position: sticky;
top: 0;
}
.settings-nav .settings-tab {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
font-weight: 400;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.settings-nav .settings-tab:hover {
background: var(--surface);
color: var(--text-primary);
}
.settings-nav .settings-tab.is-active {
background: var(--surface-hover);
border-color: var(--outline-strong);
color: var(--text-primary);
box-shadow: inset 3px 0 0 var(--accent-primary);
}
.settings-tab-icon {
font-size: 18px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.settings-tab-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.settings-tab-text strong {
font-size: 14px;
font-weight: 600;
}
.settings-tab-text small {
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-nav .settings-tab.is-active .settings-tab-text small { color: var(--text-secondary); }
/* Feste Höhe: Beim Wechsel der Bereiche springt das Fenster nicht */
.settings-pages {
height: min(60vh, 580px);
overflow-y: auto;
padding-right: 4px;
scrollbar-gutter: stable;
}
.settings-pages .settings-column {
display: grid;
gap: 14px;
max-width: 820px;
}
@media (max-width: 900px) {
.settings-shell { grid-template-columns: 1fr; }
.settings-nav {
flex-direction: row;
overflow-x: auto;
position: static;
}
.settings-nav .settings-tab { width: auto; flex: 0 0 auto; }
.settings-tab-text small { display: none; }
.settings-pages { height: auto; max-height: 60vh; }
}
/* Bildlaufleiste im App-Look */
.settings-pages::-webkit-scrollbar { width: 8px; }
.settings-pages::-webkit-scrollbar-track { background: transparent; }
.settings-pages::-webkit-scrollbar-thumb { background: var(--outline-strong); border-radius: 999px; }
.settings-pages::-webkit-scrollbar-thumb:hover { background: rgba(110, 188, 255, 0.45); }
.settings-pages::-webkit-scrollbar-button { display: none; height: 0; }
/* ===== Fortschrittsfenster Repo-Backup ===== */
.fx-progress-head {
display: flex;
align-items: center;
gap: 18px;
}
.fx-progress-percent {
font-size: 40px;
font-weight: 700;
line-height: 1;
min-width: 110px;
background: var(--accent-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-variant-numeric: tabular-nums;
}
.fx-progress-now { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.fx-progress-now .fx-lead { overflow-wrap: anywhere; font-weight: 600; }
.fx-progress-track {
height: 10px;
border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--outline);
overflow: hidden;
}
.fx-progress-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: var(--accent-gradient);
box-shadow: 0 0 12px rgba(88, 213, 255, 0.45);
transition: width 300ms ease;
}
.fx-progress-meta {
color: var(--text-secondary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.fx-backup-list { max-height: 34vh; }
.fx-backup-list .fx-list-row > .fx-mono { flex: 0 1 auto; min-width: 140px; }
.fx-backup-list .fx-list-row > .fx-muted {
margin-left: auto;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 55%;
}
.fx-backup-icon { width: 22px; text-align: center; flex-shrink: 0; }
/* Bildlaufleisten in den Dialogen im App-Look */
.fx-modal ::-webkit-scrollbar { width: 8px; height: 8px; }
.fx-modal ::-webkit-scrollbar-track { background: transparent; }
.fx-modal ::-webkit-scrollbar-thumb { background: var(--outline-strong); border-radius: 999px; }
.fx-modal ::-webkit-scrollbar-button { display: none; height: 0; width: 0; }
/* ===== Token-Felder: anzeigen / entfernen ===== */
.token-field { position: relative; display: flex; align-items: center; }
.token-field > input { flex: 1; padding-right: 76px !important; }
.token-field .token-tool {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.token-field .token-tool:nth-of-type(1) { right: 38px; }
.token-field .token-tool:nth-of-type(2) { right: 6px; }
.token-field .token-tool:hover { color: var(--text-primary); border-color: var(--outline-strong); }
.token-field .token-tool.is-on { color: var(--accent-primary); }
/* Mehrzeilige Felder in den Einstellungen */
.settings-textarea {
width: 100%;
box-sizing: border-box;
min-height: 90px;
resize: vertical;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--outline);
background: var(--bg-tertiary);
color: var(--text-primary);
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.5;
}
.settings-textarea:focus { outline: none; border-color: var(--accent-primary); }
.settings-backup-card > .fx-row + .fx-row,
.settings-backup-card > .fx-row + .input-group,
.settings-backup-card > .input-group + .input-group { margin-top: 10px; }
/* ===== Helles Design ===== */
:root[data-theme="light"] {
--bg-primary: #eef2f8;
--bg-secondary: #e4eaf3;
--bg-tertiary: #ffffff;
--surface: #f3f6fb;
--surface-hover: #e3ebf7;
--panel: rgba(255, 255, 255, 0.92);
--panel-strong: rgba(255, 255, 255, 0.98);
--panel-soft: rgba(255, 255, 255, 0.8);
--outline: rgba(30, 60, 110, 0.14);
--outline-strong: rgba(36, 112, 210, 0.38);
--accent-primary: #0f86c9;
--accent-secondary: #2f5fd6;
--accent-gradient: linear-gradient(135deg, #12a3d8 0%, #2f80ed 54%, #3b5fd9 100%);
--text-primary: #13213a;
--text-secondary: #3d4f6e;
--text-muted: #6a7a94;
--shadow-sm: 0 6px 16px rgba(20, 40, 80, 0.08);
--shadow-md: 0 12px 28px rgba(20, 40, 80, 0.12);
--shadow-lg: 0 20px 44px rgba(20, 40, 80, 0.16);
--shadow-glow: 0 0 18px rgba(47, 128, 237, 0.18);
color-scheme: light;
}
:root[data-theme="light"] body { background: var(--bg-primary); color: var(--text-primary); }
:root[data-theme="light"] input,
:root[data-theme="light"] select,
:root[data-theme="light"] textarea {
background: #ffffff;
color: var(--text-primary);
border-color: var(--outline);
}
:root[data-theme="light"] .modalContent,
:root[data-theme="light"] .card,
:root[data-theme="light"] .settings-panel,
:root[data-theme="light"] .settings-auth-card,
:root[data-theme="light"] .settings-health-box,
:root[data-theme="light"] .item-card,
:root[data-theme="light"] .fx-item,
:root[data-theme="light"] .fx-list,
:root[data-theme="light"] .settings-toggle-row {
background: #ffffff;
border-color: var(--outline);
color: var(--text-primary);
}
:root[data-theme="light"] #toolbar,
:root[data-theme="light"] #titlebar-strip,
:root[data-theme="light"] .fav-history-sidebar,
:root[data-theme="light"] #main,
:root[data-theme="light"] .explorer-grid {
background: transparent;
}
:root[data-theme="light"] button:not(.fx-btn--primary):not(.accent-btn):not(.settings-tab):not(.token-tool):not(.fx-close):not(.fx-linkish):not(.fx-icon-btn) {
background: #ffffff;
color: var(--text-primary);
border-color: var(--outline);
}
:root[data-theme="light"] .item-name,
:root[data-theme="light"] .settings-toggle-title,
:root[data-theme="light"] h2, :root[data-theme="light"] h3, :root[data-theme="light"] h4 { color: var(--text-primary); }
:root[data-theme="light"] .fx-diff { background: #f6f8fc; }
:root[data-theme="light"] .fx-hint { background: #f1f4f9; }
:root[data-theme="light"] .fx-alert--warn { color: #8a5a00; background: #fff6e0; }
:root[data-theme="light"] .fx-alert--danger { color: #a02020; background: #fdecec; }
:root[data-theme="light"] .modal { background: rgba(20, 35, 60, 0.35); }
:root[data-theme="light"] .project-gravur-title { color: rgba(40, 60, 100, 0.35); }
:root[data-theme="light"] .project-gravur-title .gravur-repo { color: rgba(30, 50, 90, 0.7); }
/* Helles Design: Kopfbereich, Schalter und Karten mit eigenen Farbverläufen */
:root[data-theme="light"] #toolbar button:not(.accent-btn):not(.platform-option),
:root[data-theme="light"] #toolbar .status,
:root[data-theme="light"] .win-btn {
background-image: none !important;
background-color: #ffffff !important;
color: var(--text-primary) !important;
border: 1px solid var(--outline) !important;
box-shadow: var(--shadow-sm) !important;
}
:root[data-theme="light"] #toolbar button:not(.accent-btn):not(.platform-option):hover,
:root[data-theme="light"] .win-btn:hover { background-color: var(--surface-hover) !important; }
:root[data-theme="light"] .win-btn--close:hover { background-color: #e5484d !important; color: #fff !important; }
:root[data-theme="light"] .toolbar-brand-copy strong { color: var(--text-primary) !important; -webkit-text-fill-color: currentColor; text-shadow: none; }
:root[data-theme="light"] .toolbar-kicker { color: var(--text-muted) !important; }
:root[data-theme="light"] .platform-switch {
background: #ffffff !important;
border-color: var(--outline) !important;
}
:root[data-theme="light"] .platform-option { color: var(--text-secondary) !important; }
:root[data-theme="light"] .platform-option.active { color: #ffffff !important; }
:root[data-theme="light"] .activity-heatmap-card {
background: #ffffff !important;
border-color: var(--outline) !important;
color: var(--text-primary) !important;
}
:root[data-theme="light"] .activity-heatmap-card * { color: inherit; }
:root[data-theme="light"] .input-group label,
:root[data-theme="light"] .settings-auth-input label { color: var(--text-secondary) !important; }
:root[data-theme="light"] .toggle-track { background: #c9d3e3 !important; }
:root[data-theme="light"] .toggle-switch input:checked + .toggle-track { background: var(--accent-primary) !important; }
/* ===== Lesbarkeit (Kontrastprüfung) ===== */
/* Auswahllisten: Windows zeichnet die geöffnete Liste selbst – Farben überall festlegen */
:root { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
select option { background: #1a2233; color: #f5f8ff; }
select option:checked { background: #2d4ea4; color: #ffffff; }
:root[data-theme="light"] select option { background: #ffffff; color: #13213a; }
:root[data-theme="light"] select option:checked { background: #d6e6fb; color: #13213a; }
/* Dunkles Design: Hinweistexte in Karten heller */
.settings-inline-hint, .settings-toggle-desc, .settings-auth-spacer,
#repoBackupStatus, #gitTransportState, .repo-size-badge { color: #a3b2cc; }
.token-field .token-tool { color: #a3b2cc; }
.project-gravur-title .gravur-repo { color: rgba(174, 189, 216, 0.75); }
.project-gravur-title .gravur-sub { color: rgba(174, 189, 216, 0.55); }
.titlebar-strip-title { color: rgba(174, 189, 216, 0.7) !important; }
/* Helles Design */
:root[data-theme="light"] .settings-inline-hint,
:root[data-theme="light"] .settings-toggle-desc,
:root[data-theme="light"] .settings-auth-spacer,
:root[data-theme="light"] #repoBackupStatus,
:root[data-theme="light"] #gitTransportState,
:root[data-theme="light"] .repo-size-badge,
:root[data-theme="light"] .token-field .token-tool { color: #4f6180; }
:root[data-theme="light"] #giteaUrlHint, :root[data-theme="light"] #githubTokenHint,
:root[data-theme="light"] #healthApi, :root[data-theme="light"] #healthAuth,
:root[data-theme="light"] #healthUrl { color: #15803d !important; }
:root[data-theme="light"] .platform-option.active { color: #04101f !important; }
:root[data-theme="light"] .project-gravur-title .gravur-sub { color: rgba(40, 60, 100, 0.6); }
:root[data-theme="light"] .titlebar-strip-title { color: #4f6180 !important; }
:root[data-theme="light"] .activity-log-category { color: #1e4f8a !important; }
/* ===========================
TOOLBAR: AUFGERÄUMTE ANORDNUNG
Zeile 1: Marke, Hauptaktionen | Steuerung
Zeile 2: Quelle | Plattform, Status
Zeile 3: Repo-Leiste, nur in der Repo-Ansicht
=========================== */
#toolbar { gap: 10px; }
#toolbar button { white-space: nowrap; flex: 0 0 auto; }
/* Hauptaktion hervorheben, der Rest bleibt ruhig */
#toolbar #btnOpenRepoActions {
background: linear-gradient(135deg, #67e8f9 0%, #58a6ff 100%);
color: #03131a;
border-color: transparent;
text-shadow: none;
}
.toolbar-row--top .toolbar-top-actions { gap: 14px; }
/* Trennlinie zwischen Hauptaktionen und Steuerung */
.tool-group--utility {
gap: 6px;
padding-left: 14px;
border-left: 1px solid rgba(138, 164, 214, 0.2);
}
.toolbar-row--bottom { justify-content: space-between; }
.tool-group--platform {
margin-left: auto;
gap: 12px;
flex-wrap: nowrap;
}
.tool-group--platform .toolbar-status-wrap { max-width: 300px; }
.tool-group--platform .status { width: min(260px, 100%); }
/* Repo-Leiste: eigene Zeile, nur sichtbar wenn mindestens ein Knopf eingeblendet ist */
.toolbar-row--context {
justify-content: flex-start;
flex-wrap: wrap;
gap: 10px;
padding: 6px 8px;
border-radius: 12px;
background: rgba(8, 16, 32, 0.55);
border: 1px solid rgba(88, 213, 255, 0.12);
}
.toolbar-row--context:not(:has(button:not(.hidden))) { display: none; }
.toolbar-row--context .tool-group {
gap: 6px;
flex-wrap: nowrap;
}
/* Leere Gruppe samt Trennlinie ausblenden */
.toolbar-row--context .tool-group:not(:has(button:not(.hidden))) { display: none; }
.toolbar-row--context .tool-group--repo-tools {
margin-left: auto;
padding-left: 12px;
border-left: 1px solid rgba(138, 164, 214, 0.2);
}
#toolbar .toolbar-row--context button { height: 28px; font-weight: 600; }
/* Schmale Fenster: Steuerung nur mit Symbolen (Beschriftung steht im Tooltip) */
@media (max-width: 1100px) {
.tool-group--utility .btn-label { display: none; }
#toolbar .tool-group--utility button { padding: 0 10px; }
}
:root[data-theme="light"] .toolbar-row--context {
background: rgba(255, 255, 255, 0.6);
border-color: rgba(30, 60, 110, 0.12);
}
:root[data-theme="light"] .tool-group--utility,
:root[data-theme="light"] .toolbar-row--context .tool-group--repo-tools {
border-left-color: rgba(30, 60, 110, 0.18);
}
/* Schmale Fenster: Zeilen bleiben nebeneinander und brechen bei Platzmangel um,
statt wie früher komplett gestapelt und gestreckt zu werden. */
@media (max-width: 1180px) {
.toolbar-row { flex-direction: row; align-items: center; flex-wrap: wrap; }
.toolbar-top-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
}
.toolbar-status-wrap { justify-content: flex-end; max-width: 300px; }
.tool-group--platform .status { width: min(260px, 100%); }
}
@media (max-width: 768px) {
.tool-group,
.toolbar-status-wrap { width: auto; }
.toolbar-row--context .tool-group { flex-wrap: wrap; }
}
:root[data-theme="light"] #toolbar #btnOpenRepoActions {
background: linear-gradient(135deg, #67e8f9 0%, #58a6ff 100%) !important;
color: #04101f !important;
border-color: transparent !important;
}
.upload-history-row { padding: 6px 8px; border-radius: 8px; cursor: pointer; }
.upload-history-row:hover { background: rgba(88, 213, 255, 0.14); }
.upload-history-err { opacity: 0.75; padding-left: 22px; }
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'self'">
<title>Git Manager Menü</title>
<style>
:root {
--bg: #0f1a2e;
--fg: #e8eefc;
--muted: #8fa0c0;
--hover: rgba(88, 213, 255, 0.16);
--border: rgba(88, 213, 255, 0.24);
--sep: rgba(138, 164, 214, 0.22);
--accent: #67e8f9;
}
body.light {
--bg: #f7f9fd;
--fg: #16233d;
--muted: #6a7a99;
--hover: rgba(40, 110, 220, 0.12);
--border: rgba(30, 60, 110, 0.2);
--sep: rgba(30, 60, 110, 0.14);
--accent: #1d6fe0;
}
html, body {
margin: 0;
background: transparent;
color: var(--fg);
font: 13px "Segoe UI Variable Text", "Segoe UI", Arial, sans-serif;
user-select: none;
overflow: hidden;
}
body { padding: 8px; }
#menu {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 6px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
.header { font-weight: 700; padding: 8px 12px 2px; }
.status { color: var(--muted); font-size: 12px; padding: 2px 12px; }
.sep { height: 1px; background: var(--sep); margin: 6px 4px; }
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 32px;
padding: 0 12px;
box-sizing: border-box;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
outline: none;
}
.item:hover:not(:disabled), .item.active:not(:disabled) { background: var(--hover); }
.item:disabled { color: var(--muted); opacity: 0.6; }
.check { width: 14px; flex: 0 0 14px; text-align: center; color: var(--accent); font-weight: 700; }
</style>
</head>
<body>
<div id="menu" role="menu"></div>
<script src="./tray-menu.js"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
// Zeichnet das Tray-Menü aus dem Zustand, den der Hauptprozess schickt.
// Ohne Electron (Seite direkt im Browser geöffnet) greift ein Stub, damit sich die Optik prüfen lässt.
(function () {
const api = window.trayApi || {
onState: () => {},
action: (id) => console.log('action', id),
resize: () => {},
close: () => {}
};
const menu = document.getElementById('menu');
let buttons = [];
let activeIndex = -1;
function setActive(index) {
activeIndex = index;
buttons.forEach((b, i) => b.classList.toggle('active', i === index));
}
function render(state) {
document.body.classList.toggle('light', state && state.dark === false);
menu.textContent = '';
buttons = [];
activeIndex = -1;
for (const item of (state && state.items) || []) {
if (item.type === 'separator') {
const sep = document.createElement('div');
sep.className = 'sep';
menu.appendChild(sep);
continue;
}
if (item.id === 'header' || item.id === 'status' || item.id === 'queue') {
const line = document.createElement('div');
line.className = item.id === 'header' ? 'header' : 'status';
line.textContent = item.label;
menu.appendChild(line);
continue;
}
const button = document.createElement('button');
button.className = 'item';
button.disabled = item.enabled === false;
button.setAttribute('role', item.type === 'checkbox' ? 'menuitemcheckbox' : 'menuitem');
if (item.type === 'checkbox') button.setAttribute('aria-checked', String(!!item.checked));
const check = document.createElement('span');
check.className = 'check';
check.textContent = item.type === 'checkbox' && item.checked ? '✓' : '';
const label = document.createElement('span');
label.textContent = item.label;
button.append(check, label);
button.addEventListener('click', () => api.action(item.id));
button.addEventListener('mouseenter', () => { if (!button.disabled) setActive(buttons.indexOf(button)); });
button.addEventListener('mouseleave', () => setActive(-1));
menu.appendChild(button);
buttons.push(button);
}
api.resize(Math.ceil(document.body.scrollHeight));
}
function move(step) {
const enabled = buttons.map((b, i) => (b.disabled ? -1 : i)).filter((i) => i >= 0);
if (enabled.length === 0) return;
const pos = enabled.indexOf(activeIndex);
const next = pos < 0 ? (step > 0 ? 0 : enabled.length - 1) : (pos + step + enabled.length) % enabled.length;
setActive(enabled[next]);
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') api.close();
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if ((e.key === 'Enter' || e.key === ' ') && buttons[activeIndex]) { e.preventDefault(); buttons[activeIndex].click(); }
});
window.__renderTray = render;
api.onState(render);
})();
+117
View File
@@ -0,0 +1,117 @@
'use strict';
/*
* build-setup.js – eigenes Setup (installer/) mit der App zusammenbauen
* ---------------------------------------------------------------------
* Läuft nach "electron-builder --win dir" (siehe "build" in package.json):
*
* 1. dist/win-unpacked (die fertige App inkl. eingebautem Git) als ZIP packen
* 2. installer/ (WPF, .NET Framework 4.8) mit der Version aus package.json bauen
* 3. ZIP hinten an die Setup-.exe hängen:
* [Setup.exe][ZIP][8 Byte ZIP-Länge][8 Byte "GITMGRPK"]
* (das Setup liest den Anhang selbst aus, siehe installer/Payload.cs)
* 4. SHA-256-Prüfsumme daneben schreiben (der Updater der App prüft sie)
* 5. Zwischenstände wegräumen → übrig bleibt dist/Git-Manager-GUI-Setup-<version>.exe
*
* Benötigt das .NET SDK (dotnet) – das Setup selbst läuft danach auf jedem
* Windows 10/11 ohne Zusatzinstallation.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const APP_DIR = path.join(DIST, 'win-unpacked');
const APP_EXE = 'Git Manager GUI.exe';
const WORK = path.join(DIST, '.setup');
const ZIP = path.join(WORK, 'app.zip');
const MAGIC = Buffer.from('GITMGRPK', 'ascii');
function schritt(text) {
console.log(`\n» ${text}`);
}
function sha256(file) {
const hash = crypto.createHash('sha256');
const fd = fs.openSync(file, 'r');
try {
const buf = Buffer.alloc(8 * 1024 * 1024);
let n;
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) hash.update(buf.subarray(0, n));
} finally { fs.closeSync(fd); }
return hash.digest('hex');
}
function main() {
const version = require(path.join(ROOT, 'package.json')).version;
const outName = `Git-Manager-GUI-Setup-${version}.exe`;
const OUT = path.join(DIST, outName);
if (!fs.existsSync(path.join(APP_DIR, APP_EXE))) {
throw new Error(`${APP_DIR}\\${APP_EXE} fehlt – vorher "electron-builder --win dir" ausführen.`);
}
if (!fs.existsSync(path.join(APP_DIR, 'resources', 'mingit', 'cmd', 'git.exe'))) {
throw new Error('Eingebautes Git fehlt in resources/mingit – extraResources in package.json prüfen.');
}
fs.rmSync(WORK, { recursive: true, force: true });
fs.mkdirSync(WORK, { recursive: true });
schritt('App wird gepackt …');
// .NET-ZIP über PowerShell: in jedem Windows enthalten, keine zusätzliche npm-Abhängigkeit
const ps = [
'Add-Type -AssemblyName System.IO.Compression.FileSystem',
`[System.IO.Compression.ZipFile]::CreateFromDirectory('${APP_DIR.replace(/'/g, "''")}', '${ZIP.replace(/'/g, "''")}', [System.IO.Compression.CompressionLevel]::Optimal, $false)`,
].join('; ');
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'inherit' });
schritt(`Setup ${version} wird gebaut …`);
const stubDir = path.join(WORK, 'stub');
execFileSync('dotnet', [
'build', path.join(ROOT, 'installer', 'GitManagerSetup.csproj'),
'-c', 'Release', `-p:Version=${version}`, '-p:DebugType=none',
'--nologo', '-v', 'q', '-o', stubDir,
], { stdio: 'inherit' });
const stub = path.join(stubDir, 'Git-Manager-GUI-Setup.exe');
if (!fs.existsSync(stub)) throw new Error('Build des Setups fehlgeschlagen.');
schritt('App wird ans Setup angehängt …');
const zipSize = fs.statSync(ZIP).size;
const footer = Buffer.alloc(16);
footer.writeBigInt64LE(BigInt(zipSize), 0);
MAGIC.copy(footer, 8);
fs.rmSync(OUT, { force: true });
fs.copyFileSync(stub, OUT);
// ZIP in Blöcken anhängen statt komplett in den Speicher zu laden
const fd = fs.openSync(OUT, 'a');
try {
const src = fs.openSync(ZIP, 'r');
try {
const buf = Buffer.alloc(8 * 1024 * 1024);
let n;
while ((n = fs.readSync(src, buf, 0, buf.length, null)) > 0) fs.writeSync(fd, buf, 0, n);
} finally { fs.closeSync(src); }
fs.writeSync(fd, footer);
} finally { fs.closeSync(fd); }
schritt('Prüfsumme wird geschrieben …');
const sum = sha256(OUT);
fs.writeFileSync(`${OUT}.sha256`, `${sum} ${outName}\n`);
schritt('Aufräumen …');
for (const t of [WORK, APP_DIR, path.join(DIST, 'builder-debug.yml'), path.join(DIST, 'builder-effective-config.yaml')]) {
fs.rmSync(t, { recursive: true, force: true });
}
const mb = (fs.statSync(OUT).size / 1024 / 1024).toFixed(1);
console.log(`\nFertig: ${OUT} (${mb} MB)\nSHA-256: ${sum}`);
}
try {
main();
} catch (err) {
console.error(`\nFehler: ${err.message}`);
process.exit(1);
}
+110
View File
@@ -0,0 +1,110 @@
// Teil des UI-Testlaufs (scripts/ui-smoke.js): prüft die Startanimation in echtem Electron mit einem
// Ersatz-Hauptfenster. Das Startfenster muss genau über dem Hauptfenster liegen, wirklich durchsichtig sein
// (kein weißes Feld), das Hauptfenster bis zum Aufbau verborgen bleiben, das Bild von der Mitte aus
// aufgezogen werden und am Ende das Hauptfenster sichtbar sein. Gibt „✓ …“ und „✗ …“ Zeilen aus.
//
// Die Durchsichtigkeit wird über eine Bildschirmaufnahme geprüft: An einer leeren Stelle des Startfensters
// muss dieselbe Farbe stehen wie vorher auf dem Desktop. Dafür darf die Hardware-Beschleunigung nicht
// abgeschaltet sein, sonst verhalten sich durchsichtige Fenster anders als im echten Programm.
const { app, BrowserWindow, desktopCapturer, screen } = require('electron');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const { begin, play, MIN_SPIN_MS } = require(path.join(ROOT, 'src', 'features', 'splash.js'));
let failed = 0;
function check(name, ok, detail = '') {
if (!ok) failed++;
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
app.on('window-all-closed', (e) => e.preventDefault());
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// Farbe des Bildschirms an einer Stelle (Koordinaten des Hauptbildschirms) als [r, g, b]
async function pixelAt(x, y) {
const d = screen.getPrimaryDisplay();
const sf = d.scaleFactor;
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: Math.round(d.size.width * sf), height: Math.round(d.size.height * sf) }
});
const src = sources.find((s) => String(s.display_id) === String(d.id)) || sources[0];
const size = src.thumbnail.getSize();
const bmp = src.thumbnail.toBitmap(); // BGRA
const i = (Math.round(y * sf) * size.width + Math.round(x * sf)) * 4;
return [bmp[i + 2], bmp[i + 1], bmp[i]];
}
const close = (a, b) => a.every((v, k) => Math.abs(v - b[k]) <= 6);
app.whenReady().then(async () => {
const page = 'data:text/html;charset=utf-8,' + encodeURIComponent(
'<body style="margin:0;background:#123"><div id="explorerGrid"><div>Repo</div></div></body>');
const main = new BrowserWindow({ width: 900, height: 600, frame: false, show: false, webPreferences: { sandbox: true } });
main.loadURL(page);
const loaded = new Promise((resolve) => main.webContents.once('did-finish-load', resolve));
await loaded;
const mainBounds = main.getBounds();
// Leere Stelle nahe der linken oberen Ecke des Fensters (das Logo sitzt in der Mitte)
const sx = mainBounds.x + 24;
const sy = mainBounds.y + 24;
const desktopColor = await pixelAt(sx, sy);
let sawSplashWindow = false;
let boundsMatch = false;
let hiddenWhileSpinning = true;
let sawGrow = false;
const startedAt = Date.now();
const handle = begin({ htmlPath: path.join(ROOT, 'renderer', 'splash.html'), bounds: mainBounds });
// Die Oberfläche gilt erst später als bereit, damit das Logo lange genug dreht, um es zu messen
const slowReady = wait(3500);
const playing = play(main, slowReady, handle);
// Durchsichtigkeit messen, solange das Logo dreht (nach dem Zeigen und der Verzögerung für die Mausereignisse)
const duringColor = (async () => {
await handle.shownAt;
await wait(1400);
return pixelAt(sx, sy);
})();
// Während der Animation beobachten
for (let i = 0; i < 500; i++) {
const others = BrowserWindow.getAllWindows().filter((w) => w !== main);
if (others.length) {
sawSplashWindow = true;
const b = others[0].getBounds();
if (b.x === mainBounds.x && b.y === mainBounds.y && b.width === mainBounds.width && b.height === mainBounds.height) boundsMatch = true;
if (Date.now() - startedAt < MIN_SPIN_MS - 250 && main.isVisible() && main.getOpacity() > 0) hiddenWhileSpinning = false;
try {
if (await others[0].webContents.executeJavaScript("document.body.classList.contains('aufbau')")) sawGrow = true;
} catch (_) { /* Fenster schon geschlossen */ }
}
if (sawSplashWindow && others.length === 0) break;
await wait(40);
}
await playing;
const duration = Date.now() - startedAt;
const measured = await duringColor;
check('Startfenster erscheint über dem Hauptfenster', sawSplashWindow && boundsMatch);
check('Startfenster ist durchsichtig (kein weißes Feld)', close(measured, desktopColor) && !close(measured, [255, 255, 255]),
`Desktop ${desktopColor.join(',')} / Startfenster ${measured.join(',')}`);
check('Hauptfenster bleibt während des Drehens unsichtbar', hiddenWhileSpinning);
check(`Logo dreht sich mindestens ${MIN_SPIN_MS} ms`, duration >= MIN_SPIN_MS, `${duration} ms`);
check('Animation endet in angemessener Zeit', duration < 12000, `${duration} ms`);
check('Fenster wird von der Mitte aus aufgezogen', sawGrow);
check('Danach ist das Hauptfenster sichtbar und deckend', main.isVisible() && main.getOpacity() === 1);
await wait(300);
check('Startfenster ist danach geschlossen', BrowserWindow.getAllWindows().filter((w) => w !== main).length === 0);
app.exit(failed ? 1 : 0);
}).catch((e) => {
console.log(`✗ Startanimation ohne Ausnahme — ${e.message}`);
app.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
// Teil des UI-Testlaufs (scripts/ui-smoke.js): prüft das Tray-Menüfenster in echtem Electron, ohne das
// Tray-Symbol selbst. Zeigt das Fenster, fährt mit der Maus darüber, klickt, drückt Esc.
// Gibt „✓ …“ und „✗ …“ Zeilen aus; Aufruf durch ui-smoke.js.
const { app } = require('electron');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const { createTrayPopup } = require(path.join(ROOT, 'src', 'features', 'trayPopup.js'));
const trayMenu = require(path.join(ROOT, 'src', 'features', 'trayMenu.js'));
let failed = 0;
function check(name, ok, detail = '') {
if (!ok) failed++;
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
app.disableHardwareAcceleration();
app.on('window-all-closed', (e) => e.preventDefault());
app.whenReady().then(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const log = [];
let active = 1;
const template = () => trayMenu.buildTrayTemplate({
version: '0.0.0', active, queued: 2, paused: false, autostart: true,
onOpen: () => log.push('open'), onHistory: () => log.push('history'), onPause: () => log.push('pause'),
onCancel: () => log.push('cancel'), onAutostart: () => log.push('autostart'), onQuit: () => log.push('quit')
});
const popup = createTrayPopup({
htmlPath: path.join(ROOT, 'renderer', 'tray-menu.html'),
preloadPath: path.join(ROOT, 'tray-menu-preload.js'),
getModel: () => trayMenu.serializeTemplate(template()),
onAction: (id) => trayMenu.runAction(template(), id)
});
const js = (code) => popup.getWindow().webContents.executeJavaScript(code);
const items = async () => JSON.parse(await js(`JSON.stringify([...document.querySelectorAll('.item')].map(b => { const r = b.getBoundingClientRect(); return { text: b.innerText.trim(), disabled: b.disabled, x: r.x, y: r.y, h: r.height }; }))`));
const mid = (i) => ({ x: Math.round(i.x + 40), y: Math.round(i.y + i.h / 2) });
const bg = (label) => js(`(() => { const b = [...document.querySelectorAll('.item')].find(x => x.innerText.includes(${JSON.stringify(label)})); return getComputedStyle(b).backgroundColor; })()`);
const mouse = (type, p, extra = {}) => popup.getWindow().webContents.sendInputEvent({ type, x: p.x, y: p.y, ...extra });
try {
popup.warmUp();
await wait(6000); // erster Renderer-Start dauert mehrere Sekunden
popup.show();
await wait(800);
check('Menüfenster wird sichtbar', popup.isVisible());
let list = await items();
check('Menü zeigt Einträge mit Pausieren und Abbrechen',
['Fenster öffnen', 'Upload-Verlauf', 'Übertragung pausieren', 'Laufende Übertragung abbrechen', 'Beenden'].every((t) => list.some((i) => i.text.includes(t))));
// Hover folgt der Maus
const history = list.find((i) => i.text.includes('Upload-Verlauf'));
// Das frisch geöffnete Fenster braucht einen Moment, bis es sein erstes Bild gezeichnet hat und Hover annimmt.
let hovered = 'rgba(0, 0, 0, 0)';
for (let i = 0; i < 12 && hovered === 'rgba(0, 0, 0, 0)'; i++) {
mouse('mouseMove', { x: mid(history).x + (i % 2), y: mid(history).y });
await wait(250);
hovered = await bg('Upload-Verlauf');
}
const other = await bg('Fenster öffnen');
check('Hover markiert den Eintrag unter der Maus', hovered !== 'rgba(0, 0, 0, 0)' && other === 'rgba(0, 0, 0, 0)', `${hovered} / ${other}`);
// Gesperrter Eintrag bleibt unmarkiert und löst nichts aus
active = 0; popup.refresh(); await wait(400);
list = await items();
const cancel = list.find((i) => i.text.includes('abbrechen'));
mouse('mouseMove', mid(cancel));
await wait(300);
check('Gesperrter Eintrag wird nicht markiert', cancel.disabled && (await bg('abbrechen')) === 'rgba(0, 0, 0, 0)');
mouse('mouseDown', mid(cancel), { button: 'left', clickCount: 1 });
mouse('mouseUp', mid(cancel), { button: 'left', clickCount: 1 });
await wait(400);
check('Klick auf gesperrten Eintrag löst keine Aktion aus', log.length === 0 && popup.isVisible());
// Klick führt die Aktion aus und schließt das Fenster
const open = list.find((i) => i.text.includes('Fenster öffnen'));
mouse('mouseMove', mid(open));
mouse('mouseDown', mid(open), { button: 'left', clickCount: 1 });
mouse('mouseUp', mid(open), { button: 'left', clickCount: 1 });
await wait(600);
check('Klick führt die Aktion aus und schließt das Menü', log.join() === 'open' && !popup.isVisible());
// Esc schließt
popup.show();
await wait(900);
const reopened = popup.isVisible();
const win = popup.getWindow();
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await wait(500);
check('Esc schließt das Menü', reopened && !popup.isVisible());
} catch (e) {
check('Menüfenster-Test ohne Ausnahme', false, e.message);
}
popup.destroy();
app.exit(failed ? 1 : 0);
});
+256
View File
@@ -0,0 +1,256 @@
// Oberflächen-Testlauf: startet die App mit einem leeren, eigenen Datenordner (APPDATA), steuert sie
// über das Chrome-DevTools-Protokoll und prüft Layout, Knöpfe und die neuen Funktionen.
// Berührt weder echte Zugangsdaten noch einen Server. Aufruf: npm run test:ui
//
// Prüft nur, was ohne Anmeldung sichtbar ist. Die Repo-Ansicht und echte Uploads sind bewusst nicht dabei.
const { spawn, spawnSync } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const electronPath = require(path.join(ROOT, 'node_modules', 'electron'));
const PORT = 9300 + Math.floor(Math.random() * 500);
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok });
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function killTree(pid) {
if (!pid) return;
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
else { try { process.kill(-pid, 'SIGKILL'); } catch (_) { try { process.kill(pid, 'SIGKILL'); } catch (__) {} } }
}
async function waitForPage(timeoutMs, state = {}) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
if (targets.some((t) => /renderer[\\/]splash\.html/.test(t.url))) state.sawSplash = true;
const page = targets.find((t) => t.type === 'page' && /renderer[\\/]index\.html/.test(t.url));
if (page) return page;
} catch (_) { /* App startet noch */ }
await sleep(500);
}
throw new Error('Die App hat sich nicht innerhalb von ' + Math.round(timeoutMs / 1000) + ' s gemeldet');
}
async function connect(page) {
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); });
let id = 0;
const pending = new Map();
ws.addEventListener('message', (m) => {
const d = JSON.parse(m.data);
if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
});
const send = (method, params = {}) => new Promise((resolve) => {
const i = ++id;
pending.set(i, resolve);
ws.send(JSON.stringify({ id: i, method, params }));
});
const ev = async (expression) => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.result.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text);
return r.result.result.value;
};
return { ev, close: () => ws.close() };
}
async function run() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ui-smoke-'));
const appData = path.join(tmp, 'appdata');
fs.mkdirSync(appData, { recursive: true });
const app = spawn(electronPath, ['.', `--remote-debugging-port=${PORT}`], {
cwd: ROOT,
env: { ...process.env, APPDATA: appData },
stdio: 'ignore',
detached: process.platform !== 'win32'
});
let page;
try {
const startup = { sawSplash: false };
page = await waitForPage(60000, startup);
const cdp = await connect(page);
const { ev } = cdp;
// Warten, bis die Seite fertig geladen ist und die Renderer-Skripte laufen (im frischen Datenordner dauert das länger)
const splashOpen = async () => {
try {
const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
return targets.some((t) => /renderer[\\/]splash\.html/.test(t.url));
} catch (_) { return false; }
};
let ready = false;
for (let i = 0; i < 60 && !ready; i++) {
if (await splashOpen()) startup.sawSplash = true;
ready = await ev("document.readyState === 'complete' && typeof showProgress === 'function' && typeof showUploadHistory === 'function' && !!document.getElementById('toolbar')").catch(() => false);
if (!ready) await sleep(300);
}
if (!ready) throw new Error('Die Oberfläche wurde nicht fertig geladen');
// 0. Startbildschirm: erscheint, verschwindet nach dem Laden, danach ist das Hauptfenster sichtbar
let splashGone = false;
for (let i = 0; i < 25 && !splashGone; i++) {
splashGone = !(await splashOpen());
if (!splashGone) await sleep(400);
}
check('Startanimation erscheint beim Start', startup.sawSplash);
check('Startanimation endet, sobald die Oberfläche geladen ist', splashGone);
check('Hauptfenster ist nach der Startanimation sichtbar', (await ev("document.visibilityState")) === 'visible');
await sleep(1000);
// 1. Start und Schnittstellen
const title = await ev('document.title');
check('App startet, Titel stimmt', title === 'Git Manager Explorer Pro', 'Titel: ' + JSON.stringify(title));
const apis = ['cancelUpload', 'pauseUpload', 'resumeUpload', 'getUploadHistory', 'exportUploadHistoryCsv', 'getGitignoreTemplates',
'inspectReleaseFile', 'prepareRelease', 'onTransferPauseState', 'onOpenUploadHistory', 'previewUploadPaths'];
const missing = await ev(`${JSON.stringify(apis)}.filter(k => typeof window.electronAPI[k] !== 'function')`);
check('Alle neuen Schnittstellen sind vorhanden', missing.length === 0, missing.join(', '));
// 2. Toolbar bricht bei keiner Fensterbreite um
for (const width of [1216, 960, 720]) {
await ev(`window.resizeTo(${width}, 780); 1`);
await sleep(900);
const r = await ev(`(() => {
const bad = [];
for (const b of document.querySelectorAll('#toolbar button')) {
const rect = b.getBoundingClientRect();
if (rect.width === 0) continue;
if (rect.height > 40) bad.push(b.id || b.textContent.trim());
}
return { iw: innerWidth, bad, overflowX: document.documentElement.scrollWidth > innerWidth };
})()`);
check(`Toolbar bei ${width} px: keine umgebrochenen Knöpfe, kein seitliches Scrollen`, r.iw === width && r.bad.length === 0 && !r.overflowX,
r.iw !== width ? `Fensterbreite ${r.iw}` : (r.bad.join(', ') || (r.overflowX ? 'seitlicher Überlauf' : '')));
}
await ev('window.resizeTo(1216, 828); 1');
await sleep(600);
// 3. Repo-Leiste ohne Repo ausgeblendet
check('Repo-Leiste ist in der Startansicht ausgeblendet', (await ev("getComputedStyle(document.querySelector('.toolbar-row--context')).display")) === 'none');
// 4. Fortschrittsbalken mit Pause und Abbrechen
const prog = await ev(`(async () => {
showProgress(40, describeTransferProgress('Upload', { processed: 48, total: 120, percent: 40, totalBytes: 48 * 1024 * 1024 }), { cancellable: true });
const cancel = document.getElementById('folderProgressCancel');
const pause = document.getElementById('folderProgressPause');
const shown = { cancel: cancel.style.display, pause: pause.style.display, text: document.getElementById('folderProgressText').innerText };
applyPauseState(true);
const paused = { label: pause.textContent, text: document.getElementById('folderProgressText').innerText };
applyPauseState(false);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await new Promise(r => setTimeout(r, 200));
const afterEsc = { label: cancel.textContent, disabled: cancel.disabled };
hideProgress();
await new Promise(r => setTimeout(r, 800));
return { shown, paused, afterEsc, reset: { cancelDisabled: cancel.disabled, display: cancel.style.display, pauseDisplay: pause.style.display } };
})()`);
check('Fortschritt zeigt Pausieren und Abbrechen', prog.shown.cancel !== 'none' && prog.shown.pause !== 'none');
check('Fortschritt nennt MB in deutscher Schreibweise', /19,2 MB \/ 48,0 MB/.test(prog.shown.text), prog.shown.text);
check('Pause schaltet die Beschriftung um und vermerkt „pausiert“', prog.paused.label === 'Fortsetzen' && /pausiert/.test(prog.paused.text));
check('Esc löst Abbrechen aus', prog.afterEsc.disabled && /abgebrochen/.test(prog.afterEsc.label));
check('Knöpfe werden nach dem Ausblenden zurückgesetzt', !prog.reset.cancelDisabled && prog.reset.display === 'none' && prog.reset.pauseDisplay === 'none');
// 5. Pause/Abbrechen ohne laufende Übertragung
const idle = await ev(`(async () => ({ pause: await window.electronAPI.pauseUpload(), state: await window.electronAPI.getPauseState(), cancel: await window.electronAPI.cancelUpload() }))()`);
check('Ohne laufende Übertragung lassen sich Pause und Abbrechen nicht auslösen', idle.pause.ok === false && idle.cancel.ok === false && idle.state.paused === false);
// 6. Upload-Verlauf: Aufzeichnung, Filter, Dialog
await ev('window.electronAPI.clearUploadHistory()');
await ev("window.electronAPI.uploadPathsToGitea({ owner: 'smoke', repo: 'alpha', paths: [] })");
await ev("window.electronAPI.uploadPathsToGitea({ owner: 'smoke', repo: 'beta', paths: [] })");
const hist = await ev(`(async () => {
const all = (await window.electronAPI.getUploadHistory()).history.length;
const ok = (await window.electronAPI.getUploadHistory({ status: 'ok' })).history.length;
const alpha = (await window.electronAPI.getUploadHistory({ query: 'ALPHA' })).history.map(e => e.repo);
showUploadHistory();
await new Promise(r => setTimeout(r, 700));
const rows = document.querySelectorAll('.upload-history-row').length;
const exportEnabled = !document.querySelector('.confirm-modal button:nth-of-type(2)').disabled;
const btns = [...document.querySelectorAll('.confirm-modal button')].map(b => b.textContent);
document.querySelector('.confirm-modal button').click();
return { all, ok, alpha, rows, btns, exportEnabled, offen: document.querySelectorAll('.confirm-modal').length };
})()`);
check('Verlauf zeichnet Uploads auf', hist.all === 2);
check('Verlauf filtert nach Status und Repo', hist.ok === 0 && hist.alpha.length === 1 && hist.alpha[0] === 'smoke/alpha');
check('Verlauf-Dialog zeigt klickbare Zeilen, Export und Leeren', hist.rows === 2 && hist.btns.includes('CSV exportieren') && hist.btns.includes('Verlauf leeren'));
check('Verlauf-Dialog schließt sich', hist.offen === 0);
await ev('window.electronAPI.clearUploadHistory()');
// 7. Sperre für Zugangsdaten (Vorschau nutzt dieselbe Dateiliste wie der Upload)
const folder = path.join(tmp, 'projekt');
fs.mkdirSync(path.join(folder, 'data'), { recursive: true });
fs.writeFileSync(path.join(folder, 'main.js'), '// ok\n');
fs.writeFileSync(path.join(folder, 'README.md'), '# ok\n');
fs.writeFileSync(path.join(folder, '.env'), 'TOKEN=geheim\n');
fs.writeFileSync(path.join(folder, 'data', 'credentials.json'), '{}\n');
fs.writeFileSync(path.join(folder, '.env.example'), 'TOKEN=\n');
const prev = await ev(`window.electronAPI.previewUploadPaths({ paths: [${JSON.stringify(folder)}], destPath: '' })`);
const names = (prev.files || []).map((f) => path.basename(f.localFile)).sort();
const blocked = (prev.blocked || []).map((b) => path.basename(b.path)).sort();
check('Sperre nimmt .env und credentials.json aus der Dateiliste', !names.includes('.env') && !names.includes('credentials.json'), names.join(', '));
check('Sperre meldet die ausgelassenen Dateien', JSON.stringify(blocked) === JSON.stringify(['.env', 'credentials.json']), blocked.join(', '));
check('Normale Dateien und Vorlagen (.env.example) bleiben erlaubt', names.includes('main.js') && names.includes('README.md') && names.includes('.env.example'));
// 8. Release-Datei prüfen (Prüfsumme, Abgleich, Version)
const exe = path.join(tmp, 'Git-Manager-GUI-Setup-9.8.7.exe');
fs.writeFileSync(exe, 'Testinhalt');
const hash = crypto.createHash('sha256').update('Testinhalt').digest('hex');
fs.writeFileSync(`${exe}.sha256`, `${hash} Git-Manager-GUI-Setup-9.8.7.exe\n`);
const good = await ev(`window.electronAPI.inspectReleaseFile({ filePath: ${JSON.stringify(exe)} })`);
check('Release-Datei: Version und Prüfsumme werden erkannt', good.ok && good.version === '9.8.7' && good.sha256 === hash);
check('Release-Datei: .sha256 stimmt überein', good.sidecarMatches === true);
fs.writeFileSync(`${exe}.sha256`, `${'0'.repeat(64)} Git-Manager-GUI-Setup-9.8.7.exe\n`);
const bad = await ev(`window.electronAPI.inspectReleaseFile({ filePath: ${JSON.stringify(exe)} })`);
check('Release-Datei: falsche .sha256 wird erkannt', bad.sidecarMatches === false);
// 9. .gitignore-Vorlagen
const tpl = await ev(`(async () => ({ list: (await window.electronAPI.getGitignoreTemplates()).templates.map(t => t.key), built: (await window.electronAPI.buildGitignore({ keys: ['java'] })).content }))()`);
check('.gitignore-Vorlagen: sieben Stück, Java enthält target/', tpl.list.length === 7 && /target\//.test(tpl.built));
// 10. Einstellungen: neue Schalter vorhanden
const toggles = await ev(`['prefBlockSecrets', 'prefNotifyTransfers'].map(id => !!document.getElementById(id))`);
check('Einstellungen: Sperre und Benachrichtigung sind einstellbar', toggles.every(Boolean));
cdp.close();
} finally {
killTree(app.pid);
await sleep(500);
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* Datei noch gesperrt */ }
}
}
// Führt eine eigenständige Electron-Prüfung aus (gibt ✓/✗-Zeilen aus) und übernimmt ihre Ergebnisse.
function runHarness(title, script) {
console.log(`\n${title}:`);
const res = spawnSync(electronPath, [path.join(__dirname, script)], { cwd: ROOT, encoding: 'utf8', timeout: 120000 });
const lines = String(res.stdout || '').split(/\r?\n/).filter((l) => /^(✓|✗) /.test(l));
for (const l of lines) {
console.log(l);
results.push({ name: l.slice(2), ok: l.startsWith('✓') });
}
if (lines.length === 0) check(`${title} liefert Ergebnisse`, false, String(res.stderr || res.error || '').slice(0, 200));
}
(async () => {
console.log(`UI-Testlauf (Debug-Port ${PORT})\n`);
try {
await run();
} catch (e) {
check('Testlauf ohne Ausnahme', false, e.message);
}
runHarness('Tray-Menüfenster', 'ui-smoke-tray.js');
runHarness('Startanimation', 'ui-smoke-splash.js');
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length} von ${results.length} Prüfungen bestanden.`);
process.exit(failed.length ? 1 : 0);
})();
+313
View File
@@ -0,0 +1,313 @@
// src/backup/GiteaRepoBackup.js
// Sichert alle Gitea-Repos des Benutzers in einen Ordner (z. B. NAS-Freigabe).
//
// Bevorzugt als Git-Spiegel (<ziel>/<owner>/<repo>.git): vollständige Historie, alle Branches
// und Tags, und ab dem zweiten Lauf werden nur Änderungen geholt. Liefert der Server keine
// Git-Pakete (early EOF), wird stattdessen ein ZIP-Archiv des Standard-Branches über die API
// geladen (<ziel>/<owner>/<repo>-archiv/<repo>-<zeit>.zip); davon bleiben die neuesten N.
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const axios = require('axios');
const INTERVALS = { off: 0, daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 };
const SCHEDULER_TICK_MS = 15 * 60 * 1000;
function stamp(d = new Date()) {
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}`;
}
function safeName(s) {
return String(s).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
}
// Token nur pro Git-Aufruf mitgeben, nie in einer Repo- oder Spiegel-Konfiguration speichern.
function authHeaderArgs(ctx) {
const basic = Buffer.from(`${ctx.token}:x-oauth-basic`, 'utf8').toString('base64');
return ['-c', `http.extraheader=Authorization: Basic ${basic}`];
}
// Ausschlussmuster: eine Zeile je Muster, * und ? als Platzhalter, ohne "/" passend auf den Repo-Namen.
function excludeMatcher(text) {
const pats = String(text || '').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
const res = pats.map(p => ({
full: p.includes('/'),
re: new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i')
}));
return (fullName) => res.some(({ full, re }) => re.test(full ? fullName : fullName.split('/').pop()));
}
function createRepoBackup({ configPath, getContext, giteaCall, runGit, isPackFailure, redact }) {
let running = null;
// Laufender Zustand für die Fortschrittsanzeige. Wird bei jeder Änderung an alle Zuhörer gemeldet,
// damit auch ein später geöffnetes Fenster (oder ein geplantes Backup) den Stand zeigen kann.
let live = null;
const listeners = new Set();
let lastBytesEmit = 0;
function snapshot() {
if (!live) return null;
const { abort, ...rest } = live;
return { ...rest, results: live.results.slice() };
}
function emit(force = true) {
if (!live) return;
if (!force) {
const now = Date.now();
if (now - lastBytesEmit < 250) return;
lastBytesEmit = now;
}
const s = snapshot();
for (const fn of listeners) { try { fn(s); } catch (_) {} }
}
function setLive(patch, force = true) {
if (!live) return;
Object.assign(live, patch);
emit(force);
}
const defaults = {
targetDir: '', interval: 'off', keep: 5, lastRun: null, lastResult: null,
excludeRepos: '', // Muster je Zeile, z. B. "M_Viper/test-*" oder "*-alt"
time: '', // frühester Zeitpunkt für automatische Backups ("HH:MM"), leer = jederzeit
notify: 'errors' // 'errors' | 'always' | 'never'
};
function readConfig() {
try { return { ...defaults, ...JSON.parse(fs.readFileSync(configPath, 'utf8')) }; } catch (_) { return { ...defaults }; }
}
function writeConfig(cfg) {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
}
function getState() {
return { config: readConfig(), running: !!running, progress: snapshot() };
}
function setConfig(data) {
const cfg = readConfig();
if (typeof data.targetDir === 'string') cfg.targetDir = data.targetDir.trim();
if (data.interval in INTERVALS) cfg.interval = data.interval;
if (data.keep != null) cfg.keep = Math.min(100, Math.max(1, Number(data.keep) || 5));
if (typeof data.excludeRepos === 'string') cfg.excludeRepos = data.excludeRepos.replace(/\r/g, '').slice(0, 20000);
if (typeof data.time === 'string') cfg.time = /^([01]\d|2[0-3]):[0-5]\d$/.test(data.time) ? data.time : '';
if (['errors', 'always', 'never'].includes(data.notify)) cfg.notify = data.notify;
writeConfig(cfg);
return getState();
}
async function listAllRepos(ctx) {
const all = [];
for (let page = 1; page <= 100; page++) {
const list = await giteaCall(ctx, 'get', '/user/repos', { params: { page, limit: 50 } });
if (!list || !list.length) break;
all.push(...list);
if (list.length < 50) break;
}
return all;
}
async function backupAsMirror(ctx, repo, dir) {
const auth = authHeaderArgs(ctx);
if (fs.existsSync(path.join(dir, 'HEAD'))) {
setLive({ step: 'Git-Spiegel wird aktualisiert …', bytes: 0 });
return runGit([...auth, '-C', dir, 'remote', 'update', '--prune'], { timeoutMs: 30 * 60000 });
}
setLive({ step: 'Git-Spiegel wird angelegt …', bytes: 0 });
fs.mkdirSync(path.dirname(dir), { recursive: true });
const r = await runGit([...auth, 'clone', '--mirror', repo.clone_url, dir], { timeoutMs: 60 * 60000 });
if (r.status !== 0) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
return r;
}
async function backupAsZip(ctx, repo, folder, keep, signal) {
const branch = repo.default_branch || 'main';
fs.mkdirSync(folder, { recursive: true });
const file = path.join(folder, `${safeName(repo.name)}-${stamp()}.zip`);
const lib = ctx.base.startsWith('https:') ? https : http;
const agent = new lib.Agent({ keepAlive: false, autoSelectFamily: true });
const tmp = `${file}.part`;
setLive({ step: `ZIP vom Branch ${branch} wird geladen …`, bytes: 0, bytesTotal: null });
try {
const res = await axios.get(`${ctx.apiBase}/repos/${encodeURIComponent(repo.owner.login)}/${encodeURIComponent(repo.name)}/archive/${encodeURIComponent(branch)}.zip`, {
headers: ctx.headers, responseType: 'stream', timeout: 30 * 60000, signal,
httpAgent: agent, httpsAgent: agent, validateStatus: () => true, maxRedirects: 5
});
if (res.status !== 200) {
res.data.resume();
throw new Error(`Archiv-Download HTTP ${res.status}`);
}
const total = Number(res.headers['content-length']) || null;
if (total) setLive({ bytesTotal: total });
let bytes = 0;
await new Promise((resolve, reject) => {
const out = fs.createWriteStream(tmp);
res.data.on('data', (chunk) => { bytes += chunk.length; setLive({ bytes }, false); });
res.data.on('error', reject);
out.on('error', reject);
out.on('finish', resolve);
res.data.pipe(out);
});
setLive({ bytes });
fs.renameSync(tmp, file);
} catch (e) {
try { fs.rmSync(tmp, { force: true }); } catch (_) {}
if (signal && signal.aborted) throw new Error('Abgebrochen');
throw e;
} finally {
agent.destroy();
}
// Ältere Archive über der Aufbewahrungsgrenze entfernen
const zips = fs.readdirSync(folder).filter(f => f.endsWith('.zip')).sort();
for (const old of zips.slice(0, Math.max(0, zips.length - keep))) {
try { fs.rmSync(path.join(folder, old), { force: true }); } catch (_) {}
}
return file;
}
async function run() {
const cfg = readConfig();
if (!cfg.targetDir) throw new Error('Kein Backup-Ziel festgelegt.');
fs.mkdirSync(cfg.targetDir, { recursive: true });
const ctx = getContext();
setLive({ phase: 'list', step: 'Repos werden abgefragt …' });
const all = await listAllRepos(ctx);
const excluded = excludeMatcher(cfg.excludeRepos);
const repos = all.filter(r => !excluded(`${r.owner.login}/${r.name}`));
setLive({ excludedCount: all.length - repos.length });
const results = live.results;
let mirrorBroken = false;
setLive({ phase: 'running', total: repos.length, repos: repos.map(r => `${r.owner.login}/${r.name}`) });
for (let i = 0; i < repos.length; i++) {
if (live.cancelRequested) break;
const repo = repos[i];
const full = `${repo.owner.login}/${repo.name}`;
setLive({ current: i + 1, repo: full, step: 'Wird vorbereitet …', bytes: 0, bytesTotal: null });
const ownerDir = path.join(cfg.targetDir, safeName(repo.owner.login));
const finish = (r) => { results.push(r); setLive({ done: results.length }); };
if (repo.empty) { finish({ repo: full, ok: true, method: 'leer' }); continue; }
// Scheitert der Spiegel einmal am Server (early EOF), nicht für jedes Repo erneut probieren.
let mirrorError = null;
if (!mirrorBroken) {
const r = await backupAsMirror(ctx, repo, path.join(ownerDir, `${safeName(repo.name)}.git`));
if (r.status === 0) { finish({ repo: full, ok: true, method: 'Git-Spiegel' }); continue; }
if (isPackFailure(r.stderr)) {
mirrorBroken = true;
setLive({ mirrorBroken: true });
} else if (!/could not read|authentication|403|401/i.test(r.stderr)) {
mirrorError = redact(r.stderr, ctx.token).trim().slice(0, 200);
}
}
if (live.cancelRequested) break;
live.abort = new AbortController();
try {
const file = await backupAsZip(ctx, repo, path.join(ownerDir, `${safeName(repo.name)}-archiv`), cfg.keep, live.abort.signal);
finish({ repo: full, ok: true, method: 'ZIP', file: path.basename(file), bytes: live.bytes });
} catch (e) {
if (live.cancelRequested) break;
finish({ repo: full, ok: false, method: 'ZIP', error: redact(e.message, ctx.token) + (mirrorError ? ` (Git-Spiegel vorher: ${mirrorError})` : '') });
} finally {
live.abort = null;
}
}
const failed = results.filter(r => !r.ok);
const summary = {
at: new Date().toISOString(),
total: repos.length,
excluded: all.length - repos.length,
ok: results.length - failed.length,
failed: failed.length,
skipped: repos.length - results.length,
cancelled: !!live.cancelRequested,
durationMs: Date.now() - live.startedAt,
mirror: results.filter(r => r.method === 'Git-Spiegel').length,
zip: results.filter(r => r.method === 'ZIP' && r.ok).length,
mirrorBroken,
errors: failed.slice(0, 10)
};
const after = readConfig();
after.lastRun = summary.at;
after.lastResult = summary;
writeConfig(after);
try {
fs.writeFileSync(path.join(cfg.targetDir, 'backup-protokoll.json'), JSON.stringify({ ...summary, results }, null, 2), 'utf8');
} catch (_) {}
console.log(`[RepoBackup] ${summary.cancelled ? 'abgebrochen' : 'fertig'}: ${summary.ok}/${summary.total} gesichert (${summary.mirror} Spiegel, ${summary.zip} ZIP), ${summary.failed} Fehler`);
return summary;
}
async function runNow(onProgress) {
if (running) return { ok: false, error: 'Ein Backup läuft bereits.', running: true };
live = {
phase: 'start', startedAt: Date.now(), total: 0, current: 0, done: 0, repo: null, repos: [],
step: 'Startet …', bytes: 0, bytesTotal: null, results: [], mirrorBroken: false,
cancelRequested: false, abort: null, targetDir: readConfig().targetDir
};
if (onProgress) listeners.add(onProgress);
emit();
running = run();
try {
const summary = await running;
setLive({ phase: 'done', summary, step: summary.cancelled ? 'Abgebrochen' : 'Fertig' });
return { ok: summary.failed === 0 && !summary.cancelled, summary };
} catch (e) {
setLive({ phase: 'error', error: e.message, step: 'Fehlgeschlagen' });
return { ok: false, error: e.message };
} finally {
if (onProgress) listeners.delete(onProgress);
running = null;
live = null;
}
}
// Bricht nach dem aktuellen Schritt ab; ein laufender ZIP-Download wird sofort beendet.
function cancel() {
if (!running || !live) return { ok: false, error: 'Es läuft kein Backup.' };
live.cancelRequested = true;
if (live.abort) { try { live.abort.abort(); } catch (_) {} }
setLive({ step: 'Wird abgebrochen …' });
return { ok: true };
}
function onProgress(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function isDue(now = new Date()) {
const cfg = readConfig();
const every = INTERVALS[cfg.interval] || 0;
if (!every || !cfg.targetDir) return false;
const last = cfg.lastRun ? new Date(cfg.lastRun).getTime() : 0;
if (!cfg.time) return !last || now.getTime() - last >= every;
// Mit Uhrzeit: erst ab HH:MM, und nur wenn seit dem letzten Lauf (fast) ein Intervall vergangen ist.
// 12 Stunden Spielraum, damit sich der Zeitpunkt nicht durch späte Läufe immer weiter verschiebt.
const [h, m] = cfg.time.split(':').map(Number);
const slot = new Date(now); slot.setHours(h, m, 0, 0);
if (slot > now) slot.setDate(slot.getDate() - 1);
return last < slot.getTime() && now.getTime() - last >= every - 12 * 3600 * 1000;
}
function startScheduler() {
const tick = async () => {
if (running || !isDue()) return;
console.log('[RepoBackup] Geplantes Backup startet');
try { await runNow(null); } catch (e) { console.warn('[RepoBackup] Geplantes Backup fehlgeschlagen:', e.message); }
};
// unref: Der Zeitplaner allein hält den Prozess nicht am Leben.
setTimeout(tick, 2 * 60 * 1000).unref?.();
setInterval(tick, SCHEDULER_TICK_MS).unref?.();
}
return { getState, setConfig, runNow, cancel, onProgress, startScheduler, isDue };
}
module.exports = { createRepoBackup, authHeaderArgs, excludeMatcher };
+224
View File
@@ -0,0 +1,224 @@
// src/backup/GiteaRepoRestore.js
// Stellt Sicherungen aus GiteaRepoBackup wieder her:
// - Git-Spiegel (<ziel>/<owner>/<repo>.git): lokal als Arbeitskopie klonen oder per
// "push --mirror" auf Gitea zurückspielen (neues Repo oder bestehendes überschreiben).
// - ZIP-Stand (<ziel>/<owner>/<repo>-archiv/*.zip): lokal entpacken oder als neues Repo
// auf Gitea anlegen (per Git-Push, bei Serverproblemen über die Contents-API).
const fs = require('fs');
const os = require('os');
const path = require('path');
const unzipper = require('unzipper');
function isEmptyOrMissing(dir) {
try { return fs.readdirSync(dir).length === 0; } catch (_) { return true; }
}
// Sicherungen im Backup-Ordner finden
function listBackups(targetDir) {
const repos = new Map();
if (!targetDir || !fs.existsSync(targetDir)) return [];
for (const owner of fs.readdirSync(targetDir)) {
const ownerDir = path.join(targetDir, owner);
let entries;
try {
if (!fs.statSync(ownerDir).isDirectory()) continue;
entries = fs.readdirSync(ownerDir);
} catch (_) { continue; }
for (const entry of entries) {
const full = path.join(ownerDir, entry);
let st;
try { st = fs.statSync(full); } catch (_) { continue; }
if (!st.isDirectory()) continue;
if (entry.endsWith('.git') && fs.existsSync(path.join(full, 'HEAD'))) {
const name = entry.slice(0, -4);
const key = `${owner}/${name}`;
const rec = repos.get(key) || { owner, repo: name, mirror: null, zips: [] };
let updated = st.mtime;
for (const marker of ['FETCH_HEAD', 'packed-refs', 'refs']) {
try { const m = fs.statSync(path.join(full, marker)).mtime; if (m > updated) updated = m; } catch (_) {}
}
rec.mirror = { path: full, updated: updated.toISOString() };
repos.set(key, rec);
} else if (entry.endsWith('-archiv')) {
const name = entry.slice(0, -'-archiv'.length);
const key = `${owner}/${name}`;
const rec = repos.get(key) || { owner, repo: name, mirror: null, zips: [] };
for (const f of fs.readdirSync(full).filter(f => f.endsWith('.zip'))) {
try {
const zst = fs.statSync(path.join(full, f));
rec.zips.push({ path: path.join(full, f), file: f, size: zst.size, date: zst.mtime.toISOString() });
} catch (_) {}
}
rec.zips.sort((a, b) => b.date.localeCompare(a.date));
repos.set(key, rec);
}
}
}
return [...repos.values()].sort((a, b) => `${a.owner}/${a.repo}`.localeCompare(`${b.owner}/${b.repo}`));
}
// Gitea-Archive enthalten einen Oberordner "<repo>/". Er wird beim Entpacken entfernt.
async function extractZip(zipPath, dest) {
const dir = await unzipper.Open.file(zipPath);
const files = dir.files.filter(f => f.type === 'File');
const tops = new Set(files.map(f => f.path.split('/')[0]));
const strip = tops.size === 1 && files.every(f => f.path.includes('/')) ? [...tops][0] + '/' : '';
const root = path.resolve(dest);
// Erst alle Pfade prüfen, dann schreiben: ein manipuliertes Archiv soll gar nichts entpacken.
// Schutz vor Pfaden, die aus dem Zielordner herausführen ("zip slip").
const plan = [];
for (const f of files) {
const rel = strip ? f.path.slice(strip.length) : f.path;
if (!rel) continue;
const out = path.resolve(root, rel);
if (out === root || !out.startsWith(root + path.sep)) throw new Error(`Unsicherer Pfad im Archiv: ${f.path}`);
plan.push({ f, out });
}
let count = 0;
for (const { f, out } of plan) {
fs.mkdirSync(path.dirname(out), { recursive: true });
await new Promise((resolve, reject) => {
f.stream().on('error', reject).pipe(fs.createWriteStream(out)).on('error', reject).on('finish', resolve);
});
count++;
}
return count;
}
function collectFiles(root) {
const items = [];
(function walk(dir) {
for (const entry of fs.readdirSync(dir)) {
if (entry === '.git') continue;
const full = path.join(dir, entry);
const st = fs.statSync(full);
if (st.isDirectory()) walk(full);
else if (st.isFile()) items.push({ localFile: full, targetPath: path.relative(root, full).split(path.sep).join('/') });
}
})(root);
return items;
}
function createRepoRestore({ getContext, giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs, identity }) {
async function restoreLocal({ kind, source, destParent, repo }) {
if (!destParent || !fs.existsSync(destParent)) throw new Error('Zielordner nicht gefunden');
const dest = path.join(destParent, repo);
if (!isEmptyOrMissing(dest)) throw new Error(`"${dest}" existiert bereits und ist nicht leer. Bitte einen anderen Ordner wählen.`);
if (kind === 'mirror') {
const r = await runGit(['clone', source, dest], { timeoutMs: 30 * 60000 });
if (r.status !== 0) throw new Error(r.stderr.trim() || 'Klonen fehlgeschlagen');
// origin soll wieder auf Gitea zeigen, nicht auf den Backup-Ordner.
// Der Spiegel kennt die ursprüngliche Adresse (ohne Token, der wird nur pro Aufruf übergeben).
const remote = await runGit(['-C', source, 'config', '--get', 'remote.origin.url']);
const url = remote.stdout.trim();
if (/^https?:/i.test(url)) await runGit(['-C', dest, 'remote', 'set-url', 'origin', url]);
const br = await runGit(['-C', dest, 'branch', '-r']);
return { ok: true, path: dest, branches: br.stdout.split('\n').filter(l => l.trim() && !l.includes('->')).length };
}
if (kind === 'zip') {
fs.mkdirSync(dest, { recursive: true });
const count = await extractZip(source, dest);
return { ok: true, path: dest, files: count };
}
throw new Error('Unbekannte Sicherungsart');
}
async function createGiteaRepo(ctx, owner, name, isPrivate) {
const me = await giteaCall(ctx, 'get', '/user');
const body = { name, private: !!isPrivate, auto_init: false, description: `Wiederhergestellt aus Backup (${new Date().toLocaleDateString('de-DE')})` };
try {
return owner && owner !== me.login
? await giteaCall(ctx, 'post', `/orgs/${encodeURIComponent(owner)}/repos`, { data: body })
: await giteaCall(ctx, 'post', '/user/repos', { data: body });
} catch (e) {
if (e.status === 409) throw new Error(`Ein Repo "${owner || me.login}/${name}" existiert bereits. Bitte einen anderen Namen wählen.`);
if (e.status === 404 || e.status === 403) throw new Error(`Repo kann unter "${owner}" nicht angelegt werden (keine Rechte oder Organisation fehlt): ${e.message}`);
throw e;
}
}
async function restoreToGitea({ kind, source, owner, repo, mode, newName, isPrivate }, onProgress) {
const ctx = getContext();
const progress = (text) => { if (onProgress) onProgress({ text }); };
let target;
let created = false;
if (mode === 'overwrite') {
if (kind !== 'mirror') throw new Error('Überschreiben ist nur mit einem Git-Spiegel möglich, weil ein ZIP-Stand keine Historie enthält.');
progress('Prüfe Ziel-Repo ...');
target = await giteaCall(ctx, 'get', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
} else {
const name = String(newName || '').trim();
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('Ungültiger Repo-Name. Erlaubt sind Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich.');
progress(`Lege Repo "${name}" an ...`);
target = await createGiteaRepo(ctx, owner, name, isPrivate);
created = true;
}
const full = target.full_name || `${target.owner.login}/${target.name}`;
if (kind === 'mirror') {
progress('Übertrage Branches und Tags ...');
const r = await runGit([...authHeaderArgs(ctx), '-C', source, 'push', '--mirror', target.clone_url], { timeoutMs: 60 * 60000 });
if (r.status !== 0) {
const msg = redact(r.stderr, ctx.token).trim();
throw new Error(isPackFailure(msg)
? `Der Server hat die Übertragung abgebrochen (${msg.split('\n').pop()}). Die Server-Diagnose zeigt die Ursache.${created ? ` Das leere Repo "${full}" wurde bereits angelegt.` : ''}`
: msg || 'Push fehlgeschlagen');
}
// Standard-Branch wie im Backup setzen
const head = await runGit(['-C', source, 'symbolic-ref', '--short', 'HEAD']);
const branch = head.status === 0 ? head.stdout.trim() : null;
if (branch && branch !== target.default_branch) {
try { await giteaCall(ctx, 'patch', `/repos/${encodeURIComponent(target.owner.login)}/${encodeURIComponent(target.name)}`, { data: { default_branch: branch } }); } catch (_) {}
}
return { ok: true, repo: full, url: target.html_url, method: 'Git-Spiegel' };
}
if (kind === 'zip') {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-restore-'));
try {
progress('Entpacke Archiv ...');
const count = await extractZip(source, tmp);
if (!count) throw new Error('Das Archiv ist leer.');
const branch = target.default_branch || 'main';
progress(`Übertrage ${count} Dateien per Git ...`);
const who = identity ? identity() : { name: 'Git Manager GUI', email: 'gui@gitmanager.local' };
const id = ['-c', `user.name=${who.name}`, '-c', `user.email=${who.email}`];
let pushed = false;
let pushErr = '';
if ((await runGit(['-C', tmp, 'init', '-q'])).status === 0) {
await runGit(['-C', tmp, 'add', '-A']);
await runGit([...id, '-C', tmp, 'commit', '-q', '-m', `Wiederhergestellt aus Backup ${path.basename(source)}`]);
const p = await runGit([...authHeaderArgs(ctx), '-C', tmp, 'push', target.clone_url, `HEAD:refs/heads/${branch}`], { timeoutMs: 30 * 60000 });
pushed = p.status === 0;
pushErr = redact(p.stderr, ctx.token).trim();
}
if (pushed) return { ok: true, repo: full, url: target.html_url, method: 'Git', files: count };
// Git-Weg nicht nutzbar: Dateien über die API hochladen
progress(`Git-Push nicht möglich, lade ${count} Dateien über die API hoch ...`);
const result = await uploadItemsViaGiteaApi({
token: ctx.token, url: ctx.base, owner: target.owner.login, repo: target.name, branch,
items: collectFiles(tmp), message: `Wiederhergestellt aus Backup ${path.basename(source)}`,
onProgress: (pct) => progress(`Lade über die API hoch ... ${pct} %`)
});
if (result.failed.length) {
throw new Error(`${result.failed.length} von ${count} Dateien konnten nicht hochgeladen werden (z. B. ${result.failed[0].path}: ${result.failed[0].error}). Git-Fehler vorher: ${pushErr.slice(0, 200)}`);
}
return { ok: true, repo: full, url: target.html_url, method: 'API', files: count };
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
}
}
throw new Error('Unbekannte Sicherungsart');
}
return { listBackups, restoreLocal, restoreToGitea };
}
module.exports = { createRepoRestore, listBackups, extractZip };
+91
View File
@@ -0,0 +1,91 @@
// src/features/appPrefs.js
// Allgemeine App-Einstellungen, die keine Geheimnisse enthalten (Tokens liegen verschlüsselt in
// den Zugangsdaten). Gespeichert als userData/app-settings.json.
const fs = require('fs');
const path = require('path');
const DEFAULTS = {
// Konten
gitName: '',
gitEmail: '',
// Uploads
uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster
uploadSecretScan: true, // Dateien mit Zugangsdaten erkennen und vorab abwählen
uploadBlockSecrets: true, // Dateien mit Zugangsdaten nie hochladen (auf allen Upload-Wegen)
uploadPreview: 'always', // 'always' | 'warnings' (nur bei Auffälligkeiten anzeigen)
notifyTransfers: 'hidden', // Windows-Meldung bei fertigem/fehlgeschlagenem Upload: 'hidden' (nur wenn die App nicht vorn ist) | 'never'
// Darstellung
theme: 'dark', // 'dark' | 'light' | 'system'
zoom: 100, // Prozent
// System
autoUpdateCheck: true,
showSplash: true, // Startbildschirm beim Öffnen (nie beim versteckten Start mit Windows)
closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit'
defaultCloneDir: ''
};
const CHOICES = {
uploadPreview: ['always', 'warnings'],
notifyTransfers: ['hidden', 'never'],
theme: ['dark', 'light', 'system'],
closeAction: ['auto', 'tray', 'quit']
};
function clean(key, value) {
const def = DEFAULTS[key];
if (CHOICES[key]) return CHOICES[key].includes(value) ? value : def;
if (typeof def === 'boolean') return !!value;
if (key === 'zoom') {
const n = Math.round(Number(value));
return Number.isFinite(n) ? Math.min(150, Math.max(70, n)) : def;
}
if (key === 'gitEmail') {
const v = String(value || '').trim().slice(0, 200);
return v === '' || /^[^\s@<>]+@[^\s@<>]+$/.test(v) ? v : def;
}
if (key === 'gitName') return String(value || '').replace(/[<>\n\r]/g, '').trim().slice(0, 100);
if (key === 'uploadExtraExcludes') return String(value || '').replace(/\r/g, '').slice(0, 20000);
return String(value || '').trim().slice(0, 1000);
}
function createPrefs(file) {
const listeners = new Set();
let cache = null;
function get() {
if (!cache) {
let stored = {};
try { stored = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch (_) {}
cache = {};
for (const key of Object.keys(DEFAULTS)) cache[key] = key in stored ? clean(key, stored[key]) : DEFAULTS[key];
}
return { ...cache };
}
/** Übernimmt nur bekannte Schlüssel; liefert die bereinigten Werte und eine Liste abgelehnter Eingaben. */
function set(patch) {
const next = get();
const rejected = [];
for (const [key, value] of Object.entries(patch || {})) {
if (!(key in DEFAULTS)) continue;
const v = clean(key, value);
if (key === 'gitEmail' && String(value || '').trim() && v !== String(value).trim()) rejected.push(key);
next[key] = v;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(next, null, 2), 'utf8');
cache = next;
for (const fn of listeners) { try { fn(get()); } catch (_) {} }
return { prefs: get(), rejected };
}
function onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
return { get, set, onChange, DEFAULTS };
}
module.exports = { createPrefs, DEFAULTS };
+539
View File
@@ -0,0 +1,539 @@
// src/features/featuresIpc.js
// IPC-Handler für: Upload-Vorschau mit Geheimnis-Warnung, Server-Diagnose,
// lokale Änderungen (Status/Diff/Commit), Pull Requests & Issues und Repo-Backup.
const fs = require('fs');
const os = require('os');
const path = require('path');
const dns = require('dns').promises;
const net = require('net');
const http = require('http');
const https = require('https');
const { spawn } = require('child_process');
const axios = require('axios');
const { normalizeAndValidateBaseUrl, uploadItemsViaGiteaApi } = require('../git/apiHandler.js');
const { scanForSecrets } = require('./secretScan.js');
const { createRepoBackup, authHeaderArgs } = require('../backup/GiteaRepoBackup.js');
const { commitIdentity } = require('../git/gitBinary.js');
const { createRepoRestore } = require('../backup/GiteaRepoRestore.js');
const agentOptions = { keepAlive: true, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 };
const api = axios.create({
httpAgent: new http.Agent(agentOptions),
httpsAgent: new https.Agent(agentOptions),
timeout: 30000,
validateStatus: () => true
});
const enc = encodeURIComponent;
const repoApiPath = (owner, repo) => `/repos/${enc(owner)}/${enc(repo)}`;
function giteaContext(readCredentials) {
const c = readCredentials() || {};
if (!c.giteaURL || !c.giteaToken) throw new Error('Gitea-Zugangsdaten fehlen. Bitte in den Einstellungen eintragen.');
const base = normalizeAndValidateBaseUrl(c.giteaURL);
return { base, apiBase: `${base}/api/v1`, token: c.giteaToken, headers: { Authorization: `token ${c.giteaToken}` } };
}
async function giteaCall(ctx, method, apiPath, { params, data, timeout } = {}) {
const res = await api.request({ method, url: ctx.apiBase + apiPath, params, data, headers: ctx.headers, timeout });
if (res.status >= 200 && res.status < 300) return res.data;
const err = new Error((res.data && res.data.message) || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
// Git mit Zeitlimit, ohne den Hauptprozess zu blockieren.
function runGit(args, { cwd, timeoutMs = 60000 } = {}) {
return new Promise((resolve) => {
let child;
try {
child = spawn('git', args, { cwd, windowsHide: true, env: process.env });
} catch (e) {
resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) });
return;
}
const out = [];
const err = [];
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch (_) {} }, timeoutMs);
child.stdout.on('data', c => out.push(c));
child.stderr.on('data', c => err.push(c));
child.on('error', e => err.push(Buffer.from(String(e && e.message || e))));
child.on('close', (code) => {
clearTimeout(timer);
resolve({
status: timedOut ? -2 : code,
timedOut,
stdout: Buffer.concat(out).toString('utf8'),
stderr: Buffer.concat(err).toString('utf8')
});
});
child.stdin.end();
});
}
// Token nie in Fehlermeldungen oder Logs durchreichen.
function redact(text, token) {
let s = String(text || '');
if (token) s = s.split(token).join('***');
return s.replace(/\/\/[^/@\s]+@/g, '//***@');
}
function isPackFailure(stderr) {
return /early eof|index-pack|bad pack header|unexpected disconnect|remote end hung up|rpc failed/i.test(String(stderr || ''));
}
/* ----------------------------------------------------------------
Upload-Vorschau
---------------------------------------------------------------- */
async function previewUpload({ paths, destPath }, deps) {
const { items, skipped, ignored, blocked } = await deps.buildItemsFromPaths(paths, destPath);
const files = [];
let totalBytes = 0;
for (const item of items) {
let size = 0;
try { size = (await fs.promises.stat(item.localFile)).size; } catch (_) {}
totalBytes += size;
files.push({ localFile: item.localFile, targetPath: item.targetPath, size });
}
const prefs = deps.prefs ? deps.prefs.get() : { uploadSecretScan: true, uploadPreview: 'always' };
// Geheimnis-Erkennung lässt sich unter Einstellungen → Uploads abschalten
const secrets = prefs.uploadSecretScan ? await scanForSecrets(files) : new Map();
for (const f of files) {
const reason = secrets.get(f.localFile);
if (reason) f.secret = reason;
}
const ignoredEntries = [
...skipped.filter(s => s.error === 'gitignore').map(s => ({ path: s.path, isDir: isDirSafe(s.path) })),
...(ignored || [])
];
return {
ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size, blocked: blocked || [],
previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan
};
}
function isDirSafe(p) {
try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
}
/* ----------------------------------------------------------------
Server-Diagnose
---------------------------------------------------------------- */
const SERVER_HINT_COMMANDS = 'docker logs --tail 300 gitea 2>&1 | grep -iE "pack|upload-pack|error|killed"\ndf -h\nfree -m';
async function probeFamily(ctx, family) {
const lib = ctx.base.startsWith('https:') ? https : http;
const agent = new lib.Agent({ family, keepAlive: false });
const started = Date.now();
try {
const res = await axios.get(`${ctx.apiBase}/version`, {
httpAgent: agent, httpsAgent: agent, timeout: 8000, validateStatus: () => true
});
return { ok: res.status < 500, ms: Date.now() - started, status: res.status };
} catch (e) {
return { ok: false, error: e.code || e.message };
} finally {
agent.destroy();
}
}
async function runDiagnosis(data, deps) {
const checks = [];
const add = (label, status, detail, hint = null) => checks.push({ label, status, detail, hint });
let ctx;
try {
ctx = giteaContext(deps.readCredentials);
add('Zugangsdaten', 'ok', `Server: ${ctx.base}`);
} catch (e) {
add('Zugangsdaten', 'fail', e.message, 'In den Einstellungen Gitea-URL und Token eintragen.');
return { ok: true, checks };
}
// Namensauflösung und Erreichbarkeit pro Adressfamilie
const host = new URL(ctx.base).hostname.replace(/^\[|\]$/g, '');
let families = [];
if (net.isIP(host)) {
families = [net.isIP(host)];
add('Namensauflösung', 'ok', `IP-Adresse direkt angegeben (IPv${families[0]})`);
} else {
try {
const addrs = await dns.lookup(host, { all: true });
const v4 = addrs.filter(a => a.family === 4).map(a => a.address);
const v6 = addrs.filter(a => a.family === 6).map(a => a.address);
if (v4.length) families.push(4);
if (v6.length) families.push(6);
add('Namensauflösung', 'ok', [v4.length ? `IPv4: ${v4.join(', ')}` : null, v6.length ? `IPv6: ${v6.join(', ')}` : null].filter(Boolean).join(' · '));
} catch (e) {
add('Namensauflösung', 'fail', `${host} lässt sich nicht auflösen (${e.code || e.message})`,
'DNS-Eintrag (bei ipv64.net: DynDNS-Update) prüfen und ob der Name richtig geschrieben ist.');
return { ok: true, checks };
}
}
const reach = {};
for (const fam of families) reach[fam] = await probeFamily(ctx, fam);
const working = families.filter(f => reach[f].ok);
for (const fam of families) {
const r = reach[fam];
if (r.ok) add(`Erreichbar über IPv${fam}`, 'ok', `Antwort in ${r.ms} ms`);
else add(`Erreichbar über IPv${fam}`, working.length ? 'warn' : 'fail', `Keine Verbindung (${r.error || 'HTTP ' + r.status})`,
working.length
? `Die App weicht automatisch auf IPv${working[0]} aus. Falls IPv${fam} gewollt ist: Firewall/Portfreigabe und ${fam === 6 ? 'AAAA' : 'A'}-Eintrag prüfen.`
: 'Server läuft nicht oder Port/Firewall/Reverse-Proxy blockiert.');
}
if (!working.length) return { ok: true, checks };
// API und Token
let login = null;
try {
const v = await giteaCall(ctx, 'get', '/version');
add('Gitea-API', 'ok', `Gitea ${v && v.version ? v.version : '(Version unbekannt)'}`);
} catch (e) {
add('Gitea-API', 'fail', e.message, 'Gitea-URL prüfen, sie muss auf die Startseite von Gitea zeigen (ohne /api).');
return { ok: true, checks };
}
try {
const u = await giteaCall(ctx, 'get', '/user');
login = u.login;
add('Token', 'ok', `Angemeldet als ${u.login}`);
} catch (e) {
add('Token', 'fail', e.status === 401 ? 'Token ungültig oder abgelaufen' : e.message, 'In Gitea unter Einstellungen → Anwendungen einen neuen Token erzeugen.');
return { ok: true, checks };
}
// Git-Transport mit einem echten Repo testen
let owner = data && data.owner;
let repo = data && data.repo;
if (!owner || !repo) {
try {
const repos = await giteaCall(ctx, 'get', '/user/repos', { params: { limit: 1 } });
if (repos && repos[0]) { owner = repos[0].owner.login; repo = repos[0].name; }
} catch (_) {}
}
if (!owner || !repo) {
add('Git-Transport', 'warn', 'Kein Repository zum Testen gefunden');
} else {
const u = new URL(ctx.base);
const authUrl = `${u.protocol}//${enc(ctx.token)}@${u.host}${u.pathname.replace(/\/$/, '')}/${owner}/${repo}.git`;
const lr = await runGit(['ls-remote', '--heads', authUrl], { timeoutMs: 30000 });
if (lr.status !== 0) {
add('Git-Anmeldung', 'fail', `ls-remote für ${owner}/${repo} fehlgeschlagen: ${redact(lr.stderr, ctx.token).trim().slice(0, 300)}`,
'Token braucht Lese- und Schreibrechte für Repositories. Reverse-Proxy muss /info/refs durchlassen.');
} else {
add('Git-Anmeldung', 'ok', `${owner}/${repo} erreichbar`);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-diag-'));
try {
const started = Date.now();
let pk = await runGit(['clone', '--bare', '--depth', '1', '--filter=tree:0', authUrl, path.join(tmp, 'r.git')], { timeoutMs: 90000 });
if (pk.status !== 0 && /filter|not recognized|unsupported/i.test(pk.stderr) && !isPackFailure(pk.stderr)) {
pk = await runGit(['clone', '--bare', '--depth', '1', authUrl, path.join(tmp, 'r2.git')], { timeoutMs: 90000 });
}
if (pk.status === 0) {
add('Git-Pakete', 'ok', `Server liefert Pakete aus (${Date.now() - started} ms)`);
if (deps.clearGitTransportBlock && deps.clearGitTransportBlock(u.host)) {
add('Schneller Git-Weg', 'ok', 'Sperre aufgehoben: Uploads und Löschungen laufen wieder über Git statt über die langsame API.');
}
} else if (pk.timedOut) {
add('Git-Pakete', 'fail', 'Zeitüberschreitung beim Abholen eines Pakets', 'Verbindung oder Server sehr langsam. Reverse-Proxy-Timeouts prüfen.');
} else if (isPackFailure(pk.stderr)) {
add('Git-Pakete', 'fail',
'Der Server bricht beim Ausliefern von Git-Paketen ab (early EOF). Deshalb laufen Uploads und Löschungen über die langsame API.',
'Das liegt am Server. Häufige Ursachen: Datenträger voll, zu wenig RAM für git pack-objects, defektes Repo oder ein Proxy, der die Antwort abschneidet. Auf dem Server prüfen:\n' + SERVER_HINT_COMMANDS);
} else {
add('Git-Pakete', 'fail', redact(pk.stderr, ctx.token).trim().slice(0, 300));
}
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
}
}
}
add('Speicherplatz & RAM', 'info', 'Lassen sich über die Gitea-API nicht abfragen.', 'Auf dem Server prüfen mit:\ndf -h\nfree -m');
return { ok: true, checks, login };
}
/* ----------------------------------------------------------------
Lokale Änderungen
---------------------------------------------------------------- */
const STATUS_LABELS = { M: 'geändert', A: 'neu', D: 'gelöscht', R: 'umbenannt', C: 'kopiert', U: 'Konflikt', '?': 'neu (nicht verfolgt)', T: 'Typ geändert' };
function parsePorcelainZ(out) {
const parts = out.split('\0');
const files = [];
for (let i = 0; i < parts.length; i++) {
const entry = parts[i];
if (!entry) continue;
const x = entry[0];
const y = entry[1];
const file = entry.slice(3);
let from = null;
if (x === 'R' || x === 'C') { from = parts[i + 1]; i++; }
const code = x === '?' ? '?' : (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) ? 'U' : (y !== ' ' ? y : x);
files.push({ path: file, from, code, label: STATUS_LABELS[code] || code, staged: x !== ' ' && x !== '?' });
}
return files;
}
async function localGit(data, deps = {}) {
const folder = data && data.folder;
if (!folder || !fs.existsSync(folder)) return { ok: false, error: 'Ordner nicht gefunden' };
const top = await runGit(['-C', folder, 'rev-parse', '--show-toplevel']);
if (top.status !== 0) return { ok: false, notRepo: true, error: 'Dieser Ordner ist kein Git-Repository.' };
const root = path.resolve(top.stdout.trim());
switch (data.action) {
case 'status': {
const st = await runGit(['-C', root, 'status', '--porcelain=v1', '-z', '-uall']);
if (st.status !== 0) return { ok: false, error: st.stderr.trim() };
const br = await runGit(['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD']);
const ahead = await runGit(['-C', root, 'rev-list', '--count', '@{u}..HEAD']);
return {
ok: true, root,
branch: br.status === 0 ? br.stdout.trim() : '(noch kein Commit)',
ahead: ahead.status === 0 ? Number(ahead.stdout.trim()) || 0 : null,
files: parsePorcelainZ(st.stdout)
};
}
case 'diff': {
const file = String(data.file || '');
if (!file) return { ok: false, error: 'Keine Datei' };
const abs = path.join(root, file);
if (data.code === '?' || data.code === 'A') {
try {
const st = await fs.promises.stat(abs);
if (st.size > 300 * 1024) return { ok: true, diff: `Neue Datei (${Math.round(st.size / 1024)} KB), zu groß für die Vorschau.` };
const buf = await fs.promises.readFile(abs);
if (buf.subarray(0, 8000).includes(0)) return { ok: true, diff: 'Neue Binärdatei' };
return { ok: true, diff: buf.toString('utf8').split('\n').map(l => '+' + l).join('\n'), allNew: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
const d = await runGit(['-C', root, 'diff', 'HEAD', '--no-color', '--', file]);
if (d.status !== 0) return { ok: false, error: d.stderr.trim() };
return { ok: true, diff: d.stdout.length > 400000 ? d.stdout.slice(0, 400000) + '\n… (gekürzt)' : (d.stdout || 'Keine inhaltlichen Unterschiede (z. B. nur Zeilenenden).') };
}
case 'commit': {
const files = Array.isArray(data.files) ? data.files.filter(Boolean) : [];
const message = String(data.message || '').trim();
if (!files.length) return { ok: false, error: 'Keine Dateien ausgewählt' };
if (!message) return { ok: false, error: 'Commit-Nachricht fehlt' };
// Nur die ausgewählten Dateien in den Commit nehmen.
await runGit(['-C', root, 'reset', '-q']);
for (let i = 0; i < files.length; i += 100) {
const r = await runGit(['-C', root, 'add', '-A', '--', ...files.slice(i, i + 100)]);
if (r.status !== 0) return { ok: false, error: r.stderr.trim() };
}
let c = await runGit(['-C', root, 'commit', '-m', message]);
if (c.status !== 0 && /tell me who you are|user\.email|user\.name/i.test(c.stderr)) {
const id = commitIdentity(deps.prefs && deps.prefs.get());
c = await runGit(['-C', root, '-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-m', message]);
}
if (c.status !== 0) return { ok: false, error: (c.stderr || c.stdout).trim() };
return { ok: true, output: c.stdout.trim() };
}
case 'push': {
const p = await runGit(['-C', root, 'push', 'origin', 'HEAD'], { timeoutMs: 600000 });
if (p.status !== 0) {
return { ok: false, error: redact(p.stderr || p.stdout, null).trim() || 'Push fehlgeschlagen',
hint: isPackFailure(p.stderr) ? 'Der Server bricht die Übertragung ab. Server-Diagnose in den Einstellungen ausführen.' : null };
}
return { ok: true, output: p.stderr.trim() };
}
case 'discard': {
const file = String(data.file || '');
if (!file) return { ok: false, error: 'Keine Datei' };
if (data.code === '?') {
await fs.promises.rm(path.join(root, file), { force: true });
return { ok: true };
}
const r = await runGit(['-C', root, 'checkout', 'HEAD', '--', file]);
return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr.trim() };
}
default:
return { ok: false, error: 'Unbekannte Aktion' };
}
}
/* ----------------------------------------------------------------
Pull Requests & Issues
---------------------------------------------------------------- */
function mapUser(u) { return u ? { login: u.login, avatar: u.avatar_url } : null; }
function mapIssue(i) {
return {
number: i.number, title: i.title, body: i.body || '', state: i.state,
user: mapUser(i.user), comments: i.comments, created: i.created_at, updated: i.updated_at,
labels: (i.labels || []).map(l => ({ name: l.name, color: l.color })), url: i.html_url,
isPull: !!i.pull_request
};
}
function mapPull(p) {
return {
...mapIssue(p), isPull: true,
head: p.head && p.head.label, base: p.base && p.base.label,
mergeable: p.mergeable, merged: !!p.merged
};
}
async function giteaCollab(data, deps) {
const ctx = giteaContext(deps.readCredentials);
const { owner, repo } = data || {};
if (!owner || !repo) return { ok: false, error: 'Repository fehlt' };
const rp = repoApiPath(owner, repo);
const idx = Number(data.index);
const state = ['open', 'closed', 'all'].includes(data.state) ? data.state : 'open';
const page = Math.max(1, Number(data.page) || 1);
switch (data.action) {
case 'listPulls': {
const list = await giteaCall(ctx, 'get', `${rp}/pulls`, { params: { state, page, limit: 30, sort: 'recentupdate' } });
return { ok: true, items: (list || []).map(mapPull) };
}
case 'listIssues': {
const list = await giteaCall(ctx, 'get', `${rp}/issues`, { params: { state, page, limit: 30, type: 'issues' } });
return { ok: true, items: (list || []).map(mapIssue) };
}
case 'get': {
const item = data.isPull
? mapPull(await giteaCall(ctx, 'get', `${rp}/pulls/${idx}`))
: mapIssue(await giteaCall(ctx, 'get', `${rp}/issues/${idx}`));
const comments = await giteaCall(ctx, 'get', `${rp}/issues/${idx}/comments`);
return {
ok: true, item,
comments: (comments || []).map(c => ({ id: c.id, body: c.body || '', user: mapUser(c.user), created: c.created_at }))
};
}
case 'comment': {
const body = String(data.body || '').trim();
if (!body) return { ok: false, error: 'Kommentar ist leer' };
await giteaCall(ctx, 'post', `${rp}/issues/${idx}/comments`, { data: { body } });
return { ok: true };
}
case 'setState': {
if (!['open', 'closed'].includes(data.state)) return { ok: false, error: 'Ungültiger Status' };
await giteaCall(ctx, 'patch', `${rp}/issues/${idx}`, { data: { state: data.state } });
return { ok: true };
}
case 'merge': {
const style = ['merge', 'rebase', 'rebase-merge', 'squash'].includes(data.style) ? data.style : 'merge';
await giteaCall(ctx, 'post', `${rp}/pulls/${idx}/merge`, {
data: { Do: style, delete_branch_after_merge: !!data.deleteBranch },
timeout: 120000
});
return { ok: true };
}
case 'createIssue': {
const title = String(data.title || '').trim();
if (!title) return { ok: false, error: 'Titel fehlt' };
const created = await giteaCall(ctx, 'post', `${rp}/issues`, { data: { title, body: String(data.body || '') } });
return { ok: true, item: mapIssue(created) };
}
default:
return { ok: false, error: 'Unbekannte Aktion' };
}
}
/* ----------------------------------------------------------------
Registrierung
---------------------------------------------------------------- */
function wrap(fn) {
return async (event, data) => {
try {
return await fn(data || {}, event);
} catch (e) {
console.error('[features]', e && e.message ? e.message : e);
return { ok: false, error: e && e.message ? e.message : String(e) };
}
};
}
function registerFeatureIpc(deps) {
const { ipcMain } = deps;
ipcMain.handle('preview-upload-paths', wrap((data) => previewUpload(data, deps)));
ipcMain.handle('run-server-diagnosis', wrap((data) => runDiagnosis(data, deps)));
ipcMain.handle('local-git', wrap((data) => localGit(data, deps)));
// App-Einstellungen (ohne Geheimnisse)
ipcMain.handle('prefs-get', wrap(() => ({ ok: true, prefs: deps.prefs ? deps.prefs.get() : {} })));
ipcMain.handle('prefs-set', wrap((data) => {
if (!deps.prefs) return { ok: false, error: 'Einstellungen nicht verfügbar' };
const r = deps.prefs.set(data);
return { ok: r.rejected.length === 0, prefs: r.prefs, rejected: r.rejected,
error: r.rejected.includes('gitEmail') ? 'Die E-Mail-Adresse ist ungültig und wurde nicht übernommen.' : undefined };
}));
// Gesperrter schneller Git-Weg (nach "early EOF") anzeigen und von Hand freigeben
ipcMain.handle('git-transport-state', wrap(() => {
const blocks = deps.getGitTransportBlocks ? deps.getGitTransportBlocks() : {};
return { ok: true, blocked: Object.entries(blocks).map(([host, since]) => ({ host, since: new Date(since).toISOString() })) };
}));
ipcMain.handle('git-transport-clear', wrap((data) => {
const host = String((data && data.host) || '');
return { ok: !!(deps.clearGitTransportBlock && deps.clearGitTransportBlock(host)) };
}));
ipcMain.handle('gitea-collab', wrap((data) => giteaCollab(data, deps)));
const backup = createRepoBackup({
configPath: path.join(deps.app.getPath('userData'), 'repo-backup.json'),
getContext: () => giteaContext(deps.readCredentials),
giteaCall,
runGit,
isPackFailure,
redact
});
ipcMain.handle('repo-backup-get-config', wrap(() => ({ ok: true, ...backup.getState() })));
ipcMain.handle('repo-backup-set-config', wrap((data) => ({ ok: true, ...backup.setConfig(data) })));
// Fortschritt an alle Fenster melden – auch bei einem geplanten Backup und für später geöffnete Ansichten
backup.onProgress((p) => {
if (deps.broadcast) deps.broadcast('repo-backup-progress', p);
// Windows-Benachrichtigung am Ende – je nach Einstellung immer, nur bei Problemen oder nie
if (!p || (p.phase !== 'done' && p.phase !== 'error') || !deps.notify) return;
const mode = backup.getState().config.notify;
const s = p.summary;
const problem = p.phase === 'error' || (s && (s.failed > 0 || s.cancelled));
if (mode === 'never' || (mode === 'errors' && !problem)) return;
if (p.phase === 'error') deps.notify('Repo-Backup fehlgeschlagen', p.error || 'Unbekannter Fehler');
else if (s.cancelled) deps.notify('Repo-Backup abgebrochen', `${s.ok} von ${s.total} Repos gesichert.`);
else if (s.failed) deps.notify('Repo-Backup mit Fehlern', `${s.ok} von ${s.total} gesichert, ${s.failed} fehlgeschlagen. Details in den Einstellungen unter Backup.`);
else deps.notify('Repo-Backup fertig', `${s.ok} von ${s.total} Repos gesichert.`);
});
// Ordner im Explorer öffnen – nur feste, bekannte Ziele, keine beliebigen Pfade aus dem Renderer
ipcMain.handle('open-known-folder', wrap(async (data) => {
const targets = {
backup: () => backup.getState().config.targetDir,
appData: () => deps.app.getPath('userData'),
logs: () => deps.app.getPath('logs')
};
const resolve = targets[data && data.which];
const dir = resolve && resolve();
if (!dir) return { ok: false, error: 'Kein Ordner festgelegt.' };
if (!fs.existsSync(dir)) return { ok: false, error: `Ordner "${dir}" nicht gefunden.` };
const err = deps.openPath ? await deps.openPath(dir) : 'Öffnen nicht möglich';
return err ? { ok: false, error: err } : { ok: true, path: dir };
}));
ipcMain.handle('repo-backup-run-now', wrap(() => backup.runNow(null)));
ipcMain.handle('repo-backup-cancel', wrap(() => backup.cancel()));
backup.startScheduler();
const restore = createRepoRestore({
getContext: () => giteaContext(deps.readCredentials),
giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs,
identity: () => commitIdentity(deps.prefs && deps.prefs.get())
});
ipcMain.handle('repo-backup-list', wrap((data) => {
const dir = (data && data.dir) || backup.getState().config.targetDir;
if (!dir) return { ok: false, error: 'Kein Backup-Ordner festgelegt.' };
if (!fs.existsSync(dir)) return { ok: false, error: `Backup-Ordner "${dir}" nicht gefunden.` };
return { ok: true, dir, backups: restore.listBackups(dir) };
}));
ipcMain.handle('repo-restore-local', wrap((data) => restore.restoreLocal(data)));
ipcMain.handle('repo-restore-gitea', wrap((data, event) => restore.restoreToGitea(data, (p) => {
try { event.sender.send('repo-restore-progress', p); } catch (_) {}
})));
return { backup, restore };
}
module.exports = { registerFeatureIpc, parsePorcelainZ };
+119
View File
@@ -0,0 +1,119 @@
// Vorlagen für die .gitignore, die per Knopf in ein Repo geschrieben werden.
const GENERAL = `# Betriebssystem / Editor
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
# Zugangsdaten und Logs
.env
.env.*
*.pem
*.key
*.log
`;
const TEMPLATES = {
node: {
label: 'Node.js',
content: `# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm/
.cache/
`
},
dotnet: {
label: '.NET',
content: `# .NET
bin/
obj/
.vs/
*.user
*.suo
*.nupkg
`
},
electron: {
label: 'Electron',
content: `# Electron
dist/
out/
release/
*.exe
*.blockmap
*.sha256
`
},
python: {
label: 'Python',
content: `# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
.pytest_cache/
.mypy_cache/
build/
dist/
`
},
java: {
label: 'Java (Maven/Gradle)',
content: `# Java
target/
build/
.gradle/
*.class
*.jar
*.war
*.iml
out/
`
},
php: {
label: 'PHP',
content: `# PHP
vendor/
composer.phar
.phpunit.result.cache
`
},
unity: {
label: 'Unity',
content: `# Unity
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]uild/
[Bb]uilds/
[Ll]ogs/
[Uu]ser[Ss]ettings/
*.csproj
*.sln
`
}
};
function listTemplates() {
return Object.entries(TEMPLATES).map(([key, t]) => ({ key, label: t.label }));
}
// Baut die Datei aus dem allgemeinen Teil und den gewählten Vorlagen (unbekannte Schlüssel entfallen).
function buildGitignore(keys) {
const parts = [GENERAL];
const seen = new Set();
for (const key of keys || []) {
if (seen.has(key) || !Object.prototype.hasOwnProperty.call(TEMPLATES, key)) continue;
seen.add(key);
parts.push(TEMPLATES[key].content);
}
return parts.join('\n');
}
module.exports = { listTemplates, buildGitignore };
+57
View File
@@ -0,0 +1,57 @@
// Pause-Schranke für Übertragungen: Arbeitsschritte rufen vor jedem Schritt wait() auf und bleiben
// dort stehen, solange pausiert ist. Ein Abbruch (AbortSignal) löst wartende Schritte sofort mit
// dem Abbruchfehler (code UPLOAD_CANCELLED) aus.
//
// Pausiert wird zwischen den Schritten, nicht mittendrin: Ein laufender Clone, Push oder
// Paket-Upload wird erst fertig, danach hält die Übertragung an.
function cancelledError() {
const err = new Error('Upload abgebrochen');
err.code = 'UPLOAD_CANCELLED';
return err;
}
function createPauseGate({ onChange } = {}) {
let paused = false;
let waiters = [];
function setPaused(value) {
if (paused === value) return false;
paused = value;
if (!value) {
const release = waiters;
waiters = [];
for (const w of release) w.resolve();
}
if (onChange) { try { onChange(paused); } catch (_) {} }
return true;
}
function wait(signal) {
if (signal && signal.aborted) return Promise.reject(cancelledError());
if (!paused) return Promise.resolve();
return new Promise((resolve, reject) => {
const waiter = { resolve: null };
const onAbort = () => {
waiters = waiters.filter((w) => w !== waiter);
reject(cancelledError());
};
waiter.resolve = () => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve();
};
waiters.push(waiter);
if (signal) signal.addEventListener('abort', onAbort, { once: true });
});
}
return {
pause: () => setPaused(true),
resume: () => setPaused(false),
isPaused: () => paused,
waitingCount: () => waiters.length,
wait
};
}
module.exports = { createPauseGate };
+64
View File
@@ -0,0 +1,64 @@
// Hilfen für „Release für den Updater veröffentlichen“.
// Der Updater (updater.js) vergleicht den Tag des neuesten Releases mit der lokalen Version, sucht das
// Installer-Asset und liest die SHA-256-Prüfsumme aus einem Asset „<datei>.sha256“ oder aus einer Zeile
// im Release-Text, die Dateiname und 64 Hex-Zeichen enthält. Dieses Modul stellt sicher, dass beides stimmt.
const SHA256_RE = /\b([a-fA-F0-9]{64})\b/;
// '2.2.1' aus 'Git-Manager-GUI-Setup-2.2.1.exe' (oder null)
function parseVersionFromFileName(name) {
const m = String(name || '').match(/(\d+\.\d+\.\d+)/);
return m ? m[1] : null;
}
// Zerlegt 'v2.10.3' in [2, 10, 3]; ungültige Teile zählen als 0.
function versionParts(v) {
return String(v || '').replace(/^[^\d]*/, '').split('.').map((p) => parseInt(p, 10) || 0);
}
function compareVersions(a, b) {
const pa = versionParts(a);
const pb = versionParts(b);
for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
// Inhalt einer .sha256-Datei ("<hash> <name>") → Hash in Kleinbuchstaben oder null
function parseSidecar(text) {
const m = String(text || '').match(SHA256_RE);
return m ? m[1].toLowerCase() : null;
}
// Prüft, ob ein neuer Tag zum Updater passt.
// releases: vorhandene Releases ({ tag_name }), neueste zuerst oder beliebig.
function checkNewTag(tag, releases) {
const wanted = String(tag || '').trim();
if (!/^v?\d+\.\d+\.\d+$/i.test(wanted)) {
return { ok: false, reason: 'Der Tag muss eine Version wie v2.2.1 sein, sonst erkennt der Updater das Release nicht.' };
}
const tags = (releases || []).map((r) => String(r.tag_name || '')).filter(Boolean);
if (tags.some((t) => t.toLowerCase() === wanted.toLowerCase())) {
return { ok: false, reason: `Den Tag „${wanted}“ gibt es schon.` };
}
const newest = tags.filter((t) => /\d+\.\d+\.\d+/.test(t)).sort(compareVersions).pop();
if (newest && compareVersions(wanted, newest) <= 0) {
return { ok: false, reason: `Die Version ${wanted} ist nicht neuer als das neueste Release ${newest}. Der Updater würde sie nicht anbieten.` };
}
return { ok: true, newest: newest || null };
}
// Hängt die Prüfsummen an die Release-Notizen, in dem Format, das der Updater liest.
function buildReleaseBody(notes, assets) {
const base = String(notes || '').replace(/\s+$/, '');
const lines = (assets || [])
.filter((a) => a && a.name && /^[a-fA-F0-9]{64}$/.test(String(a.sha256 || '')))
.map((a) => `- ${a.name}: ${String(a.sha256).toLowerCase()}`);
if (lines.length === 0) return base;
const block = ['### Prüfsummen (SHA-256)', ...lines].join('\n');
return base ? `${base}\n\n${block}\n` : `${block}\n`;
}
module.exports = { parseVersionFromFileName, compareVersions, parseSidecar, checkNewTag, buildReleaseBody };

Some files were not shown because too many files have changed in this diff Show More