Files
Git-Manager-Gui/main.js
2026-07-16 08:01:04 +02:00

4802 lines
179 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// main.js — Main-Process with concurrent folder upload/download, progress events, and temp-dir cleanup
const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage } = require('electron');
const ppath = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const { execSync, spawnSync } = require('child_process');
const http = require('http');
const https = require('https');
// IMPORTS: Zentrale Utilities
const {
logger,
normalizeBranch,
parseApiError,
formatErrorForUser,
caches,
runParallel,
retryWithBackoff
} = require('./src/utils/helpers.js');
// OPTIMIERUNG: Updater wird nicht sofort geladen, sondern verzögert nach Startup
let Updater = null;
let updater = null;
// Updater lazy-loaded nach 2 Sekunden
function initUpdaterAsync() {
setTimeout(() => {
try {
if (!Updater) Updater = require('./updater.js');
} catch (e) {
console.warn('Updater init deferred error:', e.message);
}
}, 2000);
}
const {
createRepoGitHub,
createRepoGitea,
checkGiteaConnection,
listGiteaRepos,
getGiteaUserHeatmap,
getGiteaRepoContents,
getGiteaFileContent,
uploadGiteaFile,
getGiteaCurrentUser,
getGiteaCommits,
getGiteaCommit,
getGiteaCommitDiff,
getGiteaCommitFiles,
searchGiteaCommits,
getGiteaBranches,
listGiteaReleases,
getGiteaRelease,
createGiteaRelease,
editGiteaRelease,
deleteGiteaRelease,
uploadReleaseAsset,
deleteReleaseAsset,
updateGiteaAvatar,
updateGiteaRepoAvatar,
updateGiteaRepoVisibility,
updateGiteaRepoTopics,
updateGiteaRepoArchived,
migrateRepoToGitea
,listGiteaTopicsCatalog,
// GitHub
listGithubRepos,
getGithubCurrentUser,
githubRepoExists,
getGithubUserHeatmap,
getGithubRepoContents,
getGithubFileContent,
uploadGithubFile,
deleteGithubFile,
getGithubCommits,
getGithubCommitDiff,
getGithubCommitFiles,
searchGithubCommits,
getGithubBranches,
listGithubReleases,
createGithubRelease,
editGithubRelease,
deleteGithubRelease,
updateGithubRepoVisibility,
updateGithubRepoDefaultBranch,
updateGithubRepoTopics,
updateGithubRepoArchived,
deleteGithubRepo
} = require('./src/git/apiHandler.js');
const { initRepo, commitAndPush, getBranches, getCommitLogs } = require('./src/git/gitHandler.js');
// NOTE: credentials/data location is computed via getDataDir() to avoid calling app.getPath before ready
function getCredentialsFilePath() {
return ppath.join(app.getPath('userData'), 'credentials.json');
}
function getLegacyWorkspaceCredentialsFilePath() {
return ppath.join(__dirname, 'data', 'credentials.json');
}
const ALGORITHM = 'aes-256-cbc';
const SECRET_KEY = crypto.scryptSync('SuperSecretKey123!', 'salt', 32);
const IV = Buffer.alloc(16, 0);
// default concurrency for parallel uploads/downloads
const DEFAULT_CONCURRENCY = 4;
// temp drag cleanup delay (ms)
const TMP_CLEANUP_MS = 20_000;
const RETRY_QUEUE_INTERVAL_MS = 15_000;
const RETRY_MAX_ATTEMPTS = 8;
let retryQueue = [];
let retryQueueRunning = false;
let retryQueueTimer = null;
const DIAGNOSTIC_EVENT_LIMIT = 1000;
let diagnosticsEvents = [];
let credentialReadIssueLogged = false;
let lastCredentialReadStatus = {
state: 'idle',
reason: null,
filePath: null,
message: null
};
function setCredentialReadStatus(status) {
lastCredentialReadStatus = {
state: status?.state || 'idle',
reason: status?.reason || null,
filePath: status?.filePath || null,
message: status?.message || null
};
}
function getCredentialReadStatus() {
return { ...lastCredentialReadStatus };
}
function logCredentialIssueOnce(message) {
if (credentialReadIssueLogged) return;
credentialReadIssueLogged = true;
console.warn(message);
}
function normalizeDiagnosticDetails(details) {
if (details == null) return null;
if (details instanceof Error) {
return {
name: details.name,
message: details.message,
stack: details.stack
};
}
try {
return JSON.parse(JSON.stringify(details));
} catch (_) {
return { value: String(details) };
}
}
function recordDiagnosticEvent(level, source, message, details = null) {
const event = {
ts: new Date().toISOString(),
level: String(level || 'INFO').toUpperCase(),
source: String(source || 'main'),
message: String(message || ''),
details: normalizeDiagnosticDetails(details)
};
diagnosticsEvents.push(event);
if (diagnosticsEvents.length > DIAGNOSTIC_EVENT_LIMIT) {
diagnosticsEvents.splice(0, diagnosticsEvents.length - DIAGNOSTIC_EVENT_LIMIT);
}
}
process.on('warning', (warning) => {
recordDiagnosticEvent('WARN', 'process.warning', warning?.message || 'process warning', warning);
});
process.on('unhandledRejection', (reason) => {
recordDiagnosticEvent('ERROR', 'process.unhandledRejection', 'Unhandled promise rejection', reason);
});
process.on('uncaughtExceptionMonitor', (error) => {
recordDiagnosticEvent('ERROR', 'process.uncaughtExceptionMonitor', error?.message || 'Uncaught exception', error);
});
function quarantineCredentialsFile(filePath, reason = 'invalid-format') {
try {
if (!fs.existsSync(filePath)) return null;
const backupPath = `${filePath}.corrupt-${Date.now()}`;
fs.renameSync(filePath, backupPath);
return backupPath;
} catch (e) {
console.warn('Could not quarantine credentials file', e && e.message ? e.message : e);
return null;
}
}
function tryParseJsonCredentials(buffer) {
try {
const parsed = JSON.parse(buffer.toString('utf8'));
if (!parsed || typeof parsed !== 'object') return null;
const hasKnownKey = ['giteaURL', 'giteaToken', 'githubToken', 'githubUsername'].some((k) => Object.prototype.hasOwnProperty.call(parsed, k));
return hasKnownKey ? parsed : null;
} catch (_) {
return null;
}
}
function getCredentialFileCandidates() {
return Array.from(new Set([
getCredentialsFilePath(),
getLegacyWorkspaceCredentialsFilePath()
]));
}
function ensureParentDirectory(filePath) {
const dirPath = ppath.dirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function encryptLegacyCredentials(json) {
const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, IV);
return Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
}
function decryptLegacyCredentials(buffer) {
const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, IV);
const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]);
return JSON.parse(decrypted.toString('utf8'));
}
function createCredentialsPayload(data) {
const json = JSON.stringify(data);
if (safeStorage && safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(json);
return {
v: 2,
mode: 'safeStorage',
data: encrypted.toString('base64')
};
}
return {
v: 1,
mode: 'legacyAesCbc',
data: encryptLegacyCredentials(json).toString('base64')
};
}
function writeCredentialsToFile(filePath, data) {
ensureParentDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(createCredentialsPayload(data)), 'utf8');
}
function persistCredentials(data) {
const primaryPath = getCredentialsFilePath();
const legacyPath = getLegacyWorkspaceCredentialsFilePath();
const targets = [primaryPath];
if (legacyPath !== primaryPath && fs.existsSync(legacyPath)) {
targets.push(legacyPath);
}
Array.from(new Set(targets)).forEach((filePath) => {
writeCredentialsToFile(filePath, data);
});
}
function tryReadWrappedCredentials(raw) {
try {
const wrapper = JSON.parse(raw.toString('utf8'));
if (!wrapper || typeof wrapper !== 'object' || typeof wrapper.data !== 'string') return null;
if (wrapper.v === 2 && wrapper.mode === 'safeStorage') {
if (!safeStorage || !safeStorage.isEncryptionAvailable()) {
return {
credentials: null,
needsRewrite: false,
reason: 'safeStorage-unavailable',
message: 'Gespeicherte Zugangsdaten können in dieser Sitzung nicht gelesen werden. Bitte neu anmelden.'
};
}
try {
return {
credentials: JSON.parse(safeStorage.decryptString(Buffer.from(wrapper.data, 'base64'))),
needsRewrite: false,
reason: null,
message: null
};
} catch (_) {
return {
credentials: null,
needsRewrite: false,
reason: 'safeStorage-decrypt-failed',
message: 'Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden. Bitte Token neu eingeben.'
};
}
}
if (wrapper.v === 1 && (wrapper.mode === 'legacyAesCbc' || wrapper.mode === 'legacyAes')) {
return {
credentials: decryptLegacyCredentials(Buffer.from(wrapper.data, 'base64')),
needsRewrite: !!(safeStorage && safeStorage.isEncryptionAvailable()),
reason: null,
message: null
};
}
} catch (_) {
return null;
}
return null;
}
function readCredentialsFromFile(filePath) {
const raw = fs.readFileSync(filePath);
const wrappedCreds = tryReadWrappedCredentials(raw);
if (wrappedCreds) {
return {
...wrappedCreds,
filePath
};
}
const plainJsonCreds = tryParseJsonCredentials(raw);
if (plainJsonCreds) {
return { credentials: plainJsonCreds, needsRewrite: true, reason: null, message: null, filePath };
}
try {
return {
credentials: decryptLegacyCredentials(raw),
needsRewrite: true,
reason: null,
message: null,
filePath
};
} catch (e) {
quarantineCredentialsFile(filePath, 'legacy-decrypt-failed');
return {
credentials: null,
needsRewrite: false,
reason: 'legacy-decrypt-failed',
message: 'Gespeicherte Zugangsdaten waren unlesbar und wurden zurückgesetzt. Bitte Zugangsdaten neu eingeben.',
filePath
};
}
}
function getRetryQueueFilePath() {
return ppath.join(app.getPath('userData'), 'retry-queue.json');
}
function readRetryQueueFromDisk() {
try {
const file = getRetryQueueFilePath();
if (!fs.existsSync(file)) return [];
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('readRetryQueueFromDisk error', e);
return [];
}
}
function saveRetryQueueToDisk() {
try {
const file = getRetryQueueFilePath();
const dir = app.getPath('userData');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(file, JSON.stringify(retryQueue, null, 2), 'utf8');
} catch (e) {
console.error('saveRetryQueueToDisk error', e);
}
}
function broadcastRetryQueueUpdate(extra = {}) {
const payload = {
size: retryQueue.length,
items: retryQueue.slice(0, 100),
...extra
};
for (const win of BrowserWindow.getAllWindows()) {
try { win.webContents.send('retry-queue-updated', payload); } catch (_) {}
}
}
function isRetryableNetworkError(errorLike) {
const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase();
if (!raw) return false;
return (
raw.includes('econnrefused') ||
raw.includes('enotfound') ||
raw.includes('eai_again') ||
raw.includes('getaddrinfo') ||
raw.includes('etimedout') ||
raw.includes('timeout') ||
raw.includes('econnaborted') ||
raw.includes('socket hang up') ||
raw.includes('503') ||
raw.includes('502') ||
raw.includes('504')
);
}
function enqueueRetryWriteTask(data, reason) {
const item = {
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
type: 'write-gitea-file',
payload: {
owner: data.owner,
repo: data.repo,
path: data.path,
content: data.content || '',
ref: data.ref || 'HEAD'
},
attempts: 0,
nextRetryAt: Date.now() + 5000,
createdAt: new Date().toISOString(),
lastError: String(reason || '')
};
retryQueue.push(item);
saveRetryQueueToDisk();
broadcastRetryQueueUpdate({ event: 'queued', item });
return item;
}
async function processRetryQueueOnce() {
if (retryQueueRunning) {
return { ok: true, skipped: true, reason: 'already-running', size: retryQueue.length };
}
retryQueueRunning = true;
const now = Date.now();
let processed = 0;
let succeeded = 0;
let failed = 0;
try {
const credentials = readCredentials();
const token = credentials && credentials.giteaToken;
const url = credentials && credentials.giteaURL;
if (!token || !url) {
return { ok: false, error: 'missing-token-or-url', processed: 0, size: retryQueue.length };
}
const survivors = [];
for (const item of retryQueue) {
if ((item.nextRetryAt || 0) > now) {
survivors.push(item);
continue;
}
processed++;
if (item.type !== 'write-gitea-file') {
survivors.push(item);
continue;
}
try {
const payload = item.payload || {};
const base64 = Buffer.from(payload.content || '', 'utf8').toString('base64');
await uploadGiteaFile({
token,
url,
owner: payload.owner,
repo: payload.repo,
path: payload.path,
contentBase64: base64,
message: `Retry edit ${payload.path} via Git Manager GUI`,
branch: payload.ref || 'HEAD'
});
succeeded++;
} catch (e) {
const attempts = (item.attempts || 0) + 1;
if (attempts >= RETRY_MAX_ATTEMPTS) {
failed++;
} else {
const backoffMs = Math.min(300000, 5000 * Math.pow(2, attempts));
survivors.push({
...item,
attempts,
nextRetryAt: Date.now() + backoffMs,
lastError: String(e && e.message ? e.message : e)
});
}
}
}
retryQueue = survivors;
saveRetryQueueToDisk();
broadcastRetryQueueUpdate({ event: 'processed', processed, succeeded, failed });
return { ok: true, processed, succeeded, failed, size: retryQueue.length };
} finally {
retryQueueRunning = false;
}
}
/* -----------------------------
Utilities for safe filesystem ops
----------------------------- */
function ensureDir(dirPath) {
try {
if (fs.existsSync(dirPath)) {
if (!fs.statSync(dirPath).isDirectory()) {
// If a file exists where we expect a directory, remove it
fs.unlinkSync(dirPath);
}
}
fs.mkdirSync(dirPath, { recursive: true });
} catch (e) {
// Re-throw with clearer message
throw new Error(`ensureDir failed for ${dirPath}: ${e && e.message ? e.message : e}`);
}
}
/**
* Create a safe, unique temporary directory under os.tmpdir().
* If an entry exists at that path and it's a file, it will be removed.
* If a directory exists it will be removed and recreated to ensure a clean state.
* Returns the created directory path.
*
* baseName should be a short string (no path separators). We append a timestamp to reduce collisions.
*/
function getSafeTmpDir(baseName) {
const safeBase = (baseName || 'tmp').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').substring(0, 64);
// Basis-Temp-Ordner für interne Verwaltung
const internalBase = ppath.join(os.tmpdir(), 'gitea-drag');
if (!fs.existsSync(internalBase)) fs.mkdirSync(internalBase, { recursive: true });
// Eindeutiger Unterordner, um Kollisionen zu vermeiden
const uniqueSub = crypto.randomBytes(8).toString('hex');
const internalDir = ppath.join(internalBase, uniqueSub);
fs.mkdirSync(internalDir, { recursive: true });
// Sichtbarer Ordnername = safeBase
const finalDir = ppath.join(internalDir, safeBase);
fs.mkdirSync(finalDir, { recursive: true });
return finalDir;
}
/* -----------------------------
Startup-Optimierung
----------------------------- */
// OPTIMIERUNG: V8 Code Caching aktivieren
app.commandLine.appendSwitch('enable-v8-code-caching');
// OPTIMIERUNG: GPU-Beschleunigung aktivieren (für bessere Performance)
if (os.platform() !== 'linux') {
app.commandLine.appendSwitch('enable-gpu-acceleration');
app.commandLine.appendSwitch('enable-gpu-compositing');
}
// OPTIMIERUNG: Native Binaries mit Memory-Mapping
app.commandLine.appendSwitch('v8-cache-options', 'code');
// OPTIMIERUNG: Speicher-Handling optimieren
app.commandLine.appendSwitch('enable-memory-coordination');
/* -----------------------------
app / window
----------------------------- */
let tray = null;
function ensureUsableDirectory(dirPath) {
try {
const stat = fs.existsSync(dirPath) ? fs.lstatSync(dirPath) : null;
if (stat && !stat.isDirectory()) {
fs.rmSync(dirPath, { force: true });
}
fs.mkdirSync(dirPath, { recursive: true });
return true;
} catch (_) {
return false;
}
}
function isDirectoryWritable(dirPath) {
try {
if (!ensureUsableDirectory(dirPath)) return false;
const probe = ppath.join(dirPath, `.write-probe-${process.pid}-${Date.now()}.tmp`);
fs.writeFileSync(probe, 'ok', 'utf8');
fs.rmSync(probe, { force: true });
return true;
} catch (_) {
return false;
}
}
function initializeAppStoragePaths() {
try {
// Reduce startup flakiness on Windows when cache folders are locked by stale processes.
app.commandLine.appendSwitch('disable-http-cache');
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache');
const appDataBase = process.env.APPDATA || app.getPath('appData');
const appName = (app.getName() || 'git-manager-gui').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
const preferredUserData = ppath.join(appDataBase, appName);
const fallbackBase = ppath.join(os.tmpdir(), `${appName}-runtime`);
const userDataPath = isDirectoryWritable(preferredUserData) ? preferredUserData : fallbackBase;
const usingFallbackStorage = userDataPath === fallbackBase;
const sessionDataPath = ppath.join(userDataPath, 'SessionData');
const cachePath = ppath.join(userDataPath, 'Cache');
const gpuCachePath = ppath.join(userDataPath, 'GPUCache');
// Ensure key directories are real, writable directories to avoid Chromium cache init failures.
const requiredDirs = usingFallbackStorage
? [userDataPath, sessionDataPath, cachePath, gpuCachePath]
: [userDataPath];
requiredDirs.forEach((dir) => {
if (!ensureUsableDirectory(dir)) {
throw new Error(`Storage dir not usable: ${dir}`);
}
});
app.setPath('userData', userDataPath);
// Override Chromium paths only in fallback mode to avoid repeated cache migration attempts.
if (usingFallbackStorage) {
app.setPath('sessionData', sessionDataPath);
app.setPath('cache', cachePath);
app.commandLine.appendSwitch('disk-cache-dir', cachePath);
app.commandLine.appendSwitch('gpu-shader-disk-cache-path', gpuCachePath);
console.warn('Using temporary runtime storage fallback:', fallbackBase);
}
} catch (e) {
console.warn('initializeAppStoragePaths warning', e && e.message ? e.message : e);
}
}
initializeAppStoragePaths();
function createTray(win) {
const iconPath = ppath.join(__dirname, 'renderer', 'icon.png');
tray = new Tray(nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }));
tray.setToolTip('Git Manager Explorer Pro');
const menu = Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => { win.show(); win.focus(); } },
{ type: 'separator' },
{ label: 'Beenden', click: () => { app.quit(); } }
]);
tray.setContextMenu(menu);
tray.on('double-click', () => { win.show(); win.focus(); });
}
function createWindow() {
// Entfernt das Menü (File, Edit, View...) komplett
Menu.setApplicationMenu(null);
const startHidden = process.argv.includes('--hidden');
const win = new BrowserWindow({
width: 1200,
height: 820,
frame: false,
show: !startHidden,
webPreferences: {
preload: ppath.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false
}
});
// Externe Fenster immer blockieren und nur erlaubte URLs explizit im System-Browser öffnen.
win.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedExternalUrl(url)) {
shell.openExternal(url).catch(() => {});
}
return { action: 'deny' };
});
// Verhindert Navigation auf fremde Seiten im Hauptfenster.
win.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith('file://')) {
event.preventDefault();
if (isAllowedExternalUrl(url)) {
shell.openExternal(url).catch(() => {});
}
}
});
// Keine Runtime-Berechtigungen aus dem Renderer erteilen (Kamera, Mikrofon, usw.).
win.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false);
});
win.loadFile(ppath.join(__dirname, 'renderer', 'index.html'));
// win.webContents.openDevTools();
// OPTIMIERUNG: Tray wird verzögert hergestellt (nicht beim Fenster-Create)
setImmediate(() => {
createTray(win);
});
// Schließen-Button -> Tray statt Beenden (nur wenn Autostart aktiv)
win.on('close', (e) => {
const { enabled } = app.getLoginItemSettings();
if (enabled) {
e.preventDefault();
win.hide();
}
});
}
app.whenReady().then(() => {
// OPTIMIERUNG: Fenster wird schnell erstellt
createWindow();
// OPTIMIERUNG: RetryQueue asynchron laden (nicht blockierend)
setImmediate(() => {
retryQueue = readRetryQueueFromDisk();
broadcastRetryQueueUpdate({ event: 'startup' });
retryQueueTimer = setInterval(() => {
processRetryQueueOnce().catch(e => console.error('processRetryQueueOnce timer error', e));
}, RETRY_QUEUE_INTERVAL_MS);
});
// OPTIMIERUNG: Updater wird verzögert geladen (nach Fenster erstellt)
initUpdaterAsync();
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('before-quit', () => {
if (retryQueueTimer) {
clearInterval(retryQueueTimer);
retryQueueTimer = null;
}
});
/* -----------------------------
Helper: read credentials
----------------------------- */
function readCredentials() {
try {
const primaryPath = getCredentialsFilePath();
let lastFailure = null;
setCredentialReadStatus({ state: 'empty', reason: 'no-file', filePath: null, message: null });
for (const credentialsFilePath of getCredentialFileCandidates()) {
if (!fs.existsSync(credentialsFilePath)) continue;
const result = readCredentialsFromFile(credentialsFilePath);
if (!result) continue;
if (!result.credentials) {
lastFailure = result;
if (result.message) {
logCredentialIssueOnce(result.message);
}
continue;
}
if (result.needsRewrite || credentialsFilePath !== primaryPath) {
try {
persistCredentials(result.credentials);
} catch (_) {}
}
setCredentialReadStatus({
state: 'ok',
reason: null,
filePath: credentialsFilePath,
message: null
});
return result.credentials;
}
if (lastFailure) {
setCredentialReadStatus({
state: 'error',
reason: lastFailure.reason || 'read-failed',
filePath: lastFailure.filePath || null,
message: lastFailure.message || null
});
}
return null;
} catch (e) {
logCredentialIssueOnce('Zugangsdaten konnten nicht gelesen werden. Bitte erneut anmelden.');
setCredentialReadStatus({
state: 'error',
reason: 'read-exception',
filePath: null,
message: 'Zugangsdaten konnten nicht gelesen werden. Bitte erneut anmelden.'
});
return null;
}
}
function sanitizeErrorForLog(errorLike) {
const status = errorLike?.response?.status || null;
const message = String(
errorLike?.response?.data?.message || errorLike?.message || errorLike || 'unknown-error'
);
return { status, message };
}
function mapIpcError(errorLike) {
const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase();
if (!raw) return 'Unbekannter Fehler.';
if (raw.includes('401') || raw.includes('authentifizierung') || raw.includes('unauthorized')) {
return 'Authentifizierung fehlgeschlagen. Bitte Token in den Einstellungen prüfen.';
}
if (raw.includes('403') || raw.includes('forbidden') || raw.includes('zugriff verweigert')) {
return 'Zugriff verweigert. Bitte Token-Berechtigungen prüfen.';
}
if (raw.includes('404') || raw.includes('not found') || raw.includes('nicht gefunden')) {
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')) {
return 'Server nicht erreichbar. Bitte DNS, IPv4/IPv6 und Port prüfen.';
}
if (raw.includes('timeout') || raw.includes('econnaborted')) {
return 'Zeitüberschreitung bei der Verbindung. Bitte Netzwerk oder Server prüfen.';
}
if (raw.includes('http://') || raw.includes('https://') || raw.includes('ungültige gitea url') || raw.includes('ungültige gitea url') || raw.includes('invalid')) {
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000';
}
return String(errorLike && errorLike.message ? errorLike.message : errorLike);
}
function isSafeGitRef(ref) {
const value = String(ref || '').trim();
if (!value) return false;
if (value.startsWith('-')) return false;
if (value.includes('..')) return false;
if (/[\s~^:?*\[\\]/.test(value)) return false;
return /^[A-Za-z0-9._/-]+$/.test(value);
}
function sanitizeGitRef(ref, fallback = 'main') {
// DEPRECATED: Use normalizeBranch() from helpers instead
return normalizeBranch(ref, 'gitea');
}
function runGitSync(args, cwd, options = {}) {
const res = spawnSync('git', args, {
cwd,
encoding: 'utf8',
windowsHide: true,
...options
});
if (res.status !== 0) {
throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim());
}
return String(res.stdout || '').trim();
}
/* -----------------------------
Generic concurrency runner (worker pool)
----------------------------- */
async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = null) {
const results = new Array(tasks.length);
let index = 0;
let processed = 0;
const total = tasks.length;
async function worker() {
while (true) {
const i = index++;
if (i >= tasks.length) return;
try {
const r = await tasks[i]();
results[i] = { ok: true, result: r };
} catch (e) {
results[i] = { ok: false, error: String(e) };
} finally {
processed++;
if (onProgress) {
try { onProgress(processed, total); } catch (_) {}
}
}
}
}
const workers = Array(Math.max(1, Math.min(concurrency, tasks.length))).fill(0).map(() => worker());
await Promise.all(workers);
return results;
}
/* -----------------------------
Basic IPC handlers
----------------------------- */
ipcMain.handle('select-folder', async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
return result.canceled ? null : result.filePaths[0];
});
ipcMain.handle('select-file', async () => {
const result = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] });
if (result.canceled) return { ok: false, files: [] };
return { ok: true, files: result.filePaths };
});
ipcMain.handle('save-credentials', async (event, data) => {
try {
const CREDENTIALS_FILE = getCredentialsFilePath();
persistCredentials(data);
logger.info('save-credentials', 'Credentials saved successfully', { file: CREDENTIALS_FILE });
return { ok: true };
} catch (e) {
const errInfo = formatErrorForUser(e, 'save-credentials');
logger.error('save-credentials', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: errInfo.userMessage };
}
});
ipcMain.on('renderer-debug-log', (_event, payload) => {
try {
const level = String(payload?.level || 'log').toLowerCase();
const message = String(payload?.message || 'renderer-log');
const details = payload?.payload;
logger[level === 'error' ? 'error' : (level === 'warn' ? 'warn' : 'info')]('renderer', message, details);
if (level === 'error' || level === 'warn') {
recordDiagnosticEvent(level.toUpperCase(), 'renderer', message, details);
}
} catch (e) {
logger.warn('renderer-debug-log', 'Logging failed', { error: e.message });
recordDiagnosticEvent('WARN', 'renderer-debug-log', 'Logging failed', e);
}
});
ipcMain.handle('load-credentials', async () => {
try {
return readCredentials();
} catch (e) {
logger.error('load-credentials', 'Failed to load credentials', { error: e.message });
return null;
}
});
ipcMain.handle('get-credentials-status', async () => {
try {
readCredentials();
} catch (_) {}
return getCredentialReadStatus();
});
// Debug-API für die Anwendung
ipcMain.handle('get-debug-info', async () => {
return {
version: app.getVersion(),
logs: logger.getRecent(30),
cacheStats: {
repos: caches.repos.size(),
fileTree: caches.fileTree.size(),
api: caches.api.size()
}
};
});
ipcMain.handle('clear-cache', async (event, type = 'all') => {
try {
if (type === 'all' || type === 'repos') caches.repos.clear();
if (type === 'all' || type === 'fileTree') caches.fileTree.clear();
if (type === 'all' || type === 'api') caches.api.clear();
logger.info('clear-cache', `Cache cleared: ${type}`);
return { ok: true, message: `Cache gelöscht: ${type}` };
} catch (e) {
logger.error('clear-cache', 'Failed to clear cache', { error: e.message });
return { ok: false, error: String(e) };
}
});
ipcMain.handle('get-gitea-current-user', async () => {
try {
const creds = readCredentials();
if (!creds?.giteaToken || !creds?.giteaURL) {
return { ok: false, error: 'no-credentials' };
}
const user = await getGiteaCurrentUser({ token: creds.giteaToken, url: creds.giteaURL });
logger.info('get-gitea-current-user', 'User loaded', { user: user?.login });
return { ok: true, user };
} catch (e) {
const errInfo = formatErrorForUser(e, 'get-gitea-current-user');
logger.error('get-gitea-current-user', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: errInfo.userMessage };
}
});
ipcMain.handle('update-gitea-avatar', async (event, { token, url, imageBase64 }) => {
try {
await updateGiteaAvatar({ token, url, imageBase64 });
console.log('✅ Avatar erfolgreich hochgeladen');
return { ok: true };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('update-gitea-avatar error', errStatus, errMsg, e.response?.data);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('update-gitea-repo-avatar', async (event, { token, url, owner, repo, imageBase64 }) => {
try {
await updateGiteaRepoAvatar({ token, url, owner, repo, imageBase64 });
console.log(`✅ Repo-Avatar für ${owner}/${repo} hochgeladen`);
return { ok: true };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('update-gitea-repo-avatar error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('update-gitea-repo-visibility', async (event, data) => {
try {
const { token, url, owner, repo, isPrivate, platform } = data || {};
if (platform === 'github') {
const credentials = readCredentials();
const githubToken = token || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const updated = await updateGithubRepoVisibility({ token: githubToken, owner, repo, isPrivate });
return { ok: true, repo: updated };
}
const updated = await updateGiteaRepoVisibility({ token, url, owner, repo, isPrivate });
return { ok: true, repo: updated };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('update-gitea-repo-visibility error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('update-gitea-repo-topics', async (event, data) => {
try {
const { owner, repo, topics, platform } = data || {};
const creds = readCredentials();
if (platform === 'github') {
const githubToken = (data.token) || (creds && creds.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const updated = await updateGithubRepoTopics({
token: githubToken, owner, repo,
topics: Array.isArray(topics) ? topics : []
});
return { ok: true, repo: updated };
}
if (!creds?.giteaToken || !creds?.giteaURL) {
return { ok: false, error: 'Gitea Token oder URL fehlt.' };
}
const updated = await updateGiteaRepoTopics({
token: creds.giteaToken, url: creds.giteaURL, owner, repo,
topics: Array.isArray(topics) ? topics : []
});
return { ok: true, repo: updated };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('update-gitea-repo-topics error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('update-repo-archived', async (event, data) => {
try {
const { owner, repo, archived, platform } = data || {};
const creds = readCredentials();
if (platform === 'github') {
const githubToken = (data && data.token) || (creds && creds.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const updated = await updateGithubRepoArchived({
token: githubToken,
owner,
repo,
archived: !!archived
});
return { ok: true, repo: updated };
}
if (!creds?.giteaToken || !creds?.giteaURL) {
return { ok: false, error: 'Gitea Token oder URL fehlt.' };
}
const updated = await updateGiteaRepoArchived({
token: creds.giteaToken,
url: creds.giteaURL,
owner,
repo,
archived: !!archived
});
return { ok: true, repo: updated };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('update-repo-archived error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('get-gitea-topics-catalog', async () => {
try {
const creds = readCredentials();
if (!creds?.giteaToken || !creds?.giteaURL) {
return { ok: false, error: 'Gitea Token oder URL fehlt.' };
}
const topics = await listGiteaTopicsCatalog({ token: creds.giteaToken, url: creds.giteaURL });
return { ok: true, topics };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('get-gitea-topics-catalog error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('migrate-repo-to-gitea', async (event, data) => {
try {
const creds = readCredentials();
if (!creds) return { ok: false, error: 'no-credentials' };
const result = await migrateRepoToGitea({
token: creds.giteaToken,
url: creds.giteaURL,
cloneUrl: data.cloneUrl,
repoName: data.repoName,
description: data.description || '',
isPrivate: data.isPrivate || false,
authToken: data.authToken || '',
authUsername: data.authUsername || ''
});
return { ok: true, repo: result };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('migrate-repo-to-gitea error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
/* -----------------------------
Repo & git handlers
----------------------------- */
ipcMain.handle('create-repo', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'Keine Zugangsdaten gespeichert' };
if (data.platform === 'github') {
const repo = await createRepoGitHub({
name: data.name,
token: credentials.githubToken,
auto_init: data.autoInit || true,
license: data.license || '',
private: data.private || false
});
logger.info('create-repo', `GitHub repo created: ${data.name}`);
return { ok: true, repo };
} else if (data.platform === 'gitea') {
// Cache invalidieren nach Repo-Erstellung
caches.repos.clear();
const repo = await createRepoGitea({
name: data.name,
token: credentials.giteaToken,
url: credentials.giteaURL,
auto_init: data.autoInit || true,
license: data.license || '',
private: data.private || false
});
logger.info('create-repo', `Gitea repo created: ${data.name}`);
return { ok: true, repo };
} else {
return { ok: false, error: 'Plattform nicht unterstützt' };
}
} catch (e) {
const errInfo = formatErrorForUser(e, 'create-repo');
logger.error('create-repo', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: errInfo.userMessage };
}
});
ipcMain.handle('push-project', async (event, data) => {
try {
if (!data.folder || !fs.existsSync(data.folder)) return { ok: false, error: 'folder-not-found' };
// Aktuellen Branch ermitteln (NICHT umbenennen!)
let currentBranch = data.branch || null;
try {
const detected = runGitSync(['branch', '--show-current'], data.folder);
if (detected) {
currentBranch = currentBranch || sanitizeGitRef(detected, 'main');
console.log('Current local branch:', detected);
}
} catch (e) {
console.warn('Could not check local branch (maybe not a git repo yet)', e.message);
}
// 1. Git initialisieren, adden und commiten
await initRepo(data.folder);
// 2. Prüfen, ob ein 'origin' Remote existiert
let remoteUrl = '';
try {
remoteUrl = runGitSync(['remote', 'get-url', 'origin'], data.folder);
// Bereits gespeicherte Credential-URLs in sichere Origin-URLs ohne Userinfo konvertieren.
try {
const remoteObj = new URL(remoteUrl);
if (remoteObj.username || remoteObj.password) {
remoteObj.username = '';
remoteObj.password = '';
const sanitizedRemoteUrl = remoteObj.toString();
runGitSync(['remote', 'set-url', 'origin', sanitizedRemoteUrl], data.folder);
remoteUrl = sanitizedRemoteUrl;
}
} catch (_) {}
} catch (e) {
if (data.repoName && data.platform) {
const creds = readCredentials();
if (!creds) return { ok: false, error: 'credentials-missing-for-remote' };
const parts = data.repoName.split('/');
if (parts.length === 2) {
const owner = parts[0];
const repo = parts[1];
let constructedUrl = '';
let authHeader = '';
let remoteOrigin = '';
if (data.platform === 'gitea' && creds.giteaURL) {
try {
const urlObj = new URL(creds.giteaURL);
constructedUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`;
remoteOrigin = `${urlObj.protocol}//${urlObj.host}/`;
if (creds.giteaToken) {
authHeader = `AUTHORIZATION: token ${creds.giteaToken}`;
}
} catch (err) { console.error('Invalid Gitea URL', err); }
} else if (data.platform === 'github' && creds.githubToken) {
constructedUrl = `https://github.com/${owner}/${repo}.git`;
remoteOrigin = 'https://github.com/';
authHeader = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${creds.githubToken}`, 'utf8').toString('base64')}`;
}
if (constructedUrl) {
try {
runGitSync(['remote', 'add', 'origin', constructedUrl], data.folder, { stdio: 'inherit' });
// Auth nie in der Remote-URL speichern; stattdessen Header scoped auf den Remote-Origin.
if (remoteOrigin && authHeader) {
runGitSync(['config', `http.${remoteOrigin}.extraheader`, authHeader], data.folder);
}
console.log(`Remote origin added: ${constructedUrl}`);
} catch (err) {
return { ok: false, error: 'Failed to add remote origin: ' + String(err) };
}
} else {
return { ok: false, error: 'Could not construct remote URL.' };
}
} else {
return { ok: false, error: 'Use format Owner/RepoName.' };
}
}
}
// 3. Pushen (nutze den tatsächlichen Branch - main ODER master)
const progressCb = percent => { try { event.sender.send('push-progress', percent); } catch (_) {} };
const commitMsg = data.commitMessage || 'Update from Git Manager GUI';
const pushBranch = sanitizeGitRef(currentBranch || 'main', 'main');
await commitAndPush(data.folder, pushBranch, commitMsg, progressCb);
return { ok: true };
} catch (e) {
console.error('push-project error', e);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('getBranches', async (event, data) => {
try {
const branches = await getBranches(data.folder);
// Aktuellen Branch ermitteln und nach oben sortieren
let currentBranch = null;
try {
currentBranch = runGitSync(['branch', '--show-current'], data.folder);
} catch (_) {}
branches.sort((a, b) => {
if (a === currentBranch) return -1;
if (b === currentBranch) return 1;
if (a === 'main') return -1;
if (b === 'main') return 1;
if (a === 'master') return -1;
if (b === 'master') return 1;
return 0;
});
return { ok: true, branches };
} catch (e) {
console.error('getBranches error', e);
return { ok: false, error: String(e), branches: [] };
}
});
ipcMain.handle('getCommitLogs', async (event, data) => {
try {
const logs = await getCommitLogs(data.folder, data.count || 50);
return { ok: true, logs };
} catch (e) {
console.error('getCommitLogs error', e);
return { ok: false, error: String(e), logs: [] };
}
});
/* ----------------------------------------------------------------
Neue/kompatible Handler: 'get-commits' (Renderer verwendet ggf. diesen)
- Unterstützt:
1) Lokale Git-Logs via data.folder -> getCommitLogs
2) Gitea Commits via owner+repo -> getGiteaCommits
---------------------------------------------------------------- */
ipcMain.handle('get-commits', async (event, data) => {
try {
// 1) Lokale Git-Logs (folder vorhanden)
if (data && data.folder) {
const logs = await getCommitLogs(data.folder, data.count || 50);
return { ok: true, logs };
}
// 2) Remote-Commits
if (data && data.owner && data.repo) {
const credentials = readCredentials();
// GitHub
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt. Bitte in Settings eintragen.' };
const commits = await getGithubCommits({
token: githubToken,
owner: data.owner,
repo: data.repo,
branch: data.sha || data.branch || '',
page: data.page || 1,
limit: data.limit || 50
});
return { ok: true, commits };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
// map optional params
const page = data.page || 1;
const limit = data.limit || 50;
const sha = data.sha; // optional branch/sha filter
const commits = await getGiteaCommits({
token,
url,
owner: data.owner,
repo: data.repo,
page,
limit,
sha
});
return { ok: true, commits };
}
return { ok: false, error: 'invalid-parameters-for-get-commits' };
} catch (e) {
console.error('get-commits error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('search-commits', async (event, data) => {
try {
if (!data || !data.owner || !data.repo) return { ok: false, error: 'missing-owner-or-repo' };
const credentials = readCredentials();
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const commits = await searchGithubCommits({
token: githubToken, owner: data.owner, repo: data.repo,
query: data.query || '', branch: data.branch || ''
});
return { ok: true, commits };
}
const token = (data.token) || (credentials && credentials.giteaToken);
const url = (data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const commits = await searchGiteaCommits({
token, url, owner: data.owner, repo: data.repo,
query: data.query || '', branch: data.branch || ''
});
return { ok: true, commits };
} catch (e) {
console.error('search-commits error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('get-repo-branches', async (event, data) => {
try {
if (!data || !data.owner || !data.repo) return { ok: false, error: 'missing-owner-or-repo' };
const credentials = readCredentials();
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const branches = await getGithubBranches({
token: githubToken, owner: data.owner, repo: data.repo
});
return { ok: true, branches };
}
const token = (data.token) || (credentials && credentials.giteaToken);
const url = (data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const branches = await getGiteaBranches({
token, url, owner: data.owner, repo: data.repo
});
return { ok: true, branches };
} catch (e) {
console.error('get-repo-branches error', e);
return { ok: false, error: String(e) };
}
});
/* -----------------------------
Local file tree functions
----------------------------- */
function buildTree(dirPath, options = {}) {
const { exclude = ['node_modules'], maxDepth = 10 } = options;
function walk(currentPath, depth) {
let stat;
try { stat = fs.statSync(currentPath); } catch (e) { return null; }
const name = ppath.basename(currentPath);
const node = { name, path: currentPath, isDirectory: stat.isDirectory(), children: [], depth };
if (node.isDirectory) {
if (exclude.some(ex => currentPath.split(ppath.sep).includes(ex))) return null;
if (depth >= maxDepth) return node;
let items = [];
try { items = fs.readdirSync(currentPath); } catch (e) { return node; }
items.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
for (const it of items) {
const childPath = ppath.join(currentPath, it);
const child = walk(childPath, depth +1);
if (child) node.children.push(child);
}
}
return node;
}
const roots = [];
const list = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
for (const entry of list) {
const full = ppath.join(dirPath, entry);
const n = walk(full, 0);
if (n) roots.push(n);
}
return roots;
}
function invalidateLocalFileTreeCacheForPath(targetPath) {
try {
if (!targetPath) {
caches.fileTree.invalidate('fileTree:');
return;
}
let current = ppath.resolve(targetPath);
while (true) {
caches.fileTree.invalidate(`fileTree:${current}:`);
const parent = ppath.dirname(current);
if (parent === current) break;
current = parent;
}
// Safety net: local trees are small enough, ensure no stale entries survive.
caches.fileTree.invalidate('fileTree:');
} catch (_) {}
}
function invalidateRepoContentsCache(owner, repo) {
try {
if (owner && repo) {
caches.repos.invalidate(`gitea:${owner}/${repo}:`);
caches.repos.invalidate(`${owner}/${repo}`);
return;
}
caches.repos.invalidate('gitea:');
} catch (_) {}
}
ipcMain.handle('getFileTree', async (event, data) => {
try {
const folder = data && data.folder;
if (!folder || !fs.existsSync(folder)) return { ok: false, error: 'folder-not-found' };
const opts = { exclude: (data && data.exclude) || ['node_modules'], maxDepth: (data && data.maxDepth) || 10 };
// OPTIMIERUNG: Cache für File-Trees (5 min)
const cacheKey = `fileTree:${folder}:${JSON.stringify(opts)}`;
const cached = caches.fileTree.get(cacheKey);
if (cached) {
logger.debug('getFileTree', 'Cache hit', { folder });
return { ok: true, tree: cached, cached: true };
}
const tree = buildTree(folder, opts);
caches.fileTree.set(cacheKey, tree);
return { ok: true, tree };
} catch (e) {
console.error('getFileTree error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('readFile', async (event, data) => {
try {
if (!data || !data.path || !fs.existsSync(data.path)) return { ok: false, error: 'file-not-found' };
const content = fs.readFileSync(data.path, 'utf8');
return { ok: true, content };
} catch (e) {
console.error('readFile error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('writeFile', async (event, data) => {
try {
if (!data || !data.path) return { ok: false, error: 'invalid-path' };
const dir = ppath.dirname(data.path);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(data.path, data.content || '', 'utf8');
invalidateLocalFileTreeCacheForPath(data.path);
return { ok: true };
} catch (e) {
console.error('writeFile error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('deleteFile', async (event, data) => {
try {
// --- GITHUB DELETION ---
if (data && data.isGithub) {
const credentials = readCredentials();
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const { owner, repo, path: filePath, ref, branch } = data;
if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' };
const useBranch = (branch && branch !== 'HEAD') ? branch : ((ref && ref !== 'HEAD') ? ref : 'main');
// GitHub API returns arrays for dirs — handle recursive deletion
async function deleteGithubEntry(entryPath) {
const contents = await getGithubRepoContents({ token: githubToken, owner, repo, path: entryPath, ref: useBranch });
if (!contents.ok) throw new Error(contents.error || 'Inhalt nicht ladbar');
const items = Array.isArray(contents.items) ? contents.items : [];
if (items.length > 0) {
// A single file lookup can come back as one item with the same path.
const isSingleFile = items.length === 1 && items[0].type !== 'dir' && items[0].path === entryPath;
if (isSingleFile) {
await deleteGithubFile({ token: githubToken, owner, repo, path: items[0].path, sha: items[0].sha, branch: useBranch });
return;
}
// Directory listing (also when it has only one child): recurse into dirs, delete files.
for (const item of items) {
if (item.type === 'dir') {
await deleteGithubEntry(item.path);
} else {
await deleteGithubFile({ token: githubToken, owner, repo, path: item.path, sha: item.sha, branch: useBranch });
}
}
return;
}
// Fallback for APIs that return no items payload for direct file paths.
await deleteGithubFile({ token: githubToken, owner, repo, path: entryPath, branch: useBranch });
}
await deleteGithubEntry(filePath);
return { ok: true };
}
// --- GITEA DELETION ---
if (data && data.isGitea) {
const credentials = readCredentials();
const token = (data.token) || (credentials && credentials.giteaToken);
const giteaUrl = (data.url) || (credentials && credentials.giteaURL);
if (!token || !giteaUrl) return { ok: false, error: 'missing-token-or-url' };
const owner = data.owner;
const repo = data.repo;
const filePath = data.path;
if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' };
const urlObj = new URL(giteaUrl);
const protocol = urlObj.protocol === 'https:' ? https : http;
const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80);
// Ohne explizite Ref die Default-Branch des Repos verwenden (kein harter "main"-Fallback).
const useBranch = (data.branch && data.branch !== 'HEAD')
? data.branch
: ((data.ref && data.ref !== 'HEAD') ? data.ref : null);
// Helper: GET contents from Gitea API
function giteaGet(path) {
return new Promise((resolve, reject) => {
const refQuery = useBranch ? `?ref=${encodeURIComponent(useBranch)}` : '';
const req = protocol.request({
hostname: urlObj.hostname, port,
path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}${refQuery}`,
method: 'GET',
headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.end();
});
}
// Helper: DELETE a single file by path + sha
async function giteaDeleteFile(filePath, sha, retryCount = 0) {
return new Promise((resolve) => {
const payload = {
message: `Delete ${filePath} via Git Manager GUI`,
sha
};
if (useBranch) payload.branch = useBranch;
const body = JSON.stringify(payload);
const req = protocol.request({
hostname: urlObj.hostname, port,
path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}`,
method: 'DELETE',
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
}, async (res) => {
let respBody = '';
res.on('data', chunk => respBody += chunk);
res.on('end', async () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ ok: true });
} else if (res.statusCode === 409 && retryCount < 2) {
// Conflict: retry with backoff
await new Promise(r => setTimeout(r, 100 + retryCount * 100));
const retry = await giteaDeleteFile(filePath, sha, retryCount + 1);
resolve(retry);
} else {
resolve({ ok: false, error: `HTTP ${res.statusCode}: ${respBody}` });
}
});
});
req.on('error', (e) => resolve({ ok: false, error: String(e) }));
req.write(body);
req.end();
});
}
// Helper: Recursively collect all files in a folder
async function collectAllFiles(path) {
const contents = await giteaGet(path);
const files = [];
if (contents && Array.isArray(contents.errors)) {
const notFound = contents.errors.some(err => String(err).toLowerCase().includes('does not exist'));
if (notFound) return files;
}
if (Array.isArray(contents)) {
// It's a folder — recurse into it
for (const item of contents) {
if (item.type === 'dir') {
const sub = await collectAllFiles(item.path);
files.push(...sub);
} else if (item.sha) {
// Treat files, symlinks, submodules etc. all as deletable entries with a SHA
files.push({ path: item.path, sha: item.sha });
}
// Items without SHA (e.g. empty placeholders) are silently skipped
}
} else if (contents && contents.type === 'file' && contents.sha) {
// It's a single file
files.push({ path: contents.path, sha: contents.sha });
} else if (contents && contents.sha) {
// Symlink or other single entry with a SHA
files.push({ path: contents.path, sha: contents.sha });
} else if (contents && contents.type === 'dir' && contents.path) {
// Defensive fallback: some endpoints can return dir objects.
const sub = await collectAllFiles(contents.path);
files.push(...sub);
} else {
const preview = JSON.stringify(contents).slice(0, 200);
throw new Error(`Unbekannte Antwort: ${preview}`);
}
return files;
}
async function executeDeleteBatch(files, concurrency = 2) {
const deleteOps = files.map(f =>
async () => {
const res = await giteaDeleteFile(f.path, f.sha);
return { path: f.path, ...res };
}
);
// Concurrency kann dynamisch reduziert werden, um API-Konflikte bei Nachloeschungen zu vermeiden
const deleteResults = await runParallel(deleteOps, concurrency);
const failedEntries = deleteResults.filter(r => !r.ok || !(r.result && r.result.ok));
return { deleteResults, failedEntries };
}
async function pathStillExists(path) {
const remaining = await collectAllFiles(path);
return remaining.length > 0;
}
// Collect all files to delete (handles both file and folder)
const filesToDelete = await collectAllFiles(filePath);
if (filesToDelete.length === 0) {
invalidateRepoContentsCache(owner, repo);
return { ok: true, deleted: 0 };
}
// Delete attempt (schnell) + sequenzieller Fallback bei Konflikten
let { failedEntries } = await executeDeleteBatch(filesToDelete, 2);
// Bei Teilfehlern sofort sequenziellen Retry fahren (stabiler bei parallelen Commits)
if (failedEntries.length > 0) {
const failedFiles = failedEntries
.map(r => (r.result && r.result.path) ? r.result.path : 'unknown')
.join(', ');
logger.warn('deleteFile', `Initial delete had ${failedEntries.length} failures, retrying sequentially`, { files: failedFiles });
}
// 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));
}
if (await pathStillExists(filePath)) {
const remaining = await collectAllFiles(filePath);
const remainingPreview = remaining.slice(0, 10).map(f => f.path).join(', ');
return {
ok: false,
error: `Löschen unvollständig: ${remaining.length} Dateien sind noch vorhanden (${remainingPreview}${remaining.length > 10 ? ', ...' : ''}).`
};
}
logger.info('deleteFile', `Deleted ${filesToDelete.length} files successfully`);
invalidateRepoContentsCache(owner, repo);
return { ok: true, deleted: filesToDelete.length };
}
// --- LOCAL DELETION ---
if (!data || !data.path || !fs.existsSync(data.path)) return { ok: false, error: 'file-not-found' };
fs.rmSync(data.path, { recursive: true, force: true });
invalidateLocalFileTreeCacheForPath(data.path);
return { ok: true };
} catch (e) {
console.error('deleteFile error', e);
return { ok: false, error: String(e) };
}
});
/* -----------------------------
Gitea: list repos, contents, file content
----------------------------- */
ipcMain.handle('list-gitea-repos', async (event, data) => {
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: 'missing-token-or-url' };
const repos = await listGiteaRepos({ token, url });
return { ok: true, repos };
} catch (e) {
console.error('list-gitea-repos error', e);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('list-github-repos', async (event, data) => {
try {
const credentials = readCredentials();
const githubToken = (data && data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt. Bitte Token in Settings eintragen.' };
const repos = await listGithubRepos({ token: githubToken });
return { ok: true, repos };
} catch (e) {
console.error('list-github-repos error', sanitizeErrorForLog(e));
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('get-github-current-user', async () => {
try {
const credentials = readCredentials();
if (!credentials?.githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const user = await getGithubCurrentUser({ token: credentials.githubToken });
return { ok: true, user };
} catch (e) {
const errMsg = e.response?.data?.message || e.response?.data || e.message;
const errStatus = e.response?.status;
console.error('get-github-current-user error', errStatus, errMsg);
return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` };
}
});
ipcMain.handle('get-gitea-user-heatmap', async (event, data) => {
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: 'missing-token-or-url' };
const result = await getGiteaUserHeatmap({ token, url });
return { ok: true, ...result };
} catch (e) {
console.error('get-gitea-user-heatmap error', e);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('get-github-user-heatmap', async (event, data) => {
try {
const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.githubToken);
if (!token) return { ok: false, error: 'GitHub Token fehlt.' };
const result = await getGithubUserHeatmap({ token, monthsBack: data?.monthsBack });
return { ok: true, ...result };
} catch (e) {
console.error('get-github-user-heatmap error', sanitizeErrorForLog(e));
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('get-gitea-repo-contents', async (event, data) => {
try {
const credentials = readCredentials();
const owner = data.owner;
const repo = data.repo;
const p = data.path || '';
const ref = data.ref;
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const result = await getGithubRepoContents({ token: githubToken, owner, repo, path: p, ref: ref || 'HEAD' });
return { ok: true, items: result.items || [], empty: result.empty || false };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const skipCache = data && data.noCache === true;
// OPTIMIERUNG: Cache für Repo-Inhalte (5 min)
const cacheKey = `gitea:${owner}/${repo}:${p}:${ref || 'HEAD'}`;
if (!skipCache) {
const cached = caches.repos.get(cacheKey);
if (cached) {
logger.debug('ipc-get-repo-contents', 'Cache hit', { cacheKey });
return { ok: true, ...cached };
}
}
const result = await getGiteaRepoContents({ token, url, owner, repo, path: p, ref });
const response = { items: result.items || result, empty: result.empty || false };
if (!skipCache) caches.repos.set(cacheKey, response, 4000);
return { ok: true, ...response };
} catch (e) {
const errInfo = formatErrorForUser(e, 'get-gitea-repo-contents');
logger.error('get-gitea-repo-contents', errInfo.technicalMessage, errInfo.details);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('get-gitea-file-content', async (event, data) => {
try {
const credentials = readCredentials();
const owner = data.owner;
const repo = data.repo;
const p = data.path;
const ref = data.ref;
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const content = await getGithubFileContent({ token: githubToken, owner, repo, path: p, ref: ref || 'HEAD' });
return { ok: true, content };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const content = await getGiteaFileContent({ token, url, owner, repo, path: p, ref });
return { ok: true, content };
} catch (e) {
console.error('get-gitea-file-content error', e);
return { ok: false, error: mapIpcError(e) };
}
});
// Alias für Editor: read-gitea-file (für Text und Bilder)
ipcMain.handle('read-gitea-file', async (event, data) => {
try {
const credentials = readCredentials();
const owner = data.owner;
const repo = data.repo;
const p = data.path;
const ref = data.ref || 'HEAD';
// GitHub-Pfad
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(p);
const content = await getGithubFileContent({ token: githubToken, owner, repo, path: p, ref });
// getGithubFileContent returns utf8 text; for images we re-encode as base64
if (isImage) {
const base64 = Buffer.from(content, 'utf8').toString('base64');
return { ok: true, content: base64 };
}
return { ok: true, content };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) {
console.error('Missing token or URL');
return { ok: false, error: 'missing-token-or-url' };
}
console.log(`read-gitea-file: ${owner}/${repo}/${p} (ref: ${ref})`);
// Prüfe ob es eine Bilddatei ist
const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(p);
if (isImage) {
// Für Bilder: Lade als Base64
console.log('Loading as image (Base64)');
const apiUrl = `${url}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(p)}?ref=${ref}`;
console.log('Image URL:', apiUrl);
return new Promise((resolve) => {
try {
const protocol = url.startsWith('https') ? https : http;
protocol.get(apiUrl, {
headers: {
'Authorization': `token ${token}`,
'User-Agent': 'Git-Manager-GUI'
}
}, (res) => {
console.log(`Image response status: ${res.statusCode}`);
if (res.statusCode !== 200) {
resolve({ ok: false, error: `HTTP ${res.statusCode}` });
return;
}
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const base64 = buffer.toString('base64');
console.log(`Image loaded: ${base64.length} bytes`);
resolve({ ok: true, content: base64 });
} catch (e) {
console.error('Base64 conversion error:', e.message);
resolve({ ok: false, error: String(e) });
}
});
}).on('error', (e) => {
console.error('Image HTTP error:', e.message);
resolve({ ok: false, error: String(e) });
});
} catch (e) {
console.error('Image load try error:', e.message);
resolve({ ok: false, error: String(e) });
}
});
} else {
// Für Text: Nutze normale Funktion
console.log('Loading as text file');
const content = await getGiteaFileContent({ token, url, owner, repo, path: p, ref });
console.log(`Text file loaded: ${content.length} chars`);
return { ok: true, content };
}
} catch (e) {
console.error('read-gitea-file error:', e.message, e.stack);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('upload-gitea-file', async (event, data) => {
const uploadDebugId = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try {
const credentials = readCredentials();
const owner = data.owner;
const repo = data.repo;
logger.info('upload-gitea-file', 'Upload started', {
uploadDebugId,
owner,
repo,
platform: data.platform || 'gitea',
files: Array.isArray(data.localPath) ? data.localPath.length : (data.localPath ? 1 : 0)
});
// GitHub upload path
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) {
logger.warn('upload-gitea-file', 'GitHub token missing');
return { ok: false, error: `GitHub Token fehlt.` };
}
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
const branch = normalizeBranch(data.branch, 'github');
const message = data.message || 'Upload via Git Manager GUI';
const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []);
const results = [];
for (const localFile of localFiles) {
if (!fs.existsSync(localFile)) {
results.push({ file: localFile, ok: false, error: 'local-file-not-found' });
continue;
}
const raw = fs.readFileSync(localFile);
const base64 = raw.toString('base64');
const fileName = ppath.basename(localFile);
const targetPath = destPath ? `${destPath}/${fileName}` : fileName;
try {
const uploaded = await uploadGithubFile({
token: githubToken,
owner,
repo,
path: targetPath,
contentBase64: base64,
message: `${message} - ${fileName}`,
branch
});
results.push({ file: localFile, ok: true, uploaded });
logger.debug('upload-gitea-file', `File uploaded: ${fileName}`);
} catch (e) {
const errInfo = formatErrorForUser(e, 'File upload');
results.push({ file: localFile, ok: false, error: errInfo.userMessage });
logger.error('upload-gitea-file', `Upload failed for ${fileName}`, errInfo.details);
}
}
const failedCount = results.filter(r => !r.ok).length;
logger.info('upload-gitea-file', `GitHub upload done`, { failedCount, total: results.length, uploadDebugId });
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) {
logger.warn('upload-gitea-file', 'Missing token or URL');
return { ok: false, error: `Zugangsdaten fehlen` };
}
// Invalid cache for repo after upload
caches.repos.invalidate(`${owner}/${repo}`);
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
let branch = normalizeBranch(data.branch || 'HEAD', 'gitea');
const message = data.message || 'Upload via Git Manager GUI';
const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []);
// Validierung: nur existierende Dateien (keine Ordner)
const validFiles = [];
const results = [];
for (const localFile of localFiles) {
if (!fs.existsSync(localFile)) { results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); continue; }
let stat;
try { stat = fs.statSync(localFile); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); continue; }
if (!stat.isFile()) { results.push({ file: localFile, ok: false, error: stat.isDirectory() ? 'path-is-directory' : 'path-is-not-file' }); continue; }
validFiles.push(localFile);
}
if (validFiles.length === 0) {
const failedCount = results.filter(r => !r.ok).length;
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
}
// Git-basierter Upload via simple-git — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
const simpleGit = require('simple-git');
let authUrl;
try {
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
} catch (urlErr) {
return { ok: false, error: `Ungültige Gitea-URL: ${url}`, debugId: uploadDebugId };
}
const tmpDir = getSafeTmpDir(`git-file-${owner}-${repo}`);
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
try {
const git = simpleGit({ config: gitConfig });
let repoGit;
let isEmptyRepo = false;
try {
const cloneArgs = ['--depth', '1', '--no-single-branch'];
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
for (const localFile of validFiles) {
const fileName = ppath.basename(localFile);
const targetPath = destPath ? `${destPath}/${fileName}` : fileName;
const destFile = ppath.join(tmpDir, ...targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(localFile, destFile);
results.push({ file: localFile, ok: true, targetPath });
}
await repoGit.add('.');
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 = branch;
if (pushBranch === 'HEAD' || isEmptyRepo) {
try {
const branchSummary = await repoGit.branch();
pushBranch = branchSummary.current || 'main';
} catch (_) { pushBranch = 'main'; }
}
if (isEmptyRepo) {
await repoGit.push(['-u', 'origin', pushBranch]);
} else {
await repoGit.push('origin', pushBranch);
}
}
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
} catch (gitErr) {
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 });
return { ok: false, error: `Upload fehlgeschlagen: ${gitErr.message || String(gitErr)}`, debugId: uploadDebugId };
}
const failedCount = results.filter(r => !r.ok).length;
logger.info('upload-gitea-file', 'Gitea upload done', { failedCount, total: results.length, uploadDebugId });
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
} catch (e) {
console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId };
}
});
// Alias für Editor: write-gitea-file
ipcMain.handle('write-gitea-file', async (event, data) => {
try {
const credentials = readCredentials();
const owner = data.owner;
const repo = data.repo;
const path = data.path;
const content = data.content || '';
const ref = data.ref || 'HEAD';
const base64 = Buffer.from(content, 'utf8').toString('base64');
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const uploaded = await uploadGithubFile({
token: githubToken, owner, repo, path,
contentBase64: base64,
message: `Edit ${path} via Git Manager GUI`,
branch: ref === 'HEAD' ? 'main' : ref
});
return { ok: true, uploaded };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const uploaded = await uploadGiteaFile({
token,
url,
owner,
repo,
path,
contentBase64: base64,
message: `Edit ${path} via Git Manager GUI`,
branch: ref
});
invalidateRepoContentsCache(owner, repo);
return { ok: true, uploaded };
} catch (e) {
console.error('write-gitea-file error', e);
if (isRetryableNetworkError(e)) {
const queued = enqueueRetryWriteTask(data || {}, e && e.message ? e.message : String(e));
return {
ok: true,
queued: true,
queueId: queued.id,
message: 'Netzwerkproblem erkannt. Änderung wurde in die Retry-Queue gelegt.'
};
}
return { ok: false, error: String(e) };
}
});
ipcMain.handle('get-retry-queue', async () => {
try {
return { ok: true, size: retryQueue.length, items: retryQueue.slice(0, 100) };
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('process-retry-queue-now', async () => {
try {
return await processRetryQueueOnce();
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('remove-retry-queue-item', async (event, data) => {
try {
const id = data && data.id;
if (!id) return { ok: false, error: 'missing-id' };
const before = retryQueue.length;
retryQueue = retryQueue.filter(item => item.id !== id);
if (retryQueue.length !== before) {
saveRetryQueueToDisk();
broadcastRetryQueueUpdate({ event: 'removed', id });
}
return { ok: true, size: retryQueue.length };
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
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: 'missing-token-or-url' };
const localFolder = data.localFolder;
const owner = data.owner;
const repo = data.repo;
// destPath is the target directory in the repo
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
// Branch robust behandeln: HEAD bedeutet Remote-Default-Branch nutzen
let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD');
const messagePrefix = data.messagePrefix || 'Upload folder via GUI';
if (!localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'local-folder-not-found' };
const items = [];
const folderName = ppath.basename(localFolder);
// Exclude-Liste: .git, node_modules etc. überspringen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
(function walk(dir) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (excludeList.includes(entry)) continue;
const full = ppath.join(dir, entry);
let stat;
try { stat = fs.statSync(full); } catch(e) { continue; }
if (stat.isDirectory()) {
walk(full);
} else if (stat.isFile()) {
const rel = ppath.relative(localFolder, full).split(ppath.sep).join('/');
let targetPath;
if (destPath && destPath.length > 0) {
targetPath = `${destPath}/${folderName}/${rel}`;
} else {
targetPath = `${folderName}/${rel}`;
}
items.push({ localFile: full, targetPath });
}
}
})(localFolder);
const total = items.length;
if (total === 0) return { ok: true, results: [] };
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
const simpleGit = require('simple-git');
// Auth-URL mit Token für HTTPS-Auth bauen
let authUrl;
try {
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
} catch (urlErr) {
throw new Error(`Ungültige Gitea-URL: ${url}`);
}
const tmpDir = getSafeTmpDir(`git-folder-${owner}-${repo}`);
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
// Fortschritt: Start
try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0 }); } catch (_) {}
console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir });
try {
const git = simpleGit({ config: gitConfig });
let repoGit;
let isEmptyRepo = false;
try {
const cloneArgs = ['--depth', '1', '--no-single-branch'];
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)
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30 }); } catch (_) {}
// Dateien in den Zielordner im geklonten Repo kopieren
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);
}
// Fortschritt: 60% (nach Kopieren)
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60 }); } catch (_) {}
// git add + commit + push
await repoGit.add('.');
let hasChanges = true;
try {
await repoGit.commit(`${messagePrefix} - ${folderName}`);
} catch (commitErr) {
if (String(commitErr).includes('nothing to commit')) {
hasChanges = false; // Keine Änderungen — bereits aktuell
} else {
throw commitErr;
}
}
if (hasChanges) {
let pushBranch = branch;
if (pushBranch === 'HEAD' || isEmptyRepo) {
try {
const branchSummary = await repoGit.branch();
pushBranch = branchSummary.current || 'main';
} catch (_) { pushBranch = 'main'; }
}
if (isEmptyRepo) {
await repoGit.push(['-u', 'origin', pushBranch]);
} else {
await repoGit.push('origin', pushBranch);
}
}
// Fortschritt: 100%
try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100 }); } catch (_) {}
// Temp-Ordner nach kurzer Verzögerung bereinigen
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
return { ok: true, results: successResults, failedCount: 0 };
} catch (gitErr) {
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
throw gitErr;
}
} catch (e) {
console.error('upload-local-folder-to-gitea error', e);
return { ok: false, error: String(e) };
}
});
/* -----------------------------------------------------------------
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 simpleGit = require('simple-git');
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 git = simpleGit({ config: gitConfig });
let repoGit;
let isEmptyRepo = false;
try {
const cloneArgs = ['--depth', '1', '--no-single-branch'];
if (safeBranch !== 'HEAD') cloneArgs.push('--branch', safeBranch);
console.log('[UploadPaths] Clone-Versuch:', { cloneArgs, tmpDir });
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;
}
}
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 repoGit.add('.');
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 (_) {}
throw gitErr;
}
}
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) => {
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: 'missing-token-or-url' };
const owner = data.owner;
const repo = data.repo;
const filePath = data.path;
if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' };
const content = await getGiteaFileContent({ token, url, owner, repo, path: filePath, ref: data.ref || 'HEAD' });
const save = await dialog.showSaveDialog({ defaultPath: ppath.basename(filePath) });
if (save.canceled || !save.filePath) return { ok: false, error: 'save-canceled' };
fs.writeFileSync(save.filePath, content, 'utf8');
return { ok: true, savedTo: save.filePath };
} catch (e) {
console.error('download-gitea-file error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('download-gitea-folder', async (event, data) => {
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: 'missing-token-or-url' };
const owner = data.owner;
const repo = data.repo;
const remotePath = (data.path || '').replace(/^\/+/, '').replace(/\/+$/, '');
const concurrency = data.concurrency || DEFAULT_CONCURRENCY;
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] });
if (result.canceled || !result.filePaths || result.filePaths.length === 0) return { ok: false, error: 'save-canceled' };
const destBase = result.filePaths[0];
const allFiles = [];
async function gather(pathInRepo) {
const _r = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' });
const items = _r.items || _r;
for (const item of items) {
if (item.type === 'dir') await gather(item.path);
else if (item.type === 'file') allFiles.push(item.path);
}
}
await gather(remotePath || '');
const total = allFiles.length;
if (total === 0) return { ok: true, savedTo: destBase, files: [] };
const tasks = allFiles.map(remoteFile => async () => {
const content = await getGiteaFileContent({ token, url, owner, repo, path: remoteFile, ref: data.ref || 'HEAD' });
const localPath = ppath.join(destBase, remoteFile);
fs.mkdirSync(ppath.dirname(localPath), { recursive: true });
fs.writeFileSync(localPath, content, 'utf8');
return localPath;
});
const onProgress = (processed, t) => {
try { event.sender.send('folder-download-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {}
};
const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total));
const savedFiles = results.filter(r => r.ok).map(r => r.result);
return { ok: true, savedTo: destBase, files: savedFiles };
} catch (e) {
console.error('download-gitea-folder error', e);
return { ok: false, error: String(e) };
}
});
/* -----------------------------
prepare-download-drag (robust)
- stellt sicher, dass alle Dateien komplett geschrieben sind
- erkennt Base64 vs UTF-8 und schreibt als Buffer wenn nötig
- nutzt getSafeTmpDir() (siehe oben in deiner main.js)
----------------------------- */
function isBase64Like(str) {
if (typeof str !== 'string') return false;
// Strip whitespace/newlines
const s = str.replace(/\s+/g, '');
if (s.length === 0) return false;
// Base64 valid chars + padding
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) return false;
// length must be multiple of 4 (except maybe line breaks removed)
if (s.length % 4 !== 0) return false;
try {
// Round-trip check (cheap and practical)
const decoded = Buffer.from(s, 'base64');
return decoded.toString('base64') === s;
} catch (e) {
return false;
}
}
ipcMain.handle('prepare-download-drag', async (event, data) => {
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: 'missing-token-or-url' };
const owner = data.owner;
const repo = data.repo;
const remotePath = (data.path || '').replace(/^\/+/, '').replace(/\/+$/, '');
// Create a unique temp directory (guarantees clean state)
const tmpBase = getSafeTmpDir(repo || 'gitea-repo');
// Gather list of files (recursive)
const allFiles = [];
async function gather(pathInRepo) {
const _r2 = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' });
const items = (_r2.items || _r2) || [];
for (const item of items) {
if (item.type === 'dir') await gather(item.path);
else if (item.type === 'file') allFiles.push(item.path);
}
}
await gather(remotePath || '');
// If no files, return early (still provide empty dir)
if (allFiles.length === 0) {
// schedule cleanup
setTimeout(() => { try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} }, TMP_CLEANUP_MS);
return { ok: true, tempPath: tmpBase, files: [] };
}
// Download files sequentially or with limited concurrency:
const tasks = allFiles.map(remoteFile => async () => {
const content = await getGiteaFileContent({ token, url, owner, repo, path: remoteFile, ref: data.ref || 'HEAD' });
const localPath = ppath.join(tmpBase, remoteFile);
ensureDir(ppath.dirname(localPath));
// Decide how to write: if Buffer already, write directly. If string, try base64 detection
if (Buffer.isBuffer(content)) {
fs.writeFileSync(localPath, content);
} else if (typeof content === 'string') {
if (isBase64Like(content)) {
const buf = Buffer.from(content, 'base64');
fs.writeFileSync(localPath, buf);
} else {
// treat as utf8 text
fs.writeFileSync(localPath, content, 'utf8');
}
} else {
// fallback: convert to string
fs.writeFileSync(localPath, String(content), 'utf8');
}
return localPath;
});
// runLimited ensures concurrency and waits for all writes to finish
const results = await runLimited(tasks, data.concurrency || DEFAULT_CONCURRENCY);
// verify at least one successful file
const successFiles = results.filter(r => r.ok).map(r => r.result);
if (successFiles.length === 0) {
// cleanup on complete failure
try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {}
return { ok: false, error: 'no-files-downloaded' };
}
// give renderer the temp dir (renderer should then call 'ondragstart' with the folder path)
// schedule cleanup after delay to keep files available for drag & drop
setTimeout(() => { try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} }, TMP_CLEANUP_MS);
return { ok: true, tempPath: tmpBase, files: successFiles };
} catch (e) {
console.error('prepare-download-drag error', e);
return { ok: false, error: String(e) };
}
});
/* -----------------------------
ondragstart (no change to API but more defensive)
- expects renderer to call window.electronAPI.ondragStart(tempPath)
----------------------------- */
ipcMain.on('ondragstart', async (event, filePath) => {
try {
if (!filePath || !fs.existsSync(filePath)) {
console.warn('ondragstart: path missing or not exists:', filePath);
return;
}
// Prefer folder icon when dragging a directory
let icon = nativeImage.createEmpty();
try {
// ask platform for file icon; large size for clearer drag icon
icon = await app.getFileIcon(filePath, { size: 'large' });
} catch (e) {
// ignore, keep empty icon
}
// startDrag accepts { file } where file can be a directory
try {
event.sender.startDrag({ file: filePath, icon });
} catch (e) {
// some platforms may require a single file — if folder fails, try to drop a placeholder file
console.error('startDrag failed for', filePath, e);
}
} catch (e) {
console.error('ondragstart error', e);
}
});
ipcMain.handle('delete-gitea-repo', async (event, data) => {
try {
const credentials = readCredentials();
if (data && data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
await deleteGithubRepo({ token: githubToken, owner: data.owner, repo: data.repo });
return { ok: true };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const urlStr = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !urlStr) return { ok: false, error: 'missing-token-or-url' };
const owner = data.owner;
const repo = data.repo;
const urlObj = new URL(urlStr);
const protocol = urlObj.protocol === 'https:' ? https : http;
const deletePath = `/api/v1/repos/${owner}/${repo}`;
return new Promise((resolve) => {
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: deletePath,
method: 'DELETE',
headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }
};
const req = protocol.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true });
else resolve({ ok: false, error: `HTTP ${res.statusCode}: ${body}` });
});
});
req.on('error', (e) => resolve({ ok: false, error: String(e) }));
req.end();
});
} catch (e) {
console.error('delete-gitea-repo error', e);
return { ok: false, error: String(e) };
}
});
ipcMain.handle('upload-and-push', async (event, data) => {
const uploadDebugId = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try {
if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: `local-folder-not-found (${uploadDebugId})` };
const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.giteaToken);
const giteaUrl = (data && data.url) || (credentials && credentials.giteaURL);
const owner = data.owner;
const repo = data.repo;
// Branch robust behandeln: HEAD nutzt den Remote-Default, sonst nur sichere Refs
let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD');
const cloneUrl = data.cloneUrl || null;
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
if (!owner || !repo) return { ok: false, error: `missing-owner-or-repo (${uploadDebugId})` };
invalidateRepoContentsCache(owner, repo);
console.log('[UPLOAD_DEBUG][main] upload-and-push:start', {
uploadDebugId,
localFolder: data.localFolder,
owner,
repo,
branch,
destPath,
hasCloneUrl: !!cloneUrl,
hasToken: !!token,
hasUrl: !!giteaUrl
});
const gitExecOptions = {
stdio: 'pipe',
encoding: 'utf8',
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_PAGER: 'cat',
PAGER: 'cat'
},
maxBuffer: 10 * 1024 * 1024
};
// Prüfen ob es eine Datei oder ein Ordner ist
const stat = fs.statSync(data.localFolder);
const isFile = stat.isFile();
const isDirectory = stat.isDirectory();
console.log('[UPLOAD_DEBUG][main] upload-and-push:path-type', { uploadDebugId, isFile, isDirectory });
// --- FALL 1: Einzelne Datei ---
if (isFile) {
console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId });
const raw = fs.readFileSync(data.localFolder);
const base64 = raw.toString('base64');
const fileName = ppath.basename(data.localFolder);
// Pfad-Logik für Ziel
let targetPath;
if (destPath && destPath.length > 0) {
targetPath = `${destPath}/${fileName}`;
} else {
targetPath = fileName;
}
try {
// VERSUCH 1: API Upload
const result = await uploadGiteaFile({
token,
url: giteaUrl,
owner,
repo,
path: targetPath,
contentBase64: base64,
message: `Upload ${fileName} via GUI`,
branch
});
return { ok: true, usedGit: false, singleFile: true, uploaded: result, debugId: uploadDebugId };
} catch (e) {
console.error('[UPLOAD_DEBUG][main] single-file:api-error', { uploadDebugId, error: String(e && e.message ? e.message : e) });
// FALLBACK: Wenn API fehlschlägt (z.B. 422 SHA Fehler), nutze Git
// Git löst das "Überschreiben"-Problem zuverlässig.
console.log('Falling back to Git for single file...');
let finalCloneUrl = cloneUrl;
if (!finalCloneUrl && giteaUrl) {
try {
const base = giteaUrl.replace(/\/$/, '');
const urlObj = new URL(base);
finalCloneUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`;
} catch (err) { console.error('Invalid URL', err); }
}
if (!finalCloneUrl) return { ok: false, error: `Cannot fallback to Git: No Clone URL (${uploadDebugId})` };
// Git-Workflow für einzelne Datei simulieren:
// 1. Temp Ordner erstellen
// 2. Repo klonen
// 3. Datei in Ordner kopieren
// 4. Commit & Push
let authClone = finalCloneUrl;
try {
const urlObj = new URL(finalCloneUrl);
if (token && (urlObj.protocol.startsWith('http'))) {
urlObj.username = encodeURIComponent(token);
authClone = urlObj.toString();
}
} catch (err) {}
const tmpDir = getSafeTmpDir(`git-push-file-${owner}-${repo}`);
try {
const cloneArgs = ['clone', '--depth', '1'];
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
cloneArgs.push(authClone, tmpDir);
runGitSync(cloneArgs, process.cwd(), gitExecOptions);
// Zielort im Repo bestimmen
let destDirInRepo = tmpDir;
if (destPath) {
destDirInRepo = ppath.join(tmpDir, destPath.split('/').join(ppath.sep));
ensureDir(destDirInRepo);
}
const finalFileDest = ppath.join(destDirInRepo, fileName);
// Datei kopieren
fs.copyFileSync(data.localFolder, finalFileDest);
// Git Befehle
runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try { runGitSync(['-C', tmpDir, 'commit', '-m', `Upload file ${fileName} via GUI`], process.cwd(), gitExecOptions); } catch (_) {}
let pushBranch = branch;
if (pushBranch === 'HEAD') {
try {
pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim();
} catch (_) {
pushBranch = 'main';
}
}
runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, singleFile: true, msg: 'Uploaded via Git (Fallback)', debugId: uploadDebugId };
} catch (gitErr) {
console.error('[UPLOAD_DEBUG][main] single-file:git-fallback-error', { uploadDebugId, error: String(gitErr) });
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` };
}
}
}
// --- FALL 2: Ordner ---
if (!isDirectory) {
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 });
// Fallback: API Upload (paralleler Upload)
const items = [];
// excludeList und folderName sind oben (FALL 2) bereits definiert
(function walk(dir) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (excludeList.includes(entry)) continue; // Skip excluded folders/files
const full = ppath.join(dir, entry);
let stat;
try {
stat = fs.statSync(full);
} catch(e) { continue; }
if (stat.isDirectory()) {
walk(full);
} else if (stat.isFile()) {
const rel = ppath.relative(data.localFolder, full).split(ppath.sep).join('/');
// FIXED: Pfad-Respektierung: destPath/folderName/rel
let targetPath;
if (destPath && destPath.length > 0) {
targetPath = `${destPath}/${folderName}/${rel}`;
} else {
targetPath = `${folderName}/${rel}`;
}
items.push({ localFile: full, targetPath });
}
}
})(data.localFolder);
const total = items.length;
const concurrency = data.concurrency || DEFAULT_CONCURRENCY;
if (total === 0) return { ok: true, usedGit: false, results: [], debugId: uploadDebugId };
console.log('[UPLOAD_DEBUG][main] api-fallback:start', { uploadDebugId, total, concurrency, branch, destPath });
const tasks = items.map(it => async () => {
const raw = fs.readFileSync(it.localFile);
const base64 = raw.toString('base64');
return uploadGiteaFile({
token,
url: giteaUrl,
owner,
repo,
path: it.targetPath,
contentBase64: base64,
message: `Upload via GUI - ${ppath.basename(it.localFile)}`,
branch
});
});
const onProgress = (processed, t) => {
try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {}
};
const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total));
const failedCount = uploadResults.filter(r => !r.ok).length;
console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length });
return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount };
} catch (e) {
console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId };
}
});
ipcMain.handle('get-path-type', async (_event, filePath) => {
try {
if (!filePath || typeof filePath !== 'string') {
return { ok: false, error: 'invalid-path' };
}
if (!fs.existsSync(filePath)) {
return { ok: true, type: 'missing' };
}
const stat = fs.statSync(filePath);
if (stat.isFile()) return { ok: true, type: 'file' };
if (stat.isDirectory()) return { ok: true, type: 'dir' };
return { ok: true, type: 'other' };
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('run-batch-repo-action', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials || !credentials.giteaToken || !credentials.giteaURL) {
return { ok: false, error: 'missing-token-or-url' };
}
const action = (data && data.action) || 'refresh';
const rawRepos = Array.isArray(data && data.repos) ? data.repos : [];
const options = (data && data.options) || {};
const repos = rawRepos
.map(r => String(r || '').trim())
.filter(Boolean)
.map(v => {
const [owner, repo] = v.split('/');
return { owner, repo, id: `${owner}/${repo}` };
})
.filter(r => r.owner && r.repo);
if (repos.length === 0) {
return { ok: false, error: 'no-valid-repositories' };
}
const cloneBaseUrl = String(credentials.giteaURL || '').replace(/\/$/, '');
const token = credentials.giteaToken;
const sendProgress = (payload) => {
try { event.sender.send('batch-action-progress', payload); } catch (_) {}
};
const results = [];
for (let i = 0; i < repos.length; i++) {
const item = repos[i];
const progressBase = { index: i + 1, total: repos.length, repo: item.id, action };
sendProgress({ ...progressBase, status: 'running' });
try {
if (action === 'refresh') {
await getGiteaRepoContents({
token,
url: credentials.giteaURL,
owner: item.owner,
repo: item.repo,
path: '',
ref: 'HEAD'
});
results.push({ repo: item.id, ok: true, message: 'Repository aktualisiert' });
} else if (action === 'clone') {
const targetDir = options.cloneTargetDir;
if (!targetDir) throw new Error('clone-target-missing');
const repoDir = ppath.join(targetDir, item.repo);
if (fs.existsSync(repoDir)) throw new Error(`target-exists: ${repoDir}`);
const cloneUrl = `${cloneBaseUrl}/${item.owner}/${item.repo}.git`;
let authCloneUrl = cloneUrl;
try {
const u = new URL(cloneUrl);
if (u.protocol.startsWith('http')) {
u.username = encodeURIComponent(token);
authCloneUrl = u.toString();
}
} catch (_) {}
runGitSync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' });
results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` });
} else if (action === 'create-tag') {
const tag = String(options.tag || '').trim();
if (!tag) throw new Error('tag-missing');
await createGiteaRelease({
token,
url: credentials.giteaURL,
owner: item.owner,
repo: item.repo,
data: {
tag_name: tag,
name: tag,
body: options.body || '',
draft: false,
prerelease: !!options.prerelease,
target_commitish: options.target_commitish || 'HEAD'
}
});
results.push({ repo: item.id, ok: true, message: `Tag erstellt: ${tag}` });
} else if (action === 'create-release') {
const tag = String(options.tag || '').trim();
if (!tag) throw new Error('tag-missing');
const name = String(options.name || tag).trim();
await createGiteaRelease({
token,
url: credentials.giteaURL,
owner: item.owner,
repo: item.repo,
data: {
tag_name: tag,
name,
body: options.body || '',
draft: !!options.draft,
prerelease: !!options.prerelease,
target_commitish: options.target_commitish || 'HEAD'
}
});
results.push({ repo: item.id, ok: true, message: `Release erstellt: ${name}` });
} else {
throw new Error(`unknown-action: ${action}`);
}
sendProgress({ ...progressBase, status: 'ok' });
} catch (e) {
const msg = mapIpcError(e);
results.push({ repo: item.id, ok: false, error: msg });
sendProgress({ ...progressBase, status: 'error', error: msg });
}
}
const success = results.filter(r => r.ok).length;
const failed = results.length - success;
return {
ok: true,
action,
summary: { total: results.length, success, failed },
results
};
} catch (e) {
console.error('run-batch-repo-action error', e);
return { ok: false, error: String(e) };
}
});
/* ================================
RENAME / CREATE / MOVE HANDLERS
================================ */
// Gitea: Datei/Ordner umbenennen (= alle Dateien kopieren + alte löschen)
ipcMain.handle('rename-gitea-item', async (event, data) => {
try {
const credentials = readCredentials();
const token = credentials?.giteaToken;
const giteaUrl = credentials?.giteaURL;
if (!token || !giteaUrl) return { ok: false, error: 'missing-token-or-url' };
const { owner, repo, oldPath, newPath, isDir } = data;
const urlObj = new URL(giteaUrl);
const protocol = urlObj.protocol === 'https:' ? https : http;
const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80);
function giteaRequest(method, apiPath, body) {
return new Promise((resolve, reject) => {
const bodyStr = body ? JSON.stringify(body) : null;
const req = protocol.request({
hostname: urlObj.hostname, port,
path: apiPath, method,
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {})
}
}, (res) => {
let b = '';
res.on('data', c => b += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(b) }); }
catch (_) { resolve({ status: res.statusCode, body: b }); }
});
});
req.on('error', reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
}
async function collectFiles(path) {
const r = await giteaRequest('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=main`, null);
const files = [];
if (Array.isArray(r.body)) {
for (const item of r.body) {
if (item.type === 'dir') files.push(...await collectFiles(item.path));
else files.push({ path: item.path, sha: item.sha });
}
} else if (r.body?.sha) {
files.push({ path: r.body.path, sha: r.body.sha });
}
return files;
}
async function readFileContent(filePath) {
const r = await giteaRequest('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}?ref=main`, null);
return r.body?.content ? r.body.content.replace(/\n/g, '') : '';
}
async function uploadFile(targetPath, contentBase64, message) {
// Check if exists first (need SHA for update)
const check = await giteaRequest('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${targetPath.split('/').map(encodeURIComponent).join('/')}?ref=main`, null);
const body = { message, content: contentBase64, branch: 'main' };
if (check.body?.sha) body.sha = check.body.sha;
return giteaRequest('POST', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${targetPath.split('/').map(encodeURIComponent).join('/')}`, body);
}
async function deleteFile(filePath, sha) {
return giteaRequest('DELETE', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}`, {
message: `Delete ${filePath} (rename)`, sha, branch: 'main'
});
}
// Collect all files under oldPath
const files = await collectFiles(oldPath);
// For each file: read content, upload to newPath, delete from oldPath
for (const f of files) {
const content = await readFileContent(f.path);
const relPath = isDir ? f.path.slice(oldPath.length + 1) : '';
const targetPath = isDir ? `${newPath}/${relPath}` : newPath;
await uploadFile(targetPath, content, `Rename: move ${f.path} to ${targetPath}`);
await deleteFile(f.path, f.sha);
}
invalidateRepoContentsCache(owner, repo);
return { ok: true };
} catch (e) {
console.error('rename-gitea-item error', e);
return { ok: false, error: String(e) };
}
});
// Gitea: Neue Datei oder Ordner (Ordner = Datei mit .gitkeep)
ipcMain.handle('create-gitea-item', async (event, data) => {
try {
const credentials = readCredentials();
const token = credentials?.giteaToken;
const giteaUrl = credentials?.giteaURL;
if (!token || !giteaUrl) return { ok: false, error: 'missing-token-or-url' };
const { owner, repo, path: itemPath, type } = data;
const urlObj = new URL(giteaUrl);
const protocol = urlObj.protocol === 'https:' ? https : http;
const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80);
function parseBody(raw) {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function isAlreadyExistsResponse(statusCode, rawBody) {
if (statusCode !== 409 && statusCode !== 422) return false;
const parsed = parseBody(rawBody);
const message = typeof parsed?.message === 'string' ? parsed.message : String(rawBody || '');
return /already exists/i.test(message);
}
function requestGitea(method, apiPath, body = null) {
return new Promise((resolve) => {
const bodyStr = body ? JSON.stringify(body) : null;
const req = protocol.request({
hostname: urlObj.hostname,
port,
path: apiPath,
method,
headers: {
'Authorization': `token ${token}`,
...(bodyStr ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) } : {})
}
}, (res) => {
let b = '';
res.on('data', c => b += c);
res.on('end', () => resolve({ statusCode: res.statusCode, rawBody: b, body: parseBody(b) }));
});
req.on('error', e => resolve({ statusCode: 0, rawBody: String(e), body: null, error: String(e) }));
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// Für Ordner: .gitkeep Datei anlegen
const targetPath = type === 'folder' ? `${itemPath}/.gitkeep` : itemPath;
const content = Buffer.from('').toString('base64');
// Branch-Auflösung: HEAD zu 'main' konvertieren
const branch = (data.branch && data.branch !== 'HEAD') ? data.branch : 'main';
const encodedTargetPath = targetPath.split('/').map(encodeURIComponent).join('/');
const encodedItemPath = itemPath.split('/').map(encodeURIComponent).join('/');
const baseContentsPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`;
if (type === 'folder') {
const existing = await requestGitea('GET', `${baseContentsPath}/${encodedTargetPath}?ref=${encodeURIComponent(branch)}`);
if (existing.statusCode >= 200 && existing.statusCode < 300) {
return { ok: true, exists: true };
}
const existingDir = await requestGitea('GET', `${baseContentsPath}/${encodedItemPath}?ref=${encodeURIComponent(branch)}`);
if (existingDir.statusCode >= 200 && existingDir.statusCode < 300 && Array.isArray(existingDir.body)) {
const hasGitkeep = existingDir.body.some(entry => entry?.name === '.gitkeep');
if (hasGitkeep) {
return { ok: true, exists: true };
}
}
}
const response = await requestGitea('POST', `${baseContentsPath}/${encodedTargetPath}`, { message: `Create ${itemPath}`, content, branch });
if (response.statusCode >= 200 && response.statusCode < 300) {
invalidateRepoContentsCache(owner, repo);
return { ok: true };
}
if (type === 'folder' && isAlreadyExistsResponse(response.statusCode, response.rawBody)) {
invalidateRepoContentsCache(owner, repo);
return { ok: true, exists: true };
}
return { ok: false, error: `HTTP ${response.statusCode}: ${response.rawBody}` };
} catch (e) {
console.error('create-gitea-item error', e);
return { ok: false, error: String(e) };
}
});
// Lokal: Umbenennen
ipcMain.handle('rename-local-item', async (event, data) => {
try {
const { oldPath, newName } = data;
if (!oldPath || !fs.existsSync(oldPath)) return { ok: false, error: 'path-not-found' };
const dir = ppath.dirname(oldPath);
const newPath = ppath.join(dir, newName);
fs.renameSync(oldPath, newPath);
invalidateLocalFileTreeCacheForPath(dir);
return { ok: true, newPath };
} catch (e) {
console.error('rename-local-item error', e);
return { ok: false, error: String(e) };
}
});
// Lokal: Neue Datei oder Ordner erstellen
ipcMain.handle('create-local-item', async (event, data) => {
try {
const { parentDir, name, type } = data;
const targetPath = ppath.join(parentDir, name);
if (type === 'folder') {
fs.mkdirSync(targetPath, { recursive: true });
} else {
// Sicherstellen dass Elternordner existiert
fs.mkdirSync(ppath.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, '', 'utf8');
}
invalidateLocalFileTreeCacheForPath(parentDir);
return { ok: true, path: targetPath };
} catch (e) {
console.error('create-local-item error', e);
return { ok: false, error: String(e) };
}
});
// Lokal: Verschieben (Cut & Paste)
ipcMain.handle('move-local-item', async (event, data) => {
try {
const { srcPath, destDir } = data;
if (!srcPath || !fs.existsSync(srcPath)) return { ok: false, error: 'source-not-found' };
const name = ppath.basename(srcPath);
const destPath = ppath.join(destDir, name);
fs.mkdirSync(destDir, { recursive: true });
fs.renameSync(srcPath, destPath);
invalidateLocalFileTreeCacheForPath(srcPath);
invalidateLocalFileTreeCacheForPath(destDir);
return { ok: true, destPath };
} catch (e) {
// renameSync kann über Laufwerke nicht funktionieren — dann cpSync + rmSync
try {
const { srcPath, destDir } = data;
const name = ppath.basename(srcPath);
const destPath = ppath.join(destDir, name);
if (fs.statSync(srcPath).isDirectory()) {
fs.cpSync(srcPath, destPath, { recursive: true });
} else {
fs.copyFileSync(srcPath, destPath);
}
fs.rmSync(srcPath, { recursive: true, force: true });
invalidateLocalFileTreeCacheForPath(srcPath);
invalidateLocalFileTreeCacheForPath(destDir);
return { ok: true, destPath };
} catch (e2) {
console.error('move-local-item error', e2);
return { ok: false, error: String(e2) };
}
}
});
// Lokal: Kopieren
ipcMain.handle('copy-local-item', async (event, data) => {
try {
const { src, destDir } = data;
if (!src || !fs.existsSync(src)) return { ok: false, error: 'source-not-found' };
const name = ppath.basename(src);
const dest = ppath.join(destDir, name);
fs.mkdirSync(destDir, { recursive: true });
if (fs.statSync(src).isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
} else {
fs.copyFileSync(src, dest);
}
invalidateLocalFileTreeCacheForPath(destDir);
return { ok: true, dest };
} catch (e) {
console.error('copy-local-item error', e);
return { ok: false, error: String(e) };
}
});
/* ================================
RELEASE MANAGEMENT IPC HANDLERS
================================ */
// List all releases for a repository
ipcMain.handle('list-releases', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
if (data.platform === 'github') {
const githubToken = (data.token) || credentials.githubToken;
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const releases = await listGithubReleases({ token: githubToken, owner: data.owner, repo: data.repo });
return { ok: true, releases };
}
const releases = await listGiteaReleases({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo
});
return { ok: true, releases };
} catch (error) {
console.error('list-releases error:', error);
return { ok: false, error: String(error) };
}
});
// Get a specific release by tag
ipcMain.handle('get-release', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
const release = await getGiteaRelease({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo,
tag: data.tag
});
return { ok: true, release };
} catch (error) {
console.error('get-release error:', error);
return { ok: false, error: String(error) };
}
});
// Create a new release
ipcMain.handle('create-release', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
const releaseData = {
tag_name: data.tag_name,
name: data.name,
body: data.body,
draft: data.draft,
prerelease: data.prerelease,
target_commitish: data.target_commitish
};
if (data.platform === 'github') {
const githubToken = (data.token) || credentials.githubToken;
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const release = await createGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, data: releaseData });
return { ok: true, release };
}
const release = await createGiteaRelease({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo,
data: releaseData
});
return { ok: true, release };
} catch (error) {
console.error('create-release error:', error);
const errorMsg = error.message || String(error);
return { ok: false, error: errorMsg };
}
});
// Edit/update a release
ipcMain.handle('edit-release', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
const updateData = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.body !== undefined) updateData.body = data.body;
if (data.draft !== undefined) updateData.draft = data.draft;
if (data.prerelease !== undefined) updateData.prerelease = data.prerelease;
if (data.platform === 'github') {
const githubToken = (data.token) || credentials.githubToken;
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const release = await editGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, releaseId: data.releaseId, data: updateData });
return { ok: true, release };
}
const release = await editGiteaRelease({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo,
releaseId: data.releaseId,
data: updateData
});
return { ok: true, release };
} catch (error) {
console.error('edit-release error:', error);
return { ok: false, error: String(error) };
}
});
// Delete a release
ipcMain.handle('delete-release', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
if (data.platform === 'github') {
const githubToken = (data.token) || credentials.githubToken;
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
await deleteGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, releaseId: data.releaseId });
return { ok: true };
}
await deleteGiteaRelease({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo,
releaseId: data.releaseId
});
return { ok: true };
} catch (error) {
console.error('delete-release error:', error);
return { ok: false, error: String(error) };
}
});
// Upload a release asset
ipcMain.handle('upload-release-asset', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
const asset = await uploadReleaseAsset({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo,
releaseId: data.releaseId,
filePath: data.filePath,
fileName: data.fileName
});
return { ok: true, asset };
} catch (error) {
console.error('upload-release-asset error:', error);
return { ok: false, error: String(error) };
}
});
// Delete a release asset
ipcMain.handle('delete-release-asset', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
await deleteReleaseAsset({
token: credentials.giteaToken,
url: credentials.giteaURL,
owner: data.owner,
repo: data.repo,
assetId: data.assetId
});
return { ok: true };
} catch (error) {
console.error('delete-release-asset error:', error);
return { ok: false, error: String(error) };
}
});
// Download release archive (ZIP/TAR)
ipcMain.handle('download-release-archive', async (event, data) => {
try {
const credentials = readCredentials();
if (!credentials) return { ok: false, error: 'no-credentials' };
const base = credentials.giteaURL.replace(/\/$/, '');
const archiveUrl = `${base}/${data.owner}/${data.repo}/archive/${data.tag}.zip`;
// Ask user where to save
const result = await dialog.showSaveDialog({
defaultPath: `${data.repo}-${data.tag}.zip`,
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }]
});
if (result.canceled) return { ok: false, canceled: true };
const savePath = result.filePath;
// Download archive
const axios = require('axios');
const fs = require('fs');
const writer = fs.createWriteStream(savePath);
const response = await axios({
url: archiveUrl,
method: 'GET',
responseType: 'stream',
headers: { Authorization: `token ${credentials.giteaToken}` }
});
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve({ ok: true, savedTo: savePath }));
writer.on('error', (err) => reject(err));
});
} catch (error) {
console.error('download-release-archive error:', error);
return { ok: false, error: String(error) };
}
});
/* ========================
LOCAL COMMIT HANDLERS
======================== */
ipcMain.handle('get-local-commits', async (event, data) => {
try {
const { getCommitLogs } = require('./src/git/gitHandler.js');
const commits = await getCommitLogs(data.folderPath, data.branch || 'HEAD');
return { ok: true, commits };
} catch (error) {
console.error('get-local-commits error:', error);
return { ok: false, error: String(error) };
}
});
ipcMain.handle('get-local-commit-details', async (event, data) => {
try {
const { getCommitDetails } = require('./src/git/gitHandler.js');
const details = await getCommitDetails(data.folderPath, data.sha);
return { ok: true, ...details };
} catch (error) {
console.error('get-local-commit-details error:', error);
return { ok: false, error: String(error) };
}
});
ipcMain.handle('get-local-commit-diff', async (event, data) => {
try {
const { getCommitDiff } = require('./src/git/gitHandler.js');
const diff = await getCommitDiff(data.folderPath, data.sha);
return { ok: true, diff };
} catch (error) {
console.error('get-local-commit-diff error:', error);
return { ok: false, error: String(error) };
}
});
ipcMain.handle('get-local-commit-files', async (event, data) => {
try {
const { getCommitDetails } = require('./src/git/gitHandler.js');
const details = await getCommitDetails(data.folderPath, data.sha);
return { ok: true, files: details.files || [], stats: { additions: 0, deletions: 0 } };
} catch (error) {
console.error('get-local-commit-files error:', error);
return { ok: false, error: String(error) };
}
});
/* ========================
GITEA COMMIT HANDLERS
======================== */
ipcMain.handle('get-commit-diff', async (event, data) => {
try {
const credentials = readCredentials();
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const diff = await getGithubCommitDiff({ token: githubToken, owner: data.owner, repo: data.repo, sha: data.sha });
return { ok: true, diff };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const diff = await getGiteaCommitDiff({ token, url, owner: data.owner, repo: data.repo, sha: data.sha });
return { ok: true, diff };
} catch (error) {
console.error('get-commit-diff error:', error);
return { ok: false, error: String(error) };
}
});
ipcMain.handle('get-commit-files', async (event, data) => {
try {
const credentials = readCredentials();
if (data.platform === 'github') {
const githubToken = (data.token) || (credentials && credentials.githubToken);
if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' };
const result = await getGithubCommitFiles({ token: githubToken, owner: data.owner, repo: data.repo, sha: data.sha });
return { ok: true, files: result.files, stats: result.stats };
}
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const result = await getGiteaCommitFiles({ token, url, owner: data.owner, repo: data.repo, sha: data.sha });
return { ok: true, files: result.files, stats: result.stats };
} catch (error) {
console.error('get-commit-files error:', error);
return { ok: false, error: String(error) };
}
});
/* ============================================
FAVORITEN & ZULETZT GEÖFFNET - Persistenz
============================================ */
function getFavoritesFilePath() {
return ppath.join(app.getPath('userData'), 'favorites.json');
}
function getRecentFilePath() {
return ppath.join(app.getPath('userData'), 'recent.json');
}
function safeReadJsonFile(filePath, fallbackValue) {
try {
if (!fs.existsSync(filePath)) return fallbackValue;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (_) {
return fallbackValue;
}
}
function createStampForFileName(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
function getFileMeta(filePath) {
try {
if (!filePath || !fs.existsSync(filePath)) {
return { path: filePath || '', exists: false };
}
const stat = fs.statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mtime: stat.mtime ? stat.mtime.toISOString() : null,
ctime: stat.ctime ? stat.ctime.toISOString() : null
};
} catch (e) {
return {
path: filePath || '',
exists: false,
error: String(e?.message || e)
};
}
}
ipcMain.handle('load-favorites', async () => {
try {
const p = getFavoritesFilePath();
if (!fs.existsSync(p)) return { ok: true, favorites: [] };
return { ok: true, favorites: JSON.parse(fs.readFileSync(p, 'utf8')) || [] };
} catch (e) {
return { ok: true, favorites: [] };
}
});
ipcMain.handle('save-favorites', async (event, favorites) => {
try {
fs.writeFileSync(getFavoritesFilePath(), JSON.stringify(favorites || [], null, 2), 'utf8');
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('load-recent', async () => {
try {
const p = getRecentFilePath();
if (!fs.existsSync(p)) return { ok: true, recent: [] };
return { ok: true, recent: JSON.parse(fs.readFileSync(p, 'utf8')) || [] };
} catch (e) {
return { ok: true, recent: [] };
}
});
ipcMain.handle('save-recent', async (event, recent) => {
try {
const trimmed = (recent || []).slice(0, 20);
fs.writeFileSync(getRecentFilePath(), JSON.stringify(trimmed, null, 2), 'utf8');
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('export-settings-bundle', async () => {
try {
const credentials = readCredentials() || {};
const favorites = safeReadJsonFile(getFavoritesFilePath(), []);
const recent = safeReadJsonFile(getRecentFilePath(), []);
const settingsWithoutSecrets = {
giteaURL: credentials.giteaURL || '',
avatarB64: credentials.avatarB64 || null,
featureFavorites: !!credentials.featureFavorites,
featureRecent: !!credentials.featureRecent,
compactMode: !!credentials.compactMode,
featureColoredIcons: credentials.featureColoredIcons !== false,
favCollapsedFavorites: !!credentials.favCollapsedFavorites,
favCollapsedRecent: !!credentials.favCollapsedRecent
};
const encryptedSecretsPayload = encryptLegacyCredentials(JSON.stringify({
githubToken: credentials.githubToken || '',
giteaToken: credentials.giteaToken || ''
})).toString('base64');
const bundle = {
bundleType: 'git-manager-settings-backup',
schemaVersion: 1,
exportedAt: new Date().toISOString(),
appVersion: app.getVersion(),
platform: process.platform,
settings: settingsWithoutSecrets,
encryptedSecrets: {
algorithm: 'legacyAesCbc',
payloadBase64: encryptedSecretsPayload
},
favorites: Array.isArray(favorites) ? favorites : [],
recent: Array.isArray(recent) ? recent : []
};
const fileName = `git-manager-backup-${createStampForFileName()}.json`;
const result = await dialog.showSaveDialog({
title: 'Backup exportieren',
defaultPath: ppath.join(app.getPath('documents'), fileName),
filters: [
{ name: 'JSON', extensions: ['json'] }
]
});
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
fs.writeFileSync(result.filePath, JSON.stringify(bundle, null, 2), 'utf8');
logger.info('settings-backup', 'Backup exported', { filePath: result.filePath });
return { ok: true, filePath: result.filePath };
} catch (e) {
logger.error('settings-backup', 'Backup export failed', { error: e.message });
return { ok: false, error: String(e?.message || e) };
}
});
ipcMain.handle('import-settings-bundle', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Backup importieren',
properties: ['openFile'],
filters: [
{ name: 'JSON', extensions: ['json'] }
]
});
if (result.canceled || !result.filePaths || !result.filePaths[0]) return { ok: false, canceled: true };
const filePath = result.filePaths[0];
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || parsed.bundleType !== 'git-manager-settings-backup') {
return { ok: false, error: 'Ungültige Backup-Datei.' };
}
const existing = readCredentials() || {};
const settings = parsed.settings && typeof parsed.settings === 'object' ? parsed.settings : {};
let importedSecrets = { githubToken: '', giteaToken: '' };
const encryptedPayload = parsed?.encryptedSecrets?.payloadBase64;
if (encryptedPayload) {
try {
importedSecrets = decryptLegacyCredentials(Buffer.from(String(encryptedPayload), 'base64'));
} catch (_) {
return { ok: false, error: 'Backup konnte nicht entschluesselt werden.' };
}
}
const mergedCredentials = {
...existing,
...settings,
githubToken: String(importedSecrets.githubToken || existing.githubToken || ''),
giteaToken: String(importedSecrets.giteaToken || existing.giteaToken || '')
};
persistCredentials(mergedCredentials);
if (Array.isArray(parsed.favorites)) {
fs.writeFileSync(getFavoritesFilePath(), JSON.stringify(parsed.favorites, null, 2), 'utf8');
}
if (Array.isArray(parsed.recent)) {
fs.writeFileSync(getRecentFilePath(), JSON.stringify(parsed.recent.slice(0, 20), null, 2), 'utf8');
}
logger.info('settings-backup', 'Backup imported', {
filePath,
favorites: Array.isArray(parsed.favorites) ? parsed.favorites.length : 0,
recent: Array.isArray(parsed.recent) ? parsed.recent.length : 0
});
return {
ok: true,
filePath,
favoritesImported: Array.isArray(parsed.favorites) ? parsed.favorites.length : 0,
recentImported: Array.isArray(parsed.recent) ? parsed.recent.length : 0
};
} catch (e) {
logger.error('settings-backup', 'Backup import failed', { error: e.message });
return { ok: false, error: String(e?.message || e) };
}
});
ipcMain.handle('create-diagnostics-package', async () => {
try {
const recentLogs = logger.getRecent(1200);
const capturedEvents = diagnosticsEvents.slice(-600);
const credentials = readCredentials() || {};
const favorites = safeReadJsonFile(getFavoritesFilePath(), []);
const recent = safeReadJsonFile(getRecentFilePath(), []);
const userDataPath = app.getPath('userData');
const appPaths = {
userData: userDataPath,
sessionData: app.getPath('sessionData'),
cache: app.getPath('cache'),
temp: app.getPath('temp'),
logs: app.getPath('logs'),
documents: app.getPath('documents')
};
const windows = BrowserWindow.getAllWindows().map((w, idx) => {
let bounds = null;
try { bounds = w.getBounds(); } catch (_) {}
return {
index: idx,
visible: w.isVisible(),
focused: w.isFocused(),
minimized: w.isMinimized(),
maximized: w.isMaximized(),
bounds
};
});
const settingsSnapshot = {
featureFavorites: !!credentials.featureFavorites,
featureRecent: !!credentials.featureRecent,
compactMode: !!credentials.compactMode,
featureColoredIcons: credentials.featureColoredIcons !== false,
favCollapsedFavorites: !!credentials.favCollapsedFavorites,
favCollapsedRecent: !!credentials.favCollapsedRecent,
giteaUrlConfigured: !!String(credentials.giteaURL || '').trim(),
githubTokenConfigured: !!String(credentials.githubToken || '').trim(),
giteaTokenConfigured: !!String(credentials.giteaToken || '').trim()
};
const resourceUsage = {
processUptimeSec: Number(process.uptime().toFixed(3)),
rss: process.memoryUsage().rss,
heapTotal: process.memoryUsage().heapTotal,
heapUsed: process.memoryUsage().heapUsed,
external: process.memoryUsage().external,
arrayBuffers: process.memoryUsage().arrayBuffers,
cpuUsage: process.cpuUsage()
};
const errorBySource = {};
const errorByLevel = {};
for (const item of [...(Array.isArray(recentLogs) ? recentLogs : []), ...(Array.isArray(capturedEvents) ? capturedEvents : [])]) {
const level = String(item?.level || 'INFO').toUpperCase();
const source = String(item?.source || item?.context || 'unknown');
errorByLevel[level] = (errorByLevel[level] || 0) + 1;
errorBySource[source] = (errorBySource[source] || 0) + 1;
}
const debug = {
generatedAt: new Date().toISOString(),
appVersion: app.getVersion(),
appName: app.getName(),
platform: process.platform,
arch: process.arch,
os: {
release: os.release(),
type: os.type(),
version: os.version ? os.version() : null,
hostname: os.hostname(),
uptimeSec: os.uptime()
},
runtime: {
node: process.versions.node,
electron: process.versions.electron,
chrome: process.versions.chrome,
v8: process.versions.v8
},
appPaths,
storageFiles: {
credentialsPrimary: getFileMeta(getCredentialsFilePath()),
credentialsLegacy: getFileMeta(getLegacyWorkspaceCredentialsFilePath()),
favorites: getFileMeta(getFavoritesFilePath()),
recent: getFileMeta(getRecentFilePath())
},
windowState: windows,
settingsSnapshot,
collections: {
favoritesCount: Array.isArray(favorites) ? favorites.length : 0,
recentCount: Array.isArray(recent) ? recent.length : 0
},
resourceUsage,
autostart: app.getLoginItemSettings({ args: ['--hidden'] }),
processInfo: {
pid: process.pid,
ppid: process.ppid,
argv: process.argv,
execPath: process.execPath,
cwd: process.cwd(),
env: {
NODE_ENV: process.env.NODE_ENV || null,
APPDATA: process.env.APPDATA || null,
LOCALAPPDATA: process.env.LOCALAPPDATA || null,
TEMP: process.env.TEMP || null,
TMP: process.env.TMP || null
}
},
credentialStatus: getCredentialReadStatus(),
cacheStats: {
repos: caches.repos.size(),
fileTree: caches.fileTree.size(),
api: caches.api.size()
},
retryQueue: {
size: Array.isArray(retryQueue) ? retryQueue.length : 0,
sample: Array.isArray(retryQueue) ? retryQueue.slice(0, 10) : []
},
logs: recentLogs,
capturedEvents,
counts: {
logs: Array.isArray(recentLogs) ? recentLogs.length : 0,
capturedEvents: Array.isArray(capturedEvents) ? capturedEvents.length : 0,
errorsByLevel: errorByLevel,
errorsBySource: errorBySource
},
guidance: {
note: 'Diese Datei kann an den Support weitergegeben werden. Tokens werden nicht im Klartext exportiert.',
recommendation: 'Reproduziere den Fehler direkt vor der Diagnose-Erstellung für maximale Aussagekraft.'
}
};
const logErrors = (Array.isArray(debug.logs) ? debug.logs : [])
.filter(l => ['ERROR', 'WARN'].includes(String(l?.level || '').toUpperCase()))
.slice(-80);
const eventErrors = (Array.isArray(capturedEvents) ? capturedEvents : [])
.filter(e => ['ERROR', 'WARN'].includes(String(e?.level || '').toUpperCase()))
.slice(-80);
debug.lastErrors = [...logErrors, ...eventErrors].slice(-120);
const fileName = `git-manager-diagnostics-${createStampForFileName()}.json`;
const result = await dialog.showSaveDialog({
title: 'Diagnosepaket speichern',
defaultPath: ppath.join(app.getPath('documents'), fileName),
filters: [
{ name: 'JSON', extensions: ['json'] }
]
});
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
fs.writeFileSync(result.filePath, JSON.stringify(debug, null, 2), 'utf8');
logger.info('diagnostics', 'Diagnostics package created', { filePath: result.filePath });
return { ok: true, filePath: result.filePath };
} catch (e) {
logger.error('diagnostics', 'Diagnostics package failed', { error: e.message });
return { ok: false, error: String(e?.message || e) };
}
});
// main.js - Updater IPC Handlers
// Window Controls
ipcMain.on('window-minimize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) win.minimize();
});
ipcMain.on('window-maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) { win.isMaximized() ? win.unmaximize() : win.maximize(); }
});
ipcMain.on('window-close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) win.close();
});
// Autostart
ipcMain.handle('set-autostart', (event, enable) => {
app.setLoginItemSettings({
openAtLogin: enable,
openAsHidden: true,
args: ['--hidden']
});
return { ok: true };
});
ipcMain.handle('get-autostart', () => {
const settings = app.getLoginItemSettings({ args: ['--hidden'] });
return { ok: true, enabled: settings.openAtLogin };
});
ipcMain.handle('copy-to-clipboard', async (_event, text) => {
try {
clipboard.writeText(String(text || ''));
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
});
function isAllowedExternalUrl(rawUrl) {
try {
const parsed = new URL(String(rawUrl || '').trim());
const protocol = parsed.protocol.toLowerCase();
if (protocol === 'https:' || protocol === 'mailto:') return true;
if (protocol === 'http:') {
const host = String(parsed.hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
return false;
} catch (_) {
return false;
}
}
ipcMain.handle('open-external-url', async (_event, rawUrl) => {
try {
const url = String(rawUrl || '').trim();
if (!url) return { ok: false, error: 'Leere URL' };
if (!isAllowedExternalUrl(url)) {
return { ok: false, error: 'Nicht erlaubte URL.' };
}
await shell.openExternal(url);
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
});
// 1. Version abfragen
ipcMain.handle('get-app-version', async () => {
return { ok: true, version: app.getVersion() };
});
// 2. Suche nach Updates (Manuell oder Automatisch)
ipcMain.handle('check-for-updates', async (event, options = {}) => {
console.log("[Main] Update-Check angefordert...");
try {
const silent = Boolean(options && options.silent);
if (!Updater) {
Updater = require('./updater.js');
}
const win = BrowserWindow.fromWebContents(event.sender);
if (!updater) {
if (win && Updater) updater = new Updater(win);
} else if (win) {
updater.mainWindow = win;
}
if (updater) await updater.checkForUpdates(silent);
else return { ok: false, error: 'Updater konnte nicht initialisiert werden.' };
return { ok: true };
} catch (error) {
console.error('[Main] Fehler beim Update-Check:', error);
return { ok: false, error: String(error) };
}
});
// 3. Download starten (wird vom "Jetzt installieren" Button gerufen)
ipcMain.handle('start-update-download', async (event, asset) => {
console.log("[Main] Download-Signal erhalten für:", asset ? asset.name : "Unbekannt");
try {
if (!Updater) {
Updater = require('./updater.js');
}
const win = BrowserWindow.fromWebContents(event.sender);
if (!updater) {
updater = new Updater(win);
} else if (win) {
updater.mainWindow = win;
}
if (asset && asset.browser_download_url) {
await updater.startDownload(asset);
return { ok: true };
}
return { ok: false, error: 'Ungültiges Asset' };
} catch (error) {
console.error('[Main] Download-Fehler:', error);
return { ok: false, error: String(error) };
}
});
ipcMain.handle('test-gitea-connection', async (event, data) => {
try {
const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.giteaToken);
const url = (data && data.url) || (credentials && credentials.giteaURL);
if (!url) return { ok: false, error: 'Gitea URL fehlt.' };
const result = await checkGiteaConnection({
token,
url,
timeout: (data && data.timeout) || 8000
});
return { ok: result.ok, result };
} catch (e) {
console.error('test-gitea-connection error', e);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('validate-repo-name', async (_event, data) => {
try {
const name = String(data && data.name || '').trim();
const platform = String(data && data.platform || 'gitea').trim().toLowerCase();
const validPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,98}[a-zA-Z0-9])?$/;
const validFormat = validPattern.test(name);
if (!name) {
return { ok: true, validFormat: false, existsExact: false, similar: [], checked: false, reason: 'empty-name' };
}
if (!validFormat) {
return { ok: true, validFormat: false, existsExact: false, similar: [], checked: false, reason: 'invalid-format' };
}
if (platform !== 'gitea') {
return { ok: true, validFormat: true, existsExact: false, similar: [], checked: false, reason: 'platform-not-supported' };
}
const credentials = readCredentials();
if (!credentials || !credentials.giteaToken || !credentials.giteaURL) {
return { ok: true, validFormat: true, existsExact: false, similar: [], checked: false, reason: 'missing-credentials' };
}
const list = await listGiteaRepos({ token: credentials.giteaToken, url: credentials.giteaURL });
const repos = Array.isArray(list) ? list : [];
const allNames = repos
.map(r => String(r && r.name || '').trim())
.filter(Boolean);
const lower = name.toLowerCase();
const normalized = lower.replace(/[\s._-]+/g, '');
const existsExact = allNames.some(n => n.toLowerCase() === lower);
const similar = allNames
.filter(n => {
const nLower = n.toLowerCase();
const nNorm = nLower.replace(/[\s._-]+/g, '');
return nLower.includes(lower) || lower.includes(nLower) || nNorm.includes(normalized) || normalized.includes(nNorm);
})
.filter(n => n.toLowerCase() !== lower)
.slice(0, 8);
return {
ok: true,
validFormat: true,
existsExact,
similar,
checked: true,
totalKnown: allNames.length
};
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('check-clone-target-collisions', async (_event, data) => {
try {
const targetDir = String(data && data.targetDir || '').trim();
const rawRepos = Array.isArray(data && data.repos) ? data.repos : [];
const repos = rawRepos
.map(v => String(v || '').trim())
.filter(Boolean)
.map(v => {
const parts = v.split('/');
const repo = (parts[1] || '').trim();
return { input: v, repo };
})
.filter(r => r.repo);
const duplicateRepoNames = [];
const seen = new Map();
for (const item of repos) {
const key = item.repo.toLowerCase();
const count = (seen.get(key) || 0) + 1;
seen.set(key, count);
}
for (const [name, count] of seen.entries()) {
if (count > 1) duplicateRepoNames.push(name);
}
const existingTargets = [];
if (targetDir) {
for (const item of repos) {
const repoDir = ppath.join(targetDir, item.repo);
if (fs.existsSync(repoDir)) {
existingTargets.push(repoDir);
}
}
}
return {
ok: true,
duplicateRepoNames,
existingTargets,
hasCollisions: duplicateRepoNames.length > 0 || existingTargets.length > 0
};
} catch (e) {
return { ok: false, error: String(e) };
}
});
ipcMain.handle('sync-repo-to-github', async (event, data) => {
let mirrorDir = null;
let sourceDefaultBranch = '';
try {
const creds = readCredentials();
if (!creds?.githubToken) {
return { ok: false, error: 'GitHub Token fehlt. Bitte in den Einstellungen eintragen.' };
}
const owner = String(data?.owner || '').trim();
const repo = String(data?.repo || '').trim();
if (!owner || !repo) {
return { ok: false, error: 'Owner oder Repository fehlt.' };
}
let targetOwner = String(data?.targetOwner || '').trim();
if (!targetOwner) {
const me = await getGithubCurrentUser({ token: creds.githubToken });
targetOwner = me?.login || '';
}
if (!targetOwner) {
return { ok: false, error: 'GitHub Benutzer konnte nicht ermittelt werden.' };
}
// GitHub repo erstellen (falls noch nicht vorhanden)
let repoCreated = false;
try {
await createRepoGitHub({
name: repo,
token: creds.githubToken,
auto_init: false,
private: !!data?.isPrivate,
description: String(data?.description || ''),
homepage: String(data?.homepage || '')
});
repoCreated = true;
} catch (e) {
const msg = String(e?.message || e || '');
if (!/already exists|already_exists|existiert bereits|name already exists/i.test(msg)) {
// Falls GitHub mit einer unscharfen Meldung antwortet, explizit auf Existenz pruefen.
const exists = await githubRepoExists({
token: creds.githubToken,
owner: targetOwner,
repo
}).catch(() => false);
if (!exists) {
throw e;
}
}
}
let sourceCloneUrl = String(data?.cloneUrl || '').trim();
if (!sourceCloneUrl) {
if (!creds?.giteaURL) return { ok: false, error: 'Clone-URL fehlt und Gitea URL ist nicht gesetzt.' };
sourceCloneUrl = `${String(creds.giteaURL).replace(/\/$/, '')}/${owner}/${repo}.git`;
}
// Quelle auf Gitea: URL ohne Userinfo verwenden und Auth nur über temporären Git-Header übergeben.
let cloneNeedsGiteaHeader = false;
let sourceOrigin = '';
try {
const sourceUrlObj = new URL(sourceCloneUrl);
const giteaHost = creds?.giteaURL ? new URL(creds.giteaURL).host : null;
const isGiteaSource = giteaHost && sourceUrlObj.host === giteaHost;
// Niemals eingebettete Zugangsdaten in URLs behalten.
if (sourceUrlObj.username || sourceUrlObj.password) {
sourceUrlObj.username = '';
sourceUrlObj.password = '';
}
sourceCloneUrl = sourceUrlObj.toString();
if (isGiteaSource && creds?.giteaToken) {
sourceOrigin = `${sourceUrlObj.origin}/`;
cloneNeedsGiteaHeader = true;
}
} catch (_) {}
mirrorDir = fs.mkdtempSync(ppath.join(os.tmpdir(), 'git-manager-sync-'));
// --bare statt --mirror: lädt alle Branches/Tags ohne mirror-Flag zu setzen,
// damit anschließende Refspec-Pushes (Force) funktionieren.
const cloneArgs = cloneNeedsGiteaHeader
? ['-c', `http.${sourceOrigin}.extraheader=AUTHORIZATION: token ${creds.giteaToken}`, 'clone', '--bare', sourceCloneUrl, mirrorDir]
: ['clone', '--bare', sourceCloneUrl, mirrorDir];
// runGit: gibt stdout zurück, loggt stderr immer (git push schreibt Status auf stderr)
const runGit = (args, cwd) => {
const res = spawnSync('git', args, {
cwd,
encoding: 'utf8',
windowsHide: true
});
const out = (res.stdout || '').trim();
const err = (res.stderr || '').trim();
if (err) console.log(`[sync-repo-to-github][git] ${err}`);
if (res.status !== 0) {
throw new Error((err || out || 'Git-Fehler').trim());
}
return out;
};
console.log(`[sync-repo-to-github] Klone Quell-Repo (bare): ${sourceCloneUrl}`);
runGit(cloneArgs, process.cwd());
console.log('[sync-repo-to-github] Bare-Clone abgeschlossen');
// Alle lokalen Branches auflisten
const allBranchesRaw = runGit(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], mirrorDir);
const allBranches = (allBranchesRaw || '').split('\n').map(s => s.trim()).filter(s => s && isSafeGitRef(s));
console.log(`[sync-repo-to-github] Lokale Branches: ${allBranches.join(', ') || '(keine)'}`);
// Default-Branch ermitteln
try {
const headRef = runGit(['symbolic-ref', '--short', 'HEAD'], mirrorDir);
sourceDefaultBranch = String(headRef || '').replace(/^refs\/heads\//, '').trim();
if (!isSafeGitRef(sourceDefaultBranch)) sourceDefaultBranch = '';
} catch (_) {
sourceDefaultBranch = allBranches[0] || '';
}
if (!sourceDefaultBranch && allBranches.length > 0) sourceDefaultBranch = allBranches[0];
console.log(`[sync-repo-to-github] Quell-Default-Branch: "${sourceDefaultBranch || '(unbekannt)'}"`);
const githubRemoteUrl = `https://github.com/${targetOwner}/${repo}.git`;
const githubAuthHeader = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${creds.githubToken}`, 'utf8').toString('base64')}`;
const ghAuthArgs = ['-c', 'credential.helper=', '-c', `http.https://github.com/.extraheader=${githubAuthHeader}`];
// GitHub als Push-Remote setzen
runGit(['remote', 'set-url', 'origin', githubRemoteUrl], mirrorDir);
// Lokale Commit-SHAs loggen (= Gitea-Stand)
for (const branch of allBranches) {
try {
const localSha = runGit(['rev-parse', `refs/heads/${branch}`], mirrorDir);
console.log(`[sync-repo-to-github] Gitea ${branch}: ${localSha}`);
} catch (_) {}
}
// Remote (GitHub) Commit-SHAs VOR dem Push ermitteln und vergleichen
try {
const remoteRefsRaw = runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir);
const remoteMap = {};
(remoteRefsRaw || '').split('\n').forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts.length === 2) {
const branch = parts[1].replace('refs/heads/', '');
remoteMap[branch] = parts[0];
}
});
for (const branch of allBranches) {
const remoteSha = remoteMap[branch] || '(nicht vorhanden)';
console.log(`[sync-repo-to-github] GitHub ${branch}: ${remoteSha}`);
}
} catch (lsErr) {
console.warn('[sync-repo-to-github] ls-remote Warnung:', lsErr?.message || lsErr);
}
// Jeden Branch einzeln force-pushen für maximale Zuverlässigkeit
console.log(`[sync-repo-to-github] Force-Push ${allBranches.length} Branch(es) nach GitHub: ${githubRemoteUrl}`);
let pushedCount = 0;
for (const branch of allBranches) {
console.log(`[sync-repo-to-github] Pushe Branch: ${branch}`);
runGit([...ghAuthArgs, 'push', '--force', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], mirrorDir);
pushedCount++;
}
console.log(`[sync-repo-to-github] ${pushedCount} Branch(es) erfolgreich gepusht`);
// Auf GitHub nicht mehr vorhandene Branches aufräumen (prune via API, non-fatal)
try {
const remoteRefsRaw = runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir);
const remoteHeads = (remoteRefsRaw || '').split('\n')
.map(l => { const m = l.match(/refs\/heads\/(.+)$/); return m ? m[1].trim() : null; })
.filter(Boolean);
const localSet = new Set(allBranches);
for (const remoteBranch of remoteHeads) {
if (!localSet.has(remoteBranch) && isSafeGitRef(remoteBranch)) {
console.log(`[sync-repo-to-github] Lösche veralteten Remote-Branch: ${remoteBranch}`);
runGit([...ghAuthArgs, 'push', 'origin', `--delete`, remoteBranch], mirrorDir);
}
}
} catch (pruneErr) {
console.warn('[sync-repo-to-github] Prune-Warnung:', pruneErr?.message || pruneErr);
}
// Tags mit Force-Push übertragen (non-fatal)
try {
runGit([...ghAuthArgs, 'push', 'origin', '--tags', '--force'], mirrorDir);
console.log('[sync-repo-to-github] Tags erfolgreich gepusht');
} catch (tagsErr) {
console.warn('[sync-repo-to-github] Tags-Push Warnung:', tagsErr?.message || tagsErr);
}
// Default-Branch auf GitHub per API angleichen
if (sourceDefaultBranch) {
console.log(`[sync-repo-to-github] Setze GitHub Default-Branch auf: ${sourceDefaultBranch}`);
try {
await updateGithubRepoDefaultBranch({
token: creds.githubToken,
owner: targetOwner,
repo,
defaultBranch: sourceDefaultBranch
});
console.log('[sync-repo-to-github] Default-Branch aktualisiert');
} catch (defaultBranchErr) {
console.warn('sync-repo-to-github: default-branch update warning', defaultBranchErr?.message || defaultBranchErr);
}
}
// Optional: Topics auf GitHub angleichen (nur falls übergeben)
const topics = Array.isArray(data?.topics) ? data.topics.filter(Boolean) : [];
if (topics.length > 0) {
try {
await updateGithubRepoTopics({
token: creds.githubToken,
owner: targetOwner,
repo,
topics
});
} catch (topicErr) {
console.warn('sync-repo-to-github: topics sync warning', topicErr?.message || topicErr);
}
}
return {
ok: true,
repoCreated,
githubRepo: `${targetOwner}/${repo}`,
sourceDefaultBranch: sourceDefaultBranch || null
};
} catch (e) {
console.error('sync-repo-to-github error', e);
return { ok: false, error: String(e?.message || e) };
} finally {
if (mirrorDir) {
try { fs.rmSync(mirrorDir, { recursive: true, force: true }); } catch (_) {}
}
}
});
ipcMain.handle('test-github-connection', async (_event, data) => {
try {
const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.githubToken);
if (!token) return { ok: false, error: 'GitHub Token fehlt.' };
const user = await getGithubCurrentUser({ token });
return {
ok: true,
result: {
ok: true,
checks: {
authProvided: true,
authOk: true
},
user: {
login: user && user.login ? user.login : '',
id: user && user.id ? user.id : null
}
}
};
} catch (e) {
console.error('test-github-connection error', e);
return { ok: false, error: mapIpcError(e) };
}
});