diff --git a/main.js b/main.js index 6c04742..b811044 100644 --- a/main.js +++ b/main.js @@ -1,946 +1,1003 @@ -// 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'); - -const DATA_DIR = ppath.join(__dirname, 'data'); -const CREDENTIALS_FILE = ppath.join(DATA_DIR, '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; - -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 { - 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 { - fs.mkdirSync(DATA_DIR, { recursive: true }); - 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; - } -}); - -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: Konvertiere 'master' zu 'main' - let ref = data.ref || 'main'; - if (ref === 'master') ref = 'main'; - 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: Konvertiere 'master' zu 'main' - let ref = data.ref || 'main'; - if (ref === 'master') ref = 'main'; - 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' - 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) }; - } -}); - -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(/\/+$/, ''); - - const tmpBase = ppath.join(os.tmpdir(), repo); - if (fs.existsSync(tmpBase)) { - try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (e) { console.warn('Cleanup failed', e); } - } - fs.mkdirSync(tmpBase, { recursive: true }); - - 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 tasks = allFiles.map(remoteFile => async () => { - const content = await getGiteaFileContent({ token, url, owner, repo, path: remoteFile, ref: 'main' }); - const localPath = ppath.join(tmpBase, remoteFile); - fs.mkdirSync(ppath.dirname(localPath), { recursive: true }); - fs.writeFileSync(localPath, content, 'utf8'); - return localPath; - }); - - await runLimited(tasks, DEFAULT_CONCURRENCY); - - setTimeout(() => { try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} }, TMP_CLEANUP_MS); - return { ok: true, tempPath: tmpBase }; - } catch (e) { - console.error('prepare-download-drag error', e); - return { ok: false, error: String(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 = ppath.join(os.tmpdir(), `git-push-file-${owner}-${repo}-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - - 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)); - fs.mkdirSync(destDirInRepo, { recursive: true }); - } - - 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 }); - // Wir werfen den ursprünglichen Fehler, da der Fallback auch gescheitert ist - 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 = ppath.join(os.tmpdir(), `gitea-push-${owner}-${repo}-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - - 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); - fs.mkdirSync(targetBaseDir, { recursive: true }); - } - - // 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) }; - } -}); \ No newline at end of file +// 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); + const dir = ppath.join(os.tmpdir(), `${safeBase}-${Date.now()}`); + try { + if (fs.existsSync(dir)) { + if (!fs.statSync(dir).isDirectory()) fs.unlinkSync(dir); + else fs.rmSync(dir, { recursive: true, force: true }); + } + fs.mkdirSync(dir, { recursive: true }); + return dir; + } catch (e) { + throw new Error(`getSafeTmpDir failed for ${dir}: ${e && e.message ? e.message : e}`); + } +} + +/* ----------------------------- + 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) }; + } +}); + +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(/\/+$/, ''); + + // Use safe tmp dir (unique) + const tmpBase = getSafeTmpDir(repo || 'gitea-repo'); + + 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 tasks = allFiles.map(remoteFile => async () => { + const content = await getGiteaFileContent({ token, url, owner, repo, path: remoteFile, ref: 'main' }); + const localPath = ppath.join(tmpBase, remoteFile); + fs.mkdirSync(ppath.dirname(localPath), { recursive: true }); + fs.writeFileSync(localPath, content, 'utf8'); + return localPath; + }); + + await runLimited(tasks, DEFAULT_CONCURRENCY); + + setTimeout(() => { try { if (fs.existsSync(tmpBase)) fs.rmSync(tmpBase, { recursive: true, force: true }); } catch (_) {} }, TMP_CLEANUP_MS); + return { ok: true, tempPath: tmpBase }; + } catch (e) { + console.error('prepare-download-drag error', e); + return { ok: false, error: String(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) }; + } +});