Upload via GUI (10 Dateien)

This commit is contained in:
2026-10-22 07:15:41 +00:00
parent 85a222a9fb
commit 99d9629e25
4 changed files with 862 additions and 567 deletions
+58
View File
@@ -0,0 +1,58 @@
# ===== Zugangsdaten / Secrets (NIE hochladen!) =====
/data/
credentials.json
*.env
.env*
*.pem
*.key
*.pfx
*.p12
# ===== Abhängigkeiten =====
node_modules/
.npm/
.cache/
# ===== Build-Ausgaben =====
dist/
out/
release/
*.exe
*.blockmap
*.sha256
*.sha256sum
# ===== .NET Installer (Visual Studio / dotnet) =====
installer/bin/
installer/obj/
**/bin/
**/obj/
.vs/
*.user
*.suo
# ===== Große gebündelte Binaries (MinGit, ca. 93 MB) =====
vendor/mingit/
# ===== Laufzeitdaten / Backups / Repos =====
/repos/
/backup/
*.zip
*.rar
*.7z
# ===== Logs =====
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ===== Editor / OS =====
.vscode/
.idea/
.DS_Store
Thumbs.db
desktop.ini
# ===== Claude Code (lokale Einstellungen) =====
.claude/settings.local.json
+264 -32
View File
@@ -77,6 +77,9 @@ const {
getGiteaFileContent, getGiteaFileContent,
uploadGiteaFile, uploadGiteaFile,
uploadItemsViaGiteaApi, uploadItemsViaGiteaApi,
createUploadCancelledError,
isUploadCancelledError,
throwIfUploadCancelled,
listGiteaRepoFiles, listGiteaRepoFiles,
deleteGiteaPathsBatched, deleteGiteaPathsBatched,
getGiteaCurrentUser, getGiteaCurrentUser,
@@ -290,9 +293,10 @@ const GIT_CLONE_STRATEGIES = [
* *
* @returns {Promise<{ repoGit: object, isEmptyRepo: boolean, strategy: string }>} * @returns {Promise<{ repoGit: object, isEmptyRepo: boolean, strategy: string }>}
*/ */
async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone' }) { async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone', signal }) {
const simpleGit = require('simple-git'); const simpleGit = require('simple-git');
const config = [...gitConfig, ...GIT_TRANSFER_CONFIG]; const config = [...gitConfig, ...GIT_TRANSFER_CONFIG];
throwIfUploadCancelled(signal);
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD'); const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
// Bekannt defekter Server: gar nicht erst versuchen. // Bekannt defekter Server: gar nicht erst versuchen.
@@ -313,8 +317,8 @@ async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clo
try { try {
if (attempt > 1) clearDirContents(tmpDir); if (attempt > 1) clearDirContents(tmpDir);
console.log(`[${label}] Clone-Versuch ${attempt}/${GIT_CLONE_MAX_ATTEMPTS} (${strategy.name}):`, { cloneArgs, tmpDir }); console.log(`[${label}] Clone-Versuch ${attempt}/${GIT_CLONE_MAX_ATTEMPTS} (${strategy.name}):`, { cloneArgs, tmpDir });
await simpleGit({ config }).clone(authUrl, tmpDir, cloneArgs); await simpleGit({ config, abort: signal }).clone(authUrl, tmpDir, cloneArgs);
const repoGit = simpleGit({ baseDir: tmpDir, config }); const repoGit = simpleGit({ baseDir: tmpDir, config, abort: signal });
// Ein Clone mit --no-checkout laesst den Index leer. Ohne read-tree wuerde der // Ein Clone mit --no-checkout laesst den Index leer. Ohne read-tree wuerde der
// folgende Commit den kompletten Bestand des Branches als geloescht markieren. // folgende Commit den kompletten Bestand des Branches als geloescht markieren.
@@ -325,6 +329,11 @@ async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clo
markGitTransportWorking(authUrl); markGitTransportWorking(authUrl);
return { repoGit, isEmptyRepo: false, strategy: strategy.name }; return { repoGit, isEmptyRepo: false, strategy: strategy.name };
} catch (cloneErr) { } catch (cloneErr) {
// Abbruch durch den Nutzer: weder wiederholen noch den Server als defekt markieren.
if (signal && signal.aborted) {
clearDirContents(tmpDir);
throw createUploadCancelledError();
}
const cloneMsg = String(cloneErr); const cloneMsg = String(cloneErr);
// Leeres Repo ohne Commits: lokal initialisieren statt klonen. // Leeres Repo ohne Commits: lokal initialisieren statt klonen.
@@ -332,7 +341,7 @@ async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clo
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) { cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
console.log(`[${label}] Leeres Repo erkannt — initialisiere lokal als "${safeBranch}"`); console.log(`[${label}] Leeres Repo erkannt — initialisiere lokal als "${safeBranch}"`);
clearDirContents(tmpDir); clearDirContents(tmpDir);
const repoGit = simpleGit({ baseDir: tmpDir, config }); const repoGit = simpleGit({ baseDir: tmpDir, config, abort: signal });
await repoGit.init(); await repoGit.init();
// git init nutzt init.defaultBranch (haeufig "master"). Ohne diese Korrektur landet // git init nutzt init.defaultBranch (haeufig "master"). Ohne diese Korrektur landet
@@ -401,11 +410,11 @@ async function stageUploadedFiles(repoGit) {
* *
* @returns {Promise<{ok: boolean, uploaded: string[], failed: Array<{path: string, error: string}>, commits: number, branch: string}>} * @returns {Promise<{ok: boolean, uploaded: string[], failed: Array<{path: string, error: string}>, commits: number, branch: string}>}
*/ */
async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message, onProgress, label = 'Upload' }) { async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message, onProgress, label = 'Upload', signal }) {
console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`); console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`);
const result = await uploadItemsViaGiteaApi({ const result = await uploadItemsViaGiteaApi({
token, url, owner, repo, branch, items, message, onProgress token, url, owner, repo, branch, items, message, onProgress, signal
}); });
console.log(`[${label}] API-Upload fertig:`, { console.log(`[${label}] API-Upload fertig:`, {
@@ -1190,11 +1199,11 @@ function sanitizeGitRef(ref, fallback = 'main') {
// Startet Git ohne den Hauptprozess zu blockieren. Synchrone Aufrufe (spawnSync) froren // Startet Git ohne den Hauptprozess zu blockieren. Synchrone Aufrufe (spawnSync) froren
// die Oberfläche ein, bis Windows "Anwendung reagiert nicht" meldete. // die Oberfläche ein, bis Windows "Anwendung reagiert nicht" meldete.
function execGit(args, { cwd, input, env } = {}) { function execGit(args, { cwd, input, env, signal } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
let child; let child;
try { try {
child = spawn('git', args, { cwd, env: env || process.env, windowsHide: true }); child = spawn('git', args, { cwd, env: env || process.env, windowsHide: true, signal });
} catch (e) { } catch (e) {
resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) }); resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) });
return; return;
@@ -1204,6 +1213,25 @@ function execGit(args, { cwd, input, env } = {}) {
child.stdout.on('data', c => out.push(c)); child.stdout.on('data', c => out.push(c));
child.stderr.on('data', c => err.push(c)); child.stderr.on('data', c => err.push(c));
child.on('error', e => err.push(Buffer.from(String(e && e.message || e)))); child.on('error', e => err.push(Buffer.from(String(e && e.message || e))));
// Abbruch: unter Windows beendet spawn(signal) nur git.exe, der Netzwerk-Kindprozess
// (git-remote-https) läuft weiter und hält die Pipes offen. Deshalb den ganzen
// Prozessbaum beenden und sofort zurückkehren, statt auf 'close' zu warten.
if (signal) {
const onAbort = () => {
try {
if (process.platform === 'win32' && child.pid) {
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
} else {
child.kill('SIGKILL');
}
} catch (_) {}
resolve({ status: -1, stdout: '', stderr: 'aborted' });
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}
child.on('close', (code) => resolve({ child.on('close', (code) => resolve({
status: code, status: code,
stdout: Buffer.concat(out).toString('utf8'), stdout: Buffer.concat(out).toString('utf8'),
@@ -1215,7 +1243,9 @@ function execGit(args, { cwd, input, env } = {}) {
} }
async function runGitAsync(args, cwd, options = {}) { async function runGitAsync(args, cwd, options = {}) {
const res = await execGit(args, { cwd, env: options.env }); throwIfUploadCancelled(options.signal);
const res = await execGit(args, { cwd, env: options.env, signal: options.signal });
throwIfUploadCancelled(options.signal);
if (res.status !== 0) { if (res.status !== 0) {
throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim()); throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim());
} }
@@ -1225,7 +1255,7 @@ async function runGitAsync(args, cwd, options = {}) {
/* ----------------------------- /* -----------------------------
Generic concurrency runner (worker pool) Generic concurrency runner (worker pool)
----------------------------- */ ----------------------------- */
async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = null) { async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = null, signal = null) {
const results = new Array(tasks.length); const results = new Array(tasks.length);
let index = 0; let index = 0;
let processed = 0; let processed = 0;
@@ -1233,6 +1263,7 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress =
async function worker() { async function worker() {
while (true) { while (true) {
if (signal && signal.aborted) return;
const i = index++; const i = index++;
if (i >= tasks.length) return; if (i >= tasks.length) return;
try { try {
@@ -1251,10 +1282,91 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress =
const workers = Array(Math.max(1, Math.min(concurrency, tasks.length))).fill(0).map(() => worker()); const workers = Array(Math.max(1, Math.min(concurrency, tasks.length))).fill(0).map(() => worker());
await Promise.all(workers); await Promise.all(workers);
throwIfUploadCancelled(signal);
return results; return results;
} }
/* -----------------------------
Upload-Verlauf
Jeder Upload-Handler wird beim Registrieren umhüllt und schreibt Datum, Repo,
Dateizahl und Ergebnis in upload-history.json (die letzten 100 Einträge).
----------------------------- */
const UPLOAD_HISTORY_FILE = ppath.join(app.getPath('userData'), 'upload-history.json');
const UPLOAD_HISTORY_MAX = 100;
const UPLOAD_HISTORY_CHANNELS = {
'upload-paths-to-gitea': 'Drag & Drop',
'upload-local-folder-to-gitea': 'Ordner',
'upload-gitea-file': 'Dateien',
'upload-and-push': 'Ordner/Datei'
};
function readUploadHistory() {
try {
const parsed = JSON.parse(fs.readFileSync(UPLOAD_HISTORY_FILE, 'utf8'));
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function recordUploadHistory(entry) {
try {
const history = [entry, ...readUploadHistory()].slice(0, UPLOAD_HISTORY_MAX);
fs.writeFileSync(UPLOAD_HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8');
} catch (e) {
console.warn('[UploadHistory] konnte nicht gespeichert werden:', e.message);
}
}
function buildUploadHistoryEntry(kind, data, res, startedAt, thrown) {
const results = res && Array.isArray(res.results) ? res.results : null;
let status = 'ok';
if (thrown || !res || !res.ok) status = res && res.cancelled ? 'abgebrochen' : 'fehler';
const error = thrown ? String(thrown.message || thrown) : (res && !res.ok && !res.cancelled ? String(res.error || '') : '');
return {
at: new Date().toISOString(),
kind,
repo: data && data.owner && data.repo ? `${data.owner}/${data.repo}` : '',
files: results ? results.length : (res && res.singleFile ? 1 : null),
failed: res && typeof res.failedCount === 'number' ? res.failedCount : 0,
status,
error: error.slice(0, 300),
durationMs: Date.now() - startedAt
};
}
const rawIpcHandle = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (channel, handler) => {
const kind = UPLOAD_HISTORY_CHANNELS[channel];
if (!kind) return rawIpcHandle(channel, handler);
return rawIpcHandle(channel, async (event, data) => {
const startedAt = Date.now();
let res;
try {
res = await handler(event, data);
} catch (e) {
recordUploadHistory(buildUploadHistoryEntry(kind, data, null, startedAt, e));
throw e;
}
recordUploadHistory(buildUploadHistoryEntry(kind, data, res, startedAt, null));
return res;
});
};
ipcMain.handle('get-upload-history', () => ({ ok: true, history: readUploadHistory() }));
ipcMain.handle('clear-upload-history', () => {
try { fs.writeFileSync(UPLOAD_HISTORY_FILE, '[]', 'utf8'); return { ok: true }; }
catch (e) { return { ok: false, error: String(e && e.message || e) }; }
});
const gitignoreTemplates = require('./src/features/gitignoreTemplates.js');
ipcMain.handle('get-gitignore-templates', () => ({ ok: true, templates: gitignoreTemplates.listTemplates() }));
ipcMain.handle('build-gitignore', (_event, data) => ({
ok: true,
content: gitignoreTemplates.buildGitignore(Array.isArray(data && data.keys) ? data.keys : [])
}));
/* ----------------------------- /* -----------------------------
Basic IPC handlers Basic IPC handlers
----------------------------- */ ----------------------------- */
@@ -2458,6 +2570,8 @@ ipcMain.handle('read-gitea-file', async (event, data) => {
}); });
ipcMain.handle('upload-gitea-file', async (event, data) => { ipcMain.handle('upload-gitea-file', async (event, data) => {
const uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
const uploadDebugId = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const uploadDebugId = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try { try {
const credentials = readCredentials(); const credentials = readCredentials();
@@ -2566,11 +2680,13 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
tmpDir, tmpDir,
branch, branch,
gitConfig, gitConfig,
label: 'FileUpload' label: 'FileUpload',
signal
}); });
// Dateien in den Zielordner kopieren // Dateien in den Zielordner kopieren
for (const localFile of validFiles) { for (const localFile of validFiles) {
throwIfUploadCancelled(signal);
const fileName = ppath.basename(localFile); const fileName = ppath.basename(localFile);
const targetPath = destPath ? `${destPath}/${fileName}` : fileName; const targetPath = destPath ? `${destPath}/${fileName}` : fileName;
const destFile = ppath.join(tmpDir, ...targetPath.split('/')); const destFile = ppath.join(tmpDir, ...targetPath.split('/'));
@@ -2579,6 +2695,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
results.push({ file: localFile, ok: true, targetPath }); results.push({ file: localFile, ok: true, targetPath });
} }
throwIfUploadCancelled(signal);
await stageUploadedFiles(repoGit); await stageUploadedFiles(repoGit);
let hasChanges = true; let hasChanges = true;
@@ -2612,6 +2729,9 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId }); logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId });
// Abbruch durch den Nutzer: kein API-Ausweichweg.
if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError();
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist. // Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
try { try {
const apiItems = validFiles.map(localFile => { const apiItems = validFiles.map(localFile => {
@@ -2623,7 +2743,8 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
token, url, owner, repo, branch, token, url, owner, repo, branch,
items: apiItems, items: apiItems,
message, message,
label: 'FileUpload' label: 'FileUpload',
signal
}); });
if (apiResult.ok) { if (apiResult.ok) {
@@ -2647,6 +2768,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
return { ok: false, error: describeApiFallbackFailure(gitErr, apiResult), debugId: uploadDebugId }; return { ok: false, error: describeApiFallbackFailure(gitErr, apiResult), debugId: uploadDebugId };
} catch (apiErr) { } catch (apiErr) {
if (isUploadCancelledError(apiErr)) throw apiErr;
logger.error('upload-gitea-file', 'API-Fallback fehlgeschlagen', { error: String(apiErr), uploadDebugId }); logger.error('upload-gitea-file', 'API-Fallback fehlgeschlagen', { error: String(apiErr), uploadDebugId });
return { ok: false, error: describeApiFallbackFailure(gitErr, null) + ` — API-Fallback: ${apiErr.message || String(apiErr)}`, debugId: uploadDebugId }; return { ok: false, error: describeApiFallbackFailure(gitErr, null) + ` — API-Fallback: ${apiErr.message || String(apiErr)}`, debugId: uploadDebugId };
} }
@@ -2656,8 +2778,11 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
logger.info('upload-gitea-file', 'Gitea upload done', { failedCount, total: results.length, uploadDebugId }); logger.info('upload-gitea-file', 'Gitea upload done', { failedCount, total: results.length, uploadDebugId });
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
} catch (e) { } catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) }); console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId };
} finally {
endCancellableUpload(uploadAbort);
} }
}); });
@@ -2748,7 +2873,38 @@ ipcMain.handle('remove-retry-queue-item', async (event, data) => {
} }
}); });
// Gesamtgröße der Quelldateien in Bytes (für die Fortschrittsanzeige).
function sumFileSizes(items) {
let bytes = 0;
for (const it of items) {
try { bytes += fs.statSync(it.localFile).size; } catch (_) {}
}
return bytes;
}
// Abbruch laufender Uploads und Downloads: der Renderer bricht per IPC alle laufenden ab
// (es gibt nur einen Fortschrittsbalken mit einem Abbrechen-Knopf).
const activeTransferAborts = new Set();
function beginCancellableUpload() {
const controller = new AbortController();
activeTransferAborts.add(controller);
return controller;
}
function endCancellableUpload(controller) {
activeTransferAborts.delete(controller);
}
ipcMain.handle('cancel-upload', () => {
if (activeTransferAborts.size === 0) return { ok: false, error: 'no-active-upload' };
for (const controller of activeTransferAborts) controller.abort();
return { ok: true };
});
ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
const uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
try { try {
const credentials = readCredentials(); const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.giteaToken); const token = (data && data.token) || (credentials && credentials.giteaToken);
@@ -2796,6 +2952,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
const total = items.length; const total = items.length;
if (total === 0) return { ok: true, results: [] }; if (total === 0) return { ok: true, results: [] };
const totalBytes = sumFileSizes(items);
// Git-basierter Upload: alle Dateien in einem einzigen Commit. // Git-basierter Upload: alle Dateien in einem einzigen Commit.
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett. // Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
@@ -2814,7 +2971,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
const gitConfig = gitIdentityConfig(); const gitConfig = gitIdentityConfig();
// Fortschritt: Start // Fortschritt: Start
try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0 }); } catch (_) {} try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0, totalBytes }); } catch (_) {}
console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir }); console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir });
@@ -2824,23 +2981,26 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
tmpDir, tmpDir,
branch, branch,
gitConfig, gitConfig,
label: 'FolderUpload' label: 'FolderUpload',
signal
}); });
// Fortschritt: 30% (nach Clone) // Fortschritt: 30% (nach Clone)
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30 }); } catch (_) {} try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30, totalBytes }); } catch (_) {}
// Dateien in den Zielordner im geklonten Repo kopieren // Dateien in den Zielordner im geklonten Repo kopieren
for (const item of items) { for (const item of items) {
throwIfUploadCancelled(signal);
const destFile = ppath.join(tmpDir, ...item.targetPath.split('/')); const destFile = ppath.join(tmpDir, ...item.targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true }); fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(item.localFile, destFile); fs.copyFileSync(item.localFile, destFile);
} }
// Fortschritt: 60% (nach Kopieren) // Fortschritt: 60% (nach Kopieren)
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60 }); } catch (_) {} try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60, totalBytes }); } catch (_) {}
// git add + commit + push // git add + commit + push
throwIfUploadCancelled(signal);
await stageUploadedFiles(repoGit); await stageUploadedFiles(repoGit);
let hasChanges = true; let hasChanges = true;
@@ -2870,7 +3030,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
} }
// Fortschritt: 100% // Fortschritt: 100%
try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100 }); } catch (_) {} try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100, totalBytes }); } catch (_) {}
// Temp-Ordner nach kurzer Verzögerung bereinigen // Temp-Ordner nach kurzer Verzögerung bereinigen
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000); setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
@@ -2882,17 +3042,22 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
console.error('[FolderUpload] Git-Fehler:', String(gitErr)); console.error('[FolderUpload] Git-Fehler:', String(gitErr));
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
// Abbruch durch den Nutzer: kein API-Ausweichweg.
if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError();
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist. // Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
const apiResult = await uploadItemsViaApiFallback({ const apiResult = await uploadItemsViaApiFallback({
token, url, owner, repo, branch, items, token, url, owner, repo, branch, items,
message: `${messagePrefix} - ${folderName}`, message: `${messagePrefix} - ${folderName}`,
label: 'FolderUpload', label: 'FolderUpload',
signal,
onProgress: percent => { onProgress: percent => {
try { try {
event.sender.send('folder-upload-progress', { event.sender.send('folder-upload-progress', {
processed: Math.floor(total * (percent / 100)), processed: Math.floor(total * (percent / 100)),
total, total,
percent percent,
totalBytes
}); });
} catch (_) {} } catch (_) {}
} }
@@ -2906,8 +3071,11 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
return { ok: true, results: successResults, failedCount: 0, viaApi: true }; return { ok: true, results: successResults, failedCount: 0, viaApi: true };
} }
} catch (e) { } catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen' };
console.error('upload-local-folder-to-gitea error', e); console.error('upload-local-folder-to-gitea error', e);
return { ok: false, error: String(e) }; return { ok: false, error: String(e) };
} finally {
endCancellableUpload(uploadAbort);
} }
}); });
@@ -3019,7 +3187,7 @@ async function buildItemsFromPaths(paths, destPath) {
} }
// Klont das Repo (shallow), kopiert alle items hinein, commit + push. // Klont das Repo (shallow), kopiert alle items hinein, commit + push.
async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress }) { async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress, signal }) {
const rawUrl = url.startsWith('http') ? url : `https://${url}`; const rawUrl = url.startsWith('http') ? url : `https://${url}`;
const urlObj = new URL(rawUrl.replace(/\/$/, '')); const urlObj = new URL(rawUrl.replace(/\/$/, ''));
const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`; const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
@@ -3034,12 +3202,14 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
tmpDir, tmpDir,
branch: safeBranch, branch: safeBranch,
gitConfig, gitConfig,
label: 'UploadPaths' label: 'UploadPaths',
signal
}); });
if (onProgress) onProgress(30); if (onProgress) onProgress(30);
for (const item of items) { for (const item of items) {
throwIfUploadCancelled(signal);
const destFile = ppath.join(tmpDir, ...item.targetPath.split('/')); const destFile = ppath.join(tmpDir, ...item.targetPath.split('/'));
fs.mkdirSync(ppath.dirname(destFile), { recursive: true }); fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
fs.copyFileSync(item.localFile, destFile); fs.copyFileSync(item.localFile, destFile);
@@ -3047,6 +3217,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
if (onProgress) onProgress(60); if (onProgress) onProgress(60);
throwIfUploadCancelled(signal);
await stageUploadedFiles(repoGit); await stageUploadedFiles(repoGit);
let hasChanges = true; let hasChanges = true;
@@ -3072,14 +3243,23 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
} catch (gitErr) { } catch (gitErr) {
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
// Abbruch durch den Nutzer: kein API-Ausweichweg.
if (isUploadCancelledError(gitErr) || (signal && signal.aborted)) throw createUploadCancelledError();
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist. // Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
const apiResult = await uploadItemsViaApiFallback({ const apiResult = await uploadItemsViaApiFallback({
token, url, owner, repo, branch: safeBranch, items, message, onProgress, token, url, owner, repo, branch: safeBranch, items, message, onProgress,
label: 'UploadPaths' label: 'UploadPaths',
signal
}); });
if (!apiResult.ok) { if (!apiResult.ok) {
throw new Error(describeApiFallbackFailure(gitErr, apiResult)); // Die fehlgeschlagenen Dateien mitgeben, damit der Renderer nur diese wiederholen kann.
const failedPaths = new Set((apiResult.failed || []).map(f => f.path));
const err = new Error(describeApiFallbackFailure(gitErr, apiResult));
err.failedItems = items.filter(it => failedPaths.has(it.targetPath));
err.uploadedCount = (apiResult.uploaded || []).length;
throw err;
} }
return { hasChanges: apiResult.uploaded.length > 0, viaApi: true }; return { hasChanges: apiResult.uploaded.length > 0, viaApi: true };
@@ -3087,6 +3267,8 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
} }
ipcMain.handle('upload-paths-to-gitea', async (event, data) => { ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
const uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
const uploadDebugId = `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const uploadDebugId = `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try { try {
const credentials = readCredentials(); const credentials = readCredentials();
@@ -3102,12 +3284,20 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
const branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD');
const messagePrefix = data.messagePrefix || 'Upload via GUI'; const messagePrefix = data.messagePrefix || 'Upload via GUI';
const paths = Array.isArray(data.paths) ? data.paths.filter(Boolean) : []; const paths = Array.isArray(data.paths) ? data.paths.filter(Boolean) : [];
if (paths.length === 0) return { ok: false, error: 'Keine gültigen Pfade übergeben', debugId: uploadDebugId }; // Wiederholung: bereits aufgelöste Einträge (Quelldatei + Zielpfad) statt neuer Pfade.
const retryItems = Array.isArray(data.items)
? data.items.filter(it => it && it.localFile && it.targetPath && fs.existsSync(it.localFile))
: null;
if (paths.length === 0 && !(retryItems && retryItems.length)) {
return { ok: false, error: 'Keine gültigen Pfade übergeben', debugId: uploadDebugId };
}
try { invalidateRepoContentsCache(owner, repo); } catch (_) {} try { invalidateRepoContentsCache(owner, repo); } catch (_) {}
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {} try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
const built = await buildItemsFromPaths(paths, destPath); const built = retryItems && retryItems.length
? { items: retryItems, skipped: [], ignored: [] }
: await buildItemsFromPaths(paths, destPath);
const skipped = built.skipped; const skipped = built.skipped;
const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []); const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []);
const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items; const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items;
@@ -3116,8 +3306,9 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
} }
const total = items.length; const total = items.length;
const totalBytes = sumFileSizes(items);
const onProgress = (percent) => { const onProgress = (percent) => {
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent }); } catch (_) {} try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent, totalBytes }); } catch (_) {}
}; };
onProgress(0); onProgress(0);
@@ -3125,15 +3316,25 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
await gitPushItemsToGitea({ await gitPushItemsToGitea({
token, url, owner, repo, branch, items, token, url, owner, repo, branch, items,
message: `${messagePrefix} (${total} Datei${total === 1 ? '' : 'en'})`, message: `${messagePrefix} (${total} Datei${total === 1 ? '' : 'en'})`,
onProgress onProgress,
signal
}); });
console.log('[UploadPaths] done', { uploadDebugId, total }); console.log('[UploadPaths] done', { uploadDebugId, total });
const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath })); const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath }));
return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId }; return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId };
} catch (e) { } catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) }); console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: String(e && e.message ? e.message : e), debugId: uploadDebugId }; return {
ok: false,
error: String(e && e.message ? e.message : e),
retryItems: e && Array.isArray(e.failedItems) && e.failedItems.length ? e.failedItems : undefined,
uploadedCount: e && typeof e.uploadedCount === 'number' ? e.uploadedCount : undefined,
debugId: uploadDebugId
};
} finally {
endCancellableUpload(uploadAbort);
} }
}); });
@@ -3159,6 +3360,8 @@ ipcMain.handle('download-gitea-file', async (event, data) => {
}); });
ipcMain.handle('download-gitea-folder', async (event, data) => { ipcMain.handle('download-gitea-folder', async (event, data) => {
const downloadAbort = beginCancellableUpload();
const signal = downloadAbort.signal;
try { try {
const credentials = readCredentials(); const credentials = readCredentials();
const token = (data && data.token) || (credentials && credentials.giteaToken); const token = (data && data.token) || (credentials && credentials.giteaToken);
@@ -3175,6 +3378,7 @@ ipcMain.handle('download-gitea-folder', async (event, data) => {
const allFiles = []; const allFiles = [];
async function gather(pathInRepo) { async function gather(pathInRepo) {
throwIfUploadCancelled(signal);
const _r = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' }); const _r = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'HEAD' });
const items = _r.items || _r; const items = _r.items || _r;
for (const item of items) { for (const item of items) {
@@ -3199,12 +3403,15 @@ ipcMain.handle('download-gitea-folder', async (event, data) => {
try { event.sender.send('folder-download-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} try { event.sender.send('folder-download-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {}
}; };
const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal);
const savedFiles = results.filter(r => r.ok).map(r => r.result); const savedFiles = results.filter(r => r.ok).map(r => r.result);
return { ok: true, savedTo: destBase, files: savedFiles }; return { ok: true, savedTo: destBase, files: savedFiles };
} catch (e) { } catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Download abgebrochen' };
console.error('download-gitea-folder error', e); console.error('download-gitea-folder error', e);
return { ok: false, error: String(e) }; return { ok: false, error: String(e) };
} finally {
endCancellableUpload(downloadAbort);
} }
}); });
@@ -3386,6 +3593,8 @@ ipcMain.handle('delete-gitea-repo', async (event, data) => {
}); });
ipcMain.handle('upload-and-push', async (event, data) => { ipcMain.handle('upload-and-push', async (event, data) => {
const uploadAbort = beginCancellableUpload();
const signal = uploadAbort.signal;
const uploadDebugId = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const uploadDebugId = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try { try {
if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: `local-folder-not-found (${uploadDebugId})` }; if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: `local-folder-not-found (${uploadDebugId})` };
@@ -3422,7 +3631,8 @@ ipcMain.handle('upload-and-push', async (event, data) => {
GIT_PAGER: 'cat', GIT_PAGER: 'cat',
PAGER: 'cat' PAGER: 'cat'
}, },
maxBuffer: 10 * 1024 * 1024 maxBuffer: 10 * 1024 * 1024,
signal
}; };
// Prüfen ob es eine Datei oder ein Ordner ist // Prüfen ob es eine Datei oder ein Ordner ist
@@ -3460,6 +3670,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
}); });
return { ok: true, usedGit: false, singleFile: true, uploaded: result, debugId: uploadDebugId }; return { ok: true, usedGit: false, singleFile: true, uploaded: result, debugId: uploadDebugId };
} catch (e) { } catch (e) {
throwIfUploadCancelled(signal);
console.error('[UPLOAD_DEBUG][main] single-file:api-error', { uploadDebugId, error: String(e && e.message ? e.message : 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 // FALLBACK: Wenn API fehlschlägt (z.B. 422 SHA Fehler), nutze Git
@@ -3529,6 +3740,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
} catch (gitErr) { } catch (gitErr) {
console.error('[UPLOAD_DEBUG][main] single-file:git-fallback-error', { uploadDebugId, error: String(gitErr) }); console.error('[UPLOAD_DEBUG][main] single-file:git-fallback-error', { uploadDebugId, error: String(gitErr) });
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
if (isUploadCancelledError(gitErr)) throw gitErr;
return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` }; return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` };
} }
} }
@@ -3622,6 +3834,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
throw gitErr; throw gitErr;
} }
} catch (gitErr) { } catch (gitErr) {
if (isUploadCancelledError(gitErr) || signal.aborted) throw createUploadCancelledError();
console.warn('[UPLOAD_DEBUG][main] directory-upload:git-failed-fallback-to-api', { uploadDebugId, error: String(gitErr && gitErr.message ? gitErr.message : gitErr) }); 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 // -> weiter zum API-Fallback unten
} }
@@ -3686,13 +3899,16 @@ ipcMain.handle('upload-and-push', async (event, data) => {
try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {}
}; };
const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total), signal);
const failedCount = uploadResults.filter(r => !r.ok).length; const failedCount = uploadResults.filter(r => !r.ok).length;
console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length }); console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length });
return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount }; return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount };
} catch (e) { } catch (e) {
if (isUploadCancelledError(e)) return { ok: false, cancelled: true, error: 'Upload abgebrochen', debugId: uploadDebugId };
console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) }); console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) });
return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId };
} finally {
endCancellableUpload(uploadAbort);
} }
}); });
@@ -3714,6 +3930,8 @@ ipcMain.handle('get-path-type', async (_event, filePath) => {
}); });
ipcMain.handle('run-batch-repo-action', async (event, data) => { ipcMain.handle('run-batch-repo-action', async (event, data) => {
const batchAbort = beginCancellableUpload();
const signal = batchAbort.signal;
try { try {
const credentials = readCredentials(); const credentials = readCredentials();
if (!credentials || !credentials.giteaToken || !credentials.giteaURL) { if (!credentials || !credentials.giteaToken || !credentials.giteaURL) {
@@ -3745,7 +3963,9 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
}; };
const results = []; const results = [];
let cancelled = false;
for (let i = 0; i < repos.length; i++) { for (let i = 0; i < repos.length; i++) {
if (signal.aborted) { cancelled = true; break; }
const item = repos[i]; const item = repos[i];
const progressBase = { index: i + 1, total: repos.length, repo: item.id, action }; const progressBase = { index: i + 1, total: repos.length, repo: item.id, action };
sendProgress({ ...progressBase, status: 'running' }); sendProgress({ ...progressBase, status: 'running' });
@@ -3777,7 +3997,15 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
} }
} catch (_) {} } catch (_) {}
await runGitAsync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' }); try {
await runGitAsync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore', signal });
} catch (cloneErr) {
// Abgebrochener Clone: den angelegten Teilordner entfernen (existierte vorher nicht).
if (isUploadCancelledError(cloneErr)) {
try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch (_) {}
}
throw cloneErr;
}
results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` }); results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` });
} else if (action === 'create-tag') { } else if (action === 'create-tag') {
const tag = String(options.tag || '').trim(); const tag = String(options.tag || '').trim();
@@ -3822,6 +4050,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
sendProgress({ ...progressBase, status: 'ok' }); sendProgress({ ...progressBase, status: 'ok' });
} catch (e) { } catch (e) {
if (isUploadCancelledError(e)) { cancelled = true; break; }
const msg = mapIpcError(e); const msg = mapIpcError(e);
results.push({ repo: item.id, ok: false, error: msg }); results.push({ repo: item.id, ok: false, error: msg });
sendProgress({ ...progressBase, status: 'error', error: msg }); sendProgress({ ...progressBase, status: 'error', error: msg });
@@ -3833,12 +4062,15 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
return { return {
ok: true, ok: true,
action, action,
summary: { total: results.length, success, failed }, cancelled,
summary: { total: repos.length, done: results.length, success, failed },
results results
}; };
} catch (e) { } catch (e) {
console.error('run-batch-repo-action error', e); console.error('run-batch-repo-action error', e);
return { ok: false, error: String(e) }; return { ok: false, error: String(e) };
} finally {
endCancellableUpload(batchAbort);
} }
}); });
/* ================================ /* ================================
+5
View File
@@ -39,6 +39,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
downloadGiteaFile: (data) => ipcRenderer.invoke('download-gitea-file', data), downloadGiteaFile: (data) => ipcRenderer.invoke('download-gitea-file', data),
uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data), uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data),
uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data), uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data),
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
getUploadHistory: () => ipcRenderer.invoke('get-upload-history'),
clearUploadHistory: () => ipcRenderer.invoke('clear-upload-history'),
getGitignoreTemplates: () => ipcRenderer.invoke('get-gitignore-templates'),
buildGitignore: (data) => ipcRenderer.invoke('build-gitignore', data),
// Repository & Git Management // Repository & Git Management
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data), saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),