7 Commits
12 changed files with 1663 additions and 880 deletions
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -490,13 +490,13 @@
<!-- Features --> <!-- Features -->
<section class="section" id="features"> <section class="section" id="features">
<h2 class="section-title">Warum Git Manager Pro?</h2> <h2 class="section-title">Warum Git Manager Pro?</h2>
<p class="section-desc">Alles was du brauchst um deine Repositories effizient zu verwalten.</p> <p class="section-desc">Alles, was du brauchst, um deine Repositories effizient zu verwalten.</p>
<div class="features-grid"> <div class="features-grid">
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">🌐</div> <div class="feature-icon">🌐</div>
<h3>Gitea & GitHub</h3> <h3>Gitea & GitHub</h3>
<p>Unterstützt beide Plattformen seamless — alle Repositories an einem Ort.</p> <p>Unterstützt beide Plattformen nahtlos — alle Repositories an einem Ort.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">📂</div> <div class="feature-icon">📂</div>
@@ -535,16 +535,16 @@
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">📥</div> <div class="feature-icon">📥</div>
<h3>Ein-Klick Migration</h3> <h3>Ein-Klick-Migration</h3>
<p>Migriere Repos zwischen Services — mit kompletter History.</p> <p>Migriere Repos zwischen Services — mit kompletter History.</p>
</div> </div>
</div> </div>
<div class="demo-section"> <div class="demo-section">
<h3 style="color: var(--accent2); margin-bottom: 16px;">🎥 Demo (baldiger Update)</h3> <h3 style="color: var(--accent2); margin-bottom: 16px;">🎥 Demo (baldiges Update)</h3>
<div class="demo-mockup"> <div class="demo-mockup">
Screenshot-Bereich — Demo und UI-Preview kommen bald!<br><br> Screenshot-Bereich — Demo und UI-Vorschau kommen bald!<br><br>
<em>Starte die App mit npm start um einen Live-Demo zu sehen</em> <em>Starte die App mit npm start, um eine Live-Demo zu sehen</em>
</div> </div>
</div> </div>
</section> </section>
@@ -603,7 +603,7 @@
<div class="links-section"> <div class="links-section">
<div class="link-card"> <div class="link-card">
<h3>📖 Benutzerhandbuch</h3> <h3>📖 Benutzerhandbuch</h3>
<p>Vollständiges Handbuch mit Screenshots und Schritt-für-Schritt Anleitungen.</p> <p>Vollständiges Handbuch mit Screenshots und Schritt-für-Schritt-Anleitungen.</p>
<a href="HANDBUCH.html" target="_blank">Zum Handbuch →</a> <a href="HANDBUCH.html" target="_blank">Zum Handbuch →</a>
</div> </div>
<div class="link-card"> <div class="link-card">
+636 -91
View File
@@ -43,6 +43,7 @@ const {
getGiteaRepoContents, getGiteaRepoContents,
getGiteaFileContent, getGiteaFileContent,
uploadGiteaFile, uploadGiteaFile,
uploadItemsViaGiteaApi,
getGiteaCurrentUser, getGiteaCurrentUser,
getGiteaCommits, getGiteaCommits,
getGiteaCommit, getGiteaCommit,
@@ -112,6 +113,278 @@ const TMP_CLEANUP_MS = 20_000;
const RETRY_QUEUE_INTERVAL_MS = 15_000; const RETRY_QUEUE_INTERVAL_MS = 15_000;
const RETRY_MAX_ATTEMPTS = 8; const RETRY_MAX_ATTEMPTS = 8;
// Git-Konfiguration gegen abbrechende HTTP-Transfers bei grossen Repos.
// HTTP/1.1 statt HTTP/2 verhindert die haeufigen "early EOF"/"invalid index-pack output"-Abbrueche
// hinter Reverse-Proxies; die lowSpeed-Werte verhindern, dass der Client langsame Packs abbricht.
const GIT_TRANSFER_CONFIG = [
'http.version=HTTP/1.1',
'http.postBuffer=524288000',
'http.lowSpeedLimit=0',
'http.lowSpeedTime=600',
'core.longpaths=true'
];
// Anzahl der Clone-Versuche bei abgebrochenen Uebertragungen.
const GIT_CLONE_MAX_ATTEMPTS = 3;
// Server, deren Git-Transport sich als unbrauchbar erwiesen hat (z. B. weil upload-pack
// keine Packfiles erzeugen kann). Für diese wird der Clone übersprungen und sofort der
// API-Weg genommen — sonst kostet jeder Upload erneut sechs vergebliche Versuche.
//
// Der Merker liegt auf der Platte, damit auch der erste Upload nach einem Neustart
// sofort den funktionierenden Weg nimmt. Die Ablaufzeit sorgt dafür, dass ein wieder
// reparierter Server von selbst erneut probiert wird — ohne Eingriff im Code.
const brokenGitTransports = new Map();
const GIT_TRANSPORT_BLOCK_MS = 24 * 60 * 60 * 1000;
let brokenGitTransportsLoaded = false;
function getGitTransportStateFilePath() {
return ppath.join(app.getPath('userData'), 'git-transport-state.json');
}
function loadBrokenGitTransports() {
if (brokenGitTransportsLoaded) return;
brokenGitTransportsLoaded = true;
try {
const file = getGitTransportStateFilePath();
if (!fs.existsSync(file)) return;
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const [host, since] of Object.entries(parsed || {})) {
if (typeof since === 'number') brokenGitTransports.set(host, since);
}
} catch (e) {
console.warn('loadBrokenGitTransports:', e.message || e);
}
}
function saveBrokenGitTransports() {
try {
const file = getGitTransportStateFilePath();
const dir = app.getPath('userData');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(file, JSON.stringify(Object.fromEntries(brokenGitTransports), null, 2), 'utf8');
} catch (e) {
console.warn('saveBrokenGitTransports:', e.message || e);
}
}
function gitTransportKey(authUrl) {
try {
return new URL(authUrl).host;
} catch (_) {
return String(authUrl || '');
}
}
function markGitTransportBroken(authUrl) {
loadBrokenGitTransports();
brokenGitTransports.set(gitTransportKey(authUrl), Date.now());
saveBrokenGitTransports();
}
function markGitTransportWorking(authUrl) {
loadBrokenGitTransports();
if (brokenGitTransports.delete(gitTransportKey(authUrl))) saveBrokenGitTransports();
}
function isGitTransportKnownBroken(authUrl) {
loadBrokenGitTransports();
const key = gitTransportKey(authUrl);
const since = brokenGitTransports.get(key);
if (!since) return false;
if (Date.now() - since > GIT_TRANSPORT_BLOCK_MS) {
brokenGitTransports.delete(key);
saveBrokenGitTransports();
return false;
}
return true;
}
// Fehler, die auf einen abgebrochenen Transfer hindeuten (nicht auf ein leeres/fehlendes Repo).
function isTransientGitTransferError(message) {
const raw = String(message || '').toLowerCase();
return raw.includes('early eof')
|| raw.includes('index-pack')
|| raw.includes('rpc failed')
|| raw.includes('remote end hung up')
|| raw.includes('unexpected disconnect')
|| raw.includes('transfer closed')
|| raw.includes('connection reset')
|| raw.includes('curl 92')
|| raw.includes('curl 56')
|| raw.includes('curl 18');
}
// Leert das Zielverzeichnis, damit ein erneuter Clone-Versuch nicht an Resten scheitert.
function clearDirContents(dirPath) {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
return;
}
for (const entry of fs.readdirSync(dirPath)) {
fs.rmSync(ppath.join(dirPath, entry), { recursive: true, force: true });
}
} catch (_) {}
}
// Clone-Strategien, absteigend nach Sparsamkeit.
// "blobless" laedt nur Commits und Trees, keine Dateiinhalte: Der Server muss keine Blobs packen.
// Das ist bei Repos mit grossen Binaerdateien der entscheidende Unterschied und umgeht
// serverseitige Abbrueche beim Packen. --no-checkout haelt den Arbeitsbaum leer, der Index
// wird trotzdem aus HEAD befuellt, sodass Commit und Push den bestehenden Stand erhalten.
// "shallow" ist der klassische Fallback fuer Server ohne Partial-Clone-Unterstuetzung.
const GIT_CLONE_STRATEGIES = [
{ name: 'blobless', args: ['--filter=blob:none', '--no-checkout'], checkout: false },
{ name: 'shallow', args: [], checkout: true }
];
/**
* Shallow-Clone fuer die Upload-Flows, mit Strategie- und Wiederholungs-Fallback.
* Nutzt --single-branch: es wird nur der Branch geholt, in den anschliessend gepusht wird.
* Bei abgebrochenen Transfers ("early EOF", "invalid index-pack output") wird erneut versucht,
* danach mit der naechsten Strategie. Erkennt leere Repos und initialisiert sie lokal.
*
* @returns {Promise<{ repoGit: object, isEmptyRepo: boolean, strategy: string }>}
*/
async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone' }) {
const simpleGit = require('simple-git');
const config = [...gitConfig, ...GIT_TRANSFER_CONFIG];
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
// Bekannt defekter Server: gar nicht erst versuchen.
if (isGitTransportKnownBroken(authUrl)) {
console.log(`[${label}] Git-Transport für ${gitTransportKey(authUrl)} ist als unbrauchbar bekannt — überspringe Clone`);
throw new Error('Git-Transport für diesen Server ist als unbrauchbar bekannt (übersprungen).');
}
let lastErr = null;
for (const strategy of GIT_CLONE_STRATEGIES) {
const cloneArgs = ['--depth', '1', '--single-branch', ...strategy.args];
if (safeBranch !== 'HEAD') cloneArgs.push('--branch', safeBranch);
let previousMsg = null;
for (let attempt = 1; attempt <= GIT_CLONE_MAX_ATTEMPTS; attempt++) {
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 });
// Ein Clone mit --no-checkout laesst den Index leer. Ohne read-tree wuerde der
// folgende Commit den kompletten Bestand des Branches als geloescht markieren.
if (!strategy.checkout) {
await repoGit.raw(['read-tree', 'HEAD']);
}
markGitTransportWorking(authUrl);
return { repoGit, isEmptyRepo: false, strategy: strategy.name };
} catch (cloneErr) {
const cloneMsg = String(cloneErr);
// Leeres Repo ohne Commits: lokal initialisieren statt klonen.
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
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 });
await repoGit.init();
// git init nutzt init.defaultBranch (haeufig "master"). Ohne diese Korrektur landet
// der Upload auf einem anderen Branch als dem angeforderten und ist im Gitea-Web
// nicht sichtbar, weil dort "main" der Standard ist.
if (safeBranch !== 'HEAD') {
await repoGit.raw(['symbolic-ref', 'HEAD', `refs/heads/${safeBranch}`]);
}
await repoGit.addRemote('origin', authUrl);
return { repoGit, isEmptyRepo: true, strategy: 'init' };
}
lastErr = cloneErr;
clearDirContents(tmpDir);
// Nicht-transiente Fehler (z. B. fehlende Partial-Clone-Unterstuetzung)
// sofort mit der naechsten Strategie beantworten statt zu wiederholen.
if (!isTransientGitTransferError(cloneMsg)) {
console.warn(`[${label}] Strategie "${strategy.name}" nicht nutzbar:`, cloneMsg);
break;
}
if (attempt === GIT_CLONE_MAX_ATTEMPTS) {
console.warn(`[${label}] Strategie "${strategy.name}" nach ${attempt} Versuchen aufgegeben`);
break;
}
// Zweimal exakt derselbe Fehler heißt: Das ist kein Aussetzer, sondern ein
// dauerhafter Defekt. Weitere Versuche kosten nur Zeit.
if (cloneMsg === previousMsg) {
console.warn(`[${label}] Strategie "${strategy.name}" scheitert reproduzierbar — keine weiteren Versuche`);
break;
}
previousMsg = cloneMsg;
console.warn(`[${label}] Transfer abgebrochen, neuer Versuch:`, cloneMsg);
await new Promise(resolve => setTimeout(resolve, attempt * 1500));
}
}
}
// Alle Strategien erschöpft: Server für die nächsten Uploads überspringen.
markGitTransportBroken(authUrl);
console.warn(`[${label}] Git-Transport für ${gitTransportKey(authUrl)} wird künftig übersprungen — Uploads laufen über die API`);
throw lastErr || new Error('Clone fehlgeschlagen');
}
/**
* Staged die hochgeladenen Dateien.
* --ignore-removal: Bei der blobless-Strategie steht der Bestand nur im Index (siehe read-tree
* in cloneForUpload), nicht im Arbeitsbaum. Ohne dieses Flag wuerde git add die fehlenden
* Dateien als Loeschungen stagen. Uploads loeschen ohnehin nie etwas.
*/
async function stageUploadedFiles(repoGit) {
await repoGit.raw(['add', '--ignore-removal', '.']);
}
/**
* Ausweichweg, wenn der Git-Transport nicht funktioniert.
*
* Nötig, wenn git-upload-pack auf dem Server keine Packfiles liefert ("early EOF",
* "invalid index-pack output"). Der Upload läuft dann rein über die REST-API und
* braucht weder Clone noch Push.
*
* @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' }) {
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
});
console.log(`[${label}] API-Upload fertig:`, {
commits: result.commits,
uploaded: result.uploaded.length,
failed: result.failed.length,
branch: result.branch
});
return { ok: result.failed.length === 0, ...result };
}
// Fasst die Fehler eines API-Fallbacks zu einer Meldung zusammen.
function describeApiFallbackFailure(gitErr, apiResult) {
const gitPart = `Git-Transport fehlgeschlagen: ${gitErr && gitErr.message ? gitErr.message : String(gitErr)}`;
if (!apiResult) return gitPart;
const firstErrors = apiResult.failed.slice(0, 3).map(f => `${f.path}: ${f.error}`).join('; ');
const more = apiResult.failed.length > 3 ? ` (+${apiResult.failed.length - 3} weitere)` : '';
return `${gitPart} — auch der API-Upload schlug fehl: ${firstErrors}${more}`;
}
let retryQueue = []; let retryQueue = [];
let retryQueueRunning = false; let retryQueueRunning = false;
let retryQueueTimer = null; let retryQueueTimer = null;
@@ -283,7 +556,7 @@ function tryReadWrappedCredentials(raw) {
credentials: null, credentials: null,
needsRewrite: false, needsRewrite: false,
reason: 'safeStorage-unavailable', reason: 'safeStorage-unavailable',
message: 'Gespeicherte Zugangsdaten koennen in dieser Sitzung nicht gelesen werden. Bitte neu anmelden.' message: 'Gespeicherte Zugangsdaten können in dieser Sitzung nicht gelesen werden. Bitte neu anmelden.'
}; };
} }
@@ -299,7 +572,7 @@ function tryReadWrappedCredentials(raw) {
credentials: null, credentials: null,
needsRewrite: false, needsRewrite: false,
reason: 'safeStorage-decrypt-failed', reason: 'safeStorage-decrypt-failed',
message: 'Gespeicherte Zugangsdaten konnten nicht entschluesselt werden. Bitte Token neu eingeben.' message: 'Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden. Bitte Token neu eingeben.'
}; };
} }
} }
@@ -349,7 +622,7 @@ function readCredentialsFromFile(filePath) {
credentials: null, credentials: null,
needsRewrite: false, needsRewrite: false,
reason: 'legacy-decrypt-failed', reason: 'legacy-decrypt-failed',
message: 'Gespeicherte Zugangsdaten waren unlesbar und wurden zurueckgesetzt. Bitte Zugangsdaten neu eingeben.', message: 'Gespeicherte Zugangsdaten waren unlesbar und wurden zurückgesetzt. Bitte Zugangsdaten neu eingeben.',
filePath filePath
}; };
} }
@@ -678,7 +951,7 @@ function createWindow() {
} }
}); });
// Externe Fenster immer blockieren und nur erlaubte URLs explizit im System-Browser oeffnen. // Externe Fenster immer blockieren und nur erlaubte URLs explizit im System-Browser öffnen.
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedExternalUrl(url)) { if (isAllowedExternalUrl(url)) {
shell.openExternal(url).catch(() => {}); shell.openExternal(url).catch(() => {});
@@ -818,6 +1091,10 @@ function sanitizeErrorForLog(errorLike) {
function mapIpcError(errorLike) { function mapIpcError(errorLike) {
const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase(); const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase();
if (!raw) return 'Unbekannter Fehler.'; if (!raw) return 'Unbekannter Fehler.';
// Zuerst pruefen: abgebrochene Uebertragungen sind spezifischer als die Status-Code-Heuristik unten.
if (isTransientGitTransferError(raw)) {
return 'Übertragung vom Server abgebrochen (early EOF / index-pack). Repository zu groß oder Timeout beim Gitea-Server bzw. Reverse-Proxy. Bitte erneut versuchen.';
}
if (raw.includes('401') || raw.includes('authentifizierung') || raw.includes('unauthorized')) { if (raw.includes('401') || raw.includes('authentifizierung') || raw.includes('unauthorized')) {
return 'Authentifizierung fehlgeschlagen. Bitte Token in den Einstellungen prüfen.'; return 'Authentifizierung fehlgeschlagen. Bitte Token in den Einstellungen prüfen.';
} }
@@ -828,13 +1105,23 @@ function mapIpcError(errorLike) {
return 'Server oder Ressource nicht gefunden. Bitte URL und Repository prüfen.'; return 'Server oder Ressource nicht gefunden. Bitte URL und Repository prüfen.';
} }
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) { if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
return 'Server nicht erreichbar. Bitte DNS, IPv4/IPv6 und Port prüfen.'; return 'Server nicht erreichbar. Bitte DNS, Server-IP und Port prüfen.';
} }
if (raw.includes('timeout') || raw.includes('econnaborted')) { if (raw.includes('timeout') || raw.includes('econnaborted')) {
return 'Zeitüberschreitung bei der Verbindung. Bitte Netzwerk oder Server prüfen.'; return 'Zeitüberschreitung bei der Verbindung. Bitte Netzwerk oder Server prüfen.';
} }
if (raw.includes('http://') || raw.includes('https://') || raw.includes('ungueltige gitea url') || raw.includes('ungültige gitea url') || raw.includes('invalid')) { if (
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000'; raw.includes('ungültige url') ||
raw.includes('ungueltige url') ||
raw.includes('ungültige gitea url') ||
raw.includes('ungueltige gitea url') ||
raw.includes('invalid url') ||
raw.includes('err_invalid_url') ||
raw.includes('invalid uri') ||
raw.includes('unsupported protocol') ||
raw.includes('only absolute urls')
) {
return 'Ungültige URL. Beispiel: https://git.example.net oder http://192.168.1.10:3000';
} }
return String(errorLike && errorLike.message ? errorLike.message : errorLike); return String(errorLike && errorLike.message ? errorLike.message : errorLike);
} }
@@ -1714,7 +2001,7 @@ ipcMain.handle('deleteFile', async (event, data) => {
return files; return files;
} }
async function executeDeleteBatch(files) { async function executeDeleteBatch(files, concurrency = 2) {
const deleteOps = files.map(f => const deleteOps = files.map(f =>
async () => { async () => {
const res = await giteaDeleteFile(f.path, f.sha); const res = await giteaDeleteFile(f.path, f.sha);
@@ -1722,8 +2009,8 @@ ipcMain.handle('deleteFile', async (event, data) => {
} }
); );
// Gitea: mit Concurrency=2 + Retry-Logik bei 409-Konflikten für bessere Performance // Concurrency kann dynamisch reduziert werden, um API-Konflikte bei Nachloeschungen zu vermeiden
const deleteResults = await runParallel(deleteOps, 2); const deleteResults = await runParallel(deleteOps, concurrency);
const failedEntries = deleteResults.filter(r => !r.ok || !(r.result && r.result.ok)); const failedEntries = deleteResults.filter(r => !r.ok || !(r.result && r.result.ok));
return { deleteResults, failedEntries }; return { deleteResults, failedEntries };
} }
@@ -1741,38 +2028,31 @@ ipcMain.handle('deleteFile', async (event, data) => {
return { ok: true, deleted: 0 }; return { ok: true, deleted: 0 };
} }
// Delete attempt // Delete attempt (schnell) + sequenzieller Fallback bei Konflikten
let { failedEntries } = await executeDeleteBatch(filesToDelete); let { failedEntries } = await executeDeleteBatch(filesToDelete, 2);
// Bei Teilfehlern sofort sequenziellen Retry fahren (stabiler bei parallelen Commits)
if (failedEntries.length > 0) { if (failedEntries.length > 0) {
const failedFiles = failedEntries const failedFiles = failedEntries
.map(r => (r.result && r.result.path) ? r.result.path : 'unknown') .map(r => (r.result && r.result.path) ? r.result.path : 'unknown')
.join(', '); .join(', ');
logger.error('deleteFile', `Failed to delete ${failedEntries.length} files`, { files: failedFiles }); logger.warn('deleteFile', `Initial delete had ${failedEntries.length} failures, retrying sequentially`, { files: failedFiles });
return { ok: false, error: `${failedEntries.length} von ${filesToDelete.length} Dateien konnten nicht gelöscht werden` }; }
// Verification + bis zu zwei sequenzielle Nachlöschversuche auf verbleibende Dateien
for (let attempt = 0; attempt < 2; attempt++) {
if (!(await pathStillExists(filePath))) break;
const remaining = await collectAllFiles(filePath);
if (remaining.length === 0) break;
({ failedEntries } = await executeDeleteBatch(remaining, 1));
} }
// Verification + one automatic retry on leftovers
if (await pathStillExists(filePath)) { if (await pathStillExists(filePath)) {
const remaining = await collectAllFiles(filePath); const remaining = await collectAllFiles(filePath);
if (remaining.length > 0) { const remainingPreview = remaining.slice(0, 10).map(f => f.path).join(', ');
({ failedEntries } = await executeDeleteBatch(remaining));
if (failedEntries.length > 0) {
const failedFiles = failedEntries
.map(r => (r.result && r.result.path) ? r.result.path : 'unknown')
.join(', ');
return {
ok: false,
error: `Nachloeschung fehlgeschlagen. ${failedEntries.length} Dateien konnten nicht gelöscht werden (${failedFiles}).`
};
}
}
}
if (await pathStillExists(filePath)) {
return { return {
ok: false, ok: false,
error: 'Löschen unvollständig: Der Ordnerpfad ist nach zwei Versuchen noch vorhanden.' error: `Löschen unvollständig: ${remaining.length} Dateien sind noch vorhanden (${remainingPreview}${remaining.length > 10 ? ', ...' : ''}).`
}; };
} }
@@ -2114,8 +2394,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
} }
// Git-basierter Upload via simple-git — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig. // Git-basierter Upload via cloneForUpload() — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
const simpleGit = require('simple-git');
let authUrl; let authUrl;
try { try {
const rawUrl = url.startsWith('http') ? url : `https://${url}`; const rawUrl = url.startsWith('http') ? url : `https://${url}`;
@@ -2129,27 +2408,13 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI']; const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
try { try {
const git = simpleGit({ config: gitConfig }); const { repoGit, isEmptyRepo } = await cloneForUpload({
let repoGit; authUrl,
let isEmptyRepo = false; tmpDir,
branch,
try { gitConfig,
const cloneArgs = ['--depth', '1', '--no-single-branch']; label: 'FileUpload'
if (branch !== 'HEAD') cloneArgs.push('--branch', branch); });
await git.clone(authUrl, tmpDir, cloneArgs);
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
} catch (cloneErr) {
const cloneMsg = String(cloneErr);
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
isEmptyRepo = true;
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
await repoGit.init();
await repoGit.addRemote('origin', authUrl);
} else {
throw cloneErr;
}
}
// Dateien in den Zielordner kopieren // Dateien in den Zielordner kopieren
for (const localFile of validFiles) { for (const localFile of validFiles) {
@@ -2161,7 +2426,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
results.push({ file: localFile, ok: true, targetPath }); results.push({ file: localFile, ok: true, targetPath });
} }
await repoGit.add('.'); await stageUploadedFiles(repoGit);
let hasChanges = true; let hasChanges = true;
try { try {
@@ -2193,7 +2458,45 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
} catch (gitErr) { } catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} 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 }); logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId });
return { ok: false, error: `Upload fehlgeschlagen: ${gitErr.message || String(gitErr)}`, debugId: uploadDebugId };
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
try {
const apiItems = validFiles.map(localFile => {
const fileName = ppath.basename(localFile);
return { localFile, targetPath: destPath ? `${destPath}/${fileName}` : fileName };
});
const apiResult = await uploadItemsViaApiFallback({
token, url, owner, repo, branch,
items: apiItems,
message,
label: 'FileUpload'
});
if (apiResult.ok) {
// Erfolgsmeldungen des gescheiterten Git-Versuchs verwerfen, damit keine
// Dubletten entstehen. Die Fehler zu ungültigen Pfaden bleiben erhalten.
for (let i = results.length - 1; i >= 0; i--) {
if (results[i].ok) results.splice(i, 1);
}
for (const item of apiItems) {
results.push({ file: item.localFile, ok: true, targetPath: item.targetPath });
}
const apiFailedCount = results.filter(r => !r.ok).length;
logger.info('upload-gitea-file', 'Upload über API-Fallback erfolgreich', {
commits: apiResult.commits,
failedCount: apiFailedCount,
uploadDebugId
});
return { ok: apiFailedCount === 0, results, failedCount: apiFailedCount, viaApi: true, debugId: uploadDebugId };
}
return { ok: false, error: describeApiFallbackFailure(gitErr, apiResult), debugId: uploadDebugId };
} catch (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 };
}
} }
const failedCount = results.filter(r => !r.ok).length; const failedCount = results.filter(r => !r.ok).length;
@@ -2341,7 +2644,6 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
// Git-basierter Upload: alle Dateien in einem einzigen Commit. // Git-basierter Upload: alle Dateien in einem einzigen Commit.
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett. // Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
const simpleGit = require('simple-git');
// Auth-URL mit Token für HTTPS-Auth bauen // Auth-URL mit Token für HTTPS-Auth bauen
let authUrl; let authUrl;
@@ -2362,32 +2664,13 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir }); console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir });
try { try {
const git = simpleGit({ config: gitConfig }); const { repoGit, isEmptyRepo } = await cloneForUpload({
let repoGit; authUrl,
let isEmptyRepo = false; tmpDir,
branch,
try { gitConfig,
const cloneArgs = ['--depth', '1', '--no-single-branch']; label: 'FolderUpload'
if (branch !== 'HEAD') cloneArgs.push('--branch', branch); });
console.log('[FolderUpload] Clone-Versuch:', { cloneArgs, tmpDir });
await git.clone(authUrl, tmpDir, cloneArgs);
console.log('[FolderUpload] Clone erfolgreich');
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
} catch (cloneErr) {
const cloneMsg = String(cloneErr);
console.warn('[FolderUpload] Clone-Fehler:', cloneMsg);
// Leeres Repo ohne Commits: lokal initialisieren
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
console.log('[FolderUpload] Leeres Repo erkannt — initialisiere lokal');
isEmptyRepo = true;
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
await repoGit.init();
await repoGit.addRemote('origin', authUrl);
} else {
throw cloneErr;
}
}
// Fortschritt: 30% (nach Clone) // 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 }); } catch (_) {}
@@ -2403,7 +2686,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
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 }); } catch (_) {}
// git add + commit + push // git add + commit + push
await repoGit.add('.'); await stageUploadedFiles(repoGit);
let hasChanges = true; let hasChanges = true;
try { try {
@@ -2443,7 +2726,29 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
} catch (gitErr) { } catch (gitErr) {
console.error('[FolderUpload] Git-Fehler:', String(gitErr)); console.error('[FolderUpload] Git-Fehler:', String(gitErr));
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
throw gitErr;
// 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',
onProgress: percent => {
try {
event.sender.send('folder-upload-progress', {
processed: Math.floor(total * (percent / 100)),
total,
percent
});
} catch (_) {}
}
});
if (!apiResult.ok) {
throw new Error(describeApiFallbackFailure(gitErr, apiResult));
}
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
return { ok: true, results: successResults, failedCount: 0, viaApi: true };
} }
} catch (e) { } catch (e) {
console.error('upload-local-folder-to-gitea error', e); console.error('upload-local-folder-to-gitea error', e);
@@ -2451,6 +2756,168 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
} }
}); });
/* -----------------------------------------------------------------
Gemeinsamer Git-Upload für einen kompletten Drop (Dateien + Ordner).
Erzeugt EINEN Clone + EINEN Commit + EINEN Push für ALLE Pfade
zusammen – egal wie viele Dateien/Ordner gedroppt wurden.
----------------------------------------------------------------- */
const GIT_UPLOAD_EXCLUDE = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// Wandelt gedroppte Pfade (Dateien und/oder Ordner) in eine flache Liste
// { localFile, targetPath } um. Dateien -> destPath/<name>, Ordner ->
// destPath/<ordnername>/<rel> (identisch zum bisherigen Verhalten).
function buildItemsFromPaths(paths, destPath) {
const items = [];
const skipped = [];
for (const p of paths) {
let stat;
try { stat = fs.statSync(p); } catch (e) { skipped.push({ path: p, error: 'not-found' }); continue; }
const baseName = ppath.basename(p);
if (stat.isFile()) {
const targetPath = destPath ? `${destPath}/${baseName}` : baseName;
items.push({ localFile: p, targetPath });
} else if (stat.isDirectory()) {
const folderName = baseName;
(function walk(dir) {
let entries;
try { entries = fs.readdirSync(dir); } catch (e) { return; }
for (const entry of entries) {
if (GIT_UPLOAD_EXCLUDE.includes(entry)) continue;
const full = ppath.join(dir, entry);
let st;
try { st = fs.statSync(full); } catch (e) { continue; }
if (st.isDirectory()) {
walk(full);
} else if (st.isFile()) {
const rel = ppath.relative(p, full).split(ppath.sep).join('/');
const targetPath = destPath ? `${destPath}/${folderName}/${rel}` : `${folderName}/${rel}`;
items.push({ localFile: full, targetPath });
}
}
})(p);
} else {
skipped.push({ path: p, error: 'unsupported' });
}
}
return { items, skipped };
}
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress }) {
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`;
const tmpDir = getSafeTmpDir(`git-paths-${owner}-${repo}`);
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
try {
const { repoGit, isEmptyRepo } = await cloneForUpload({
authUrl,
tmpDir,
branch: safeBranch,
gitConfig,
label: 'UploadPaths'
});
if (onProgress) onProgress(30);
for (const item of items) {
const destFile = ppath.join(tmpDir, ...item.targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(item.localFile, destFile);
}
if (onProgress) onProgress(60);
await stageUploadedFiles(repoGit);
let hasChanges = true;
try {
await repoGit.commit(message);
} catch (commitErr) {
if (String(commitErr).includes('nothing to commit')) hasChanges = false;
else throw commitErr;
}
if (hasChanges) {
let pushBranch = safeBranch;
if (pushBranch === 'HEAD' || isEmptyRepo) {
try { const bs = await repoGit.branch(); pushBranch = bs.current || 'main'; } catch (_) { pushBranch = 'main'; }
}
if (isEmptyRepo) await repoGit.push(['-u', 'origin', pushBranch]);
else await repoGit.push('origin', pushBranch);
}
if (onProgress) onProgress(100);
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
return { hasChanges };
} catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
// 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'
});
if (!apiResult.ok) {
throw new Error(describeApiFallbackFailure(gitErr, apiResult));
}
return { hasChanges: apiResult.uploaded.length > 0, viaApi: true };
}
}
ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
const uploadDebugId = `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try {
const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'Zugangsdaten fehlen', debugId: uploadDebugId };
const owner = data.owner;
const repo = data.repo;
if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo', debugId: uploadDebugId };
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
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 };
try { invalidateRepoContentsCache(owner, repo); } catch (_) {}
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
const { items, skipped } = buildItemsFromPaths(paths, destPath);
if (items.length === 0) {
return { ok: false, error: 'Keine Dateien zum Hochladen gefunden', skipped, debugId: uploadDebugId };
}
const total = items.length;
const onProgress = (percent) => {
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent }); } catch (_) {}
};
onProgress(0);
console.log('[UploadPaths] start', { uploadDebugId, owner, repo, branch, total, destPath, pathCount: paths.length });
await gitPushItemsToGitea({
token, url, owner, repo, branch, items,
message: `${messagePrefix} (${total} Datei${total === 1 ? '' : 'en'})`,
onProgress
});
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) {
console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: String(e && e.message ? e.message : e), debugId: uploadDebugId };
}
});
ipcMain.handle('download-gitea-file', async (event, data) => { ipcMain.handle('download-gitea-file', async (event, data) => {
try { try {
const credentials = readCredentials(); const credentials = readCredentials();
@@ -2848,19 +3315,97 @@ ipcMain.handle('upload-and-push', async (event, data) => {
} }
} }
// --- FALL 2: Ordner (Normale Git-Logik) --- // --- FALL 2: Ordner ---
if (!isDirectory) { if (!isDirectory) {
return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` }; return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` };
} }
const folderName = ppath.basename(data.localFolder);
// Dateien/Ordner, die niemals hochgeladen werden sollen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
// === PRIMÄRWEG: git push =========================================
// Ein einziger komprimierter Transfer + genau EIN Commit statt
// hunderter API-Requests. Das behebt sowohl die Langsamkeit als auch
// die "cannot lock ref"/Timeout-Fehler durch parallele Einzel-Commits.
// Bei Fehler (z.B. git nicht installiert) fällt der Code auf den
// API-Upload weiter unten zurück.
try {
let finalCloneUrl = cloneUrl;
if (!finalCloneUrl && giteaUrl) {
const base = giteaUrl.replace(/\/$/, '');
const urlObj = new URL(base);
finalCloneUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`;
}
if (!finalCloneUrl) throw new Error('no-clone-url');
// Token in die Clone-URL einbetten (für Authentifizierung beim Push)
let authClone = finalCloneUrl;
try {
const urlObj = new URL(finalCloneUrl);
if (token && urlObj.protocol.startsWith('http')) {
urlObj.username = encodeURIComponent(token);
authClone = urlObj.toString();
}
} catch (_) {}
const tmpDir = getSafeTmpDir(`git-push-folder-${owner}-${repo}`);
try {
console.log('[UPLOAD_DEBUG][main] directory-upload:git-clone', { uploadDebugId, branch, destPath });
const cloneArgs = ['clone', '--depth', '1'];
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
cloneArgs.push(authClone, tmpDir);
runGitSync(cloneArgs, process.cwd(), gitExecOptions);
// Zielverzeichnis im Repo: <destPath>/<folderName>/ (wie im API-Weg)
let destDirInRepo = tmpDir;
if (destPath) destDirInRepo = ppath.join(tmpDir, destPath.split('/').join(ppath.sep));
destDirInRepo = ppath.join(destDirInRepo, folderName);
ensureDir(destDirInRepo);
// Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen
fs.cpSync(data.localFolder, destDirInRepo, {
recursive: true,
filter: (src) => !excludeList.includes(ppath.basename(src))
});
runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try {
runGitSync(['-C', tmpDir, 'commit', '-m', `Upload ${folderName} via GUI`], process.cwd(), gitExecOptions);
} catch (commitErr) {
// "nothing to commit" ist kein echter Fehler
if (!/nothing to commit/i.test(String(commitErr))) throw commitErr;
console.log('[UPLOAD_DEBUG][main] directory-upload:git-nothing-to-commit', { uploadDebugId });
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
}
let pushBranch = branch;
if (pushBranch === 'HEAD') {
try {
pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim();
} catch (_) { pushBranch = 'main'; }
if (!pushBranch || pushBranch === 'HEAD') pushBranch = 'main';
}
runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
console.log('[UPLOAD_DEBUG][main] directory-upload:git-push-done', { uploadDebugId, pushBranch });
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId };
} catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
throw gitErr;
}
} catch (gitErr) {
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
}
console.log('[UPLOAD_DEBUG][main] directory-upload:using-api-path', { uploadDebugId, branch, destPath }); console.log('[UPLOAD_DEBUG][main] directory-upload:using-api-path', { uploadDebugId, branch, destPath });
// Fallback: API Upload (paralleler Upload) // Fallback: API Upload (paralleler Upload)
const items = []; const items = [];
const folderName = ppath.basename(data.localFolder); // excludeList und folderName sind oben (FALL 2) bereits definiert
// FIXED EXCLUDE LIST: Filter out .git, node_modules etc.
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
(function walk(dir) { (function walk(dir) {
const entries = fs.readdirSync(dir); const entries = fs.readdirSync(dir);
@@ -3844,7 +4389,7 @@ ipcMain.handle('import-settings-bundle', async () => {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!parsed || parsed.bundleType !== 'git-manager-settings-backup') { if (!parsed || parsed.bundleType !== 'git-manager-settings-backup') {
return { ok: false, error: 'Ungueltige Backup-Datei.' }; return { ok: false, error: 'Ungültige Backup-Datei.' };
} }
const existing = readCredentials() || {}; const existing = readCredentials() || {};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "git-manager-gui", "name": "git-manager-gui",
"version": "2.1.3", "version": "2.1.7",
"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",
+1 -2
View File
@@ -38,6 +38,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
downloadGiteaFolder: (data) => ipcRenderer.invoke('download-gitea-folder', data), downloadGiteaFolder: (data) => ipcRenderer.invoke('download-gitea-folder', data),
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),
// Repository & Git Management // Repository & Git Management
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data), saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),
@@ -95,7 +96,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Commit History & Visualization // Commit History & Visualization
getCommits: (data) => ipcRenderer.invoke('get-commits', data), getCommits: (data) => ipcRenderer.invoke('get-commits', data),
getCommitDetails: (data) => ipcRenderer.invoke('get-commit-details', data),
getCommitDiff: (data) => ipcRenderer.invoke('get-commit-diff', data), getCommitDiff: (data) => ipcRenderer.invoke('get-commit-diff', data),
getCommitFiles: (data) => ipcRenderer.invoke('get-commit-files', data), getCommitFiles: (data) => ipcRenderer.invoke('get-commit-files', data),
searchCommits: (data) => ipcRenderer.invoke('search-commits', data), searchCommits: (data) => ipcRenderer.invoke('search-commits', data),
@@ -105,7 +105,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
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),
getLocalCommitDetails: (data) => ipcRenderer.invoke('get-local-commit-details', data), getLocalCommitDetails: (data) => ipcRenderer.invoke('get-local-commit-details', data),
searchLocalCommits: (data) => ipcRenderer.invoke('search-local-commits', data),
// === FAVORITEN & ZULETZT GEÖFFNET === // === FAVORITEN & ZULETZT GEÖFFNET ===
loadFavorites: () => ipcRenderer.invoke('load-favorites'), loadFavorites: () => ipcRenderer.invoke('load-favorites'),
+157 -142
View File
@@ -360,6 +360,23 @@ function loadRepos() {
} }
} }
// Gravur-/Projekttitel zurücksetzen, wenn zur Übersicht gewechselt wird.
// Einmalig auf Modulebene definiert, damit die Funktion sofort ab Programmstart
// verfügbar ist (früher versehentlich in loadRepoContents definiert -> beim
// Klick vor dem ersten Repo-Laden "resetProjectGravurTitle is not a function").
window.resetProjectGravurTitle = function() {
const gravurTitle = document.getElementById('project-gravur-title');
if (gravurTitle) gravurTitle.textContent = '';
};
// loadRepos EINMAL umhüllen, sodass beim Laden der Repo-Liste der Projekttitel
// zurückgesetzt wird (früher wurde bei jedem Repo-Aufruf neu umhüllt).
const origLoadRepos = loadRepos;
loadRepos = function(...args) {
window.resetProjectGravurTitle();
return origLoadRepos.apply(this, args);
};
/** Load repositories from GitHub and render the grid using loadGiteaRepos's renderer */ /** Load repositories from GitHub and render the grid using loadGiteaRepos's renderer */
async function loadGithubRepos(requestId = null) { async function loadGithubRepos(requestId = null) {
const activeRequestId = requestId || ++repoLoadRequestId; const activeRequestId = requestId || ++repoLoadRequestId;
@@ -831,10 +848,6 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl
return { ok: false, error: 'Drag-and-Drop Upload ist aktuell nur für Gitea aktiv.' }; return { ok: false, error: 'Drag-and-Drop Upload ist aktuell nur für Gitea aktiv.' };
} }
let successCount = 0;
let failedCount = 0;
const errors = [];
const withTimeout = (promise, ms, label) => { const withTimeout = (promise, ms, label) => {
return Promise.race([ return Promise.race([
promise, promise,
@@ -842,79 +855,57 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl
]); ]);
}; };
for (let i = 0; i < safePaths.length; i++) { // Der KOMPLETTE Drop (alle Dateien UND Ordner zusammen) wird in EINEM
const p = safePaths[i]; // Aufruf hochgeladen: ein Repo-Clone, ein Commit, ein Push – egal wie
const baseName = p.split(/[\\/]/).pop() || p; // viele Objekte gedroppt wurden. Das war die Ursache für die langen
showProgress(Math.round(((i + 1) / safePaths.length) * 100), `Upload ${i + 1}/${safePaths.length}: ${baseName}`); // Wartezeiten und Zeitüberschreitungen (vorher: ein kompletter Clone+Push
try { // pro einzelner Datei bzw. pro Ordner).
let res = null; showProgress(15, `Bereite Upload von ${safePaths.length} Objekt(en) vor ...`);
try {
const res = await withTimeout(window.electronAPI.uploadPathsToGitea({
paths: safePaths,
owner,
repo,
destPath,
branch,
messagePrefix: 'Upload via GUI'
}), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout
showProgress(100, 'Upload abgeschlossen');
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:result', res);
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:result', { count: safePaths.length, ok: res?.ok, failedCount: res?.failedCount }); } catch (_) {}
const pathType = await withTimeout(window.electronAPI.getPathType(p), 5000, 'get-path-type'); if (!res?.ok) {
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:pathType', { path: p, pathType }); return {
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:pathType', { path: p, pathType }); } catch (_) {} ok: false,
error: res?.error || 'Upload fehlgeschlagen',
if (!pathType?.ok) { failedFiles: Array.isArray(res?.results) ? res.results.filter(r => !r.ok) : [],
throw new Error(pathType?.error || 'path-type-failed'); successCount: 0,
} failedCount: safePaths.length
};
if (pathType.type === 'file') {
res = await withTimeout(window.electronAPI.uploadGiteaFile({
owner,
repo,
localPath: [p],
destPath,
branch,
platform: currentState.platform
}), 15000, 'upload-gitea-file');
} else if (pathType.type === 'dir') {
res = await withTimeout(window.electronAPI.uploadLocalFolderToGitea({
localFolder: p,
owner,
repo,
destPath,
branch,
messagePrefix: 'Upload folder via GUI'
}), 600000, 'upload-local-folder-to-gitea');
} else {
throw new Error(`Nicht unterstuetzter Pfadtyp: ${pathType.type || 'unknown'}`);
}
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:itemResult', { path: p, res });
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:itemResult', { path: p, res }); } catch (_) {}
if (!res?.ok) {
failedCount++;
const detailedFailure = Array.isArray(res?.failedFiles) && res.failedFiles.length > 0
? `${baseName}: ${res.failedFiles[0].targetPath} - ${res.failedFiles[0].error || 'Unbekannter Fehler'}`
: `${baseName}: ${res?.error || 'Unbekannter Fehler'}`;
errors.push(detailedFailure);
continue;
}
const failedEntries = Array.isArray(res.results) ? res.results.filter(r => !r.ok) : [];
if (failedEntries.length > 0) {
failedCount++;
errors.push(`${baseName}: ${failedEntries[0]?.error || 'Teilweise fehlgeschlagen'}`);
} else {
successCount++;
}
} catch (err) {
failedCount++;
const errMsg = String(err && err.message ? err.message : err);
errors.push(`${baseName}: ${errMsg}`);
console.error('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:itemError', { path: p, err: errMsg });
try { window.electronAPI.debugToMain('error', 'uploadDroppedPaths:itemError', { path: p, err: errMsg }); } catch (_) {}
} }
}
if (failedCount > 0) { const failedEntries = Array.isArray(res.results) ? res.results.filter(r => !r.ok) : [];
return { ok: false, error: errors[0] || `${failedCount} Upload(s) fehlgeschlagen`, successCount, failedCount }; const uploaded = (Array.isArray(res.results) ? res.results.length : 0) - failedEntries.length;
} if (failedEntries.length > 0) {
return {
ok: false,
error: failedEntries[0]?.error || 'Teilweise fehlgeschlagen',
failedFiles: failedEntries,
successCount: uploaded,
failedCount: failedEntries.length
};
}
const result = { ok: true, uploadedFiles: successCount, uploadedDirs: 0 }; const result = { ok: true, uploadedFiles: uploaded, uploadedDirs: 0 };
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:done', result); } catch (_) {} try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:done', result); } catch (_) {}
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:done', result); console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:done', result);
return result; return result;
} catch (err) {
const errMsg = String(err && err.message ? err.message : err);
console.error('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:error', { err: errMsg });
try { window.electronAPI.debugToMain('error', 'uploadDroppedPaths:error', { err: errMsg }); } catch (_) {}
return { ok: false, error: errMsg, successCount: 0, failedCount: safePaths.length };
}
} }
function extractDroppedPaths(files) { function extractDroppedPaths(files) {
@@ -944,7 +935,7 @@ async function toggleRepoVisibility(owner, repoName, currentPrivate) {
} }
const targetPrivate = !currentPrivate; const targetPrivate = !currentPrivate;
const actionText = targetPrivate ? 'privat' : 'oeffentlich'; const actionText = targetPrivate ? 'privat' : 'öffentlich';
showProgress(35, `Repository wird ${actionText} gesetzt...`); showProgress(35, `Repository wird ${actionText} gesetzt...`);
const result = await window.electronAPI.updateGiteaRepoVisibility({ const result = await window.electronAPI.updateGiteaRepoVisibility({
token: creds.giteaToken, token: creds.giteaToken,
@@ -1048,7 +1039,7 @@ function showTagsEditorModal(owner, repoName, seed, knownTopics) {
const hint = document.createElement('div'); const hint = document.createElement('div');
hint.className = 'settings-inline-hint'; hint.className = 'settings-inline-hint';
hint.textContent = 'Vorschlaege kommen live von deiner Gitea-Seite. Neue Tags sind ebenfalls erlaubt.'; hint.textContent = 'Vorschläge kommen live von deiner Gitea-Seite. Neue Tags sind ebenfalls erlaubt.';
group.appendChild(repoLabel); group.appendChild(repoLabel);
group.appendChild(selectedHostEl); group.appendChild(selectedHostEl);
@@ -1130,7 +1121,7 @@ function showTagsEditorModal(owner, repoName, seed, knownTopics) {
} }
list = list.filter(t => !selected.some(s => s.toLowerCase() === t.toLowerCase())).slice(0, 50); list = list.filter(t => !selected.some(s => s.toLowerCase() === t.toLowerCase())).slice(0, 50);
if (list.length === 0) { if (list.length === 0) {
suggestionsHost.innerHTML = '<div style="padding:10px 12px;color:var(--text-muted);font-size:12px;">Keine Vorschlaege</div>'; suggestionsHost.innerHTML = '<div style="padding:10px 12px;color:var(--text-muted);font-size:12px;">Keine Vorschläge</div>';
return; return;
} }
suggestionsHost.innerHTML = ''; suggestionsHost.innerHTML = '';
@@ -1521,7 +1512,7 @@ async function buildGithubHeatmapFromRepoCommits(monthsBack) {
dayMap.set(key, (dayMap.get(key) || 0) + 1); dayMap.set(key, (dayMap.get(key) || 0) + 1);
} }
} catch (_) { } catch (_) {
// einzelne Repos koennen fehlschlagen ohne den Gesamtprozess zu blockieren // einzelne Repos können fehlschlagen, ohne den Gesamtprozess zu blockieren
} }
} }
}; };
@@ -2134,6 +2125,36 @@ function updateSettingsHealth(patch) {
syncSettingsPanelHeights(); syncSettingsPanelHeights();
} }
const INVALID_URL_HINT = 'Ungültige URL. Beispiel: https://git.example.net oder http://192.168.1.10:3000';
// Interne Fehlercodes aus dem Main-Prozess in verständliche Texte übersetzen.
const ERROR_CODE_MESSAGES = {
'missing-token-or-url': 'Gitea Token oder URL fehlt. Bitte in den Einstellungen speichern.',
'missing-token': 'Token fehlt. Bitte in den Einstellungen speichern.',
'missing-url': 'Gitea URL fehlt. Bitte in den Einstellungen speichern.'
};
// Abgebrochene Git-Uebertragungen erkennen (grosses Repo, Server-/Proxy-Timeout).
function isTransferAbortedError(raw) {
return raw.includes('early eof')
|| raw.includes('index-pack')
|| raw.includes('rpc failed')
|| raw.includes('remote end hung up')
|| raw.includes('unexpected disconnect')
|| raw.includes('transfer closed');
}
// Nur echte URL-Parse-Fehler erkennen - nicht jede Meldung, in der "URL" vorkommt.
function isInvalidUrlError(raw) {
return raw.includes('ungültige url')
|| raw.includes('ungueltige url')
|| raw.includes('invalid url')
|| raw.includes('err_invalid_url')
|| raw.includes('invalid uri')
|| raw.includes('unsupported protocol')
|| raw.includes('only absolute urls');
}
function normalizeAndValidateGiteaUrl(rawUrl) { function normalizeAndValidateGiteaUrl(rawUrl) {
const value = (rawUrl || '').trim(); const value = (rawUrl || '').trim();
if (!value) return { ok: true, value: '' }; if (!value) return { ok: true, value: '' };
@@ -2144,7 +2165,7 @@ function normalizeAndValidateGiteaUrl(rawUrl) {
} catch (_) { } catch (_) {
return { return {
ok: false, ok: false,
error: 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000' error: INVALID_URL_HINT
}; };
} }
@@ -2182,8 +2203,16 @@ function renderGithubTokenHint(rawToken) {
} }
function mapErrorMessage(message) { function mapErrorMessage(message) {
const raw = String(message || '').toLowerCase(); const original = String(message ?? '').trim();
if (!raw) return 'Unbekannter Fehler'; if (!original) return 'Unbekannter Fehler';
const raw = original.toLowerCase();
if (ERROR_CODE_MESSAGES[raw]) return ERROR_CODE_MESSAGES[raw];
// Zuerst pruefen: abgebrochene Uebertragungen sind spezifischer als die Status-Code-Heuristik unten.
if (isTransferAbortedError(raw)) {
return 'Übertragung vom Server abgebrochen (early EOF / index-pack). Repository zu groß oder Timeout beim Gitea-Server bzw. Reverse-Proxy. Bitte erneut versuchen.';
}
if (raw.includes('401') || raw.includes('unauthorized') || raw.includes('authentifizierung')) { if (raw.includes('401') || raw.includes('unauthorized') || raw.includes('authentifizierung')) {
return 'Authentifizierung fehlgeschlagen. Bitte Token prüfen.'; return 'Authentifizierung fehlgeschlagen. Bitte Token prüfen.';
@@ -2195,15 +2224,15 @@ function mapErrorMessage(message) {
return 'Server oder Ressource nicht gefunden. URL/Repo prüfen.'; return 'Server oder Ressource nicht gefunden. URL/Repo prüfen.';
} }
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) { if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
return 'Server nicht erreichbar. DNS, IPv4/IPv6 und Port prüfen.'; return 'Server nicht erreichbar. DNS, Server-IP und Port prüfen.';
} }
if (raw.includes('timeout') || raw.includes('econnaborted') || raw.includes('zeitueberschreitung') || raw.includes('zeitüberschreitung')) { if (raw.includes('timeout') || raw.includes('econnaborted') || raw.includes('zeitueberschreitung') || raw.includes('zeitüberschreitung')) {
return 'Zeitüberschreitung bei der Verbindung. Bitte erneut versuchen.'; return 'Zeitüberschreitung bei der Verbindung. Bitte erneut versuchen.';
} }
if (raw.includes('ungueltige') || raw.includes('ungültige') || raw.includes('invalid') || raw.includes('url')) { if (isInvalidUrlError(raw)) {
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000'; return INVALID_URL_HINT;
} }
return String(message); return original;
} }
function setStatus(txt) { function setStatus(txt) {
@@ -2742,9 +2771,9 @@ function renderTabs() {
e.stopPropagation(); e.stopPropagation();
if (tab.dirty) { if (tab.dirty) {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Ungespeicherte Aenderungen', title: 'Ungespeicherte Änderungen',
message: `${tab.name} hat ungespeicherte Aenderungen. Wirklich schliessen?`, message: `${tab.name} hat ungespeicherte Änderungen. Wirklich schließen?`,
confirmText: 'Schliessen', confirmText: 'Schließen',
danger: true danger: true
}); });
if (!ok) return; if (!ok) return;
@@ -3009,9 +3038,9 @@ async function closeFileEditor() {
if (unsaved.length > 0) { if (unsaved.length > 0) {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Ungespeicherte Aenderungen', title: 'Ungespeicherte Änderungen',
message: `${unsaved.length} Datei(en) haben ungespeicherte Aenderungen. Wirklich schliessen?`, message: `${unsaved.length} Datei(en) haben ungespeicherte Änderungen. Wirklich schließen?`,
confirmText: 'Schliessen', confirmText: 'Schließen',
danger: true danger: true
}); });
if (!ok) return; if (!ok) return;
@@ -3049,7 +3078,7 @@ async function openFileEditor(filePath, fileName) {
if (response.ok) { if (response.ok) {
addTab(filePath, fileName, response.content); addTab(filePath, fileName, response.content);
} else { } else {
await showInfoModal('Datei konnte nicht geoeffnet werden', `Fehler: ${response.error || 'Unbekannter Fehler'}`, true); await showInfoModal('Datei konnte nicht geöffnet werden', `Fehler: ${response.error || 'Unbekannter Fehler'}`, true);
return; return;
} }
} }
@@ -3065,7 +3094,7 @@ async function openFileEditor(filePath, fileName) {
console.log('✅ File opened'); console.log('✅ File opened');
} catch (error) { } catch (error) {
console.error('Error opening file:', error); console.error('Error opening file:', error);
await showInfoModal('Datei konnte nicht geoeffnet werden', 'Fehler beim Oeffnen der Datei.', true); await showInfoModal('Datei konnte nicht geöffnet werden', 'Fehler beim Öffnen der Datei.', true);
} }
} }
@@ -3110,7 +3139,7 @@ async function openGiteaFileInEditor(owner, repo, filePath, fileName) {
} }
} catch (error) { } catch (error) {
console.error('Error opening Gitea file:', error); console.error('Error opening Gitea file:', error);
await showInfoModal('Datei konnte nicht geoeffnet werden', 'Fehler beim Oeffnen der Datei.', true); await showInfoModal('Datei konnte nicht geöffnet werden', 'Fehler beim Öffnen der Datei.', true);
showError('Fehler'); showError('Fehler');
} }
} }
@@ -3277,7 +3306,7 @@ async function saveCurrentFile(isAutoSave = false) {
// Prüfe ob es eine Bilddatei ist // Prüfe ob es eine Bilddatei ist
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(currentActiveTab)) { if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(currentActiveTab)) {
await showInfoModal('Nicht bearbeitbar', 'Bilder koennen nicht bearbeitet werden.'); await showInfoModal('Nicht bearbeitbar', 'Bilder können nicht bearbeitet werden.');
return; return;
} }
@@ -3941,20 +3970,6 @@ async function loadRepoContents(owner, repo, path) {
showError('Error: ' + (res.error || 'Unknown error')); showError('Error: ' + (res.error || 'Unknown error'));
return; return;
} }
// Wenn zur Übersicht gewechselt wird, Gravur zurücksetzen
window.resetProjectGravurTitle = function() {
const gravurTitle = document.getElementById('project-gravur-title');
if (gravurTitle) gravurTitle.textContent = '';
};
// ...existing code...
// Nach dem Laden der Repo-Liste oder beim Klick auf "Zurück" rufe resetProjectGravurTitle() auf
const origLoadRepos = loadRepos;
loadRepos = function(...args) {
resetProjectGravurTitle();
return origLoadRepos.apply(this, args);
};
const grid = $('explorerGrid'); const grid = $('explorerGrid');
if (!grid) return; if (!grid) return;
@@ -4374,7 +4389,7 @@ function showSyncConfirmModal({ title = 'Bestaetigen', message = '', confirmText
const detailTitle = document.createElement('div'); const detailTitle = document.createElement('div');
detailTitle.style.cssText = 'font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:#b9c9e8;margin-bottom:4px;'; detailTitle.style.cssText = 'font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:#b9c9e8;margin-bottom:4px;';
detailTitle.textContent = 'Was wird uebernommen'; detailTitle.textContent = 'Was wird übernommen';
detailBlock.appendChild(detailTitle); detailBlock.appendChild(detailTitle);
rows.forEach((d) => { rows.forEach((d) => {
@@ -4600,7 +4615,7 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
details: [ details: [
{ label: 'Richtung', value: 'Gitea -> GitHub' }, { label: 'Richtung', value: 'Gitea -> GitHub' },
{ label: 'Repository', value: `${owner}/${repoName}` }, { label: 'Repository', value: `${owner}/${repoName}` },
{ label: 'Sichtbarkeit', value: isPrivate ? 'Privat' : 'Oeffentlich' }, { label: 'Sichtbarkeit', value: isPrivate ? 'Privat' : 'Öffentlich' },
{ label: 'Beschreibung', value: (repoMeta?.description || '-').slice(0, 160) }, { label: 'Beschreibung', value: (repoMeta?.description || '-').slice(0, 160) },
{ label: 'Topics', value: (Array.isArray(repoMeta?.topics) && repoMeta.topics.length > 0) ? repoMeta.topics.join(', ') : '-' } { label: 'Topics', value: (Array.isArray(repoMeta?.topics) && repoMeta.topics.length > 0) ? repoMeta.topics.join(', ') : '-' }
] ]
@@ -4633,7 +4648,7 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
details: [ details: [
{ label: 'Richtung', value: 'GitHub -> Gitea' }, { label: 'Richtung', value: 'GitHub -> Gitea' },
{ label: 'Repository', value: `${owner}/${repoName}` }, { label: 'Repository', value: `${owner}/${repoName}` },
{ label: 'Sichtbarkeit', value: isPrivate ? 'Privat' : 'Oeffentlich' }, { label: 'Sichtbarkeit', value: isPrivate ? 'Privat' : 'Öffentlich' },
{ label: 'Beschreibung', value: (repoMeta?.description || '-').slice(0, 160) }, { label: 'Beschreibung', value: (repoMeta?.description || '-').slice(0, 160) },
{ label: 'Topics', value: (Array.isArray(repoMeta?.topics) && repoMeta.topics.length > 0) ? repoMeta.topics.join(', ') : '-' } { label: 'Topics', value: (Array.isArray(repoMeta?.topics) && repoMeta.topics.length > 0) ? repoMeta.topics.join(', ') : '-' }
] ]
@@ -4757,9 +4772,9 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
const deleteItem = createMenuItem('🗑️', 'Repo löschen', async () => { const deleteItem = createMenuItem('🗑️', 'Repo löschen', async () => {
menu.remove(); menu.remove();
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Repository loeschen', title: 'Repository löschen',
message: `Delete ${repoName}?`, message: `Repository ${repoName} wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (ok) { if (ok) {
@@ -4767,13 +4782,13 @@ function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate =
const res = await window.electronAPI.deleteGiteaRepo({ owner, repo: repoName, platform: currentState.platform }); const res = await window.electronAPI.deleteGiteaRepo({ owner, repo: repoName, platform: currentState.platform });
if (res.ok) { if (res.ok) {
element.remove(); element.remove();
showSuccess('Repository deleted'); showSuccess('Repository gelöscht');
} else { } else {
showError('Delete failed: ' + res.error); showError('Löschen fehlgeschlagen: ' + res.error);
} }
} catch (error) { } catch (error) {
console.error('Delete error:', error); console.error('Delete error:', error);
showError('Delete failed'); showError('Löschen fehlgeschlagen');
} }
} }
}, '#ef4444'); }, '#ef4444');
@@ -4832,9 +4847,9 @@ function showGiteaItemContextMenu(ev, item, owner, repo) {
addItem('🗑️', `Alle ${selectedItems.size} löschen`, async () => { addItem('🗑️', `Alle ${selectedItems.size} löschen`, async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Mehrfach-Loeschen', title: 'Mehrfach-Löschen',
message: `${selectedItems.size} Elemente wirklich loeschen?`, message: `${selectedItems.size} Elemente wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (!ok) return; if (!ok) return;
@@ -4911,10 +4926,10 @@ function showGiteaItemContextMenu(ev, item, owner, repo) {
// --- LÖSCHEN --- // --- LÖSCHEN ---
addItem('🗑️', 'Löschen', async () => { addItem('🗑️', 'Löschen', async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Element loeschen', title: 'Element löschen',
message: `"${item.name}" wirklich loeschen?`, message: `"${item.name}" wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (!ok) return; if (!ok) return;
@@ -4927,7 +4942,7 @@ function showGiteaItemContextMenu(ev, item, owner, repo) {
loadRepoContents(owner, repo, currentState.path); loadRepoContents(owner, repo, currentState.path);
} else { } else {
showError('Löschen fehlgeschlagen: ' + (res?.error || '')); showError('Löschen fehlgeschlagen: ' + (res?.error || ''));
await showInfoModal('Loeschen fehlgeschlagen', 'Loeschen fehlgeschlagen:\n' + (res?.error || 'Unbekannter Fehler'), true); await showInfoModal('Löschen fehlgeschlagen', 'Löschen fehlgeschlagen:\n' + (res?.error || 'Unbekannter Fehler'), true);
} }
}, '#ef4444'); }, '#ef4444');
@@ -4980,9 +4995,9 @@ function showLocalItemContextMenu(ev, node) {
addItem('🗑️', `Alle ${selectedItems.size} löschen`, async () => { addItem('🗑️', `Alle ${selectedItems.size} löschen`, async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Mehrfach-Loeschen', title: 'Mehrfach-Löschen',
message: `${selectedItems.size} Elemente wirklich loeschen?`, message: `${selectedItems.size} Elemente wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (!ok) return; if (!ok) return;
@@ -5041,10 +5056,10 @@ function showLocalItemContextMenu(ev, node) {
// --- LÖSCHEN --- // --- LÖSCHEN ---
addItem('🗑️', 'Löschen', async () => { addItem('🗑️', 'Löschen', async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Element loeschen', title: 'Element löschen',
message: `"${node.name}" wirklich loeschen?`, message: `"${node.name}" wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (!ok) return; if (!ok) return;
@@ -6807,9 +6822,9 @@ function createReleaseCard(release, isLatest) {
`; `;
deleteAssetBtn.onclick = async () => { deleteAssetBtn.onclick = async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Asset loeschen', title: 'Asset löschen',
message: `Delete asset "${asset.name}"?`, message: `Asset "${asset.name}" wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (ok) { if (ok) {
@@ -6820,7 +6835,7 @@ function createReleaseCard(release, isLatest) {
}); });
if (res.ok) { if (res.ok) {
assetItem.remove(); assetItem.remove();
setStatus('Asset deleted'); setStatus('Asset gelöscht');
} }
} }
}; };
@@ -6910,7 +6925,7 @@ function createReleaseCard(release, isLatest) {
addAssetBtn.onclick = () => showUploadAssetDialog(release); addAssetBtn.onclick = () => showUploadAssetDialog(release);
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.textContent = '🗑️ Delete'; deleteBtn.textContent = '🗑️ Löschen';
deleteBtn.style.cssText = ` deleteBtn.style.cssText = `
background: transparent; background: transparent;
color: var(--danger); color: var(--danger);
@@ -6923,9 +6938,9 @@ function createReleaseCard(release, isLatest) {
`; `;
deleteBtn.onclick = async () => { deleteBtn.onclick = async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Release loeschen', title: 'Release löschen',
message: `Delete release "${release.name || release.tag_name}"?`, message: `Release "${release.name || release.tag_name}" wirklich löschen?`,
confirmText: 'Loeschen', confirmText: 'Löschen',
danger: true danger: true
}); });
if (ok) { if (ok) {
@@ -7093,7 +7108,7 @@ function showCreateReleaseModal(owner, repo) {
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Auswählen der Datei:', error); console.error('Fehler beim Auswählen der Datei:', error);
await showInfoModal('Dateidialog', 'Konnte Dateidialog nicht oeffnen.', true); await showInfoModal('Dateidialog', 'Konnte Dateidialog nicht öffnen.', true);
} }
}; };
@@ -8103,7 +8118,7 @@ async function initUpdater() {
$('btnImportSettingsBundle').onclick = async () => { $('btnImportSettingsBundle').onclick = async () => {
const ok = await showActionConfirmModal({ const ok = await showActionConfirmModal({
title: 'Backup importieren', title: 'Backup importieren',
message: 'Vorhandene Einstellungen koennen ueberschrieben werden. Fortfahren?', message: 'Vorhandene Einstellungen können überschrieben werden. Fortfahren?',
confirmText: 'Import starten', confirmText: 'Import starten',
danger: false danger: false
}); });
+183 -183
View File
@@ -1,183 +1,183 @@
/** /**
* BackupManager — Orchestrates backup operations * BackupManager — Orchestrates backup operations
*/ */
const archiver = require('archiver') const archiver = require('archiver')
const { createReadStream, createWriteStream } = require('fs') const { createReadStream, createWriteStream } = require('fs')
const { mkdir } = require('fs').promises const { mkdir } = require('fs').promises
const path = require('path') const path = require('path')
const { Transform } = require('stream') const { Transform } = require('stream')
class BackupManager { class BackupManager {
constructor(provider) { constructor(provider) {
this.provider = provider this.provider = provider
} }
/** /**
* Create a backup from a project folder * Create a backup from a project folder
* @param {string} projectPath - Path to project folder * @param {string} projectPath - Path to project folder
* @param {string} repoName - Repository name (used for filenames) * @param {string} repoName - Repository name (used for filenames)
* @returns {Promise<{filename, size, timestamp}>} * @returns {Promise<{filename, size, timestamp}>}
*/ */
async createBackup(projectPath, repoName) { async createBackup(projectPath, repoName) {
try { try {
// Create ZIP buffer // Create ZIP buffer
const buffer = await this._createZip(projectPath) const buffer = await this._createZip(projectPath)
const timestamp = this._getTimestamp() const timestamp = this._getTimestamp()
const filename = `${repoName}-backup-${timestamp}.zip` const filename = `${repoName}-backup-${timestamp}.zip`
// Upload to provider // Upload to provider
const result = await this.provider.uploadBackup(buffer, filename) const result = await this.provider.uploadBackup(buffer, filename)
// Cleanup old backups // Cleanup old backups
await this._cleanupOldBackups(repoName) await this._cleanupOldBackups(repoName)
return { return {
filename, filename,
size: result.size || buffer.length, size: result.size || buffer.length,
timestamp timestamp
} }
} catch (err) { } catch (err) {
throw new Error(`Backup creation failed: ${err.message}`) throw new Error(`Backup creation failed: ${err.message}`)
} }
} }
/** /**
* List all backups for a repository * List all backups for a repository
* @param {string} repoName - Repository name * @param {string} repoName - Repository name
* @returns {Promise<Array>} * @returns {Promise<Array>}
*/ */
async listBackups(repoName) { async listBackups(repoName) {
try { try {
const backups = await this.provider.listBackups() const backups = await this.provider.listBackups()
return backups return backups
.filter(b => b.name.startsWith(repoName)) .filter(b => b.name.startsWith(repoName))
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name)) .sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
} catch (err) { } catch (err) {
throw new Error(`Failed to list backups: ${err.message}`) throw new Error(`Failed to list backups: ${err.message}`)
} }
} }
/** /**
* Restore a backup to a target folder * Restore a backup to a target folder
* @param {string} repoName - Repository name * @param {string} repoName - Repository name
* @param {string} filename - Backup filename * @param {string} filename - Backup filename
* @param {string} targetPath - Target folder path * @param {string} targetPath - Target folder path
*/ */
async restoreBackup(repoName, filename, targetPath) { async restoreBackup(repoName, filename, targetPath) {
try { try {
// Download backup // Download backup
const buffer = await this.provider.downloadBackup(filename) const buffer = await this.provider.downloadBackup(filename)
// Extract ZIP // Extract ZIP
await this._extractZip(buffer, targetPath) await this._extractZip(buffer, targetPath)
return { ok: true, restored: filename } return { ok: true, restored: filename }
} catch (err) { } catch (err) {
throw new Error(`Restore failed: ${err.message}`) throw new Error(`Restore failed: ${err.message}`)
} }
} }
/** /**
* Delete a backup * Delete a backup
* @param {string} filename - Backup filename * @param {string} filename - Backup filename
*/ */
async deleteBackup(filename) { async deleteBackup(filename) {
try { try {
await this.provider.deleteBackup(filename) await this.provider.deleteBackup(filename)
return { ok: true } return { ok: true }
} catch (err) { } catch (err) {
throw new Error(`Delete failed: ${err.message}`) throw new Error(`Delete failed: ${err.message}`)
} }
} }
// ==================== PRIVATE METHODS ==================== // ==================== PRIVATE METHODS ====================
/** /**
* Create ZIP buffer from project folder * Create ZIP buffer from project folder
* Excludes: .git, node_modules, dist, build, .env * Excludes: .git, node_modules, dist, build, .env
*/ */
async _createZip(projectPath) { async _createZip(projectPath) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const output = [] const output = []
const archive = archiver('zip', { zlib: { level: 5 } }) const archive = archiver('zip', { zlib: { level: 5 } })
archive.on('data', chunk => output.push(chunk)) archive.on('data', chunk => output.push(chunk))
archive.on('end', () => resolve(Buffer.concat(output))) archive.on('end', () => resolve(Buffer.concat(output)))
archive.on('error', reject) archive.on('error', reject)
// Add files with exclusions // Add files with exclusions
archive.glob('**/*', { archive.glob('**/*', {
cwd: projectPath, cwd: projectPath,
ignore: [ ignore: [
'.git/**', '.git/**',
'.git', '.git',
'node_modules/**', 'node_modules/**',
'node_modules', 'node_modules',
'dist/**', 'dist/**',
'dist', 'dist',
'build/**', 'build/**',
'build', 'build',
'.env', '.env',
'.env.local', '.env.local',
'.env.*.local', '.env.*.local',
'*.log', '*.log',
'data/backups/**' 'data/backups/**'
], ],
dot: true dot: true
}) })
archive.finalize() archive.finalize()
}) })
} }
/** /**
* Extract ZIP buffer to target folder * Extract ZIP buffer to target folder
*/ */
async _extractZip(buffer, targetPath) { async _extractZip(buffer, targetPath) {
const unzipper = require('unzipper') const unzipper = require('unzipper')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { Readable } = require('stream') const { Readable } = require('stream')
const stream = Readable.from(buffer) const stream = Readable.from(buffer)
stream stream
.pipe(unzipper.Extract({ path: targetPath })) .pipe(unzipper.Extract({ path: targetPath }))
.on('close', resolve) .on('close', resolve)
.on('error', reject) .on('error', reject)
}) })
} }
/** /**
* Get ISO timestamp (YYYYMMDD-HHMMSS format) * Get ISO timestamp (YYYYMMDD-HHMMSS format)
*/ */
_getTimestamp() { _getTimestamp() {
const now = new Date() const now = new Date()
const year = now.getFullYear() const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0') const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0') const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0') const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0') const seconds = String(now.getSeconds()).padStart(2, '0')
return `${year}${month}${day}-${hours}${minutes}${seconds}` return `${year}${month}${day}-${hours}${minutes}${seconds}`
} }
/** /**
* Cleanup old backups (keep only last N versions) * Cleanup old backups (keep only last N versions)
*/ */
async _cleanupOldBackups(repoName, maxVersions = 5) { async _cleanupOldBackups(repoName, maxVersions = 5) {
try { try {
const backups = await this.listBackups(repoName) const backups = await this.listBackups(repoName)
for (let i = maxVersions; i < backups.length; i++) { for (let i = maxVersions; i < backups.length; i++) {
await this.provider.deleteBackup(backups[i].name) await this.provider.deleteBackup(backups[i].name)
} }
} catch (err) { } catch (err) {
// Silently ignore cleanup errors // Silently ignore cleanup errors
console.warn(`Cleanup warning: ${err.message}`) console.warn(`Cleanup warning: ${err.message}`)
} }
} }
} }
module.exports = BackupManager module.exports = BackupManager
+59 -59
View File
@@ -1,59 +1,59 @@
/** /**
* BackupProvider — Abstract base class for backup providers * BackupProvider — Abstract base class for backup providers
* All providers must implement these methods * All providers must implement these methods
*/ */
class BackupProvider { class BackupProvider {
/** /**
* Authenticate with the backup service * Authenticate with the backup service
* @param {Object} credentials - Provider-specific credentials * @param {Object} credentials - Provider-specific credentials
*/ */
async authenticate(credentials) { async authenticate(credentials) {
throw new Error('authenticate() not implemented in ' + this.constructor.name) throw new Error('authenticate() not implemented in ' + this.constructor.name)
} }
/** /**
* Upload a backup file * Upload a backup file
* @param {Buffer} buffer - File content * @param {Buffer} buffer - File content
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip') * @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
* @returns {Promise<{size: number}>} * @returns {Promise<{size: number}>}
*/ */
async uploadBackup(buffer, filename) { async uploadBackup(buffer, filename) {
throw new Error('uploadBackup() not implemented in ' + this.constructor.name) throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
} }
/** /**
* List all backups for a repository * List all backups for a repository
* @returns {Promise<Array>} Array of {name, size, date} * @returns {Promise<Array>} Array of {name, size, date}
*/ */
async listBackups() { async listBackups() {
throw new Error('listBackups() not implemented in ' + this.constructor.name) throw new Error('listBackups() not implemented in ' + this.constructor.name)
} }
/** /**
* Download a specific backup * Download a specific backup
* @param {string} filename - Filename to download * @param {string} filename - Filename to download
* @returns {Promise<Buffer>} * @returns {Promise<Buffer>}
*/ */
async downloadBackup(filename) { async downloadBackup(filename) {
throw new Error('downloadBackup() not implemented in ' + this.constructor.name) throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
} }
/** /**
* Delete a backup file * Delete a backup file
* @param {string} filename - Filename to delete * @param {string} filename - Filename to delete
*/ */
async deleteBackup(filename) { async deleteBackup(filename) {
throw new Error('deleteBackup() not implemented in ' + this.constructor.name) throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
} }
/** /**
* Test connection to backup service * Test connection to backup service
* @returns {Promise<{ok: boolean, error?: string}>} * @returns {Promise<{ok: boolean, error?: string}>}
*/ */
async testConnection() { async testConnection() {
throw new Error('testConnection() not implemented in ' + this.constructor.name) throw new Error('testConnection() not implemented in ' + this.constructor.name)
} }
} }
module.exports = BackupProvider module.exports = BackupProvider
+84 -84
View File
@@ -1,84 +1,84 @@
/** /**
* LocalProvider — Backup provider for local folders * LocalProvider — Backup provider for local folders
*/ */
const path = require('path') const path = require('path')
const fs = require('fs').promises const fs = require('fs').promises
const BackupProvider = require('./BackupProvider') const BackupProvider = require('./BackupProvider')
class LocalProvider extends BackupProvider { class LocalProvider extends BackupProvider {
constructor() { constructor() {
super() super()
this.basePath = null this.basePath = null
} }
async authenticate(credentials) { async authenticate(credentials) {
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim() const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
if (!basePath) { if (!basePath) {
throw new Error('Lokaler Backup-Ordner fehlt') throw new Error('Lokaler Backup-Ordner fehlt')
} }
this.basePath = path.resolve(basePath) this.basePath = path.resolve(basePath)
await fs.mkdir(this.basePath, { recursive: true }) await fs.mkdir(this.basePath, { recursive: true })
} }
async testConnection() { async testConnection() {
if (!this.basePath) return { ok: false, error: 'Not authenticated' } if (!this.basePath) return { ok: false, error: 'Not authenticated' }
try { try {
const stat = await fs.stat(this.basePath) const stat = await fs.stat(this.basePath)
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
return { ok: false, error: 'Pfad ist kein Ordner' } return { ok: false, error: 'Pfad ist kein Ordner' }
} }
return { ok: true } return { ok: true }
} catch (err) { } catch (err) {
return { ok: false, error: err.message } return { ok: false, error: err.message }
} }
} }
async uploadBackup(buffer, filename) { async uploadBackup(buffer, filename) {
if (!this.basePath) throw new Error('Not authenticated') if (!this.basePath) throw new Error('Not authenticated')
const target = path.join(this.basePath, filename) const target = path.join(this.basePath, filename)
await fs.mkdir(path.dirname(target), { recursive: true }) await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, buffer) await fs.writeFile(target, buffer)
return { size: buffer.length } return { size: buffer.length }
} }
async listBackups() { async listBackups() {
if (!this.basePath) throw new Error('Not authenticated') if (!this.basePath) throw new Error('Not authenticated')
const entries = await fs.readdir(this.basePath, { withFileTypes: true }) const entries = await fs.readdir(this.basePath, { withFileTypes: true })
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip')) const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
const backups = [] const backups = []
for (const f of files) { for (const f of files) {
const full = path.join(this.basePath, f.name) const full = path.join(this.basePath, f.name)
const stat = await fs.stat(full) const stat = await fs.stat(full)
backups.push({ backups.push({
name: f.name, name: f.name,
size: stat.size, size: stat.size,
date: stat.mtime.toISOString() date: stat.mtime.toISOString()
}) })
} }
backups.sort((a, b) => new Date(b.date) - new Date(a.date)) backups.sort((a, b) => new Date(b.date) - new Date(a.date))
return backups return backups
} }
async downloadBackup(filename) { async downloadBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated') if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename) const file = path.join(this.basePath, filename)
return fs.readFile(file) return fs.readFile(file)
} }
async deleteBackup(filename) { async deleteBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated') if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename) const file = path.join(this.basePath, filename)
await fs.unlink(file) await fs.unlink(file)
} }
} }
module.exports = LocalProvider module.exports = LocalProvider
+239 -16
View File
@@ -3,6 +3,7 @@
// getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile // getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile
const axios = require('axios'); const axios = require('axios');
const fs = require('fs');
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
@@ -16,6 +17,12 @@ const axiosInstance = axios.create({
httpsAgent: ipv4HttpsAgent, httpsAgent: ipv4HttpsAgent,
}); });
// Kurzlebiger Cache für die Default-Branch-Auflösung (HEAD -> z.B. "main").
// Verhindert, dass bei einem Ordner-Upload für jede einzelne Datei erneut
// die Repo-Info vom Server geladen wird.
const defaultBranchCache = new Map();
const DEFAULT_BRANCH_TTL_MS = 60_000;
function normalizeAndValidateBaseUrl(rawUrl) { function normalizeAndValidateBaseUrl(rawUrl) {
const value = (rawUrl || '').trim(); const value = (rawUrl || '').trim();
if (!value) { if (!value) {
@@ -26,7 +33,7 @@ function normalizeAndValidateBaseUrl(rawUrl) {
try { try {
parsed = new URL(value); parsed = new URL(value);
} catch (_) { } catch (_) {
throw new Error('Ungueltige Gitea URL. Beispiel fuer IPv6: http://[2001:db8::1]:3000'); throw new Error('Ungültige Gitea-URL. Beispiel für IPv6: http://[2001:db8::1]:3000');
} }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
@@ -685,23 +692,32 @@ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, m
// Behalte den branch so wie übergeben - aber 'HEAD' muss zum echten Branch aufgelöst werden // Behalte den branch so wie übergeben - aber 'HEAD' muss zum echten Branch aufgelöst werden
let branchName = branch || 'HEAD'; let branchName = branch || 'HEAD';
// HEAD-Auflösung: Wenn branch === 'HEAD', den Default-Branch des Repos abrufen // HEAD-Auflösung: Wenn branch === 'HEAD', den Default-Branch des Repos abrufen.
// Ergebnis kurz cachen, damit bei einem Ordner-Upload nicht für JEDE Datei
// erneut die Repo-Info geladen wird (das war eine Hauptursache der Langsamkeit).
if (branchName === 'HEAD') { if (branchName === 'HEAD') {
try { const cacheKey = `${base}::${owner}/${repo}`;
const repoInfoUrl = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const cached = defaultBranchCache.get(cacheKey);
const repoInfo = await tryRequest(repoInfoUrl, token); if (cached && (Date.now() - cached.ts) < DEFAULT_BRANCH_TTL_MS) {
if (repoInfo.ok && repoInfo.data.default_branch) { branchName = cached.branch;
branchName = repoInfo.data.default_branch; } else {
console.log(`[Upload Debug] HEAD aufgelöst zu: ${branchName}`); try {
} else { const repoInfoUrl = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
// Fallback auf 'main' wenn Auflösung fehlschlägt const repoInfo = await tryRequest(repoInfoUrl, token);
if (repoInfo.ok && repoInfo.data.default_branch) {
branchName = repoInfo.data.default_branch;
console.log(`[Upload Debug] HEAD aufgelöst zu: ${branchName}`);
} else {
// Fallback auf 'main' wenn Auflösung fehlschlägt
branchName = 'main';
console.warn(`[Upload Debug] HEAD-Auflösung fehlgeschlagen, verwende Fallback: ${branchName}`);
}
} catch (e) {
// Fallback auf 'main' wenn Fehler
branchName = 'main'; branchName = 'main';
console.warn(`[Upload Debug] HEAD-Auflösung fehlgeschlagen, verwende Fallback: ${branchName}`); console.warn(`[Upload Debug] HEAD-Auflösung fehlgeschlagen (${e.message}), verwende Fallback: ${branchName}`);
} }
} catch (e) { defaultBranchCache.set(cacheKey, { branch: branchName, ts: Date.now() });
// Fallback auf 'main' wenn Fehler
branchName = 'main';
console.warn(`[Upload Debug] HEAD-Auflösung fehlgeschlagen (${e.message}), verwende Fallback: ${branchName}`);
} }
} }
@@ -767,6 +783,25 @@ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, m
} catch (err) { } catch (err) {
console.error(`Upload Attempt ${retryCount + 1} for ${path}:`, err.response ? err.response.data : err.message); console.error(`Upload Attempt ${retryCount + 1} for ${path}:`, err.response ? err.response.data : err.message);
// Netzwerk-Timeout / abgebrochene Verbindung: mit Backoff erneut versuchen,
// statt sofort abzubrechen. Deckt ECONNABORTED (axios-Timeout), ETIMEDOUT,
// ECONNRESET und EPIPE ab.
const netCode = err.code || '';
const isNetworkTimeout = !err.response &&
(netCode === 'ECONNABORTED' || netCode === 'ETIMEDOUT' ||
netCode === 'ECONNRESET' || netCode === 'EPIPE' ||
/timeout/i.test(String(err.message || '')));
if (isNetworkTimeout) {
if (retryCount < MAX_RETRIES) {
retryCount++;
const wait = 1000 * retryCount; // 1s, 2s, 3s ...
console.warn(`-> Netzwerk-Timeout (${netCode || 'timeout'}). Erneuter Versuch in ${wait}ms... (Retry ${retryCount}/${MAX_RETRIES})`);
await sleep(wait);
continue;
}
throw new Error(`Zeitüberschreitung beim Hochladen von "${path}". Bitte Netzwerk/Server prüfen und erneut versuchen.`);
}
// Behandle 500 Server-Fehler speziell // Behandle 500 Server-Fehler speziell
if (err.response && err.response.status === 500) { if (err.response && err.response.status === 500) {
const errorMsg = err.response.data?.message || err.message; const errorMsg = err.response.data?.message || err.message;
@@ -1293,7 +1328,7 @@ async function updateGiteaRepoTopics({ token, url, owner, repo, topics }) {
); );
return res.data; return res.data;
} catch (err) { } catch (err) {
// Fallback fuer aeltere/abweichende Gitea-Versionen // Fallback für ältere/abweichende Gitea-Versionen
const res = await axiosInstance.patch( const res = await axiosInstance.patch(
`${base}/api/v1/repos/${repoPath}`, `${base}/api/v1/repos/${repoPath}`,
{ topics: safeTopics }, { topics: safeTopics },
@@ -2021,6 +2056,193 @@ async function updateGiteaAvatar({ token, url, imageBase64 }) {
); );
} }
/* -----------------------------
Batch-Upload über die Contents-API (Gitea >= 1.20)
Fallback für den Fall, dass der Git-Transport nicht nutzbar ist — etwa wenn
git-upload-pack auf dem Server keine Packfiles erzeugen kann ("early EOF").
Dieser Weg braucht weder Clone noch Push, sondern schreibt die Dateien direkt
über die REST-API. Mehrere Dateien landen dabei in einem gemeinsamen Commit.
----------------------------- */
// Zielgröße einer Anfrage (Rohbytes vor Base64). Bewusst klein gehalten, damit
// ein Reverse-Proxy mit knappem client_max_body_size nicht dazwischenfunkt.
const API_UPLOAD_TARGET_BYTES = 6 * 1024 * 1024;
const API_UPLOAD_MAX_FILES_PER_COMMIT = 50;
// Löst "HEAD" zum tatsächlichen Default-Branch auf.
async function resolveGiteaBranchName({ token, base, owner, repo, branch }) {
const name = branch || 'HEAD';
if (name !== 'HEAD') return name;
const cacheKey = `${base}::${owner}/${repo}`;
const cached = defaultBranchCache.get(cacheKey);
if (cached && (Date.now() - cached.ts) < DEFAULT_BRANCH_TTL_MS) return cached.branch;
try {
const info = await tryRequest(
`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
token
);
const resolved = (info.ok && info.data && info.data.default_branch) ? info.data.default_branch : 'main';
defaultBranchCache.set(cacheKey, { branch: resolved, ts: Date.now() });
return resolved;
} catch (_) {
return 'main';
}
}
// Ermittelt die SHAs bereits vorhandener Dateien. Die Contents-API verlangt bei einem
// "update" die SHA der Vorgängerversion. Abgefragt wird pro Verzeichnis, nicht pro Datei.
async function fetchExistingFileShas({ token, base, owner, repo, branch, paths }) {
const shaByPath = new Map();
const dirs = new Set(paths.map(p => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')));
for (const dir of dirs) {
try {
const res = await getGiteaRepoContents({ token, url: base, owner, repo, path: dir, ref: branch });
const list = (res && res.items) ? res.items : (Array.isArray(res) ? res : []);
for (const item of list) {
if (item && item.type === 'file' && item.path && item.sha) {
shaByPath.set(item.path, item.sha);
}
}
} catch (_) {
// Verzeichnis existiert noch nicht — dann sind alle Dateien darin neu.
}
}
return shaByPath;
}
// Ein Commit mit mehreren Dateiänderungen.
async function changeGiteaFiles({ token, base, owner, repo, branch, files, message }) {
const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`;
const res = await axiosInstance.post(
endpoint,
{ branch, message, files },
{
headers: { Authorization: `token ${token}` },
timeout: 180000,
maxBodyLength: Infinity,
maxContentLength: Infinity
}
);
return res.data;
}
function isPayloadTooLargeError(err) {
const status = err && err.response && err.response.status;
if (status === 413) return true;
const msg = String((err && err.message) || '').toLowerCase();
return msg.includes('entity too large') || msg.includes('request body too large');
}
/**
* Lädt Dateien ausschließlich über die REST-API hoch — ohne Clone, ohne Push.
*
* @param {Array<{localFile: string, targetPath: string}>} items
* @param {function(number):void} [onProgress] Fortschritt in Prozent
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>}
*/
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress }) {
const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL');
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen');
const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch });
const shaByPath = await fetchExistingFileShas({
token, base, owner, repo, branch: branchName,
paths: items.map(i => i.targetPath)
});
const uploaded = [];
const failed = [];
let commits = 0;
let processedBytes = 0;
// Gesamtgröße für die Fortschrittsanzeige.
let totalBytes = 0;
for (const item of items) {
try { totalBytes += fs.statSync(item.localFile).size; } catch (_) {}
}
if (totalBytes === 0) totalBytes = 1;
// Dateien zu Paketen bündeln, die je einen Commit ergeben.
const batches = [];
let current = [];
let currentBytes = 0;
for (const item of items) {
let size = 0;
try { size = fs.statSync(item.localFile).size; } catch (e) {
failed.push({ path: item.targetPath, error: `Datei nicht lesbar: ${e.message || e}` });
continue;
}
if (current.length > 0 &&
(currentBytes + size > API_UPLOAD_TARGET_BYTES || current.length >= API_UPLOAD_MAX_FILES_PER_COMMIT)) {
batches.push(current);
current = [];
currentBytes = 0;
}
current.push({ ...item, size });
currentBytes += size;
}
if (current.length > 0) batches.push(current);
// Ein Paket absenden; bei zu großem Body rekursiv halbieren.
const sendBatch = async (batch) => {
const files = batch.map(item => {
const existingSha = shaByPath.get(item.targetPath);
const entry = {
operation: existingSha ? 'update' : 'create',
path: item.targetPath,
content: fs.readFileSync(item.localFile).toString('base64')
};
if (existingSha) entry.sha = existingSha;
return entry;
});
try {
await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message });
commits++;
for (const item of batch) {
uploaded.push(item.targetPath);
processedBytes += item.size;
}
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
return;
} catch (err) {
// Body zu groß: aufteilen und erneut versuchen.
if (isPayloadTooLargeError(err) && batch.length > 1) {
const half = Math.ceil(batch.length / 2);
console.warn(`[ApiUpload] Paket zu groß (${batch.length} Dateien) — wird geteilt`);
await sendBatch(batch.slice(0, half));
await sendBatch(batch.slice(half));
return;
}
const detail = (err.response && err.response.data && err.response.data.message)
|| err.message || String(err);
for (const item of batch) {
failed.push({ path: item.targetPath, error: String(detail) });
processedBytes += item.size;
}
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
}
};
for (const batch of batches) {
await sendBatch(batch);
}
if (onProgress) onProgress(100);
return { branch: branchName, commits, uploaded, failed };
}
module.exports = { module.exports = {
normalizeAndValidateBaseUrl, normalizeAndValidateBaseUrl,
createRepoGitHub, createRepoGitHub,
@@ -2039,6 +2261,7 @@ module.exports = {
getGiteaRepoContents, getGiteaRepoContents,
getGiteaFileContent, getGiteaFileContent,
uploadGiteaFile, uploadGiteaFile,
uploadItemsViaGiteaApi,
// Commit History // Commit History
getGiteaCommits, getGiteaCommits,
getGiteaCommit, getGiteaCommit,
+293 -293
View File
@@ -1,293 +1,293 @@
/** /**
* Gemeinsame Utility-Funktionen für Git Manager GUI * Gemeinsame Utility-Funktionen für Git Manager GUI
* - Branch Handling * - Branch Handling
* - API Error Handling * - API Error Handling
* - Standardisiertes Logging * - Standardisiertes Logging
* - Caching * - Caching
*/ */
const fs = require('fs'); const fs = require('fs');
const ppath = require('path'); const ppath = require('path');
// ===== LOGGING SYSTEM ===== // ===== LOGGING SYSTEM =====
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG; let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
let logQueue = []; let logQueue = [];
const MAX_LOG_BUFFER = 2000; const MAX_LOG_BUFFER = 2000;
function formatLog(level, context, message, details = null) { function formatLog(level, context, message, details = null) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level); const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
return { return {
timestamp, timestamp,
level: levelStr, level: levelStr,
context, context,
message, message,
details, details,
pid: process.pid pid: process.pid
}; };
} }
function writeLog(logEntry) { function writeLog(logEntry) {
logQueue.push(logEntry); logQueue.push(logEntry);
if (logQueue.length > MAX_LOG_BUFFER) { if (logQueue.length > MAX_LOG_BUFFER) {
logQueue.shift(); logQueue.shift();
} }
// Auch in Console schreiben // Auch in Console schreiben
const { level, timestamp, context, message, details } = logEntry; const { level, timestamp, context, message, details } = logEntry;
const prefix = `[${timestamp}] [${level}] [${context}]`; const prefix = `[${timestamp}] [${level}] [${context}]`;
if (level === 'ERROR' && details?.error) { if (level === 'ERROR' && details?.error) {
console.error(prefix, message, details.error); console.error(prefix, message, details.error);
} else if (level === 'WARN') { } else if (level === 'WARN') {
console.warn(prefix, message, details ? JSON.stringify(details) : ''); console.warn(prefix, message, details ? JSON.stringify(details) : '');
} else if (level !== 'DEBUG' || process.env.DEBUG) { } else if (level !== 'DEBUG' || process.env.DEBUG) {
console.log(prefix, message, details ? JSON.stringify(details) : ''); console.log(prefix, message, details ? JSON.stringify(details) : '');
} }
} }
const logger = { const logger = {
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)), debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)), info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)), warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)), error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
getRecent: (count = 20) => logQueue.slice(-count), getRecent: (count = 20) => logQueue.slice(-count),
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; } setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
}; };
// ===== BRANCH HANDLING ===== // ===== BRANCH HANDLING =====
const BRANCH_DEFAULTS = { const BRANCH_DEFAULTS = {
gitea: 'main', gitea: 'main',
github: 'main' github: 'main'
}; };
function normalizeBranch(branch = 'HEAD', platform = 'gitea') { function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
const value = String(branch || '').trim(); const value = String(branch || '').trim();
// HEAD sollte immer zu Standard konvertiert werden // HEAD sollte immer zu Standard konvertiert werden
if (value.toLowerCase() === 'head') { if (value.toLowerCase() === 'head') {
return BRANCH_DEFAULTS[platform] || 'main'; return BRANCH_DEFAULTS[platform] || 'main';
} }
// Validierung: nur sichere Git-Referenzen // Validierung: nur sichere Git-Referenzen
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) { if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
return value; return value;
} }
return BRANCH_DEFAULTS[platform] || 'main'; return BRANCH_DEFAULTS[platform] || 'main';
} }
function isSafeBranch(branch) { function isSafeBranch(branch) {
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || '')); return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
} }
// ===== ERROR HANDLING ===== // ===== ERROR HANDLING =====
const ERROR_CODES = { const ERROR_CODES = {
NETWORK: 'NETWORK_ERROR', NETWORK: 'NETWORK_ERROR',
AUTH_FAILED: 'AUTH_FAILED', AUTH_FAILED: 'AUTH_FAILED',
NOT_FOUND: 'NOT_FOUND', NOT_FOUND: 'NOT_FOUND',
VALIDATION: 'VALIDATION_ERROR', VALIDATION: 'VALIDATION_ERROR',
RATE_LIMIT: 'RATE_LIMIT', RATE_LIMIT: 'RATE_LIMIT',
SERVER_ERROR: 'SERVER_ERROR', SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN: 'UNKNOWN_ERROR' UNKNOWN: 'UNKNOWN_ERROR'
}; };
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) { function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
if (!error) { if (!error) {
return { code: defaultCode, message: 'Unknown error', statusCode: null }; return { code: defaultCode, message: 'Unknown error', statusCode: null };
} }
// Axios-style error // Axios-style error
if (error.response) { if (error.response) {
const status = error.response.status; const status = error.response.status;
const data = error.response.data; const data = error.response.data;
let code = defaultCode; let code = defaultCode;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
code = ERROR_CODES.AUTH_FAILED; code = ERROR_CODES.AUTH_FAILED;
} else if (status === 404) { } else if (status === 404) {
code = ERROR_CODES.NOT_FOUND; code = ERROR_CODES.NOT_FOUND;
} else if (status === 429) { } else if (status === 429) {
code = ERROR_CODES.RATE_LIMIT; code = ERROR_CODES.RATE_LIMIT;
} else if (status >= 500) { } else if (status >= 500) {
code = ERROR_CODES.SERVER_ERROR; code = ERROR_CODES.SERVER_ERROR;
} }
return { return {
code, code,
message: data?.message || error.message || `HTTP ${status}`, message: data?.message || error.message || `HTTP ${status}`,
statusCode: status, statusCode: status,
rawMessage: data?.message rawMessage: data?.message
}; };
} }
// Network error // Network error
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) { if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null }; return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
} }
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) { if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null }; return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
} }
return { return {
code: ERROR_CODES.UNKNOWN, code: ERROR_CODES.UNKNOWN,
message: error.message || String(error), message: error.message || String(error),
statusCode: null statusCode: null
}; };
} }
function formatErrorForUser(error, context = 'Operation') { function formatErrorForUser(error, context = 'Operation') {
const parsed = parseApiError(error); const parsed = parseApiError(error);
const messages = { const messages = {
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`, [ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`, [ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`, [ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`, [ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`, [ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.` [ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
}; };
return { return {
userMessage: messages[parsed.code], userMessage: messages[parsed.code],
technicalMessage: parsed.message, technicalMessage: parsed.message,
code: parsed.code, code: parsed.code,
details: parsed details: parsed
}; };
} }
// ===== CACHING SYSTEM ===== // ===== CACHING SYSTEM =====
class Cache { class Cache {
constructor(ttl = 300000) { // 5 min default constructor(ttl = 300000) { // 5 min default
this.store = new Map(); this.store = new Map();
this.ttl = ttl; this.ttl = ttl;
} }
set(key, value, customTtl = null) { set(key, value, customTtl = null) {
const expiry = Date.now() + (customTtl || this.ttl); const expiry = Date.now() + (customTtl || this.ttl);
this.store.set(key, { value, expiry }); this.store.set(key, { value, expiry });
} }
get(key) { get(key) {
const item = this.store.get(key); const item = this.store.get(key);
if (!item) return null; if (!item) return null;
if (Date.now() > item.expiry) { if (Date.now() > item.expiry) {
this.store.delete(key); this.store.delete(key);
return null; return null;
} }
return item.value; return item.value;
} }
invalidate(keyPattern) { invalidate(keyPattern) {
for (const [key] of this.store) { for (const [key] of this.store) {
if (key.includes(keyPattern)) { if (key.includes(keyPattern)) {
this.store.delete(key); this.store.delete(key);
} }
} }
} }
clear() { clear() {
this.store.clear(); this.store.clear();
} }
size() { size() {
return this.store.size; return this.store.size;
} }
} }
// Standard Caches // Standard Caches
const caches = { const caches = {
repos: new Cache(600000), // 10 min repos: new Cache(600000), // 10 min
fileTree: new Cache(300000), // 5 min fileTree: new Cache(300000), // 5 min
api: new Cache(120000) // 2 min api: new Cache(120000) // 2 min
}; };
// ===== PARALLEL OPERATIONS ===== // ===== PARALLEL OPERATIONS =====
async function runParallel(operations, concurrency = 4, onProgress = null) { async function runParallel(operations, concurrency = 4, onProgress = null) {
const results = new Array(operations.length); const results = new Array(operations.length);
let completed = 0; let completed = 0;
let index = 0; let index = 0;
async function worker() { async function worker() {
while (index < operations.length) { while (index < operations.length) {
const i = index++; const i = index++;
try { try {
results[i] = { ok: true, result: await operations[i]() }; results[i] = { ok: true, result: await operations[i]() };
} catch (e) { } catch (e) {
results[i] = { ok: false, error: e }; results[i] = { ok: false, error: e };
} }
completed++; completed++;
if (onProgress) { if (onProgress) {
try { onProgress(completed, operations.length); } catch (_) {} try { onProgress(completed, operations.length); } catch (_) {}
} }
} }
} }
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker()); const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
await Promise.all(workers); await Promise.all(workers);
return results; return results;
} }
// ===== RETRY LOGIC ===== // ===== RETRY LOGIC =====
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) { async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
let lastError; let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
return await fn(); return await fn();
} catch (e) { } catch (e) {
lastError = e; lastError = e;
if (attempt < maxAttempts - 1) { if (attempt < maxAttempts - 1) {
const delay = baseDelay * Math.pow(2, attempt); const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
} }
} }
throw lastError; throw lastError;
} }
// ===== FILE OPERATIONS ===== // ===== FILE OPERATIONS =====
function ensureDirectory(dirPath) { function ensureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
} }
} }
function safeReadFile(filePath, defaultValue = null) { function safeReadFile(filePath, defaultValue = null) {
try { try {
if (!fs.existsSync(filePath)) return defaultValue; if (!fs.existsSync(filePath)) return defaultValue;
return fs.readFileSync(filePath, 'utf8'); return fs.readFileSync(filePath, 'utf8');
} catch (e) { } catch (e) {
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message }); logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
return defaultValue; return defaultValue;
} }
} }
function safeWriteFile(filePath, content) { function safeWriteFile(filePath, content) {
try { try {
ensureDirectory(ppath.dirname(filePath)); ensureDirectory(ppath.dirname(filePath));
fs.writeFileSync(filePath, content, 'utf8'); fs.writeFileSync(filePath, content, 'utf8');
return true; return true;
} catch (e) { } catch (e) {
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message }); logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
return false; return false;
} }
} }
// ===== EXPORTS ===== // ===== EXPORTS =====
module.exports = { module.exports = {
logger, logger,
normalizeBranch, normalizeBranch,
isSafeBranch, isSafeBranch,
parseApiError, parseApiError,
formatErrorForUser, formatErrorForUser,
ERROR_CODES, ERROR_CODES,
Cache, Cache,
caches, caches,
runParallel, runParallel,
retryWithBackoff, retryWithBackoff,
ensureDirectory, ensureDirectory,
safeReadFile, safeReadFile,
safeWriteFile, safeWriteFile,
LOG_LEVELS LOG_LEVELS
}; };
+2 -2
View File
@@ -74,7 +74,7 @@ class Updater {
} }
} catch (error) { } catch (error) {
console.error('[Updater] Fehler beim Update-Check:', error); console.error('[Updater] Fehler beim Update-Check:', error);
this.emitUpdateError('Update-Pruefung fehlgeschlagen.', { this.emitUpdateError('Update-Prüfung fehlgeschlagen.', {
phase: 'check', phase: 'check',
silent: !!silent, silent: !!silent,
error: String(error?.message || error) error: String(error?.message || error)
@@ -325,7 +325,7 @@ class Updater {
} catch (verifyErr) { } catch (verifyErr) {
console.error('[Updater] Checksum-Validierung konnte nicht ausgeführt werden:', verifyErr?.message || verifyErr); console.error('[Updater] Checksum-Validierung konnte nicht ausgeführt werden:', verifyErr?.message || verifyErr);
fs.unlink(tempPath, () => {}); fs.unlink(tempPath, () => {});
this.emitUpdateError('Update-Download fehlgeschlagen: Checksum-Validierung nicht moeglich.', { this.emitUpdateError('Update-Download fehlgeschlagen: Checksum-Validierung nicht möglich.', {
phase: 'download', phase: 'download',
reason: 'checksum-verify-failed', reason: 'checksum-verify-failed',
error: String(verifyErr?.message || verifyErr) error: String(verifyErr?.message || verifyErr)