From 99d9629e25d41b7e26efd1cf3a5b6bda26ec6e89 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 22 Oct 2026 07:15:41 +0000 Subject: [PATCH] Upload via GUI (10 Dateien) --- .gitignore | 58 +++ DEV_GUIDE.md | 1070 +++++++++++++++++++++++++------------------------- main.js | 296 ++++++++++++-- preload.js | 5 + 4 files changed, 862 insertions(+), 567 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdab14d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index 71c0408..3bd7af1 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -1,535 +1,535 @@ -# Entwicklerhandbuch — Git Manager Explorer Pro - -Dieses Dokument richtet sich an Entwickler, die an **Git Manager Explorer Pro** beitragen oder das Projekt erweitern möchten. - ---- - -## Inhaltsverzeichnis - -1. [Projektstruktur](#projektstruktur) -2. [Setup & Abhängigkeiten](#setup--abhängigkeiten) -3. [Entwicklung starten](#entwicklung-starten) -4. [Wichtige Module](#wichtige-module) -5. [Neue Features hinzufügen](#neue-features-hinzufügen) -6. [Build & Release](#build--release) -7. [Debugging & Testing](#debugging--testing) -8. [Code-Konventionen](#code-konventionen) -9. [Häufige Aufgaben](#häufige-aufgaben) - ---- - -## Projektstruktur - -``` -git-manager-gui/ -├── main.js # Electron Main Process — Fenster, Menü, IPC -├── preload.js # Preload Script — sichere Electron API für Renderer -├── renderer/ # Frontend (React/Vanilla JS) -│ ├── App.jsx # React-Komponente (optional, optional genutzt) -│ ├── Settings.jsx # Settings-Modal -│ ├── index.html # HTML Entry Point -│ ├── index.js # Renderer Init -│ ├── renderer.js # Hauptlogik: UI, Grid, Editor, API-Calls -│ ├── style.css # Styling -│ └── modules/ # Feature-Module -│ ├── editor.js # Editor-Funktion (Syntax Highlighting, etc.) -│ ├── gitea.js # Gitea API Wrapper -│ ├── github.js # GitHub API Wrapper -│ ├── progress.js # Progress-Bar & Ladeindikatoren -│ ├── state.js # State Management (Favoriten, Verlauf, etc.) -│ └── ui.js # UI Helpers (Dialoge, Toast, etc.) -├── src/ # Backend (Node.js Main Process) -│ ├── git/ -│ │ ├── gitHandler.js # Git CLI Wrapper (Commits, Tags, etc.) -│ │ └── apiHandler.js # Gitea/GitHub API HTTP Client -│ ├── backup/ -│ │ ├── BackupManager.js # Backup/Restore Logik -│ │ ├── BackupProvider.js # Provider Interface -│ │ └── LocalProvider.js # Lokale Datei-Provider -│ └── utils/ -│ └── helpers.js # Allgemeine Hilfsfunktionen -├── __tests__/ -│ └── gitHandler.test.js # Jest Unit Tests -├── updater.js # App-Update-Logik -├── package.json # Dependencies & Scripts -├── HANDBUCH.html # Benutzerhandbuch (HTML) -└── README.md # Projekt-Übersicht - -``` - ---- - -## Setup & Abhängigkeiten - -### Voraussetzungen -- **Node.js** 16+ (empfohlen: LTS) -- **npm** 7+ -- **Git** (für Git-Operationen) -- **Visual Studio Code** (optional, empfohlen) - -### Installation - -```bash -# Repository klonen -git clone https://github.com/dein-username/git-manager-gui.git -cd git-manager-gui - -# Dependencies installieren -npm install - -# App starten -npm start -``` - -### Wichtige Dependencies - -| Package | Verwendung | -|---------|-----------| -| `electron` | Desktop-Framework | -| `sqlite3` | Lokale Datenspeicherung (Favoriten, Verlauf) | -| `axios` | HTTP-Requests zu Gitea/GitHub APIs | -| `crypto` | Verschlüsselung von Credentials | -| `jest` | Testing Framework | - ---- - -## Entwicklung starten - -### Dev-Server / Hot Reload - -Es gibt aktuell keinen automatischen Hot Reload. Nach Code-Änderungen: - -```bash -# App neu starten -npm start - -# Oder im laufenden Fenster: DevTools öffnen -# Strg+Shift+I → Reload (Strg+R) -``` - -### DevTools debuggen - -```javascript -// In main.js, beim Erstellen des Fensters: -mainWindow.webContents.openDevTools(); - -// Oder per Shortcut im laufenden Fenster: -// Strg+Shift+I -``` - -### Logs anschauen - -```bash -# Logs der letzten Session anschauen: -# Windows: %APPDATA%\git-manager-gui\logs\ -# oder direkt im app data folder - -# Mit Winston logger (falls implementiert): -npm run logs -``` - ---- - -## Wichtige Module - -### `renderer/renderer.js` — Hauptlogik - -Das Herzstück der App. Enthält: - -- **Globale State-Variablen**: `favorites`, `recentRepos`, `currentState`, `pinnedRepos` -- **Grid-Rendering**: `loadRepos()`, `loadGiteaRepos()`, `loadGithubRepos()` -- **Kontext-Menü**: `showRepoContextMenu()` -- **Datei-Explorer**: `loadRepoContents()`, `renderExplorer()` -- **Editor**: `openFileEditor()`, mit Speichern & Syntax-Highlighting -- **Favoriten/Verlauf**: `addToFavorites()`, `addToRecent()` - -**Wichtige Funktionen:** - -```javascript -// Repository-Liste laden -loadRepos() → loadGiteaRepos() oder loadGithubRepos() - -// Repository öffnen (Datei-Explorer) -loadRepoContents(owner, repo, path) - -// Datei bearbeiten -openFileEditor(owner, repo, path, content) - -// Favoriten hinzufügen -async addToFavorites(owner, repo, cloneUrl, platform) - -// Settings speichern -async saveSettings(settings) -``` - -### `src/git/gitHandler.js` — Git-Operationen - -Wrapper um Git CLI. Beispiele: - -```javascript -// Commits einsehen -const commits = await getCommitLog(repoPath, branch); - -// Tags auflisten -const tags = await getTagList(repoPath); - -// Branch wechseln -await checkoutBranch(repoPath, branchName); - -// Lokales Repo klonen -await cloneRepository(cloneUrl, targetPath); -``` - -### `src/git/apiHandler.js` — API-Calls - -HTTP-Wrapper für Gitea/GitHub APIs. - -```javascript -// Gitea Repositories auflisten -const repos = await getGiteaRepos(url, token, username); - -// GitHub Repositories -const repos = await getGithubRepos(token); - -// Repository erstellen -await createGiteaRepo(url, token, { name, description, private }); - -// Datei speichern -await updateGiteaFile(url, token, owner, repo, path, content, message); -``` - -### `renderer/modules/state.js` — State Management - -Persistiert Favoriten, Verlauf und Settings. - -```javascript -// State laden/speichern -loadState() → returns { favorites, recent, pinnedRepos } -saveState(state) → speichert lokal - -// Helpers -addFavorite(repo) -removeFavorite(repo) -addRecentRepo(repo) -clearRecent() -``` - ---- - -## Neue Features hinzufügen - -### Beispiel: Neuer API-Endpoint integrieren - -#### Schritt 1: API-Wrapper in `src/git/apiHandler.js` ergänzen - -```javascript -/** - * Gitea Webhook auflisten - */ -async function getGiteaWebhooks(url, token, owner, repo) { - const path = `/api/v1/repos/${owner}/${repo}/hooks`; - return axios.get(`${url}${path}`, { - headers: { 'Authorization': `token ${token}` } - }).then(r => r.data); -} - -module.exports = { getGiteaWebhooks, /* ... other functions */ }; -``` - -#### Schritt 2: IPC-Handler in `main.js` ergänzen - -```javascript -// In ipcMain.handle() Bereich: -ipcMain.handle('getGiteaWebhooks', async (event, { url, token, owner, repo }) => { - try { - const webhooks = await apiHandler.getGiteaWebhooks(url, token, owner, repo); - return { ok: true, webhooks }; - } catch (err) { - return { ok: false, error: err.message }; - } -}); -``` - -#### Schritt 3: UI in `renderer/renderer.js` hinzufügen - -```javascript -// Button im Repo-Kontextmenü: -if (option === 'webhooks') { - const webhooks = await window.electronAPI.getGiteaWebhooks({ - url: currentState.giteaUrl, - token: credentials.giteaToken, - owner, - repo - }); - if (webhooks.ok) { - showWebhookList(webhooks.webhooks); - } else { - showError('Webhooks laden fehlgeschlagen: ' + webhooks.error); - } -} -``` - ---- - -## Build & Release - -### Production Build - -```bash -# Electron App als .exe packagen -npm run build - -# Output: dist/git-manager-gui-Setup-2.x.x.exe -``` - -### Version erhöhen - -1. Version in `package.json` updaten: `"version": "2.1.4"` -2. Changelog aktualisieren (optional) -3. Build ausführen -4. GitHub Release erstellen mit der .exe - ---- - -## Debugging & Testing - -### Unit Tests schreiben - -Jest ist konfiguriert. Test-Datei Vorlage: - -```javascript -// __tests__/myFeature.test.js -const { myFunction } = require('../src/git/gitHandler'); - -describe('Git Handler', () => { - it('should parse commit log', () => { - const result = myFunction(testData); - expect(result.length).toBe(3); - expect(result[0].sha).toMatch(/^[a-f0-9]{7}/); - }); -}); -``` - -Tests starten: - -```bash -npm test - -# Mit Coverage: -npm test -- --coverage - -# Watch Mode: -npm test -- --watch -``` - -### Debugging im DevTools - -```javascript -// Im renderer.js oder irgendwo im Renderer: -console.log('Debug Info:', data); - -// Öffne DevTools: Strg+Shift+I -// Console Tab → alle Logs sichtbar -``` - -### Main Process debuggen - -```javascript -// In main.js: -console.log('Main Process Log:', data); - -// Logs erscheinen in der Terminal-Console -// wenn die App mit npm start gestartet wurde -``` - ---- - -## Code-Konventionen - -### Variablen & Funktionen - -```javascript -// camelCase für Variablen und Funktionen -const repoName = "mein-projekt"; -function loadRepositories() { } - -// SCREAMING_SNAKE_CASE für Konstanten -const MAX_RETRIES = 5; -const DEFAULT_TIMEOUT = 3000; - -// Präfixe für Boolean-Variablen -const isPrivate = true; -const hasCredentials = false; -const shouldRetry = true; -``` - -### Async/Await - -```javascript -// Immer try-catch für async Funktionen -async function loadData() { - try { - const data = await fetchFromAPI(); - return { ok: true, data }; - } catch (error) { - console.error('Error:', error); - return { ok: false, error: error.message }; - } -} -``` - -### Kommentare - -```javascript -// Kurze Inline-Kommentare über Code -const delay = 1000; // Millisekunden - -/** - * Längere Beschreibung vor Funktion - * @param {string} repoName - Name des Repositories - * @returns {Promise} Result mit { ok, data, error } - */ -async function loadRepository(repoName) { - // Implementierung -} -``` - -### Fehlerbehandlung - -```javascript -// Immer aussagekräftige Fehlermeldungen -if (!token) { - return { ok: false, error: 'missing-token' }; -} - -// Oder sprechend für Benutzer: -if (!token) { - return { ok: false, error: 'Gitea Token nicht gespeichert. Bitte Settings öffnen.' }; -} -``` - ---- - -## Häufige Aufgaben - -### Neue Einstellung hinzufügen - -1. **In `Settings.jsx` UI-Control hinzufügen** - ```jsx - - setSetting('newOption', e.target.checked)} /> - ``` - -2. **In `renderer.js` speichern** - ```javascript - async function saveSettings(settings) { - const result = await window.electronAPI.saveSettings(settings); - // ... - } - ``` - -3. **In `main.js` Handler** - ```javascript - ipcMain.handle('saveSettings', async (event, settings) => { - // Validation und Speicherung - return { ok: true }; - }); - ``` - -### Neuer Button in der Toolbar - -1. **In `index.html` Button hinzufügen** - ```html - - ``` - -2. **In `renderer.js` Event-Handler** - ```javascript - if ($('btnMyFeature')) { - $('btnMyFeature').onclick = () => { - myFeatureHandler(); - }; - } - ``` - -### Neue Kontextmenü-Option - -1. **In `showRepoContextMenu()` Item hinzufügen** - ```javascript - items.push({ - label: '🎯 Meine Aktion', - click: () => myActionHandler(owner, repo) - }); - ``` - -2. **Handler implementieren** - ```javascript - function myActionHandler(owner, repo) { - // Logik... - } - ``` - -### Neue API-Methode aufrufen - -1. **In `src/git/apiHandler.js` ergänzen** -2. **In `main.js` IPC-Handler anlegen** -3. **In `renderer.js` aufrufen:** - ```javascript - const result = await window.electronAPI.myNewMethod({ param1, param2 }); - ``` - ---- - -## Troubleshooting für Entwickler - -### App startet nicht -```bash -# Node/npm Version prüfen -node --version # sollte >= 16 -npm --version # sollte >= 7 - -# Dependencies neu installieren -rm -r node_modules -npm install -npm start -``` - -### Fehler: "Cannot find module" -```bash -# Dependencies aktualisieren -npm install - -# Cache löschen -npm cache clean --force -``` - -### Axios/API-Calls schlagen fehl -- URL/Token in Settings prüfen -- Gitea-Server online? `curl https://git.example.com/api/v1/user` -- Token noch gültig? Im Gitea-Interface regenerieren -- Firewall/VPN blockiert? - -### Datei-Upload funktioniert nicht -- Lokale Temp-Pfade prüfen (in `main.js`) -- Git-Auth konfiguriert? -- Ausreichend Festplatte? - ---- - -## Ressourcen - -- [Electron Dokumentation](https://www.electronjs.org/docs) -- [Gitea API](https://docs.gitea.io/en-us/api-usage/) -- [GitHub API v3](https://docs.github.com/en/rest) -- [Jest Testing](https://jestjs.io/docs/getting-started) - ---- - -## Lizenz & Beitragen - -Dieses Projekt ist unter der **MIT-Lizenz** lizenziert. -Für Beiträge bitte einen **Pull Request** einreichen mit aussagekräftiger Beschreibung. - ---- - -**Letzte Aktualisierung:** 13. Mai 2026 | **Ersteller:** M_Viper +# Entwicklerhandbuch — Git Manager Explorer Pro + +Dieses Dokument richtet sich an Entwickler, die an **Git Manager Explorer Pro** beitragen oder das Projekt erweitern möchten. + +--- + +## Inhaltsverzeichnis + +1. [Projektstruktur](#projektstruktur) +2. [Setup & Abhängigkeiten](#setup--abhängigkeiten) +3. [Entwicklung starten](#entwicklung-starten) +4. [Wichtige Module](#wichtige-module) +5. [Neue Features hinzufügen](#neue-features-hinzufügen) +6. [Build & Release](#build--release) +7. [Debugging & Testing](#debugging--testing) +8. [Code-Konventionen](#code-konventionen) +9. [Häufige Aufgaben](#häufige-aufgaben) + +--- + +## Projektstruktur + +``` +git-manager-gui/ +├── main.js # Electron Main Process — Fenster, Menü, IPC +├── preload.js # Preload Script — sichere Electron API für Renderer +├── renderer/ # Frontend (React/Vanilla JS) +│ ├── App.jsx # React-Komponente (optional, optional genutzt) +│ ├── Settings.jsx # Settings-Modal +│ ├── index.html # HTML Entry Point +│ ├── index.js # Renderer Init +│ ├── renderer.js # Hauptlogik: UI, Grid, Editor, API-Calls +│ ├── style.css # Styling +│ └── modules/ # Feature-Module +│ ├── editor.js # Editor-Funktion (Syntax Highlighting, etc.) +│ ├── gitea.js # Gitea API Wrapper +│ ├── github.js # GitHub API Wrapper +│ ├── progress.js # Progress-Bar & Ladeindikatoren +│ ├── state.js # State Management (Favoriten, Verlauf, etc.) +│ └── ui.js # UI Helpers (Dialoge, Toast, etc.) +├── src/ # Backend (Node.js Main Process) +│ ├── git/ +│ │ ├── gitHandler.js # Git CLI Wrapper (Commits, Tags, etc.) +│ │ └── apiHandler.js # Gitea/GitHub API HTTP Client +│ ├── backup/ +│ │ ├── BackupManager.js # Backup/Restore Logik +│ │ ├── BackupProvider.js # Provider Interface +│ │ └── LocalProvider.js # Lokale Datei-Provider +│ └── utils/ +│ └── helpers.js # Allgemeine Hilfsfunktionen +├── __tests__/ +│ └── gitHandler.test.js # Jest Unit Tests +├── updater.js # App-Update-Logik +├── package.json # Dependencies & Scripts +├── HANDBUCH.html # Benutzerhandbuch (HTML) +└── README.md # Projekt-Übersicht + +``` + +--- + +## Setup & Abhängigkeiten + +### Voraussetzungen +- **Node.js** 16+ (empfohlen: LTS) +- **npm** 7+ +- **Git** (für Git-Operationen) +- **Visual Studio Code** (optional, empfohlen) + +### Installation + +```bash +# Repository klonen +git clone https://github.com/dein-username/git-manager-gui.git +cd git-manager-gui + +# Dependencies installieren +npm install + +# App starten +npm start +``` + +### Wichtige Dependencies + +| Package | Verwendung | +|---------|-----------| +| `electron` | Desktop-Framework | +| `sqlite3` | Lokale Datenspeicherung (Favoriten, Verlauf) | +| `axios` | HTTP-Requests zu Gitea/GitHub APIs | +| `crypto` | Verschlüsselung von Credentials | +| `jest` | Testing Framework | + +--- + +## Entwicklung starten + +### Dev-Server / Hot Reload + +Es gibt aktuell keinen automatischen Hot Reload. Nach Code-Änderungen: + +```bash +# App neu starten +npm start + +# Oder im laufenden Fenster: DevTools öffnen +# Strg+Shift+I → Reload (Strg+R) +``` + +### DevTools debuggen + +```javascript +// In main.js, beim Erstellen des Fensters: +mainWindow.webContents.openDevTools(); + +// Oder per Shortcut im laufenden Fenster: +// Strg+Shift+I +``` + +### Logs anschauen + +```bash +# Logs der letzten Session anschauen: +# Windows: %APPDATA%\git-manager-gui\logs\ +# oder direkt im app data folder + +# Mit Winston logger (falls implementiert): +npm run logs +``` + +--- + +## Wichtige Module + +### `renderer/renderer.js` — Hauptlogik + +Das Herzstück der App. Enthält: + +- **Globale State-Variablen**: `favorites`, `recentRepos`, `currentState`, `pinnedRepos` +- **Grid-Rendering**: `loadRepos()`, `loadGiteaRepos()`, `loadGithubRepos()` +- **Kontext-Menü**: `showRepoContextMenu()` +- **Datei-Explorer**: `loadRepoContents()`, `renderExplorer()` +- **Editor**: `openFileEditor()`, mit Speichern & Syntax-Highlighting +- **Favoriten/Verlauf**: `addToFavorites()`, `addToRecent()` + +**Wichtige Funktionen:** + +```javascript +// Repository-Liste laden +loadRepos() → loadGiteaRepos() oder loadGithubRepos() + +// Repository öffnen (Datei-Explorer) +loadRepoContents(owner, repo, path) + +// Datei bearbeiten +openFileEditor(owner, repo, path, content) + +// Favoriten hinzufügen +async addToFavorites(owner, repo, cloneUrl, platform) + +// Settings speichern +async saveSettings(settings) +``` + +### `src/git/gitHandler.js` — Git-Operationen + +Wrapper um Git CLI. Beispiele: + +```javascript +// Commits einsehen +const commits = await getCommitLog(repoPath, branch); + +// Tags auflisten +const tags = await getTagList(repoPath); + +// Branch wechseln +await checkoutBranch(repoPath, branchName); + +// Lokales Repo klonen +await cloneRepository(cloneUrl, targetPath); +``` + +### `src/git/apiHandler.js` — API-Calls + +HTTP-Wrapper für Gitea/GitHub APIs. + +```javascript +// Gitea Repositories auflisten +const repos = await getGiteaRepos(url, token, username); + +// GitHub Repositories +const repos = await getGithubRepos(token); + +// Repository erstellen +await createGiteaRepo(url, token, { name, description, private }); + +// Datei speichern +await updateGiteaFile(url, token, owner, repo, path, content, message); +``` + +### `renderer/modules/state.js` — State Management + +Persistiert Favoriten, Verlauf und Settings. + +```javascript +// State laden/speichern +loadState() → returns { favorites, recent, pinnedRepos } +saveState(state) → speichert lokal + +// Helpers +addFavorite(repo) +removeFavorite(repo) +addRecentRepo(repo) +clearRecent() +``` + +--- + +## Neue Features hinzufügen + +### Beispiel: Neuer API-Endpoint integrieren + +#### Schritt 1: API-Wrapper in `src/git/apiHandler.js` ergänzen + +```javascript +/** + * Gitea Webhook auflisten + */ +async function getGiteaWebhooks(url, token, owner, repo) { + const path = `/api/v1/repos/${owner}/${repo}/hooks`; + return axios.get(`${url}${path}`, { + headers: { 'Authorization': `token ${token}` } + }).then(r => r.data); +} + +module.exports = { getGiteaWebhooks, /* ... other functions */ }; +``` + +#### Schritt 2: IPC-Handler in `main.js` ergänzen + +```javascript +// In ipcMain.handle() Bereich: +ipcMain.handle('getGiteaWebhooks', async (event, { url, token, owner, repo }) => { + try { + const webhooks = await apiHandler.getGiteaWebhooks(url, token, owner, repo); + return { ok: true, webhooks }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); +``` + +#### Schritt 3: UI in `renderer/renderer.js` hinzufügen + +```javascript +// Button im Repo-Kontextmenü: +if (option === 'webhooks') { + const webhooks = await window.electronAPI.getGiteaWebhooks({ + url: currentState.giteaUrl, + token: credentials.giteaToken, + owner, + repo + }); + if (webhooks.ok) { + showWebhookList(webhooks.webhooks); + } else { + showError('Webhooks laden fehlgeschlagen: ' + webhooks.error); + } +} +``` + +--- + +## Build & Release + +### Production Build + +```bash +# Electron App als .exe packagen +npm run build + +# Output: dist/git-manager-gui-Setup-2.x.x.exe +``` + +### Version erhöhen + +1. Version in `package.json` updaten: `"version": "2.1.4"` +2. Changelog aktualisieren (optional) +3. Build ausführen +4. GitHub Release erstellen mit der .exe + +--- + +## Debugging & Testing + +### Unit Tests schreiben + +Jest ist konfiguriert. Test-Datei Vorlage: + +```javascript +// __tests__/myFeature.test.js +const { myFunction } = require('../src/git/gitHandler'); + +describe('Git Handler', () => { + it('should parse commit log', () => { + const result = myFunction(testData); + expect(result.length).toBe(3); + expect(result[0].sha).toMatch(/^[a-f0-9]{7}/); + }); +}); +``` + +Tests starten: + +```bash +npm test + +# Mit Coverage: +npm test -- --coverage + +# Watch Mode: +npm test -- --watch +``` + +### Debugging im DevTools + +```javascript +// Im renderer.js oder irgendwo im Renderer: +console.log('Debug Info:', data); + +// Öffne DevTools: Strg+Shift+I +// Console Tab → alle Logs sichtbar +``` + +### Main Process debuggen + +```javascript +// In main.js: +console.log('Main Process Log:', data); + +// Logs erscheinen in der Terminal-Console +// wenn die App mit npm start gestartet wurde +``` + +--- + +## Code-Konventionen + +### Variablen & Funktionen + +```javascript +// camelCase für Variablen und Funktionen +const repoName = "mein-projekt"; +function loadRepositories() { } + +// SCREAMING_SNAKE_CASE für Konstanten +const MAX_RETRIES = 5; +const DEFAULT_TIMEOUT = 3000; + +// Präfixe für Boolean-Variablen +const isPrivate = true; +const hasCredentials = false; +const shouldRetry = true; +``` + +### Async/Await + +```javascript +// Immer try-catch für async Funktionen +async function loadData() { + try { + const data = await fetchFromAPI(); + return { ok: true, data }; + } catch (error) { + console.error('Error:', error); + return { ok: false, error: error.message }; + } +} +``` + +### Kommentare + +```javascript +// Kurze Inline-Kommentare über Code +const delay = 1000; // Millisekunden + +/** + * Längere Beschreibung vor Funktion + * @param {string} repoName - Name des Repositories + * @returns {Promise} Result mit { ok, data, error } + */ +async function loadRepository(repoName) { + // Implementierung +} +``` + +### Fehlerbehandlung + +```javascript +// Immer aussagekräftige Fehlermeldungen +if (!token) { + return { ok: false, error: 'missing-token' }; +} + +// Oder sprechend für Benutzer: +if (!token) { + return { ok: false, error: 'Gitea Token nicht gespeichert. Bitte Settings öffnen.' }; +} +``` + +--- + +## Häufige Aufgaben + +### Neue Einstellung hinzufügen + +1. **In `Settings.jsx` UI-Control hinzufügen** + ```jsx + + setSetting('newOption', e.target.checked)} /> + ``` + +2. **In `renderer.js` speichern** + ```javascript + async function saveSettings(settings) { + const result = await window.electronAPI.saveSettings(settings); + // ... + } + ``` + +3. **In `main.js` Handler** + ```javascript + ipcMain.handle('saveSettings', async (event, settings) => { + // Validation und Speicherung + return { ok: true }; + }); + ``` + +### Neuer Button in der Toolbar + +1. **In `index.html` Button hinzufügen** + ```html + + ``` + +2. **In `renderer.js` Event-Handler** + ```javascript + if ($('btnMyFeature')) { + $('btnMyFeature').onclick = () => { + myFeatureHandler(); + }; + } + ``` + +### Neue Kontextmenü-Option + +1. **In `showRepoContextMenu()` Item hinzufügen** + ```javascript + items.push({ + label: '🎯 Meine Aktion', + click: () => myActionHandler(owner, repo) + }); + ``` + +2. **Handler implementieren** + ```javascript + function myActionHandler(owner, repo) { + // Logik... + } + ``` + +### Neue API-Methode aufrufen + +1. **In `src/git/apiHandler.js` ergänzen** +2. **In `main.js` IPC-Handler anlegen** +3. **In `renderer.js` aufrufen:** + ```javascript + const result = await window.electronAPI.myNewMethod({ param1, param2 }); + ``` + +--- + +## Troubleshooting für Entwickler + +### App startet nicht +```bash +# Node/npm Version prüfen +node --version # sollte >= 16 +npm --version # sollte >= 7 + +# Dependencies neu installieren +rm -r node_modules +npm install +npm start +``` + +### Fehler: "Cannot find module" +```bash +# Dependencies aktualisieren +npm install + +# Cache löschen +npm cache clean --force +``` + +### Axios/API-Calls schlagen fehl +- URL/Token in Settings prüfen +- Gitea-Server online? `curl https://git.example.com/api/v1/user` +- Token noch gültig? Im Gitea-Interface regenerieren +- Firewall/VPN blockiert? + +### Datei-Upload funktioniert nicht +- Lokale Temp-Pfade prüfen (in `main.js`) +- Git-Auth konfiguriert? +- Ausreichend Festplatte? + +--- + +## Ressourcen + +- [Electron Dokumentation](https://www.electronjs.org/docs) +- [Gitea API](https://docs.gitea.io/en-us/api-usage/) +- [GitHub API v3](https://docs.github.com/en/rest) +- [Jest Testing](https://jestjs.io/docs/getting-started) + +--- + +## Lizenz & Beitragen + +Dieses Projekt ist unter der **MIT-Lizenz** lizenziert. +Für Beiträge bitte einen **Pull Request** einreichen mit aussagekräftiger Beschreibung. + +--- + +**Letzte Aktualisierung:** 13. Mai 2026 | **Ersteller:** M_Viper diff --git a/main.js b/main.js index bf999af..1e4fd94 100644 --- a/main.js +++ b/main.js @@ -77,6 +77,9 @@ const { getGiteaFileContent, uploadGiteaFile, uploadItemsViaGiteaApi, + createUploadCancelledError, + isUploadCancelledError, + throwIfUploadCancelled, listGiteaRepoFiles, deleteGiteaPathsBatched, getGiteaCurrentUser, @@ -290,9 +293,10 @@ const GIT_CLONE_STRATEGIES = [ * * @returns {Promise<{ repoGit: object, isEmptyRepo: boolean, strategy: string }>} */ -async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone' }) { +async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone', signal }) { const simpleGit = require('simple-git'); const config = [...gitConfig, ...GIT_TRANSFER_CONFIG]; + throwIfUploadCancelled(signal); const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD'); // Bekannt defekter Server: gar nicht erst versuchen. @@ -313,8 +317,8 @@ async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clo try { if (attempt > 1) clearDirContents(tmpDir); console.log(`[${label}] Clone-Versuch ${attempt}/${GIT_CLONE_MAX_ATTEMPTS} (${strategy.name}):`, { cloneArgs, tmpDir }); - await simpleGit({ config }).clone(authUrl, tmpDir, cloneArgs); - const repoGit = simpleGit({ baseDir: tmpDir, config }); + await simpleGit({ config, abort: signal }).clone(authUrl, tmpDir, cloneArgs); + const repoGit = simpleGit({ baseDir: tmpDir, config, abort: signal }); // Ein Clone mit --no-checkout laesst den Index leer. Ohne read-tree wuerde der // folgende Commit den kompletten Bestand des Branches als geloescht markieren. @@ -325,6 +329,11 @@ async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clo markGitTransportWorking(authUrl); return { repoGit, isEmptyRepo: false, strategy: strategy.name }; } catch (cloneErr) { + // Abbruch durch den Nutzer: weder wiederholen noch den Server als defekt markieren. + if (signal && signal.aborted) { + clearDirContents(tmpDir); + throw createUploadCancelledError(); + } const cloneMsg = String(cloneErr); // Leeres Repo ohne Commits: lokal initialisieren statt klonen. @@ -332,7 +341,7 @@ async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clo cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) { console.log(`[${label}] Leeres Repo erkannt — initialisiere lokal als "${safeBranch}"`); clearDirContents(tmpDir); - const repoGit = simpleGit({ baseDir: tmpDir, config }); + const repoGit = simpleGit({ baseDir: tmpDir, config, abort: signal }); await repoGit.init(); // git init nutzt init.defaultBranch (haeufig "master"). Ohne diese Korrektur landet @@ -401,11 +410,11 @@ async function stageUploadedFiles(repoGit) { * * @returns {Promise<{ok: boolean, uploaded: string[], failed: Array<{path: string, error: string}>, commits: number, branch: string}>} */ -async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message, onProgress, label = 'Upload' }) { +async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message, onProgress, label = 'Upload', signal }) { console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`); const result = await uploadItemsViaGiteaApi({ - token, url, owner, repo, branch, items, message, onProgress + token, url, owner, repo, branch, items, message, onProgress, signal }); console.log(`[${label}] API-Upload fertig:`, { @@ -1190,11 +1199,11 @@ function sanitizeGitRef(ref, fallback = 'main') { // Startet Git ohne den Hauptprozess zu blockieren. Synchrone Aufrufe (spawnSync) froren // die Oberfläche ein, bis Windows "Anwendung reagiert nicht" meldete. -function execGit(args, { cwd, input, env } = {}) { +function execGit(args, { cwd, input, env, signal } = {}) { return new Promise((resolve) => { let child; try { - child = spawn('git', args, { cwd, env: env || process.env, windowsHide: true }); + child = spawn('git', args, { cwd, env: env || process.env, windowsHide: true, signal }); } catch (e) { resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) }); return; @@ -1204,6 +1213,25 @@ function execGit(args, { cwd, input, env } = {}) { 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)))); + + // Abbruch: unter Windows beendet spawn(signal) nur git.exe, der Netzwerk-Kindprozess + // (git-remote-https) läuft weiter und hält die Pipes offen. Deshalb den ganzen + // Prozessbaum beenden und sofort zurückkehren, statt auf 'close' zu warten. + if (signal) { + const onAbort = () => { + try { + if (process.platform === 'win32' && child.pid) { + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); + } else { + child.kill('SIGKILL'); + } + } catch (_) {} + resolve({ status: -1, stdout: '', stderr: 'aborted' }); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + } + child.on('close', (code) => resolve({ status: code, stdout: Buffer.concat(out).toString('utf8'), @@ -1215,7 +1243,9 @@ function execGit(args, { cwd, input, env } = {}) { } async function runGitAsync(args, cwd, options = {}) { - const res = await execGit(args, { cwd, env: options.env }); + throwIfUploadCancelled(options.signal); + const res = await execGit(args, { cwd, env: options.env, signal: options.signal }); + throwIfUploadCancelled(options.signal); if (res.status !== 0) { throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim()); } @@ -1225,7 +1255,7 @@ async function runGitAsync(args, cwd, options = {}) { /* ----------------------------- Generic concurrency runner (worker pool) ----------------------------- */ -async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = null) { +async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = null, signal = null) { const results = new Array(tasks.length); let index = 0; let processed = 0; @@ -1233,6 +1263,7 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = async function worker() { while (true) { + if (signal && signal.aborted) return; const i = index++; if (i >= tasks.length) return; try { @@ -1251,10 +1282,91 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = const workers = Array(Math.max(1, Math.min(concurrency, tasks.length))).fill(0).map(() => worker()); await Promise.all(workers); + throwIfUploadCancelled(signal); return results; } +/* ----------------------------- + Upload-Verlauf + Jeder Upload-Handler wird beim Registrieren umhüllt und schreibt Datum, Repo, + Dateizahl und Ergebnis in upload-history.json (die letzten 100 Einträge). + ----------------------------- */ +const UPLOAD_HISTORY_FILE = ppath.join(app.getPath('userData'), 'upload-history.json'); +const UPLOAD_HISTORY_MAX = 100; +const UPLOAD_HISTORY_CHANNELS = { + 'upload-paths-to-gitea': 'Drag & Drop', + 'upload-local-folder-to-gitea': 'Ordner', + 'upload-gitea-file': 'Dateien', + 'upload-and-push': 'Ordner/Datei' +}; + +function readUploadHistory() { + try { + const parsed = JSON.parse(fs.readFileSync(UPLOAD_HISTORY_FILE, 'utf8')); + return Array.isArray(parsed) ? parsed : []; + } catch (_) { + return []; + } +} + +function recordUploadHistory(entry) { + try { + const history = [entry, ...readUploadHistory()].slice(0, UPLOAD_HISTORY_MAX); + fs.writeFileSync(UPLOAD_HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8'); + } catch (e) { + console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message); + } +} + +function buildUploadHistoryEntry(kind, data, res, startedAt, thrown) { + const results = res && Array.isArray(res.results) ? res.results : null; + let status = 'ok'; + if (thrown || !res || !res.ok) status = res && res.cancelled ? 'abgebrochen' : 'fehler'; + const error = thrown ? String(thrown.message || thrown) : (res && !res.ok && !res.cancelled ? String(res.error || '') : ''); + return { + at: new Date().toISOString(), + kind, + repo: data && data.owner && data.repo ? `${data.owner}/${data.repo}` : '', + files: results ? results.length : (res && res.singleFile ? 1 : null), + failed: res && typeof res.failedCount === 'number' ? res.failedCount : 0, + status, + error: error.slice(0, 300), + durationMs: Date.now() - startedAt + }; +} + +const rawIpcHandle = ipcMain.handle.bind(ipcMain); +ipcMain.handle = (channel, handler) => { + const kind = UPLOAD_HISTORY_CHANNELS[channel]; + if (!kind) return rawIpcHandle(channel, handler); + return rawIpcHandle(channel, async (event, data) => { + const startedAt = Date.now(); + let res; + try { + res = await handler(event, data); + } catch (e) { + recordUploadHistory(buildUploadHistoryEntry(kind, data, null, startedAt, e)); + throw e; + } + recordUploadHistory(buildUploadHistoryEntry(kind, data, res, startedAt, null)); + return res; + }); +}; + +ipcMain.handle('get-upload-history', () => ({ ok: true, history: readUploadHistory() })); +ipcMain.handle('clear-upload-history', () => { + try { fs.writeFileSync(UPLOAD_HISTORY_FILE, '[]', 'utf8'); return { ok: true }; } + catch (e) { return { ok: false, error: String(e && e.message || e) }; } +}); + +const gitignoreTemplates = require('./src/features/gitignoreTemplates.js'); +ipcMain.handle('get-gitignore-templates', () => ({ ok: true, templates: gitignoreTemplates.listTemplates() })); +ipcMain.handle('build-gitignore', (_event, data) => ({ + ok: true, + content: gitignoreTemplates.buildGitignore(Array.isArray(data && data.keys) ? data.keys : []) +})); + /* ----------------------------- Basic IPC handlers ----------------------------- */ @@ -2458,6 +2570,8 @@ ipcMain.handle('read-gitea-file', async (event, data) => { }); ipcMain.handle('upload-gitea-file', async (event, data) => { + const uploadAbort = beginCancellableUpload(); + const signal = uploadAbort.signal; const uploadDebugId = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { const credentials = readCredentials(); @@ -2566,11 +2680,13 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { tmpDir, branch, gitConfig, - label: 'FileUpload' + label: 'FileUpload', + signal }); // Dateien in den Zielordner kopieren for (const localFile of validFiles) { + throwIfUploadCancelled(signal); const fileName = ppath.basename(localFile); const targetPath = destPath ? `${destPath}/${fileName}` : fileName; const destFile = ppath.join(tmpDir, ...targetPath.split('/')); @@ -2579,6 +2695,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { results.push({ file: localFile, ok: true, targetPath }); } + throwIfUploadCancelled(signal); await stageUploadedFiles(repoGit); let hasChanges = true; @@ -2612,6 +2729,9 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId }); + // Abbruch durch den Nutzer: kein API-Ausweichweg. + if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError(); + // Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist. try { const apiItems = validFiles.map(localFile => { @@ -2623,7 +2743,8 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { token, url, owner, repo, branch, items: apiItems, message, - label: 'FileUpload' + label: 'FileUpload', + signal }); if (apiResult.ok) { @@ -2647,6 +2768,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { return { ok: false, error: describeApiFallbackFailure(gitErr, apiResult), debugId: uploadDebugId }; } catch (apiErr) { + if (isUploadCancelledError(apiErr)) throw apiErr; logger.error('upload-gitea-file', 'API-Fallback fehlgeschlagen', { error: String(apiErr), uploadDebugId }); return { ok: false, error: describeApiFallbackFailure(gitErr, null) + ` — API-Fallback: ${apiErr.message || String(apiErr)}`, debugId: uploadDebugId }; } @@ -2656,8 +2778,11 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { logger.info('upload-gitea-file', 'Gitea upload done', { failedCount, total: results.length, uploadDebugId }); return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; } catch (e) { + if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId }; console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) }); return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; + } finally { + endCancellableUpload(uploadAbort); } }); @@ -2748,7 +2873,38 @@ ipcMain.handle('remove-retry-queue-item', async (event, data) => { } }); +// Gesamtgröße der Quelldateien in Bytes (für die Fortschrittsanzeige). +function sumFileSizes(items) { + let bytes = 0; + for (const it of items) { + try { bytes += fs.statSync(it.localFile).size; } catch (_) {} + } + return bytes; +} + +// Abbruch laufender Uploads und Downloads: der Renderer bricht per IPC alle laufenden ab +// (es gibt nur einen Fortschrittsbalken mit einem Abbrechen-Knopf). +const activeTransferAborts = new Set(); + +function beginCancellableUpload() { + const controller = new AbortController(); + activeTransferAborts.add(controller); + return controller; +} + +function endCancellableUpload(controller) { + activeTransferAborts.delete(controller); +} + +ipcMain.handle('cancel-upload', () => { + if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' }; + for (const controller of activeTransferAborts) controller.abort(); + return { ok: true }; +}); + ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { + const uploadAbort = beginCancellableUpload(); + const signal = uploadAbort.signal; try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); @@ -2796,6 +2952,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { const total = items.length; if (total === 0) return { ok: true, results: [] }; + const totalBytes = sumFileSizes(items); // Git-basierter Upload: alle Dateien in einem einzigen Commit. // Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett. @@ -2814,7 +2971,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { const gitConfig = gitIdentityConfig(); // Fortschritt: Start - try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0 }); } catch (_) {} + try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0, totalBytes }); } catch (_) {} console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir }); @@ -2824,23 +2981,26 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { tmpDir, branch, gitConfig, - label: 'FolderUpload' + label: 'FolderUpload', + signal }); // Fortschritt: 30% (nach Clone) - try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30 }); } catch (_) {} + try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30, totalBytes }); } catch (_) {} // Dateien in den Zielordner im geklonten Repo kopieren for (const item of items) { + throwIfUploadCancelled(signal); const destFile = ppath.join(tmpDir, ...item.targetPath.split('/')); fs.mkdirSync(ppath.dirname(destFile), { recursive: true }); fs.copyFileSync(item.localFile, destFile); } // Fortschritt: 60% (nach Kopieren) - try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60 }); } catch (_) {} + try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60, totalBytes }); } catch (_) {} // git add + commit + push + throwIfUploadCancelled(signal); await stageUploadedFiles(repoGit); let hasChanges = true; @@ -2870,7 +3030,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { } // Fortschritt: 100% - try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100 }); } catch (_) {} + try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100, totalBytes }); } catch (_) {} // Temp-Ordner nach kurzer Verzögerung bereinigen setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000); @@ -2882,17 +3042,22 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { console.error('[FolderUpload] Git-Fehler:', String(gitErr)); try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} + // Abbruch durch den Nutzer: kein API-Ausweichweg. + if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError(); + // Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist. const apiResult = await uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message: `${messagePrefix} - ${folderName}`, label: 'FolderUpload', + signal, onProgress: percent => { try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * (percent / 100)), total, - percent + percent, + totalBytes }); } catch (_) {} } @@ -2906,8 +3071,11 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { return { ok: true, results: successResults, failedCount: 0, viaApi: true }; } } catch (e) { + if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen' }; console.error('upload-local-folder-to-gitea error', e); return { ok: false, error: String(e) }; + } finally { + endCancellableUpload(uploadAbort); } }); @@ -3019,7 +3187,7 @@ async function buildItemsFromPaths(paths, destPath) { } // Klont das Repo (shallow), kopiert alle items hinein, commit + push. -async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress }) { +async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress, signal }) { const rawUrl = url.startsWith('http') ? url : `https://${url}`; const urlObj = new URL(rawUrl.replace(/\/$/, '')); const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`; @@ -3034,12 +3202,14 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes tmpDir, branch: safeBranch, gitConfig, - label: 'UploadPaths' + label: 'UploadPaths', + signal }); if (onProgress) onProgress(30); for (const item of items) { + throwIfUploadCancelled(signal); const destFile = ppath.join(tmpDir, ...item.targetPath.split('/')); fs.mkdirSync(ppath.dirname(destFile), { recursive: true }); fs.copyFileSync(item.localFile, destFile); @@ -3047,6 +3217,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes if (onProgress) onProgress(60); + throwIfUploadCancelled(signal); await stageUploadedFiles(repoGit); let hasChanges = true; @@ -3072,14 +3243,23 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes } catch (gitErr) { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} + // Abbruch durch den Nutzer: kein API-Ausweichweg. + if (isUploadCancelledError(gitErr) || (signal && signal.aborted)) throw createUploadCancelledError(); + // Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist. const apiResult = await uploadItemsViaApiFallback({ token, url, owner, repo, branch: safeBranch, items, message, onProgress, - label: 'UploadPaths' + label: 'UploadPaths', + signal }); if (!apiResult.ok) { - throw new Error(describeApiFallbackFailure(gitErr, apiResult)); + // Die fehlgeschlagenen Dateien mitgeben, damit der Renderer nur diese wiederholen kann. + const failedPaths = new Set((apiResult.failed || []).map(f => f.path)); + const err = new Error(describeApiFallbackFailure(gitErr, apiResult)); + err.failedItems = items.filter(it => failedPaths.has(it.targetPath)); + err.uploadedCount = (apiResult.uploaded || []).length; + throw err; } return { hasChanges: apiResult.uploaded.length > 0, viaApi: true }; @@ -3087,6 +3267,8 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes } ipcMain.handle('upload-paths-to-gitea', async (event, data) => { + const uploadAbort = beginCancellableUpload(); + const signal = uploadAbort.signal; const uploadDebugId = `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { const credentials = readCredentials(); @@ -3102,12 +3284,20 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => { const branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const messagePrefix = data.messagePrefix || 'Upload via GUI'; const paths = Array.isArray(data.paths) ? data.paths.filter(Boolean) : []; - if (paths.length === 0) return { ok: false, error: 'Keine gültigen Pfade übergeben', debugId: uploadDebugId }; + // Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt neuer Pfade. + const retryItems = Array.isArray(data.items) + ? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile)) + : null; + if (paths.length === 0 && !(retryItems && retryItems.length)) { + return { ok: false, error: 'Keine gültigen Pfade übergeben', debugId: uploadDebugId }; + } try { invalidateRepoContentsCache(owner, repo); } catch (_) {} try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {} - const built = await buildItemsFromPaths(paths, destPath); + const built = retryItems && retryItems.length + ? { items: retryItems, skipped: [], ignored: [] } + : await buildItemsFromPaths(paths, destPath); const skipped = built.skipped; const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []); const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items; @@ -3116,8 +3306,9 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => { } const total = items.length; + const totalBytes = sumFileSizes(items); const onProgress = (percent) => { - try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent }); } catch (_) {} + try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent, totalBytes }); } catch (_) {} }; onProgress(0); @@ -3125,15 +3316,25 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => { await gitPushItemsToGitea({ token, url, owner, repo, branch, items, message: `${messagePrefix} (${total} Datei${total === 1 ? '' : 'en'})`, - onProgress + onProgress, + signal }); console.log('[UploadPaths] done', { uploadDebugId, total }); const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath })); return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId }; } catch (e) { + if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId }; console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) }); - return { ok: false, error: String(e && e.message ? e.message : e), debugId: uploadDebugId }; + return { + ok: false, + error: String(e && e.message ? e.message : e), + retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined, + uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined, + debugId: uploadDebugId + }; + } finally { + endCancellableUpload(uploadAbort); } }); @@ -3159,6 +3360,8 @@ ipcMain.handle('download-gitea-file', async (event, data) => { }); ipcMain.handle('download-gitea-folder', async (event, data) => { + const downloadAbort = beginCancellableUpload(); + const signal = downloadAbort.signal; try { const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); @@ -3175,6 +3378,7 @@ ipcMain.handle('download-gitea-folder', async (event, data) => { const allFiles = []; async function gather(pathInRepo) { + throwIfUploadCancelled(signal); const _r = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' }); const items = _r.items || _r; for (const item of items) { @@ -3199,12 +3403,15 @@ ipcMain.handle('download-gitea-folder', async (event, data) => { try { event.sender.send('folder-download-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} }; - const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); + const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal); const savedFiles = results.filter(r => r.ok).map(r => r.result); return { ok: true, savedTo: destBase, files: savedFiles }; } catch (e) { + if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Download abgebrochen' }; console.error('download-gitea-folder error', e); return { ok: false, error: String(e) }; + } finally { + endCancellableUpload(downloadAbort); } }); @@ -3386,6 +3593,8 @@ ipcMain.handle('delete-gitea-repo', async (event, data) => { }); ipcMain.handle('upload-and-push', async (event, data) => { + const uploadAbort = beginCancellableUpload(); + const signal = uploadAbort.signal; const uploadDebugId = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: `local-folder-not-found (${uploadDebugId})` }; @@ -3422,7 +3631,8 @@ ipcMain.handle('upload-and-push', async (event, data) => { GIT_PAGER: 'cat', PAGER: 'cat' }, - maxBuffer: 10 * 1024 * 1024 + maxBuffer: 10 * 1024 * 1024, + signal }; // Prüfen ob es eine Datei oder ein Ordner ist @@ -3460,6 +3670,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { }); return { ok: true, usedGit: false, singleFile: true, uploaded: result, debugId: uploadDebugId }; } catch (e) { + throwIfUploadCancelled(signal); console.error('[UPLOAD_DEBUG][main] single-file:api-error', { uploadDebugId, error: String(e && e.message ? e.message : e) }); // FALLBACK: Wenn API fehlschlägt (z.B. 422 SHA Fehler), nutze Git @@ -3529,6 +3740,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { } catch (gitErr) { console.error('[UPLOAD_DEBUG][main] single-file:git-fallback-error', { uploadDebugId, error: String(gitErr) }); if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); + if (isUploadCancelledError(gitErr)) throw gitErr; return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` }; } } @@ -3622,6 +3834,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { throw gitErr; } } catch (gitErr) { + if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError(); console.warn('[UPLOAD_DEBUG][main] directory-upload:git-failed-fallback-to-api', { uploadDebugId, error: String(gitErr && gitErr.message ? gitErr.message : gitErr) }); // -> weiter zum API-Fallback unten } @@ -3686,13 +3899,16 @@ ipcMain.handle('upload-and-push', async (event, data) => { try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} }; - const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); + const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal); const failedCount = uploadResults.filter(r => !r.ok).length; console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length }); return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount }; } catch (e) { + if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId }; console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) }); return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; + } finally { + endCancellableUpload(uploadAbort); } }); @@ -3714,6 +3930,8 @@ ipcMain.handle('get-path-type', async (_event, filePath) => { }); ipcMain.handle('run-batch-repo-action', async (event, data) => { + const batchAbort = beginCancellableUpload(); + const signal = batchAbort.signal; try { const credentials = readCredentials(); if (!credentials || !credentials.giteaToken || !credentials.giteaURL) { @@ -3745,7 +3963,9 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => { }; const results = []; + let cancelled = false; for (let i = 0; i < repos.length; i++) { + if (signal.aborted) { cancelled = true; break; } const item = repos[i]; const progressBase = { index: i + 1, total: repos.length, repo: item.id, action }; sendProgress({ ...progressBase, status: 'running' }); @@ -3777,7 +3997,15 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => { } } catch (_) {} - await runGitAsync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' }); + try { + await runGitAsync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore', signal }); + } catch (cloneErr) { + // Abgebrochener Clone: den angelegten Teilordner entfernen (existierte vorher nicht). + if (isUploadCancelledError(cloneErr)) { + try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch (_) {} + } + throw cloneErr; + } results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` }); } else if (action === 'create-tag') { const tag = String(options.tag || '').trim(); @@ -3822,6 +4050,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => { sendProgress({ ...progressBase, status: 'ok' }); } catch (e) { + if (isUploadCancelledError(e)) { cancelled = true; break; } const msg = mapIpcError(e); results.push({ repo: item.id, ok: false, error: msg }); sendProgress({ ...progressBase, status: 'error', error: msg }); @@ -3833,12 +4062,15 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => { return { ok: true, action, - summary: { total: results.length, success, failed }, + cancelled, + summary: { total: repos.length, done: results.length, success, failed }, results }; } catch (e) { console.error('run-batch-repo-action error', e); return { ok: false, error: String(e) }; + } finally { + endCancellableUpload(batchAbort); } }); /* ================================ diff --git a/preload.js b/preload.js index df3baf1..2dc9c77 100644 --- a/preload.js +++ b/preload.js @@ -39,6 +39,11 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadGiteaFile: (data) => ipcRenderer.invoke('download-gitea-file', data), uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data), uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data), + cancelUpload: () => ipcRenderer.invoke('cancel-upload'), + getUploadHistory: () => ipcRenderer.invoke('get-upload-history'), + clearUploadHistory: () => ipcRenderer.invoke('clear-upload-history'), + getGitignoreTemplates: () => ipcRenderer.invoke('get-gitignore-templates'), + buildGitignore: (data) => ipcRenderer.invoke('build-gitignore', data), // Repository & Git Management saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),