diff --git a/main.js b/main.js index 258e217..759696f 100644 --- a/main.js +++ b/main.js @@ -5,6 +5,7 @@ const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); const { execSync, spawnSync } = require('child_process'); +const http = require('http'); const https = require('https'); // IMPORTS: Zentrale Utilities @@ -1387,6 +1388,37 @@ function buildTree(dirPath, options = {}) { return roots; } +function invalidateLocalFileTreeCacheForPath(targetPath) { + try { + if (!targetPath) { + caches.fileTree.invalidate('fileTree:'); + return; + } + + let current = ppath.resolve(targetPath); + while (true) { + caches.fileTree.invalidate(`fileTree:${current}:`); + const parent = ppath.dirname(current); + if (parent === current) break; + current = parent; + } + + // Safety net: local trees are small enough, ensure no stale entries survive. + caches.fileTree.invalidate('fileTree:'); + } catch (_) {} +} + +function invalidateRepoContentsCache(owner, repo) { + try { + if (owner && repo) { + caches.repos.invalidate(`gitea:${owner}/${repo}:`); + caches.repos.invalidate(`${owner}/${repo}`); + return; + } + caches.repos.invalidate('gitea:'); + } catch (_) {} +} + ipcMain.handle('getFileTree', async (event, data) => { try { const folder = data && data.folder; @@ -1430,6 +1462,7 @@ ipcMain.handle('writeFile', async (event, data) => { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(data.path, data.content || '', 'utf8'); + invalidateLocalFileTreeCacheForPath(data.path); return { ok: true }; } catch (e) { console.error('writeFile error', e); @@ -1451,17 +1484,28 @@ ipcMain.handle('deleteFile', async (event, data) => { async function deleteGithubEntry(entryPath) { const contents = await getGithubRepoContents({ token: githubToken, owner, repo, path: entryPath, ref: useBranch }); if (!contents.ok) throw new Error(contents.error || 'Inhalt nicht ladbar'); - if (contents.items && contents.items.length > 1) { - // It's a directory — recurse - for (const item of contents.items) { - if (item.type === 'dir') await deleteGithubEntry(item.path); - else await deleteGithubFile({ token: githubToken, owner, repo, path: item.path, branch: useBranch }); + const items = Array.isArray(contents.items) ? contents.items : []; + if (items.length > 0) { + // A single file lookup can come back as one item with the same path. + const isSingleFile = items.length === 1 && items[0].type !== 'dir' && items[0].path === entryPath; + if (isSingleFile) { + await deleteGithubFile({ token: githubToken, owner, repo, path: items[0].path, sha: items[0].sha, branch: useBranch }); + return; } - } else if (contents.items && contents.items.length === 1 && contents.items[0].type !== 'dir') { - await deleteGithubFile({ token: githubToken, owner, repo, path: contents.items[0].path, sha: contents.items[0].sha, branch: useBranch }); - } else { - await deleteGithubFile({ token: githubToken, owner, repo, path: entryPath, branch: useBranch }); + + // Directory listing (also when it has only one child): recurse into dirs, delete files. + for (const item of items) { + if (item.type === 'dir') { + await deleteGithubEntry(item.path); + } else { + await deleteGithubFile({ token: githubToken, owner, repo, path: item.path, sha: item.sha, branch: useBranch }); + } + } + return; } + + // Fallback for APIs that return no items payload for direct file paths. + await deleteGithubFile({ token: githubToken, owner, repo, path: entryPath, branch: useBranch }); } await deleteGithubEntry(filePath); return { ok: true }; @@ -1477,21 +1521,50 @@ ipcMain.handle('deleteFile', async (event, data) => { const owner = data.owner; const repo = data.repo; const filePath = data.path; + const softDelete = data.softDelete !== false; if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' }; const urlObj = new URL(giteaUrl); const protocol = urlObj.protocol === 'https:' ? https : http; const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); - // HEAD-Auflösung: Wenn ref === 'HEAD', zu Fallback konvertieren (wird gleich wie GitHub behandelt) - let useBranch = (data.ref && data.ref !== 'HEAD') ? data.ref : 'main'; + // Ohne explizite Ref die Default-Branch des Repos verwenden (kein harter "main"-Fallback). + const useBranch = (data.branch && data.branch !== 'HEAD') + ? data.branch + : ((data.ref && data.ref !== 'HEAD') ? data.ref : null); + + async function resolveGiteaDefaultBranch() { + return new Promise((resolve) => { + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + method: 'GET', + headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } + }, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + try { + const parsed = JSON.parse(body || '{}'); + resolve(parsed.default_branch || null); + } catch (_) { + resolve(null); + } + }); + }); + req.on('error', () => resolve(null)); + req.end(); + }); + } // Helper: GET contents from Gitea API function giteaGet(path) { return new Promise((resolve, reject) => { + const refQuery = useBranch ? `?ref=${encodeURIComponent(useBranch)}` : ''; const req = protocol.request({ hostname: urlObj.hostname, port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${useBranch}`, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}${refQuery}`, method: 'GET', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } }, (res) => { @@ -1509,11 +1582,12 @@ ipcMain.handle('deleteFile', async (event, data) => { // Helper: DELETE a single file by path + sha function giteaDeleteFile(filePath, sha) { return new Promise((resolve) => { - const body = JSON.stringify({ + const payload = { message: `Delete ${filePath} via Git Manager GUI`, - sha, - branch: useBranch - }); + sha + }; + if (useBranch) payload.branch = useBranch; + const body = JSON.stringify(payload); const req = protocol.request({ hostname: urlObj.hostname, port, path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}`, @@ -1541,6 +1615,10 @@ ipcMain.handle('deleteFile', async (event, data) => { async function collectAllFiles(path) { const contents = await giteaGet(path); const files = []; + if (contents && Array.isArray(contents.errors)) { + const notFound = contents.errors.some(err => String(err).toLowerCase().includes('does not exist')); + if (notFound) return files; + } if (Array.isArray(contents)) { // It's a folder — recurse into it for (const item of contents) { @@ -1551,46 +1629,180 @@ ipcMain.handle('deleteFile', async (event, data) => { files.push({ path: item.path, sha: item.sha }); } } - } else if (contents && contents.sha) { + } else if (contents && contents.type === 'file' && contents.sha) { // It's a single file files.push({ path: contents.path, sha: contents.sha }); + } else if (contents && contents.type === 'dir' && contents.path) { + // Defensive fallback: some endpoints can return dir objects. + const sub = await collectAllFiles(contents.path); + files.push(...sub); } else { throw new Error(`Unbekannte Antwort: ${JSON.stringify(contents)}`); } return files; } + async function getFileContentBase64(path) { + const payload = await giteaGet(path); + if (payload && typeof payload.content === 'string') { + return payload.content.replace(/\n/g, ''); + } + const decoded = await getGiteaFileContent({ + token, + url: giteaUrl, + owner, + repo, + path, + ref: useBranch || 'HEAD' + }); + return Buffer.from(decoded || '', 'utf8').toString('base64'); + } + + function giteaWriteFile(path, method, bodyObj) { + return new Promise((resolve) => { + const body = JSON.stringify(bodyObj || {}); + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, + method, + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, (res) => { + let respBody = ''; + res.on('data', chunk => respBody += chunk); + res.on('end', () => { + resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: respBody }); + }); + }); + req.on('error', (e) => resolve({ ok: false, statusCode: 0, body: String(e) })); + req.write(body); + req.end(); + }); + } + + async function upsertTrashFile(path, contentBase64, branchName) { + const existing = await giteaGet(path); + const existingSha = (existing && existing.sha) ? existing.sha : null; + const payload = { + message: `Soft-delete copy ${path}`, + content: contentBase64 + }; + if (branchName) payload.branch = branchName; + + if (existingSha) { + payload.sha = existingSha; + const putRes = await giteaWriteFile(path, 'PUT', payload); + if (!putRes.ok) throw new Error(`Trash-Update fehlgeschlagen (${putRes.statusCode}): ${putRes.body}`); + return; + } + + const postRes = await giteaWriteFile(path, 'POST', payload); + if (postRes.ok) return; + + // Fallback: einige Server akzeptieren nur PUT, selbst für neue Dateien. + const putRes = await giteaWriteFile(path, 'PUT', payload); + if (!putRes.ok) throw new Error(`Trash-Create fehlgeschlagen (POST ${postRes.statusCode} / PUT ${putRes.statusCode})`); + } + + async function executeDeleteBatch(files) { + const deleteOps = files.map(f => + async () => { + const res = await giteaDeleteFile(f.path, f.sha); + return { path: f.path, ...res }; + } + ); + + const deleteResults = await runParallel(deleteOps, 4); + const failedEntries = deleteResults.filter(r => !r.ok || !(r.result && r.result.ok)); + return { deleteResults, failedEntries }; + } + + async function pathStillExists(path) { + const remaining = await collectAllFiles(path); + return remaining.length > 0; + } + // Collect all files to delete (handles both file and folder) const filesToDelete = await collectAllFiles(filePath); if (filesToDelete.length === 0) { - return { ok: false, error: 'Keine Dateien zum Löschen gefunden' }; + invalidateRepoContentsCache(owner, repo); + return { ok: true, deleted: 0 }; } - // OPTIMIERUNG: Delete all files in parallel (up to 4 concurrent) - const deleteOps = filesToDelete.map(f => - async () => { - const res = await giteaDeleteFile(f.path, f.sha); - return { path: f.path, ...res }; + let trashRoot = null; + let softDeleteFailures = []; + if (softDelete) { + const effectiveBranch = useBranch || await resolveGiteaDefaultBranch() || 'main'; + trashRoot = `_trash/${new Date().toISOString().replace(/[.:]/g, '-')}`; + for (const file of filesToDelete) { + try { + const contentBase64 = await getFileContentBase64(file.path); + const trashPath = `${trashRoot}/${String(file.path).replace(/^\/+/, '')}`; + await upsertTrashFile(trashPath, contentBase64, effectiveBranch); + } catch (e) { + softDeleteFailures.push({ path: file.path, error: String(e) }); + } } - ); - - const deleteResults = await runParallel(deleteOps, 4); - const failed = deleteResults.filter(r => !r.ok).length; + } - if (failed > 0) { - const failedFiles = deleteResults.filter(r => !r.ok).map(r => r.path).join(', '); - logger.error('deleteFile', `Failed to delete ${failed} files`, { files: failedFiles }); - return { ok: false, error: `${failed} von ${filesToDelete.length} Dateien konnten nicht gelöscht werden` }; + // First delete attempt + let { failedEntries } = await executeDeleteBatch(filesToDelete); + + if (failedEntries.length > 0) { + const failedFiles = failedEntries + .map(r => (r.result && r.result.path) ? r.result.path : 'unknown') + .join(', '); + logger.error('deleteFile', `Failed to delete ${failedEntries.length} files`, { files: failedFiles }); + return { ok: false, error: `${failedEntries.length} von ${filesToDelete.length} Dateien konnten nicht gelöscht werden` }; + } + + // Verification + one automatic retry on leftovers + if (await pathStillExists(filePath)) { + const remaining = await collectAllFiles(filePath); + if (remaining.length > 0) { + ({ failedEntries } = await executeDeleteBatch(remaining)); + if (failedEntries.length > 0) { + const failedFiles = failedEntries + .map(r => (r.result && r.result.path) ? r.result.path : 'unknown') + .join(', '); + return { + ok: false, + error: `Nachloeschung fehlgeschlagen. ${failedEntries.length} Dateien konnten nicht gelöscht werden (${failedFiles}).` + }; + } + } + } + + if (await pathStillExists(filePath)) { + return { + ok: false, + error: 'Löschen unvollständig: Der Ordnerpfad ist nach zwei Versuchen noch vorhanden.' + }; } logger.info('deleteFile', `Deleted ${filesToDelete.length} files successfully`); - return { ok: true, deleted: filesToDelete.length }; + invalidateRepoContentsCache(owner, repo); + return { + ok: true, + deleted: filesToDelete.length, + softDeleted: softDelete, + trashRoot, + softDeleteFailed: softDeleteFailures.length, + softDeleteWarning: softDeleteFailures.length > 0 + ? `${softDeleteFailures.length} Dateien konnten nicht in den Papierkorb verschoben werden.` + : null + }; } // --- LOCAL DELETION --- if (!data || !data.path || !fs.existsSync(data.path)) return { ok: false, error: 'file-not-found' }; fs.rmSync(data.path, { recursive: true, force: true }); + invalidateLocalFileTreeCacheForPath(data.path); return { ok: true }; } catch (e) { console.error('deleteFile error', e); @@ -1689,18 +1901,21 @@ ipcMain.handle('get-gitea-repo-contents', async (event, data) => { const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + const skipCache = data && data.noCache === true; // OPTIMIERUNG: Cache für Repo-Inhalte (5 min) const cacheKey = `gitea:${owner}/${repo}:${p}:${ref || 'HEAD'}`; - const cached = caches.repos.get(cacheKey); - if (cached) { - logger.debug('ipc-get-repo-contents', 'Cache hit', { cacheKey }); - return { ok: true, ...cached }; + if (!skipCache) { + const cached = caches.repos.get(cacheKey); + if (cached) { + logger.debug('ipc-get-repo-contents', 'Cache hit', { cacheKey }); + return { ok: true, ...cached }; + } } const result = await getGiteaRepoContents({ token, url, owner, repo, path: p, ref }); const response = { items: result.items || result, empty: result.empty || false }; - caches.repos.set(cacheKey, response); + if (!skipCache) caches.repos.set(cacheKey, response, 4000); return { ok: true, ...response }; } catch (e) { @@ -1710,6 +1925,360 @@ ipcMain.handle('get-gitea-repo-contents', async (event, data) => { } }); +async function purgeGiteaTrashEntries({ token, url, owner, repo, ref = 'HEAD', olderThanDays = 7 }) { + const base = String(url || '').trim().replace(/\/+$/, ''); + if (!base || !/^https?:\/\//i.test(base)) throw new Error('Invalid Gitea base URL'); + + const cutoffMs = Number(olderThanDays) <= 0 + ? Number.POSITIVE_INFINITY + : Date.now() - (Number(olderThanDays) * 24 * 60 * 60 * 1000); + + function parseTrashTimestamp(ts) { + const m = String(ts || '').match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/); + if (!m) return NaN; + const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`; + return Date.parse(iso); + } + + async function walk(path, out) { + const res = await getGiteaRepoContents({ token, url: base, owner, repo, path, ref }); + const items = Array.isArray(res?.items) ? res.items : []; + for (const item of items) { + if (item.type === 'dir') { + await walk(item.path, out); + } else if (item.type === 'file') { + out.push(item); + } + } + } + + const root = await getGiteaRepoContents({ token, url: base, owner, repo, path: '_trash', ref }); + if (!root?.ok || !Array.isArray(root.items) || root.items.length === 0) { + return { ok: true, purgedFiles: 0, purgedRoots: 0, failedFiles: 0 }; + } + + const allFiles = []; + await walk('_trash', allFiles); + + const targets = allFiles.filter((f) => { + const m = String(f.path || '').match(/^_trash\/([^/]+)\//); + if (!m) return false; + const ts = parseTrashTimestamp(m[1]); + if (!Number.isFinite(ts)) return false; + return ts <= cutoffMs; + }); + + if (targets.length === 0) { + return { ok: true, purgedFiles: 0, purgedRoots: 0, failedFiles: 0 }; + } + + const urlObj = new URL(base); + const protocol = urlObj.protocol === 'https:' ? https : http; + const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); + const branchName = ref && ref !== 'HEAD' ? ref : null; + + function deleteFile(path, sha, withBranch = true) { + return new Promise((resolve) => { + const payload = { message: `Purge ${path} from trash`, sha }; + if (withBranch && branchName) payload.branch = branchName; + const body = JSON.stringify(payload); + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, + method: 'DELETE', + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, (res) => { + let resp = ''; + res.on('data', c => resp += c); + res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: resp })); + }); + req.on('error', (e) => resolve({ ok: false, statusCode: 0, body: String(e) })); + req.write(body); + req.end(); + }); + } + + let failedFiles = 0; + for (const file of targets) { + let r = await deleteFile(file.path, file.sha, true); + if (!r.ok && branchName) r = await deleteFile(file.path, file.sha, false); + if (!r.ok) failedFiles++; + } + + const purgedFiles = targets.length - failedFiles; + const roots = new Set(targets.map(f => (String(f.path).match(/^_trash\/([^/]+)\//) || [])[1]).filter(Boolean)); + + return { + ok: failedFiles === 0, + purgedFiles, + purgedRoots: roots.size, + failedFiles + }; +} + +ipcMain.handle('list-gitea-trash', async (event, data) => { + try { + const credentials = readCredentials(); + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + const owner = data && data.owner; + const repo = data && data.repo; + const ref = data && data.ref ? data.ref : 'HEAD'; + const autoPurge = !data || data.autoPurge !== false; + const autoPurgeDays = (data && Number.isFinite(Number(data.autoPurgeDays))) ? Number(data.autoPurgeDays) : 7; + + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo' }; + + let purgeSummary = { ok: true, purgedFiles: 0, purgedRoots: 0, failedFiles: 0 }; + if (autoPurge) { + purgeSummary = await purgeGiteaTrashEntries({ token, url, owner, repo, ref, olderThanDays: autoPurgeDays }); + } + + const trashRoot = '_trash'; + const entries = []; + + async function walk(path) { + const res = await getGiteaRepoContents({ token, url, owner, repo, path, ref }); + const items = Array.isArray(res?.items) ? res.items : []; + for (const item of items) { + if (item.type === 'dir') { + await walk(item.path); + continue; + } + if (item.type !== 'file') continue; + const m = String(item.path || '').match(/^_trash\/([^/]+)\/(.+)$/); + if (!m) continue; + entries.push({ + trashPath: item.path, + restoreTimestamp: m[1], + originalPath: m[2], + name: item.name, + size: item.size || 0 + }); + } + } + + const trashRes = await getGiteaRepoContents({ token, url, owner, repo, path: trashRoot, ref }); + if (!trashRes?.ok || !Array.isArray(trashRes.items) || trashRes.items.length === 0) { + return { ok: true, items: [] }; + } + + await walk(trashRoot); + entries.sort((a, b) => String(b.restoreTimestamp).localeCompare(String(a.restoreTimestamp))); + return { ok: true, items: entries, purgeSummary }; + } catch (e) { + console.error('list-gitea-trash error', e); + return { ok: false, error: String(e) }; + } +}); + +ipcMain.handle('purge-gitea-trash', async (event, data) => { + try { + const credentials = readCredentials(); + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + const owner = data && data.owner; + const repo = data && data.repo; + const ref = data && data.ref ? data.ref : 'HEAD'; + const olderThanDays = (data && Number.isFinite(Number(data.olderThanDays))) ? Number(data.olderThanDays) : 7; + + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo' }; + + const result = await purgeGiteaTrashEntries({ token, url, owner, repo, ref, olderThanDays }); + invalidateRepoContentsCache(owner, repo); + return { ok: result.ok, ...result }; + } catch (e) { + console.error('purge-gitea-trash error', e); + return { ok: false, error: String(e) }; + } +}); + +ipcMain.handle('restore-gitea-trash-item', async (event, data) => { + try { + const credentials = readCredentials(); + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + const owner = data && data.owner; + const repo = data && data.repo; + const trashPath = data && data.trashPath; + const restorePath = data && data.restorePath; + const ref = data && data.ref ? data.ref : 'HEAD'; + + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + if (!owner || !repo || !trashPath || !restorePath) return { ok: false, error: 'missing-restore-data' }; + + const urlObj = new URL(url); + const protocol = urlObj.protocol === 'https:' ? https : http; + const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); + + function giteaGet(path, refName = null) { + return new Promise((resolve, reject) => { + const refQuery = refName && refName !== 'HEAD' ? `?ref=${encodeURIComponent(refName)}` : ''; + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}${refQuery}`, + method: 'GET', + headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } + }, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + try { resolve(JSON.parse(body || '{}')); } catch (e) { reject(e); } + }); + }); + req.on('error', reject); + req.end(); + }); + } + + function giteaWrite(path, method, payload) { + return new Promise((resolve) => { + const body = JSON.stringify(payload || {}); + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, + method, + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, (res) => { + let resp = ''; + res.on('data', c => resp += c); + res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: resp })); + }); + req.on('error', (e) => resolve({ ok: false, statusCode: 0, body: String(e) })); + req.write(body); + req.end(); + }); + } + + async function resolveBranch() { + if (ref && ref !== 'HEAD') return ref; + try { + const req = await new Promise((resolve) => { + const r = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + method: 'GET', + headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' } + }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { + try { resolve(JSON.parse(body || '{}')); } catch (_) { resolve({}); } + }); + }); + r.on('error', () => resolve({})); + r.end(); + }); + return req.default_branch || 'main'; + } catch (_) { + return 'main'; + } + } + + const sourcePayload = await giteaGet(trashPath, ref); + const sourceItem = sourcePayload && sourcePayload.type === 'file' ? sourcePayload : null; + if (!sourceItem || sourceItem.type !== 'file' || !sourceItem.sha) { + return { ok: false, error: 'trash-source-not-found' }; + } + + const contentBase64 = String(sourcePayload.content || '').replace(/\n/g, ''); + if (!contentBase64) return { ok: false, error: 'trash-source-content-empty' }; + + const branchName = await resolveBranch(); + const targetPayload = await giteaGet(restorePath, branchName); + const targetSha = targetPayload && targetPayload.sha ? targetPayload.sha : null; + const upsertPayload = { + message: `Restore ${restorePath} from trash`, + content: contentBase64, + branch: branchName + }; + if (targetSha) upsertPayload.sha = targetSha; + + let writeRes; + if (targetSha) { + writeRes = await giteaWrite(restorePath, 'PUT', upsertPayload); + } else { + writeRes = await giteaWrite(restorePath, 'POST', upsertPayload); + if (!writeRes.ok) { + writeRes = await giteaWrite(restorePath, 'PUT', upsertPayload); + } + } + if (!writeRes.ok) { + return { ok: false, error: `Restore-Schreiben fehlgeschlagen: HTTP ${writeRes.statusCode}` }; + } + + async function deleteTrashEntry(includeBranch) { + const payload = { + message: `Delete ${trashPath} after restore`, + sha: sourceItem.sha + }; + if (includeBranch && branchName) payload.branch = branchName; + const body = JSON.stringify(payload); + return await new Promise((resolve) => { + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${trashPath.split('/').map(encodeURIComponent).join('/')}`, + method: 'DELETE', + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, (res) => { + let resp = ''; + res.on('data', c => resp += c); + res.on('end', () => resolve({ statusCode: res.statusCode, body: resp })); + }); + req.on('error', (e) => resolve({ statusCode: 0, body: String(e) })); + req.write(body); + req.end(); + }); + } + + let deleteResult = await deleteTrashEntry(true); + if (!(deleteResult.statusCode >= 200 && deleteResult.statusCode < 300)) { + // Fallback for servers that reject branch on delete payload. + deleteResult = await deleteTrashEntry(false); + } + + invalidateRepoContentsCache(owner, repo); + if (!(deleteResult.statusCode >= 200 && deleteResult.statusCode < 300)) { + logger.warn('restore-gitea-trash-item', 'Restore succeeded but trash cleanup failed', { + owner, + repo, + trashPath, + statusCode: deleteResult.statusCode, + body: String(deleteResult.body || '') + }); + return { + ok: true, + restoredPath: restorePath, + warning: `Wiederhergestellt, aber Papierkorb-Eintrag konnte nicht entfernt werden (HTTP ${deleteResult.statusCode}).` + }; + } + + return { ok: true, restoredPath: restorePath }; + } catch (e) { + console.error('restore-gitea-trash-item error', e); + return { ok: false, error: String(e) }; + } +}); + ipcMain.handle('get-gitea-file-content', async (event, data) => { try { const credentials = readCredentials(); @@ -1892,53 +2461,113 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { logger.warn('upload-gitea-file', 'Missing token or URL'); return { ok: false, error: `Zugangsdaten fehlen` }; } - + // Invalid cache for repo after upload caches.repos.invalidate(`${owner}/${repo}`); - - const owner2 = owner; + const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); let branch = normalizeBranch(data.branch || 'HEAD', 'gitea'); const message = data.message || 'Upload via Git Manager GUI'; const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []); + + // Validierung: nur existierende Dateien (keine Ordner) + const validFiles = []; const results = []; - for (const localFile of localFiles) { - if (!fs.existsSync(localFile)) { - results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); - continue; - } - const raw = fs.readFileSync(localFile); - const base64 = raw.toString('base64'); - const fileName = ppath.basename(localFile); - - let targetPath; - if (destPath && destPath.length > 0) { - targetPath = `${destPath}/${fileName}`; - } else { - targetPath = fileName; - } + if (!fs.existsSync(localFile)) { results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); continue; } + let stat; + try { stat = fs.statSync(localFile); } catch (e) { results.push({ file: localFile, ok: false, error: String(e) }); continue; } + if (!stat.isFile()) { results.push({ file: localFile, ok: false, error: stat.isDirectory() ? 'path-is-directory' : 'path-is-not-file' }); continue; } + validFiles.push(localFile); + } + + if (validFiles.length === 0) { + const failedCount = results.filter(r => !r.ok).length; + return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; + } + + // Git-basierter Upload via simple-git — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig. + const simpleGit = require('simple-git'); + let authUrl; + try { + const rawUrl = url.startsWith('http') ? url : `https://${url}`; + const urlObj = new URL(rawUrl.replace(/\/$/, '')); + authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`; + } catch (urlErr) { + return { ok: false, error: `Ungültige Gitea-URL: ${url}`, debugId: uploadDebugId }; + } + + const tmpDir = getSafeTmpDir(`git-file-${owner}-${repo}`); + const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI']; + + try { + const git = simpleGit({ config: gitConfig }); + let repoGit; + let isEmptyRepo = false; try { - const uploaded = await uploadGiteaFile({ - token, - url, - owner: owner2, - repo, - path: targetPath, - contentBase64: base64, - message: `${message} - ${fileName}`, - branch - }); - results.push({ file: localFile, ok: true, uploaded }); - logger.debug('upload-gitea-file', `File uploaded: ${fileName}`); - } catch (e) { - const errInfo = formatErrorForUser(e, 'File upload'); - results.push({ file: localFile, ok: false, error: errInfo.userMessage }); - logger.error('upload-gitea-file', `Upload failed for ${fileName}`, errInfo.details); + const cloneArgs = ['--depth', '1', '--no-single-branch']; + if (branch !== 'HEAD') cloneArgs.push('--branch', branch); + await git.clone(authUrl, tmpDir, cloneArgs); + repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig }); + } catch (cloneErr) { + const cloneMsg = String(cloneErr); + if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') || + cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) { + isEmptyRepo = true; + repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig }); + await repoGit.init(); + await repoGit.addRemote('origin', authUrl); + } else { + throw cloneErr; + } } + + // Dateien in den Zielordner kopieren + for (const localFile of validFiles) { + const fileName = ppath.basename(localFile); + const targetPath = destPath ? `${destPath}/${fileName}` : fileName; + const destFile = ppath.join(tmpDir, ...targetPath.split('/')); + fs.mkdirSync(ppath.dirname(destFile), { recursive: true }); + fs.copyFileSync(localFile, destFile); + results.push({ file: localFile, ok: true, targetPath }); + } + + await repoGit.add('.'); + + let hasChanges = true; + try { + await repoGit.commit(message); + } catch (commitErr) { + if (String(commitErr).includes('nothing to commit')) { + hasChanges = false; + } else { + throw commitErr; + } + } + + if (hasChanges) { + let pushBranch = branch; + if (pushBranch === 'HEAD' || isEmptyRepo) { + try { + const branchSummary = await repoGit.branch(); + pushBranch = branchSummary.current || 'main'; + } catch (_) { pushBranch = 'main'; } + } + if (isEmptyRepo) { + await repoGit.push(['-u', 'origin', pushBranch]); + } else { + await repoGit.push('origin', pushBranch); + } + } + + setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000); + } catch (gitErr) { + try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} + logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId }); + return { ok: false, error: `Upload fehlgeschlagen: ${gitErr.message || String(gitErr)}`, debugId: uploadDebugId }; } - + const failedCount = results.filter(r => !r.ok).length; logger.info('upload-gitea-file', 'Gitea upload done', { failedCount, total: results.length, uploadDebugId }); return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; @@ -1985,6 +2614,7 @@ ipcMain.handle('write-gitea-file', async (event, data) => { message: `Edit ${path} via Git Manager GUI`, branch: ref }); + invalidateRepoContentsCache(owner, repo); return { ok: true, uploaded }; } catch (e) { @@ -2048,31 +2678,25 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { // Branch robust behandeln: HEAD bedeutet Remote-Default-Branch nutzen let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); 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. + // Exclude-Liste: .git, node_modules etc. überspringen const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea']; (function walk(dir) { const entries = fs.readdirSync(dir); for (const entry of entries) { - if (excludeList.includes(entry)) continue; // Skip excluded folders/files - + if (excludeList.includes(entry)) continue; const full = ppath.join(dir, entry); let stat; - try { - stat = fs.statSync(full); - } catch(e) { continue; } // Skip unreadable files - + try { stat = fs.statSync(full); } catch(e) { continue; } if (stat.isDirectory()) { walk(full); } else if (stat.isFile()) { const rel = ppath.relative(localFolder, full).split(ppath.sep).join('/'); - // FIXED: Respect folder structure. Result: destPath/folderName/rel let targetPath; if (destPath && destPath.length > 0) { targetPath = `${destPath}/${folderName}/${rel}`; @@ -2087,27 +2711,112 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { 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 - }); - }); + // Git-basierter Upload: alle Dateien in einem einzigen Commit. + // Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett. + const simpleGit = require('simple-git'); - const onProgress = (processed, t) => { - try { event.sender.send('folder-upload-progress', { processed, total: t, percent: Math.round((processed / t) * 100) }); } catch (_) {} - }; + // Auth-URL mit Token für HTTPS-Auth bauen + let authUrl; + try { + const rawUrl = url.startsWith('http') ? url : `https://${url}`; + const urlObj = new URL(rawUrl.replace(/\/$/, '')); + authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`; + } catch (urlErr) { + throw new Error(`Ungültige Gitea-URL: ${url}`); + } - const results = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); - return { ok: true, results }; + const tmpDir = getSafeTmpDir(`git-folder-${owner}-${repo}`); + const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI']; + + // Fortschritt: Start + try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0 }); } catch (_) {} + + console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir }); + + try { + const git = simpleGit({ config: gitConfig }); + let repoGit; + let isEmptyRepo = false; + + try { + const cloneArgs = ['--depth', '1', '--no-single-branch']; + if (branch !== 'HEAD') cloneArgs.push('--branch', branch); + console.log('[FolderUpload] Clone-Versuch:', { cloneArgs, tmpDir }); + await git.clone(authUrl, tmpDir, cloneArgs); + console.log('[FolderUpload] Clone erfolgreich'); + repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig }); + } catch (cloneErr) { + const cloneMsg = String(cloneErr); + console.warn('[FolderUpload] Clone-Fehler:', cloneMsg); + // Leeres Repo ohne Commits: lokal initialisieren + if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') || + cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) { + console.log('[FolderUpload] Leeres Repo erkannt — initialisiere lokal'); + isEmptyRepo = true; + repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig }); + await repoGit.init(); + await repoGit.addRemote('origin', authUrl); + } else { + throw cloneErr; + } + } + + // Fortschritt: 30% (nach Clone) + try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30 }); } catch (_) {} + + // Dateien in den Zielordner im geklonten Repo kopieren + for (const item of items) { + const destFile = ppath.join(tmpDir, ...item.targetPath.split('/')); + fs.mkdirSync(ppath.dirname(destFile), { recursive: true }); + fs.copyFileSync(item.localFile, destFile); + } + + // Fortschritt: 60% (nach Kopieren) + try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60 }); } catch (_) {} + + // git add + commit + push + await repoGit.add('.'); + + let hasChanges = true; + try { + await repoGit.commit(`${messagePrefix} - ${folderName}`); + } catch (commitErr) { + if (String(commitErr).includes('nothing to commit')) { + hasChanges = false; // Keine Änderungen — bereits aktuell + } else { + throw commitErr; + } + } + + if (hasChanges) { + let pushBranch = branch; + if (pushBranch === 'HEAD' || isEmptyRepo) { + try { + const branchSummary = await repoGit.branch(); + pushBranch = branchSummary.current || 'main'; + } catch (_) { pushBranch = 'main'; } + } + if (isEmptyRepo) { + await repoGit.push(['-u', 'origin', pushBranch]); + } else { + await repoGit.push('origin', pushBranch); + } + } + + // Fortschritt: 100% + try { event.sender.send('folder-upload-progress', { processed: total, total, percent: 100 }); } catch (_) {} + + // Temp-Ordner nach kurzer Verzögerung bereinigen + setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000); + + const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath })); + return { ok: true, results: successResults, failedCount: 0 }; + + } catch (gitErr) { + console.error('[FolderUpload] Git-Fehler:', String(gitErr)); + try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} + throw gitErr; + } } catch (e) { console.error('upload-local-folder-to-gitea error', e); return { ok: false, error: String(e) }; @@ -2376,6 +3085,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { const cloneUrl = data.cloneUrl || null; const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); if (!owner || !repo) return { ok: false, error: `missing-owner-or-repo (${uploadDebugId})` }; + invalidateRepoContentsCache(owner, repo); console.log('[UPLOAD_DEBUG][main] upload-and-push:start', { uploadDebugId, @@ -2515,96 +3225,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` }; } - let gitAvailable = true; - try { runGitSync(['--version'], process.cwd(), { stdio: 'ignore', env: gitExecOptions.env }); } 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 { - const cloneArgs = ['clone', '--depth', '1']; - if (branch !== 'HEAD') cloneArgs.push('--branch', branch); - cloneArgs.push(authClone, tmpDir); - runGitSync(cloneArgs, process.cwd(), gitExecOptions); - - // 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 { - const copyRecursive = (src, dst) => { - const st = fs.statSync(src); - if (st.isDirectory()) { - if (!fs.existsSync(dst)) fs.mkdirSync(dst, { recursive: true }); - const entries = fs.readdirSync(src); - for (const entry of entries) { - copyRecursive(ppath.join(src, entry), ppath.join(dst, entry)); - } - return; - } - fs.copyFileSync(src, dst); - }; - copyRecursive(data.localFolder, finalDest); - } - - try { - runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions); - try { runGitSync(['-C', tmpDir, 'commit', '-m', 'Update from Git Manager GUI'], process.cwd(), gitExecOptions); } catch (_) {} - let pushBranch = branch; - if (pushBranch === 'HEAD') { - try { - pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim(); - } catch (_) { - pushBranch = 'main'; - } - } - runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions); - } 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', debugId: uploadDebugId }; - } catch (e) { - console.error('[UPLOAD_DEBUG][main] git-flow-error', { uploadDebugId, error: String(e) }); - try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} - } - } + console.log('[UPLOAD_DEBUG][main] directory-upload:using-api-path', { uploadDebugId, branch, destPath }); // Fallback: API Upload (paralleler Upload) const items = []; @@ -2907,6 +3528,8 @@ ipcMain.handle('rename-gitea-item', async (event, data) => { await deleteFile(f.path, f.sha); } + invalidateRepoContentsCache(owner, repo); + return { ok: true }; } catch (e) { console.error('rename-gitea-item error', e); @@ -2927,6 +3550,45 @@ ipcMain.handle('create-gitea-item', async (event, data) => { const protocol = urlObj.protocol === 'https:' ? https : http; const port = urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80); + function parseBody(raw) { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (_) { + return null; + } + } + + function isAlreadyExistsResponse(statusCode, rawBody) { + if (statusCode !== 409 && statusCode !== 422) return false; + const parsed = parseBody(rawBody); + const message = typeof parsed?.message === 'string' ? parsed.message : String(rawBody || ''); + return /already exists/i.test(message); + } + + function requestGitea(method, apiPath, body = null) { + return new Promise((resolve) => { + const bodyStr = body ? JSON.stringify(body) : null; + const req = protocol.request({ + hostname: urlObj.hostname, + port, + path: apiPath, + method, + headers: { + 'Authorization': `token ${token}`, + ...(bodyStr ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) } : {}) + } + }, (res) => { + let b = ''; + res.on('data', c => b += c); + res.on('end', () => resolve({ statusCode: res.statusCode, rawBody: b, body: parseBody(b) })); + }); + req.on('error', e => resolve({ statusCode: 0, rawBody: String(e), body: null, error: String(e) })); + if (bodyStr) req.write(bodyStr); + req.end(); + }); + } + // Für Ordner: .gitkeep Datei anlegen const targetPath = type === 'folder' ? `${itemPath}/.gitkeep` : itemPath; const content = Buffer.from('').toString('base64'); @@ -2934,25 +3596,35 @@ ipcMain.handle('create-gitea-item', async (event, data) => { // Branch-Auflösung: HEAD zu 'main' konvertieren const branch = (data.branch && data.branch !== 'HEAD') ? data.branch : 'main'; - return new Promise((resolve) => { - const body = JSON.stringify({ message: `Create ${itemPath}`, content, branch }); - const req = protocol.request({ - hostname: urlObj.hostname, port, - path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${targetPath.split('/').map(encodeURIComponent).join('/')}`, - method: 'POST', - headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } - }, (res) => { - let b = ''; - res.on('data', c => b += c); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true }); - else resolve({ ok: false, error: `HTTP ${res.statusCode}: ${b}` }); - }); - }); - req.on('error', e => resolve({ ok: false, error: String(e) })); - req.write(body); - req.end(); - }); + const encodedTargetPath = targetPath.split('/').map(encodeURIComponent).join('/'); + const encodedItemPath = itemPath.split('/').map(encodeURIComponent).join('/'); + const baseContentsPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`; + + if (type === 'folder') { + const existing = await requestGitea('GET', `${baseContentsPath}/${encodedTargetPath}?ref=${encodeURIComponent(branch)}`); + if (existing.statusCode >= 200 && existing.statusCode < 300) { + return { ok: true, exists: true }; + } + + const existingDir = await requestGitea('GET', `${baseContentsPath}/${encodedItemPath}?ref=${encodeURIComponent(branch)}`); + if (existingDir.statusCode >= 200 && existingDir.statusCode < 300 && Array.isArray(existingDir.body)) { + const hasGitkeep = existingDir.body.some(entry => entry?.name === '.gitkeep'); + if (hasGitkeep) { + return { ok: true, exists: true }; + } + } + } + + const response = await requestGitea('POST', `${baseContentsPath}/${encodedTargetPath}`, { message: `Create ${itemPath}`, content, branch }); + if (response.statusCode >= 200 && response.statusCode < 300) { + invalidateRepoContentsCache(owner, repo); + return { ok: true }; + } + if (type === 'folder' && isAlreadyExistsResponse(response.statusCode, response.rawBody)) { + invalidateRepoContentsCache(owner, repo); + return { ok: true, exists: true }; + } + return { ok: false, error: `HTTP ${response.statusCode}: ${response.rawBody}` }; } catch (e) { console.error('create-gitea-item error', e); return { ok: false, error: String(e) }; @@ -2967,6 +3639,7 @@ ipcMain.handle('rename-local-item', async (event, data) => { const dir = ppath.dirname(oldPath); const newPath = ppath.join(dir, newName); fs.renameSync(oldPath, newPath); + invalidateLocalFileTreeCacheForPath(dir); return { ok: true, newPath }; } catch (e) { console.error('rename-local-item error', e); @@ -2986,6 +3659,7 @@ ipcMain.handle('create-local-item', async (event, data) => { fs.mkdirSync(ppath.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, '', 'utf8'); } + invalidateLocalFileTreeCacheForPath(parentDir); return { ok: true, path: targetPath }; } catch (e) { console.error('create-local-item error', e); @@ -3002,6 +3676,8 @@ ipcMain.handle('move-local-item', async (event, data) => { const destPath = ppath.join(destDir, name); fs.mkdirSync(destDir, { recursive: true }); fs.renameSync(srcPath, destPath); + invalidateLocalFileTreeCacheForPath(srcPath); + invalidateLocalFileTreeCacheForPath(destDir); return { ok: true, destPath }; } catch (e) { // renameSync kann über Laufwerke nicht funktionieren — dann cpSync + rmSync @@ -3015,6 +3691,8 @@ ipcMain.handle('move-local-item', async (event, data) => { fs.copyFileSync(srcPath, destPath); } fs.rmSync(srcPath, { recursive: true, force: true }); + invalidateLocalFileTreeCacheForPath(srcPath); + invalidateLocalFileTreeCacheForPath(destDir); return { ok: true, destPath }; } catch (e2) { console.error('move-local-item error', e2); @@ -3036,6 +3714,7 @@ ipcMain.handle('copy-local-item', async (event, data) => { } else { fs.copyFileSync(src, dest); } + invalidateLocalFileTreeCacheForPath(destDir); return { ok: true, dest }; } catch (e) { console.error('copy-local-item error', e);