// main.js — Main-Process with concurrent folder upload/download, progress events, and temp-dir cleanup const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage } = require('electron'); const ppath = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); const { execSync } = require('child_process'); const https = require('https'); const http = require('http'); const { createRepoGitHub, createRepoGitea, listGiteaRepos, getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile } = 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 getDataDir() { try { return ppath.join(app.getPath('userData'), 'data'); } catch (e) { // Fallback: use __dirname/data (only if app.getPath not available) return ppath.join(__dirname, 'data'); } } function getCredentialsFilePath() { return ppath.join(getDataDir(), '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; /* ----------------------------- 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; } /* ----------------------------- app / window ----------------------------- */ function createWindow() { // Entfernt das Menü (File, Edit, View...) komplett Menu.setApplicationMenu(null); const win = new BrowserWindow({ width: 1200, height: 820, webPreferences: { preload: ppath.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true } }); win.loadFile(ppath.join(__dirname, 'renderer', 'index.html')); // win.webContents.openDevTools(); } app.whenReady().then(() => { createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); /* ----------------------------- Helper: read credentials ----------------------------- */ function readCredentials() { try { const CREDENTIALS_FILE = getCredentialsFilePath(); if (!fs.existsSync(CREDENTIALS_FILE)) return null; const encrypted = fs.readFileSync(CREDENTIALS_FILE); const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, IV); const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); return JSON.parse(decrypted.toString('utf8')); } catch (e) { console.error('readCredentials error', e); return null; } } /* ----------------------------- 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 DATA_DIR = getDataDir(); const CREDENTIALS_FILE = getCredentialsFilePath(); ensureDir(DATA_DIR); // robust gegen ENOTDIR const json = JSON.stringify(data); const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, IV); const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); fs.writeFileSync(CREDENTIALS_FILE, encrypted); return { ok: true }; } catch (e) { console.error('save-credentials error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('load-credentials', async () => { try { return readCredentials(); } catch (e) { console.error('load-credentials', e); return null; } }); /* ----------------------------- Repo & git handlers ----------------------------- */ ipcMain.handle('create-repo', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; 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 }); return { ok: true, repo }; } else if (data.platform === 'gitea') { 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 }); return { ok: true, repo }; } else return { ok: false, error: 'unknown-platform' }; } catch (e) { console.error('create-repo error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('push-project', async (event, data) => { try { if (!data.folder || !fs.existsSync(data.folder)) return { ok: false, error: 'folder-not-found' }; // Prüfen, ob der lokale Branch 'master' heißt und in 'main' umbenennen try { const currentBranch = execSync('git branch --show-current', { cwd: data.folder, stdio: 'pipe', encoding: 'utf-8' }).trim(); console.log('Current local branch:', currentBranch); if (currentBranch === 'master') { console.log('Attempting to rename master to main...'); try { execSync('git branch -m master main', { cwd: data.folder, stdio: 'inherit' }); console.log('Successfully renamed local branch master to main'); } catch (e) { console.warn('Failed to rename branch (maybe main already exists)', e.message); } } } 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 = execSync('git remote get-url origin', { cwd: data.folder, stdio: 'pipe', encoding: 'utf-8' }).trim(); } 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 = ''; if (data.platform === 'gitea' && creds.giteaURL) { try { const urlObj = new URL(creds.giteaURL); constructedUrl = `${urlObj.protocol}//${creds.giteaToken}@${urlObj.host}/${owner}/${repo}.git`; } catch (err) { console.error('Invalid Gitea URL', err); } } else if (data.platform === 'github' && creds.githubToken) { constructedUrl = `https://${creds.githubToken}@github.com/${owner}/${repo}.git`; } if (constructedUrl) { try { execSync(`git remote add origin "${constructedUrl}"`, { cwd: data.folder, stdio: 'inherit' }); 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 'main') const progressCb = percent => { try { event.sender.send('push-progress', percent); } catch (_) {} }; await commitAndPush(data.folder, 'main', 'Update from Git Manager GUI', progressCb); return { ok: true }; } catch (e) { console.error('push-project error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('getBranches', async (event, data) => { try { const branches = await getBranches(data.folder); // Sortieren, damit 'main' oben steht branches.sort((a, b) => (a === 'main' ? -1 : b === 'main' ?1 : 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: [] }; } }); /* ----------------------------- 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; } 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 }; const tree = buildTree(folder, opts); 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('deleteFile', async (event, data) => { try { if (!data || !data.path || !fs.existsSync(data.path)) return { ok: false, error: 'file-not-found' }; fs.rmSync(data.path, { recursive: true, force: true }); 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: String(e) }; } }); ipcMain.handle('get-gitea-repo-contents', 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 p = data.path || ''; // FIXED: Pass data.ref directly to apiHandler without forcing 'main' // This allows apiHandler.js to try ['main', 'master'] if no ref is passed const ref = data.ref; const items = await getGiteaRepoContents({ token, url, owner, repo, path: p, ref }); return { ok: true, items }; } catch (e) { console.error('get-gitea-repo-contents error', e); return { ok: false, error: String(e) }; } }); ipcMain.handle('get-gitea-file-content', 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 p = data.path; // FIXED: Pass data.ref directly const ref = data.ref; 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: String(e) }; } }); ipcMain.handle('upload-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; // destPath is the target folder in the repo const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); // FIXED: Konvertiere 'master' zu 'main' (Upload should generally target main) let branch = data.branch || 'main'; if (branch === 'master') branch = 'main'; 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); // FIXED: Handle destPath correctly. Always combine destPath + filename. let targetPath; if (destPath && destPath.length > 0) { targetPath = `${destPath}/${fileName}`; } else { targetPath = fileName; } try { const uploaded = await uploadGiteaFile({ token, url, owner, repo, path: targetPath, contentBase64: base64, message: `${message} - ${fileName}`, branch }); results.push({ file: localFile, ok: true, uploaded }); } catch (e) { console.error('upload error for', localFile, e); results.push({ file: localFile, ok: false, error: String(e) }); } } return { ok: true, results }; } catch (e) { console.error('upload-gitea-file error', 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(/\/$/, ''); // FIXED: Konvertiere 'master' zu 'main' let branch = data.branch || 'main'; if (branch === 'master') branch = 'main'; const messagePrefix = data.messagePrefix || 'Upload folder via GUI'; const concurrency = data.concurrency || DEFAULT_CONCURRENCY; if (!localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'local-folder-not-found' }; const items = []; const folderName = ppath.basename(localFolder); // FIXED EXCLUDE LIST: Filter out .git, node_modules etc. 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; // Skip excluded folders/files const full = ppath.join(dir, entry); let stat; try { stat = fs.statSync(full); } catch(e) { continue; } // Skip unreadable files if (stat.isDirectory()) { walk(full); } else if (stat.isFile()) { const rel = ppath.relative(localFolder, full).split(ppath.sep).join('/'); // FIXED: Respect folder structure. Result: destPath/folderName/rel 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: [] }; const tasks = items.map(it => async () => { const raw = fs.readFileSync(it.localFile); const base64 = raw.toString('base64'); return uploadGiteaFile({ token, url, owner, repo, path: it.targetPath, contentBase64: base64, message: `${messagePrefix} - ${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 results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); return { ok: true, results }; } catch (e) { console.error('upload-local-folder-to-gitea error', e); return { ok: false, error: String(e) }; } }); 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: 'main' }); 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 items = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: 'main' }); 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: 'main' }); 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 items = await getGiteaRepoContents({ token, url, owner, repo, path: pathInRepo, ref: data.ref || 'main' }); 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 || 'main' }); 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 { 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.on('ondragstart', async (event, filePath) => { try { if (!filePath || !fs.existsSync(filePath)) return; let icon = nativeImage.createEmpty(); try { icon = await app.getFileIcon(filePath); } catch (e) {} try { event.sender.startDrag({ file: filePath, icon: icon }); } catch (e) { console.error('startDrag failed', e); } } catch (e) { console.error('ondragstart error', e); } }); ipcMain.handle('delete-gitea-repo', async (event, data) => { try { const credentials = readCredentials(); 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) => { try { if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: 'local-folder-not-found' }; 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; // FIXED: Konvertiere 'master' zu 'main' let branch = data.branch || 'main'; if (branch === 'master') branch = 'main'; const cloneUrl = data.cloneUrl || null; const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo' }; // Prüfen ob es eine Datei oder ein Ordner ist const stat = fs.statSync(data.localFolder); const isFile = stat.isFile(); const isDirectory = stat.isDirectory(); // --- FALL 1: Einzelne Datei --- if (isFile) { console.log('Detected single file. Attempting API upload...'); 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 }; } catch (e) { console.error('Single file API upload error:', e.message); // 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' }; // 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 { execSync(`git clone --depth 1 --branch ${branch} "${authClone}" "${tmpDir}"`, { stdio: 'inherit' }); // 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 execSync(`git -C "${tmpDir}" add .`, { stdio: 'inherit' }); try { execSync(`git -C "${tmpDir}" commit -m "Upload file ${fileName} via GUI"`, { stdio: 'inherit' }); } catch (_) {} execSync(`git -C "${tmpDir}" push origin ${branch}`, { stdio: 'inherit' }); 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)' }; } catch (gitErr) { console.error('Git Fallback failed:', 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)}` }; } } } // --- FALL 2: Ordner (Normale Git-Logik) --- if (!isDirectory) { return { ok: false, error: 'Path is neither file nor directory' }; } let gitAvailable = true; try { execSync('git --version', { stdio: 'ignore' }); } catch (e) { gitAvailable = false; } 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 (e) {} } if (gitAvailable && finalCloneUrl) { let authClone = finalCloneUrl; try { const urlObj = new URL(finalCloneUrl); if (token && (urlObj.protocol.startsWith('http'))) { urlObj.username = encodeURIComponent(token); authClone = urlObj.toString(); } } catch (e) {} const tmpDir = getSafeTmpDir(`gitea-push-${owner}-${repo}`); try { execSync(`git clone --depth 1 --branch ${branch} "${authClone}" "${tmpDir}"`, { stdio: 'inherit' }); // FIXED: Respektieren von destPath und Ordnernamen im Git-Workflow const folderName = ppath.basename(data.localFolder); let targetBaseDir = tmpDir; if (destPath) { // Wenn ein Zielpfad angegeben ist (z.B. 'src'), erstelle diesen Ordner im Repo const osDestPath = destPath.split('/').join(ppath.sep); targetBaseDir = ppath.join(tmpDir, osDestPath); ensureDir(targetBaseDir); } // Ziel ist immer: targetBaseDir/folderName const finalDest = ppath.join(targetBaseDir, folderName); // FIXED: Korrekter Umgang mit fs.cpSync // Wenn finalDest bereits existiert, muss es entfernt werden, damit cpSync funktioniert if (fs.existsSync(finalDest)) { fs.rmSync(finalDest, { recursive: true, force: true }); } // Kopieren if (fs.cpSync) { fs.cpSync(data.localFolder, finalDest, { recursive: true, force: true }); } else { if (process.platform === 'win32') { execSync(`robocopy "${data.localFolder}" "${finalDest}" /E /NFL /NDL /NJH /NJS /nc /ns`, { stdio: 'inherit', shell: true }); } else { execSync(`cp -r "${data.localFolder}/." "${finalDest}"`, { stdio: 'inherit', shell: true }); } } try { execSync(`git -C "${tmpDir}" add .`, { stdio: 'inherit' }); try { execSync(`git -C "${tmpDir}" commit -m "Update from Git Manager GUI"`, { stdio: 'inherit' }); } catch (_) {} execSync(`git -C "${tmpDir}" push origin ${branch}`, { stdio: 'inherit' }); } catch (e) { throw e; } setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); return { ok: true, usedGit: true, msg: 'Uploaded via git push' }; } catch (e) { console.error('upload-and-push git-flow error', e); try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} } } // Fallback: API Upload (paralleler Upload) const items = []; const folderName = ppath.basename(data.localFolder); // FIXED EXCLUDE LIST: Filter out .git, node_modules etc. 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; // 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: [] }; 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)); return { ok: true, usedGit: false, results: uploadResults }; } catch (e) { console.error('upload-and-push error', e); return { ok: false, error: String(e) }; } });