Files
Git-Manager-Gui/renderer/renderer.js
2026-03-28 00:03:52 +01:00

7534 lines
277 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// renderer.js — Grid-UI + Navigation + Drag'n'Drop mit Fehlerbehandlung
const $ = id => document.getElementById(id);
let selectedFolder = null;
let giteaCache = {};
let currentLocalProjects = [];
/* ================================================
FAVORITEN & ZULETZT GEÖFFNET — State & Helpers
================================================ */
let favorites = []; // [{ owner, repo, cloneUrl, addedAt }]
let recentRepos = []; // [{ owner, repo, cloneUrl, openedAt }]
// Feature-Flags (aus Settings, persistent via Credentials)
let featureFavorites = true;
let featureRecent = true;
let compactMode = false;
let featureAutostart = false;
let repoNameValidationTimer = null;
let batchCloneValidationTimer = null;
let activityHeatmapCollapsed = true;
let activityHeatmapRangeMonths = 20;
// Sidebar-State für die linke Favoriten/Verlauf-Fläche
let sidebarMode = 'favorites';
let currentGiteaRepos = [];
let repoPrivacyByFullName = {}; // owner/repo -> boolean(private)
let repoTopicsByFullName = {}; // owner/repo -> string[]
let repoKnownTopics = []; // all known topics across repos
let repoKnownTopicsLoadedAt = 0;
let currentGiteaUsername = '';
let activeRepoOwnerFilter = 'mine'; // mine | all | owner:<name>
function normalizePlatform(value) {
return value === 'github' ? 'github' : 'gitea';
}
function currentPlatformKey() {
return normalizePlatform(currentState.platform);
}
function withPlatform(entry, fallback = 'gitea') {
if (!entry || typeof entry !== 'object') return entry;
return { ...entry, platform: normalizePlatform(entry.platform || fallback) };
}
function platformEntries(items, platform = currentPlatformKey()) {
const p = normalizePlatform(platform);
return (Array.isArray(items) ? items : []).filter(e => normalizePlatform(e?.platform || 'gitea') === p);
}
async function loadFavoritesAndRecent() {
try {
const [favRes, recRes] = await Promise.all([
window.electronAPI.loadFavorites(),
window.electronAPI.loadRecent()
]);
if (favRes && favRes.ok) favorites = (favRes.favorites || []).map(e => withPlatform(e, 'gitea'));
if (recRes && recRes.ok) recentRepos = (recRes.recent || []).map(e => withPlatform(e, 'gitea'));
} catch(e) {
console.error('loadFavoritesAndRecent:', e);
}
}
function isFavorite(owner, repo, platform = currentPlatformKey()) {
const p = normalizePlatform(platform);
return favorites.some(f => f.owner === owner && f.repo === repo && normalizePlatform(f.platform || 'gitea') === p);
}
async function toggleFavorite(owner, repo, cloneUrl, platform = currentPlatformKey()) {
const p = normalizePlatform(platform);
if (isFavorite(owner, repo, p)) {
favorites = favorites.filter(f => !(f.owner === owner && f.repo === repo && normalizePlatform(f.platform || 'gitea') === p));
} else {
favorites.unshift({ owner, repo, cloneUrl, platform: p, addedAt: new Date().toISOString() });
}
await window.electronAPI.saveFavorites(favorites);
refreshFavHistoryUi();
}
async function addToRecent(owner, repo, cloneUrl, platform = currentPlatformKey()) {
if (!featureRecent) return;
const p = normalizePlatform(platform);
recentRepos = recentRepos.filter(r => !(r.owner === owner && r.repo === repo && normalizePlatform(r.platform || 'gitea') === p));
recentRepos.unshift({ owner, repo, cloneUrl, platform: p, openedAt: new Date().toISOString() });
recentRepos = recentRepos.slice(0, 20);
await window.electronAPI.saveRecent(recentRepos);
refreshFavHistoryUi();
}
function formatRelDate(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
const h = Math.floor(diff / 3600000);
const d = Math.floor(diff / 86400000);
if (m < 1) return 'Gerade eben';
if (m < 60) return `vor ${m} Min.`;
if (h < 24) return `vor ${h} Std.`;
if (d < 7) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
return new Date(iso).toLocaleDateString('de-DE');
}
async function setPlatformSelection(platform) {
currentState.platform = platform;
const platformInput = $('platform');
if (platformInput) {
platformInput.value = platform;
}
document.querySelectorAll('.platform-option').forEach(button => {
const isActive = button.dataset.platform === platform;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
// Direkt laden; fehlende Credentials werden im Loader selbst sauber behandelt.
loadRepos();
}
function initializePlatformSelection() {
const platformInput = $('platform');
const initialPlatform = platformInput?.value || 'gitea';
setPlatformSelection(initialPlatform);
document.querySelectorAll('.platform-option').forEach(button => {
button.addEventListener('click', () => {
const p = button.dataset.platform || 'gitea';
if (p !== currentState.platform) {
setPlatformSelection(p);
}
});
});
}
/** Dispatches to the correct repo loader based on the active platform */
function loadRepos() {
const requestId = ++repoLoadRequestId;
if (currentState.platform === 'github') {
loadGithubRepos(requestId);
} else {
loadGiteaRepos(null, requestId);
}
}
/** Load repositories from GitHub and render the grid using loadGiteaRepos's renderer */
async function loadGithubRepos(requestId = null) {
const activeRequestId = requestId || ++repoLoadRequestId;
currentState.view = 'gitea-list';
currentState.path = '';
updateNavigationUI();
const btnCommits = $('btnCommits');
const btnReleases = $('btnReleases');
if (btnCommits) btnCommits.classList.add('hidden');
if (btnReleases) btnReleases.classList.add('hidden');
const grid = $('explorerGrid');
if (grid) grid.style.gridTemplateColumns = '';
setStatus('Loading GitHub repos...');
try {
const res = await window.electronAPI.listGithubRepos();
if (activeRequestId !== repoLoadRequestId) return;
if (!res.ok) {
showError('GitHub: ' + (res.error || 'Unbekannter Fehler'));
return;
}
// Normalize GitHub repo fields to match what the Gitea renderer expects
const normalizedRepos = (Array.isArray(res.repos) ? res.repos : []).map(r => ({
...r,
stars_count: r.stargazers_count || 0,
updated: r.updated_at || r.pushed_at || '',
clone_url: r.clone_url || r.html_url || ''
}));
try {
const cachedName = getCachedUsername('github');
if (cachedName) {
currentGiteaUsername = cachedName;
} else {
const meRes = await window.electronAPI.getGithubCurrentUser();
if (activeRequestId !== repoLoadRequestId) return;
currentGiteaUsername = meRes?.ok ? (meRes.user?.login || '') : '';
setCachedUsername('github', currentGiteaUsername);
}
} catch (_) {
currentGiteaUsername = '';
}
// Delegate rendering to loadGiteaRepos with pre-loaded data
await loadGiteaRepos({ ok: true, repos: normalizedRepos }, activeRequestId);
} catch (error) {
if (activeRequestId !== repoLoadRequestId) return;
console.error('Error loading GitHub repos:', error);
showError('GitHub Fehler: ' + error.message);
}
}
/* Rendert Favoriten + Zuletzt-geöffnet-Bereich in ein beliebiges Container-Element */
// Collapse-Zustand (wird in Credentials persistiert)
const favSectionCollapsed = { favorites: false, recent: false };
function makeFavSectionBlock(type, allRepos) {
const isFav = type === 'favorites';
const icon = isFav ? '⭐' : '🕐';
const label = isFav ? 'Favoriten' : 'Zuletzt geöffnet';
const sec = document.createElement('div');
sec.style.cssText = `margin-bottom: ${isFav ? '20' : '24'}px;`;
// ── Header (klickbar) ──────────────────────────────
const hdr = document.createElement('div');
hdr.className = 'fav-section-header fav-section-header--toggle';
const iconEl = document.createElement('span');
iconEl.className = 'fav-section-icon';
if (isFav) iconEl.style.color = '#f59e0b';
iconEl.textContent = icon;
const labelEl = document.createElement('span');
labelEl.textContent = label;
const arrow = document.createElement('span');
arrow.className = 'fav-collapse-arrow';
arrow.textContent = favSectionCollapsed[type] ? '▶' : '▼';
hdr.appendChild(iconEl);
hdr.appendChild(labelEl);
hdr.appendChild(arrow);
sec.appendChild(hdr);
// ── Inhalt ────────────────────────────────────────
const row = document.createElement('div');
row.className = 'fav-chips-row';
row.style.cssText = favSectionCollapsed[type]
? 'display:none;'
: 'display:flex;flex-wrap:wrap;gap:8px;';
const items = isFav ? favorites : recentRepos.slice(0, 8);
items.forEach(entry => row.appendChild(makeChip(entry, isFav ? 'favorite' : 'recent', allRepos)));
sec.appendChild(row);
// ── Toggle-Logik ──────────────────────────────────
hdr.addEventListener('click', () => {
favSectionCollapsed[type] = !favSectionCollapsed[type];
const collapsed = favSectionCollapsed[type];
row.style.display = collapsed ? 'none' : 'flex';
arrow.textContent = collapsed ? '▶' : '▼';
// Zustand persistent speichern
window.electronAPI.loadCredentials().then(c => {
if (c && c.ok) {
window.electronAPI.saveCredentials({
...c,
favCollapsedFavorites: favSectionCollapsed.favorites,
favCollapsedRecent: favSectionCollapsed.recent
});
}
}).catch(() => {});
});
return sec;
}
function renderFavRecentSection(container, allRepos) {
container.innerHTML = '';
const showFav = featureFavorites && favorites.length > 0;
const showRec = featureRecent && recentRepos.length > 0;
if (!showFav && !showRec) return;
if (showFav) container.appendChild(makeFavSectionBlock('favorites', allRepos));
if (showRec) container.appendChild(makeFavSectionBlock('recent', allRepos));
// Trennlinie
const div = document.createElement('div');
div.className = 'fav-divider';
container.appendChild(div);
}
function renderFavHistorySidebar(allRepos) {
const sidebar = $('favHistorySidebar');
if (!sidebar) return;
const hasFavFeature = featureFavorites;
const hasRecFeature = featureRecent;
const canShowSidebar = hasFavFeature || hasRecFeature;
// Sidebar nur einblenden wenn Feature aktiv — nichts am main/explorerGrid ändern
sidebar.classList.toggle('visible', canShowSidebar);
if (!canShowSidebar) {
sidebar.innerHTML = '';
return;
}
if (!hasFavFeature && sidebarMode === 'favorites') sidebarMode = 'recent';
if (!hasRecFeature && sidebarMode === 'recent') sidebarMode = 'favorites';
const activeType = sidebarMode;
const currentPlatform = currentPlatformKey();
const favoritesCurrent = platformEntries(favorites, currentPlatform);
const recentCurrent = platformEntries(recentRepos, currentPlatform);
const items = activeType === 'favorites' ? favoritesCurrent : recentCurrent;
const inner = document.createElement('div');
inner.className = 'fav-history-sidebar-inner';
const tabs = document.createElement('div');
tabs.className = 'fav-history-switch';
if (hasFavFeature) {
const btnFav = document.createElement('button');
btnFav.className = 'fav-history-tab' + (activeType === 'favorites' ? ' active' : '');
btnFav.textContent = `⭐ Favoriten (${favoritesCurrent.length})`;
btnFav.onclick = () => {
sidebarMode = 'favorites';
renderFavHistorySidebar(allRepos);
};
tabs.appendChild(btnFav);
}
if (hasRecFeature) {
const btnRec = document.createElement('button');
btnRec.className = 'fav-history-tab' + (activeType === 'recent' ? ' active' : '');
btnRec.textContent = `🕐 Verlauf (${recentCurrent.length})`;
btnRec.onclick = () => {
sidebarMode = 'recent';
renderFavHistorySidebar(allRepos);
};
tabs.appendChild(btnRec);
}
const list = document.createElement('div');
list.className = 'fav-history-list';
const visibleItems = activeType === 'favorites' ? items : items.slice(0, 30);
if (visibleItems.length === 0) {
const empty = document.createElement('div');
empty.className = 'fav-history-empty';
empty.textContent = activeType === 'favorites'
? 'Noch keine Favoriten markiert.'
: 'Noch kein Verlauf vorhanden.';
list.appendChild(empty);
} else {
visibleItems.forEach((entry) => {
const itemBtn = document.createElement('button');
itemBtn.className = 'fav-history-item';
itemBtn.type = 'button';
const name = document.createElement('span');
name.className = 'fav-history-item-name';
name.textContent = entry.repo || '-';
const meta = document.createElement('span');
meta.className = 'fav-history-item-meta';
if (activeType === 'recent' && entry.openedAt) {
meta.textContent = `${entry.owner || '-'}${formatRelDate(entry.openedAt)}`;
} else {
meta.textContent = entry.owner || '-';
}
itemBtn.appendChild(name);
itemBtn.appendChild(meta);
itemBtn.onclick = () => {
addToRecent(entry.owner, entry.repo, entry.cloneUrl, entry.platform || currentPlatform);
loadRepoContents(entry.owner, entry.repo, '');
};
itemBtn.oncontextmenu = (ev) => {
ev.preventDefault();
ev.stopPropagation();
showChipContextMenu(ev, entry, activeType === 'favorites' ? 'favorite' : 'recent');
};
list.appendChild(itemBtn);
});
}
inner.appendChild(tabs);
inner.appendChild(list);
sidebar.innerHTML = '';
sidebar.appendChild(inner);
}
function refreshFavHistoryUi() {
renderFavHistorySidebar(currentGiteaRepos);
// Stern-Buttons im Grid aktualisieren
document.querySelectorAll('.fav-star-btn').forEach(btn => {
const card = btn.closest('.item-card');
if (!card) return;
const owner = card.dataset.owner;
const repo = card.dataset.repo;
if (!owner || !repo) return;
const active = isFavorite(owner, repo);
btn.classList.toggle('active', active);
btn.textContent = active ? '⭐' : '☆';
btn.title = active ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen';
});
}
function makeChip(entry, type, allRepos) {
const isFav = type === 'favorite';
const chip = document.createElement('div');
chip.className = `fav-chip${isFav ? ' fav-chip--star' : ''}`;
chip.title = `${entry.owner}/${entry.repo}`;
const icon = document.createElement('span');
icon.className = 'fav-chip-icon';
icon.textContent = isFav ? '⭐' : '🕐';
const label = document.createElement('span');
label.className = 'fav-chip-label';
label.textContent = `${entry.owner}/${entry.repo}`;
chip.appendChild(icon);
chip.appendChild(label);
if (!isFav && entry.openedAt) {
const time = document.createElement('span');
time.className = 'fav-chip-time';
time.textContent = formatRelDate(entry.openedAt);
chip.appendChild(time);
}
chip.onclick = () => {
addToRecent(entry.owner, entry.repo, entry.cloneUrl);
loadRepoContents(entry.owner, entry.repo, '');
};
chip.oncontextmenu = (ev) => {
ev.preventDefault();
ev.stopPropagation();
showChipContextMenu(ev, entry, type);
};
// Drag-Reorder (nur für Favoriten)
if (isFav) {
chip.draggable = true;
chip.dataset.owner = entry.owner;
chip.dataset.repo = entry.repo;
chip.addEventListener('dragstart', (ev) => {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/fav-owner', entry.owner);
ev.dataTransfer.setData('text/fav-repo', entry.repo);
chip.classList.add('fav-chip--dragging');
});
chip.addEventListener('dragend', () => chip.classList.remove('fav-chip--dragging'));
chip.addEventListener('dragover', (ev) => {
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
chip.classList.add('fav-chip--drop-target');
});
chip.addEventListener('dragleave', () => chip.classList.remove('fav-chip--drop-target'));
chip.addEventListener('drop', async (ev) => {
ev.preventDefault();
chip.classList.remove('fav-chip--drop-target');
const srcOwner = ev.dataTransfer.getData('text/fav-owner');
const srcRepo = ev.dataTransfer.getData('text/fav-repo');
if (srcOwner === entry.owner && srcRepo === entry.repo) return;
const fromIdx = favorites.findIndex(f => f.owner === srcOwner && f.repo === srcRepo);
const toIdx = favorites.findIndex(f => f.owner === entry.owner && f.repo === entry.repo);
if (fromIdx < 0 || toIdx < 0) return;
// Reorder
const [moved] = favorites.splice(fromIdx, 1);
favorites.splice(toIdx, 0, moved);
await window.electronAPI.saveFavorites(favorites);
// Sektion neu rendern
const sec = $('favRecentSection');
if (sec) {
// allRepos fehlt hier, daher einfach neu laden
const favBlock = sec.querySelector('.fav-chips-row');
if (favBlock) {
const allChips = Array.from(favBlock.querySelectorAll('.fav-chip'));
const movedChip = allChips.find(c => c.dataset.owner === srcOwner && c.dataset.repo === srcRepo);
const targetChip = allChips.find(c => c.dataset.owner === entry.owner && c.dataset.repo === entry.repo);
if (movedChip && targetChip) {
favBlock.insertBefore(movedChip, fromIdx > toIdx ? targetChip : targetChip.nextSibling);
}
}
}
});
}
return chip;
}
function showChipContextMenu(ev, entry, type) {
const old = $('ctxMenu');
if (old) old.remove();
const menu = document.createElement('div');
menu.id = 'ctxMenu';
menu.className = 'context-menu';
menu.style.left = Math.min(ev.clientX, window.innerWidth - 240) + 'px';
menu.style.top = Math.min(ev.clientY, window.innerHeight - 160) + 'px';
const addItem = (icon, text, cb, color) => {
const el = document.createElement('div');
el.className = 'context-item';
el.textContent = `${icon} ${text}`;
if (color) el.style.color = color;
el.onclick = () => { menu.remove(); cb(); };
menu.appendChild(el);
};
addItem('📂', 'Öffnen', () => {
addToRecent(entry.owner, entry.repo, entry.cloneUrl, entry.platform || currentPlatformKey());
loadRepoContents(entry.owner, entry.repo, '');
});
const fullName = `${entry.owner || ''}/${entry.repo || ''}`;
const isPrivate = !!repoPrivacyByFullName[fullName];
addItem(
isPrivate ? '🌍' : '🔒',
isPrivate ? 'Öffentlich machen' : 'Privat machen',
async () => {
await toggleRepoVisibility(entry.owner, entry.repo, isPrivate);
}
);
const chipTopics = repoTopicsByFullName[fullName] || [];
addItem('🏷️', 'Tags bearbeiten', async () => {
await editRepoTopics(entry.owner, entry.repo, chipTopics);
});
// Separator
const sep = document.createElement('div');
sep.style.cssText = 'height:1px;background:rgba(255,255,255,0.1);margin:4px 0;';
menu.appendChild(sep);
if (type === 'favorite') {
addItem('⭐', 'Aus Favoriten entfernen', async () => {
await toggleFavorite(entry.owner, entry.repo, entry.cloneUrl, entry.platform || currentPlatformKey());
loadGiteaRepos();
}, '#f59e0b');
} else {
addItem('⭐', 'Zu Favoriten hinzufügen', async () => {
await toggleFavorite(entry.owner, entry.repo, entry.cloneUrl, entry.platform || currentPlatformKey());
loadGiteaRepos();
});
addItem('✕', 'Aus Verlauf entfernen', async () => {
const p = normalizePlatform(entry.platform || currentPlatformKey());
recentRepos = recentRepos.filter(r => !(r.owner === entry.owner && r.repo === entry.repo && normalizePlatform(r.platform || 'gitea') === p));
await window.electronAPI.saveRecent(recentRepos);
loadGiteaRepos();
}, '#ef4444');
}
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
}
async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl = null, branch = 'HEAD' }) {
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:start', { owner, repo, destPath, branch, pathCount: Array.isArray(paths) ? paths.length : 0 }); } catch (_) {}
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:start', {
owner,
repo,
destPath,
branch,
pathCount: Array.isArray(paths) ? paths.length : 0,
platform: currentState.platform
});
const safePaths = (Array.isArray(paths) ? paths : []).filter(Boolean);
if (safePaths.length === 0) {
try { window.electronAPI.debugToMain('warn', 'uploadDroppedPaths:no-safe-paths', { owner, repo, destPath, branch, rawPaths: paths }); } catch (_) {}
return { ok: false, error: 'Keine gueltigen lokalen Dateipfade aus dem Drop ermittelt.' };
}
if (currentState.platform === 'github') {
return { ok: false, error: 'Drag-and-Drop Upload ist aktuell nur für Gitea aktiv.' };
}
let successCount = 0;
let failedCount = 0;
const errors = [];
const withTimeout = (promise, ms, label) => {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label}-timeout-${ms}ms`)), ms))
]);
};
for (let i = 0; i < safePaths.length; i++) {
const p = safePaths[i];
const baseName = p.split(/[\\/]/).pop() || p;
showProgress(Math.round(((i + 1) / safePaths.length) * 100), `Upload ${i + 1}/${safePaths.length}: ${baseName}`);
try {
let res = null;
// Fast path: treat dropped item as file upload first.
// If it is actually a directory, the call returns ok=false and we fall back to uploadAndPush.
const fileTry = await withTimeout(window.electronAPI.uploadGiteaFile({
owner,
repo,
localPath: [p],
destPath,
branch,
platform: currentState.platform
}), 15000, 'upload-gitea-file');
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:fileTry', { path: p, fileTry });
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:fileTry', { path: p, fileTry }); } catch (_) {}
const fileFailed = !fileTry?.ok || (Array.isArray(fileTry.results) && fileTry.results.some(r => !r.ok));
if (!fileFailed) {
res = { ok: true, via: 'upload-gitea-file', results: fileTry.results || [] };
} else {
res = await withTimeout(window.electronAPI.uploadAndPush({
localFolder: p,
owner,
repo,
destPath,
cloneUrl,
branch
}), 30000, 'upload-and-push');
}
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:itemResult', { path: p, res });
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:itemResult', { path: p, res }); } catch (_) {}
if (!res?.ok) {
failedCount++;
errors.push(`${baseName}: ${res?.error || 'Unbekannter Fehler'}`);
continue;
}
const failedEntries = Array.isArray(res.results) ? res.results.filter(r => !r.ok) : [];
if (failedEntries.length > 0) {
failedCount++;
errors.push(`${baseName}: ${failedEntries[0]?.error || 'Teilweise fehlgeschlagen'}`);
} else {
successCount++;
}
} catch (err) {
failedCount++;
const errMsg = String(err && err.message ? err.message : err);
errors.push(`${baseName}: ${errMsg}`);
console.error('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:itemError', { path: p, err: errMsg });
try { window.electronAPI.debugToMain('error', 'uploadDroppedPaths:itemError', { path: p, err: errMsg }); } catch (_) {}
}
}
if (failedCount > 0) {
return { ok: false, error: errors[0] || `${failedCount} Upload(s) fehlgeschlagen`, successCount, failedCount };
}
const result = { ok: true, uploadedFiles: successCount, uploadedDirs: 0 };
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:done', result); } catch (_) {}
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:done', result);
return result;
}
function extractDroppedPaths(files) {
const list = Array.isArray(files) ? files : Array.from(files || []);
const out = [];
for (const f of list) {
const directPath = f && typeof f.path === 'string' ? f.path : '';
if (directPath) {
out.push(directPath);
continue;
}
let resolvedPath = '';
try {
resolvedPath = window.electronAPI.getPathForFile ? window.electronAPI.getPathForFile(f) : '';
} catch (_) {}
if (resolvedPath) out.push(resolvedPath);
}
return out.filter(Boolean);
}
async function toggleRepoVisibility(owner, repoName, currentPrivate) {
try {
const creds = await window.electronAPI.loadCredentials();
if (!creds?.giteaToken || !creds?.giteaURL) {
showError('Gitea Token oder URL fehlt. Bitte zuerst in den Einstellungen speichern.');
return;
}
const targetPrivate = !currentPrivate;
const actionText = targetPrivate ? 'privat' : 'oeffentlich';
showProgress(35, `Repository wird ${actionText} gesetzt...`);
const result = await window.electronAPI.updateGiteaRepoVisibility({
token: creds.giteaToken,
url: creds.giteaURL,
owner,
repo: repoName,
isPrivate: targetPrivate
});
hideProgress();
if (result?.ok) {
showSuccess(`Repository ist jetzt ${actionText}.`);
loadGiteaRepos();
} else {
showError('Umschalten fehlgeschlagen: ' + (result?.error || 'Unbekannter Fehler'));
}
} catch (error) {
hideProgress();
console.error('Visibility toggle error:', error);
showError('Umschalten fehlgeschlagen');
}
}
function showTagsEditorModal(owner, repoName, seed, knownTopics) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal';
modal.style.zIndex = '99999';
const initial = Array.isArray(seed)
? seed.filter(Boolean)
: String(seed || '').split(',').map(t => t.trim()).filter(Boolean);
const known = Array.from(new Set([...(knownTopics || []), ...initial]))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, 'de'));
const card = document.createElement('div');
card.className = 'modalContent card';
card.style.maxWidth = '620px';
const title = document.createElement('h2');
title.textContent = '🏷️ Tags bearbeiten';
const group = document.createElement('div');
group.className = 'input-group';
const repoLabel = document.createElement('label');
repoLabel.textContent = `${owner}/${repoName}`;
const selectedHostEl = document.createElement('div');
selectedHostEl.id = 'repoTagsSelected';
selectedHostEl.className = 'tags-editor-selected';
const row = document.createElement('div');
row.className = 'tags-editor-row';
const tagInput = document.createElement('input');
tagInput.id = 'repoTagInput';
tagInput.className = 'tags-editor-input';
tagInput.type = 'text';
tagInput.placeholder = 'Vorhandene Tags suchen oder neuen Tag eingeben';
tagInput.autocomplete = 'off';
const tagAddBtn = document.createElement('button');
tagAddBtn.id = 'btnRepoTagAdd';
tagAddBtn.className = 'tags-editor-add-btn';
tagAddBtn.textContent = 'Hinzufügen';
row.appendChild(tagInput);
row.appendChild(tagAddBtn);
const suggestionsHostEl = document.createElement('div');
suggestionsHostEl.id = 'repoTagSuggestions';
suggestionsHostEl.className = 'tags-editor-suggestions';
const hint = document.createElement('div');
hint.className = 'settings-inline-hint';
hint.textContent = 'Vorschlaege kommen live von deiner Gitea-Seite. Neue Tags sind ebenfalls erlaubt.';
group.appendChild(repoLabel);
group.appendChild(selectedHostEl);
group.appendChild(row);
group.appendChild(suggestionsHostEl);
group.appendChild(hint);
const buttons = document.createElement('div');
buttons.className = 'modal-buttons';
buttons.style.marginTop = '16px';
const saveButton = document.createElement('button');
saveButton.id = 'btnRepoTagsSave';
saveButton.className = 'accent-btn';
saveButton.textContent = 'Speichern';
const cancelButton = document.createElement('button');
cancelButton.id = 'btnRepoTagsCancel';
cancelButton.className = 'secondary';
cancelButton.textContent = 'Abbrechen';
buttons.appendChild(saveButton);
buttons.appendChild(cancelButton);
card.appendChild(title);
card.appendChild(group);
card.appendChild(buttons);
modal.appendChild(card);
document.body.appendChild(modal);
const selectedHost = modal.querySelector('#repoTagsSelected');
const input = modal.querySelector('#repoTagInput');
const addBtn = modal.querySelector('#btnRepoTagAdd');
const suggestionsHost = modal.querySelector('#repoTagSuggestions');
const selected = [...initial];
const renderSelected = () => {
if (!selectedHost) return;
if (selected.length === 0) {
selectedHost.innerHTML = '<span style="color:var(--text-muted);font-size:12px;">Noch keine Tags gesetzt</span>';
return;
}
selectedHost.innerHTML = '';
selected.forEach((tag, idx) => {
const tagChip = document.createElement('span');
tagChip.style.cssText = 'display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;border:1px solid rgba(88,213,255,0.35);background:rgba(88,213,255,0.12);font-size:12px;';
tagChip.appendChild(document.createTextNode(String(tag || '')));
const removeBtn = document.createElement('button');
removeBtn.style.cssText = 'all:unset;cursor:pointer;font-size:12px;opacity:0.85;';
removeBtn.textContent = '✕';
removeBtn.onclick = () => {
if (Number.isInteger(idx) && idx >= 0 && idx < selected.length) {
selected.splice(idx, 1);
renderSelected();
}
};
tagChip.appendChild(removeBtn);
selectedHost.appendChild(tagChip);
});
};
const addTag = (rawTag) => {
const tag = String(rawTag || '').trim();
if (!tag) return;
if (selected.length >= 30) return;
const exists = selected.some(t => t.toLowerCase() === tag.toLowerCase());
if (!exists) selected.push(tag);
renderSelected();
};
const renderSuggestions = (queryRaw) => {
if (!suggestionsHost) return;
const query = String(queryRaw || '').trim().toLowerCase();
let list = known;
if (query) {
list = known.filter(t => t.toLowerCase().includes(query));
}
list = list.filter(t => !selected.some(s => s.toLowerCase() === t.toLowerCase())).slice(0, 50);
if (list.length === 0) {
suggestionsHost.innerHTML = '<div style="padding:10px 12px;color:var(--text-muted);font-size:12px;">Keine Vorschlaege</div>';
return;
}
suggestionsHost.innerHTML = '';
list.forEach(tag => {
const btn = document.createElement('button');
btn.className = 'tags-editor-suggestion-item';
btn.textContent = String(tag || '');
btn.onclick = () => {
addTag(tag);
if (input) {
input.value = '';
input.focus();
}
renderSuggestions('');
};
suggestionsHost.appendChild(btn);
});
};
const closeWith = (value) => {
modal.remove();
resolve(value);
};
if (input) {
input.focus();
input.addEventListener('input', () => renderSuggestions(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(input.value);
input.value = '';
renderSuggestions('');
}
if (e.key === 'Escape') closeWith(null);
});
}
renderSelected();
renderSuggestions('');
const saveBtn = modal.querySelector('#btnRepoTagsSave');
const cancelBtn = modal.querySelector('#btnRepoTagsCancel');
if (addBtn) addBtn.onclick = (e) => {
e.preventDefault();
addTag(input ? input.value : '');
if (input) {
input.value = '';
input.focus();
}
renderSuggestions('');
};
if (saveBtn) saveBtn.onclick = () => {
if (input && input.value.trim()) addTag(input.value.trim());
closeWith(selected.slice(0, 30));
};
if (cancelBtn) cancelBtn.onclick = () => closeWith(null);
modal.onclick = (e) => { if (e.target === modal) closeWith(null); };
});
}
async function fetchKnownTopicsFromGitea(force = false) {
const now = Date.now();
if (!force && repoKnownTopics.length > 0 && (now - repoKnownTopicsLoadedAt) < 5 * 60 * 1000) {
return repoKnownTopics;
}
if (!window.electronAPI.getGiteaTopicsCatalog) return repoKnownTopics;
try {
const res = await window.electronAPI.getGiteaTopicsCatalog();
if (res?.ok && Array.isArray(res.topics)) {
repoKnownTopics = res.topics;
repoKnownTopicsLoadedAt = now;
}
} catch (_) {}
return repoKnownTopics;
}
async function editRepoTopics(owner, repoName, currentTopics = []) {
const seed = Array.isArray(currentTopics) ? currentTopics : [];
const known = await fetchKnownTopicsFromGitea();
const raw = await showTagsEditorModal(owner, repoName, seed, known);
if (raw === null) return;
const topics = Array.isArray(raw) ? raw : [];
showProgress(35, 'Tags werden aktualisiert...');
const result = await window.electronAPI.updateGiteaRepoTopics({ owner, repo: repoName, topics });
hideProgress();
if (result?.ok) {
showSuccess('Tags aktualisiert');
loadGiteaRepos();
} else {
showError('Tags konnten nicht gespeichert werden: ' + (result?.error || 'Unbekannter Fehler'));
}
}
// Speichert den default_branch pro Repo (owner/repo -> 'main' oder 'master')
let repoDefaultBranches = {};
function getDefaultBranch(owner, repo) {
return repoDefaultBranches[`${owner}/${repo}`] || 'HEAD';
}
// Navigations-Status für die Explorer-Ansicht
let currentState = {
view: 'none', // 'local', 'gitea-list', 'gitea-repo'
owner: null,
repo: null,
path: '',
platform: 'gitea' // 'gitea' | 'github'
};
let repoLoadRequestId = 0;
const USER_CACHE_MS = 5 * 60 * 1000;
let currentUserCache = {
gitea: { name: '', ts: 0 },
github: { name: '', ts: 0 }
};
function getCachedUsername(platform) {
const p = platform === 'github' ? 'github' : 'gitea';
const item = currentUserCache[p];
if (!item?.name) return '';
if ((Date.now() - Number(item.ts || 0)) > USER_CACHE_MS) return '';
return item.name;
}
function setCachedUsername(platform, name) {
const p = platform === 'github' ? 'github' : 'gitea';
currentUserCache[p] = {
name: String(name || ''),
ts: Date.now()
};
}
const MAX_ACTIVITY_ITEMS = 300;
let activityEntries = [];
let retryQueueCount = 0;
const HEATMAP_CACHE_MS = 5 * 60 * 1000;
let remoteHeatmapFetchState = 'idle'; // idle | ok | error
let remoteHeatmapFetchedAt = 0;
let remoteHeatmapCounts = new Map();
let remoteHeatmapUsername = '';
let remoteHeatmapPlatform = '';
let remoteHeatmapMonths = 0;
function getActiveHeatmapMonths() {
return activityHeatmapRangeMonths;
}
function logActivity(level, message) {
const entry = {
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
level: level || 'info',
message: String(message || ''),
ts: new Date().toISOString()
};
activityEntries.unshift(entry);
if (activityEntries.length > MAX_ACTIVITY_ITEMS) {
activityEntries = activityEntries.slice(0, MAX_ACTIVITY_ITEMS);
}
renderActivityLog();
refreshActivityHeatmapIfVisible();
}
function refreshActivityHeatmapIfVisible() {
const host = $('repoActivityHeatmapHost');
if (!host) return;
renderActivityHeatmap(host);
}
function normalizeHeatmapEntryDate(value) {
if (value == null) return null;
if (typeof value === 'string') {
const m = value.match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return formatDateKey(d);
return null;
}
if (typeof value === 'number' && Number.isFinite(value)) {
const ms = value < 1e12 ? value * 1000 : value;
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return null;
return formatDateKey(d);
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return formatDateKey(value);
}
return null;
}
function setRemoteHeatmapEntries(entries) {
const next = new Map();
if (Array.isArray(entries)) {
for (const item of entries) {
if (!item) continue;
const date = normalizeHeatmapEntryDate(item.date || item.day || item.timestamp || item.ts);
if (!date) continue;
const countNum = Number(item.count ?? item.value ?? item.contributions ?? 0);
const count = Number.isFinite(countNum) ? Math.max(0, Math.floor(countNum)) : 0;
next.set(date, (next.get(date) || 0) + count);
}
}
remoteHeatmapCounts = next;
}
function hasPositiveHeatmapEntries(entries) {
return Array.isArray(entries) && entries.some(item => Number(item?.count || 0) > 0);
}
async function buildGithubHeatmapFromRepoCommits(monthsBack) {
if (currentPlatformKey() !== 'github') return [];
const repos = Array.isArray(currentGiteaRepos) ? currentGiteaRepos.slice(0, 60) : [];
if (repos.length === 0) return [];
const since = new Date();
since.setMonth(since.getMonth() - Math.max(1, Number(monthsBack) || 20));
const sinceTs = since.getTime();
const myLogin = String(currentGiteaUsername || '').trim().toLowerCase();
const dayMap = new Map();
const concurrency = 4;
let repoIndex = 0;
const worker = async () => {
while (repoIndex < repos.length) {
const repo = repos[repoIndex++];
const owner = repo?.owner?.login || repo?.owner?.username;
const repoName = repo?.name;
if (!owner || !repoName) continue;
try {
const res = await window.electronAPI.getCommits({
owner,
repo: repoName,
branch: repo?.default_branch || 'HEAD',
limit: 100,
platform: 'github'
});
if (!res?.ok || !Array.isArray(res.commits)) continue;
for (const commit of res.commits) {
const authorLogin = String(commit?.author?.login || '').toLowerCase();
if (myLogin && authorLogin && authorLogin !== myLogin) continue;
const dateValue = commit?.commit?.author?.date || commit?.commit?.committer?.date;
if (!dateValue) continue;
const ts = new Date(dateValue).getTime();
if (!Number.isFinite(ts) || ts < sinceTs) continue;
const key = normalizeHeatmapEntryDate(dateValue);
if (!key) continue;
dayMap.set(key, (dayMap.get(key) || 0) + 1);
}
} catch (_) {
// einzelne Repos koennen fehlschlagen ohne den Gesamtprozess zu blockieren
}
}
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return Array.from(dayMap.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
}
async function loadRemoteHeatmapData(force = false) {
const platform = currentState.platform === 'github' ? 'github' : 'gitea';
const monthsBack = getActiveHeatmapMonths();
const now = Date.now();
if (!force && remoteHeatmapFetchState === 'ok' && remoteHeatmapPlatform === platform && remoteHeatmapMonths === monthsBack && (now - remoteHeatmapFetchedAt) < HEATMAP_CACHE_MS) {
return;
}
const loadFn = platform === 'github' ? window.electronAPI?.getGithubUserHeatmap : window.electronAPI?.getGiteaUserHeatmap;
if (!window.electronAPI || typeof loadFn !== 'function') {
remoteHeatmapFetchState = 'error';
remoteHeatmapPlatform = platform;
remoteHeatmapMonths = monthsBack;
remoteHeatmapUsername = '';
setRemoteHeatmapEntries([]);
return;
}
try {
const res = await loadFn({ monthsBack });
if (res && res.ok) {
remoteHeatmapUsername = res.username || currentGiteaUsername || '';
let entries = Array.isArray(res.entries) ? res.entries : [];
if (platform === 'github' && !hasPositiveHeatmapEntries(entries)) {
entries = await buildGithubHeatmapFromRepoCommits(monthsBack);
}
setRemoteHeatmapEntries(entries);
remoteHeatmapFetchedAt = now;
remoteHeatmapFetchState = 'ok';
remoteHeatmapPlatform = platform;
remoteHeatmapMonths = monthsBack;
return;
}
remoteHeatmapFetchState = 'error';
remoteHeatmapPlatform = platform;
remoteHeatmapMonths = monthsBack;
remoteHeatmapUsername = '';
setRemoteHeatmapEntries([]);
} catch (_) {
remoteHeatmapFetchState = 'error';
remoteHeatmapPlatform = platform;
remoteHeatmapMonths = monthsBack;
remoteHeatmapUsername = '';
setRemoteHeatmapEntries([]);
}
}
function formatDateKey(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addDays(date, days) {
const n = new Date(date);
n.setDate(n.getDate() + days);
return n;
}
function shiftMonths(date, deltaMonths) {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
d.setMonth(d.getMonth() + deltaMonths);
return d;
}
function buildHeatmapData(monthsBack = activityHeatmapRangeMonths) {
const today = startOfDay(new Date());
const from = startOfDay(shiftMonths(today, -(Math.max(1, Number(monthsBack) || 6))));
const fromDow = (from.getDay() + 6) % 7; // Monday=0
const gridStart = addDays(from, -fromDow);
const counts = new Map();
let source = 'local';
if (remoteHeatmapFetchState === 'ok') {
source = 'remote';
for (const [key, count] of remoteHeatmapCounts.entries()) {
const day = startOfDay(new Date(key));
if (Number.isNaN(day.getTime()) || day < from || day > today) continue;
counts.set(key, Math.max(0, Number(count) || 0));
}
} else {
for (const entry of activityEntries) {
const ts = entry && entry.ts ? new Date(entry.ts) : null;
if (!ts || Number.isNaN(ts.getTime())) continue;
const day = startOfDay(ts);
if (day < from || day > today) continue;
const key = formatDateKey(day);
counts.set(key, (counts.get(key) || 0) + 1);
}
}
const totalDays = Math.floor((today - gridStart) / 86400000) + 1;
const weekCount = Math.ceil(totalDays / 7);
const weeks = [];
let maxCount = 0;
let total = 0;
for (let w = 0; w < weekCount; w++) {
const week = [];
for (let d = 0; d < 7; d++) {
const date = addDays(gridStart, w * 7 + d);
const inRange = date >= from && date <= today;
const key = formatDateKey(date);
const count = inRange ? (counts.get(key) || 0) : 0;
if (count > maxCount) maxCount = count;
total += count;
week.push({ date, inRange, count });
}
weeks.push(week);
}
return { weeks, total, maxCount, source };
}
function heatmapLevel(count, max) {
if (!count) return 0;
const q1 = Math.max(1, Math.ceil(max * 0.25));
const q2 = Math.max(q1 + 1, Math.ceil(max * 0.5));
const q3 = Math.max(q2 + 1, Math.ceil(max * 0.75));
if (count <= q1) return 1;
if (count <= q2) return 2;
if (count <= q3) return 3;
return 4;
}
function renderActivityHeatmap(host) {
if (!host) return;
host.innerHTML = '';
const monthsBack = getActiveHeatmapMonths();
const { weeks, total, maxCount, source } = buildHeatmapData(monthsBack);
// Dynamische Zellgröße: passt die Heatmap an die verfügbare Kartenbreite an,
// damit rechts keine Monate/Tage abgeschnitten werden.
const weekCount = Math.max(1, weeks.length);
const weekdayCol = 22;
const gridGap = 8;
const weekGap = 2;
const hostWidth = Math.max(320, host.clientWidth || 0);
const horizontalPadding = 24;
const availableForGrid = Math.max(220, hostWidth - horizontalPadding - weekdayCol - gridGap);
const computedCell = (availableForGrid - ((weekCount - 1) * weekGap)) / weekCount;
const cellSize = Math.max(6, Math.min(12, Number(computedCell.toFixed(2))));
const card = document.createElement('section');
card.className = 'activity-heatmap-card' + (activityHeatmapCollapsed ? ' collapsed' : '');
const header = document.createElement('div');
header.className = 'activity-heatmap-header';
const title = document.createElement('strong');
title.textContent = 'Aktivitäts-Heatmap';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'activity-heatmap-toggle';
toggleBtn.textContent = activityHeatmapCollapsed ? '▸ Ausklappen' : '▾ Einklappen';
toggleBtn.onclick = () => {
activityHeatmapCollapsed = !activityHeatmapCollapsed;
renderActivityHeatmap(host);
};
const controls = document.createElement('div');
controls.className = 'activity-heatmap-controls';
controls.appendChild(toggleBtn);
header.appendChild(title);
header.appendChild(controls);
card.appendChild(header);
const body = document.createElement('div');
body.className = 'activity-heatmap-body';
body.style.setProperty('--hm-cell-size', `${cellSize}px`);
body.style.setProperty('--hm-week-gap', `${weekGap}px`);
const months = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
const monthsRow = document.createElement('div');
monthsRow.className = 'activity-heatmap-months';
monthsRow.style.setProperty('--hm-weeks', String(weeks.length));
let prevMonth = -1;
weeks.forEach((week, idx) => {
const firstInRange = week.find(day => day.inRange);
if (!firstInRange) return;
const month = firstInRange.date.getMonth();
if (month !== prevMonth) {
const label = document.createElement('span');
label.textContent = months[month];
label.style.gridColumn = `${idx + 1} / span 2`;
monthsRow.appendChild(label);
prevMonth = month;
}
});
const gridWrap = document.createElement('div');
gridWrap.className = 'activity-heatmap-grid-wrap';
const weekdays = document.createElement('div');
weekdays.className = 'activity-heatmap-weekdays';
['Mo', '', 'Mi', '', 'Fr', '', ''].forEach(txt => {
const el = document.createElement('span');
el.textContent = txt;
weekdays.appendChild(el);
});
const grid = document.createElement('div');
grid.className = 'activity-heatmap-grid';
grid.style.setProperty('--hm-weeks', String(weeks.length));
weeks.forEach(week => {
const col = document.createElement('div');
col.className = 'activity-heatmap-week';
week.forEach(day => {
const cell = document.createElement('div');
const level = day.inRange ? heatmapLevel(day.count, maxCount || 1) : 0;
cell.className = `activity-heatmap-cell lv${level}` + (day.inRange ? '' : ' out');
cell.title = day.inRange
? `${day.count} Aktivitäten am ${day.date.toLocaleDateString('de-DE')}`
: '';
col.appendChild(cell);
});
grid.appendChild(col);
});
gridWrap.appendChild(weekdays);
gridWrap.appendChild(grid);
const footer = document.createElement('div');
footer.className = 'activity-heatmap-footer';
const summary = document.createElement('span');
if (source === 'remote') {
const profileLabel = currentState.platform === 'github' ? 'GitHub-Profil' : 'Git-Profil';
summary.textContent = `${total.toLocaleString('de-DE')} Beiträge vom ${profileLabel} in den letzten ${monthsBack} Monaten${remoteHeatmapUsername ? ` (${remoteHeatmapUsername})` : ''}`;
} else {
summary.textContent = `${total.toLocaleString('de-DE')} lokale Einträge in den letzten ${monthsBack} Monaten`;
}
const legend = document.createElement('div');
legend.className = 'activity-heatmap-legend';
const less = document.createElement('span');
less.textContent = 'Weniger';
legend.appendChild(less);
[0, 1, 2, 3, 4].forEach((lv) => {
const cell = document.createElement('i');
cell.className = `activity-heatmap-cell lv${lv}`;
legend.appendChild(cell);
});
const more = document.createElement('span');
more.textContent = 'Mehr';
legend.appendChild(more);
footer.appendChild(summary);
footer.appendChild(legend);
body.appendChild(monthsRow);
body.appendChild(gridWrap);
body.appendChild(footer);
card.appendChild(body);
host.appendChild(card);
}
function formatActivityTimestamp(iso) {
try {
return new Date(iso).toLocaleTimeString('de-DE', { hour12: false });
} catch (_) {
return '--:--:--';
}
}
function renderActivityLog() {
const list = $('activityLogList');
if (!list) return;
const filter = ($('activityFilterLevel') && $('activityFilterLevel').value) || 'all';
const visible = activityEntries.filter(e => filter === 'all' || e.level === filter);
if (visible.length === 0) {
list.innerHTML = '';
const row = document.createElement('div');
row.className = 'activity-log-item info';
const msg = document.createElement('span');
msg.className = 'activity-log-message';
msg.textContent = 'Noch keine Einträge.';
row.appendChild(msg);
list.appendChild(row);
return;
}
list.innerHTML = '';
for (const e of visible) {
const row = document.createElement('div');
row.className = `activity-log-item ${e.level || 'info'}`;
const time = document.createElement('span');
time.className = 'activity-log-time';
time.textContent = formatActivityTimestamp(e.ts);
const level = document.createElement('span');
level.className = 'activity-log-level';
level.textContent = (e.level || 'info').toUpperCase();
const msg = document.createElement('span');
msg.className = 'activity-log-message';
msg.textContent = String(e.message || '');
row.appendChild(time);
row.appendChild(level);
row.appendChild(msg);
list.appendChild(row);
}
}
function updateRetryQueueBadge(count) {
retryQueueCount = Math.max(0, Number(count || 0));
const btn = $('btnRetryQueueNow');
if (btn) btn.textContent = `🔁 Queue (${retryQueueCount})`;
const info = $('activityQueueInfo');
if (info) info.textContent = `Retry-Queue: ${retryQueueCount}`;
}
function parseBatchRepoInput(raw) {
return String(raw || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.filter(line => line.includes('/'));
}
function renderInlineHint(id, text, tone = 'muted') {
const el = $(id);
if (!el) return;
el.textContent = text || '';
el.classList.remove('error', 'success', 'warn');
if (tone === 'error') el.classList.add('error');
if (tone === 'success') el.classList.add('success');
if (tone === 'warn') el.classList.add('warn');
}
function isRepoNameFormatValid(name) {
return /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,98}[a-zA-Z0-9])?$/.test(String(name || '').trim());
}
function findSimilarRepoNamesLocally(name) {
const target = String(name || '').toLowerCase();
const normalizedTarget = target.replace(/[\s._-]+/g, '');
const names = (currentGiteaRepos || [])
.map(r => String(r && r.name || '').trim())
.filter(Boolean);
const exact = names.some(n => n.toLowerCase() === target);
const similar = names
.filter(n => {
const lower = n.toLowerCase();
const normalized = lower.replace(/[\s._-]+/g, '');
return lower.includes(target) || target.includes(lower) || normalized.includes(normalizedTarget) || normalizedTarget.includes(normalized);
})
.filter(n => n.toLowerCase() !== target)
.slice(0, 8);
return { exact, similar };
}
async function validateRepoNameLive(name) {
const value = String(name || '').trim();
if (!value) {
renderInlineHint('repoNameValidationHint', 'Name prüfen: Duplikate, ähnliche Namen und ungültige Zeichen werden erkannt.', 'muted');
return { ok: true, blocking: false, existsExact: false, similar: [] };
}
if (!isRepoNameFormatValid(value)) {
renderInlineHint('repoNameValidationHint', 'Ungültiger Name. Erlaubt: Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich (1-100 Zeichen).', 'error');
return { ok: true, blocking: true, existsExact: false, similar: [] };
}
let checkedRemotely = false;
let existsExact = false;
let similar = [];
if (window.electronAPI.validateRepoName) {
try {
const platform = $('platform')?.value || 'gitea';
const res = await window.electronAPI.validateRepoName({ name: value, platform });
if (res && res.ok) {
checkedRemotely = !!res.checked;
existsExact = !!res.existsExact;
similar = Array.isArray(res.similar) ? res.similar : [];
}
} catch (_) {}
}
if (!checkedRemotely) {
const local = findSimilarRepoNamesLocally(value);
existsExact = local.exact;
similar = local.similar;
}
if (existsExact) {
renderInlineHint('repoNameValidationHint', 'Dieses Repository existiert bereits. Bitte einen anderen Namen wählen.', 'error');
return { ok: true, blocking: true, existsExact, similar };
}
if (similar.length > 0) {
renderInlineHint('repoNameValidationHint', `Ähnliche Namen gefunden: ${similar.slice(0, 3).join(', ')}`, 'warn');
return { ok: true, blocking: false, existsExact, similar };
}
renderInlineHint('repoNameValidationHint', 'Name ist frei und valide.', 'success');
return { ok: true, blocking: false, existsExact, similar };
}
function scheduleRepoNameValidation() {
if (repoNameValidationTimer) clearTimeout(repoNameValidationTimer);
repoNameValidationTimer = setTimeout(() => {
validateRepoNameLive($('repoName')?.value || '');
}, 180);
}
async function validateBatchCloneCollisions(strict = false) {
const action = $('batchActionType')?.value || 'refresh';
if (action !== 'clone') {
renderInlineHint('batchCloneValidationHint', 'Kollisionsprüfung aktiv: vorhandene Zielordner und Namenskonflikte werden angezeigt.', 'muted');
return true;
}
const repos = parseBatchRepoInput($('batchRepoList')?.value || '');
const targetDir = $('batchCloneTarget')?.value || '';
if (repos.length === 0) {
renderInlineHint('batchCloneValidationHint', 'Keine Repositories eingetragen (Format: owner/repo).', strict ? 'error' : 'warn');
return !strict;
}
if (!targetDir) {
renderInlineHint('batchCloneValidationHint', 'Bitte Zielordner wählen, um Kollisionen zu prüfen.', strict ? 'error' : 'warn');
return !strict;
}
if (!window.electronAPI.checkCloneTargetCollisions) {
renderInlineHint('batchCloneValidationHint', 'Kollisionsprüfung nicht verfügbar.', strict ? 'error' : 'warn');
return !strict;
}
try {
const res = await window.electronAPI.checkCloneTargetCollisions({ targetDir, repos });
if (!res || !res.ok) {
renderInlineHint('batchCloneValidationHint', 'Kollisionsprüfung fehlgeschlagen.', strict ? 'error' : 'warn');
return !strict;
}
const dupes = Array.isArray(res.duplicateRepoNames) ? res.duplicateRepoNames : [];
const existing = Array.isArray(res.existingTargets) ? res.existingTargets : [];
if (dupes.length > 0 || existing.length > 0) {
const duplicateText = dupes.length > 0 ? `Doppelte Repo-Namen: ${dupes.slice(0, 3).join(', ')}` : '';
const existingText = existing.length > 0 ? `Vorhandene Zielordner: ${existing.slice(0, 2).join(' | ')}` : '';
const joined = [duplicateText, existingText].filter(Boolean).join(' • ');
renderInlineHint('batchCloneValidationHint', joined || 'Kollision erkannt.', 'error');
return false;
}
renderInlineHint('batchCloneValidationHint', 'Keine Kollisionen gefunden. Clone-Ziel ist sauber.', 'success');
return true;
} catch (_) {
renderInlineHint('batchCloneValidationHint', 'Kollisionsprüfung fehlgeschlagen.', strict ? 'error' : 'warn');
return !strict;
}
}
function scheduleBatchCloneValidation() {
if (batchCloneValidationTimer) clearTimeout(batchCloneValidationTimer);
batchCloneValidationTimer = setTimeout(() => {
validateBatchCloneCollisions(false);
}, 220);
}
function updateBatchActionFields() {
const action = $('batchActionType')?.value || 'refresh';
const cloneGroup = $('batchCloneGroup');
const tagGroup = $('batchTagGroup');
const releaseNameGroup = $('batchReleaseNameGroup');
const releaseBodyGroup = $('batchReleaseBodyGroup');
if (cloneGroup) cloneGroup.classList.toggle('hidden', action !== 'clone');
if (tagGroup) tagGroup.classList.toggle('hidden', !(action === 'create-tag' || action === 'create-release'));
if (releaseNameGroup) releaseNameGroup.classList.toggle('hidden', action !== 'create-release');
if (releaseBodyGroup) releaseBodyGroup.classList.toggle('hidden', !(action === 'create-tag' || action === 'create-release'));
}
// Clipboard für Cut & Paste
let clipboard = {
item: null, // { path, name, type, owner, repo, isGitea, isLocal, nodePath }
action: null // 'cut'
};
// Mehrfachauswahl
let selectedItems = new Set(); // Set von item-Pfaden
let isMultiSelectMode = false;
// Zuletzt angeklicktes Item (für F2/Entf)
let lastSelectedItem = null; // { type:'gitea', item, owner, repo } | { type:'local', node }
// Feature-Flag für farbige Icons
let featureColoredIcons = true;
let repoSearchHotkeyBound = false;
let settingsHealth = {
url: 'Unbekannt',
api: 'Unbekannt',
auth: 'Unbekannt',
latency: '-',
version: '-',
lastError: '-'
};
function setHealthField(id, value) {
const el = $(id);
if (!el) return;
el.textContent = value;
el.classList.remove('health-ok', 'health-warn', 'health-error');
const v = (value || '').toLowerCase();
if (v === 'ok' || v === 'erreichbar' || v === 'gueltig' || v === 'gültig') {
el.classList.add('health-ok');
} else if (v === 'fehler' || v === 'ungueltig' || v === 'ungültig') {
el.classList.add('health-error');
} else if (v === 'unbekannt' || v === 'kein token' || v === 'token vorhanden' || v === 'nicht konfiguriert') {
el.classList.add('health-warn');
}
}
function renderSettingsHealth() {
setHealthField('healthUrl', settingsHealth.url);
setHealthField('healthApi', settingsHealth.api);
setHealthField('healthAuth', settingsHealth.auth);
setHealthField('healthLatency', settingsHealth.latency);
setHealthField('healthVersion', settingsHealth.version);
setHealthField('healthLastError', settingsHealth.lastError);
}
function syncSettingsPanelHeights() {
const credentialsPanel = document.querySelector('#settingsModal .settings-panel--credentials');
const healthPanel = document.querySelector('#settingsModal .settings-panel--health');
if (!credentialsPanel || !healthPanel) return;
healthPanel.style.minHeight = '';
// In der einspaltigen Ansicht sollen die Karten natuerlich fliessen.
if (window.matchMedia('(max-width: 1120px)').matches) return;
const targetHeight = Math.ceil(credentialsPanel.getBoundingClientRect().height);
if (targetHeight > 0) {
healthPanel.style.minHeight = `${targetHeight}px`;
}
}
function updateSettingsHealth(patch) {
settingsHealth = { ...settingsHealth, ...patch };
renderSettingsHealth();
syncSettingsPanelHeights();
}
function normalizeAndValidateGiteaUrl(rawUrl) {
const value = (rawUrl || '').trim();
if (!value) return { ok: true, value: '' };
let parsed;
try {
parsed = new URL(value);
} catch (_) {
return {
ok: false,
error: 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000'
};
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
ok: false,
error: 'URL muss mit http:// oder https:// beginnen.'
};
}
return { ok: true, value: value.replace(/\/$/, '') };
}
function renderGiteaUrlHint(rawValue, rawToken = '') {
const hint = $('giteaUrlHint');
if (!hint) return;
const url = String(rawValue || '').trim();
const token = String(rawToken || '').trim();
const result = normalizeAndValidateGiteaUrl(url);
const connected = !!token && !!url && !!result.ok;
hint.className = `settings-inline-hint ${connected ? 'success' : 'error'}`;
hint.textContent = connected ? 'Verbunden' : 'Nicht verbunden';
}
function renderGithubTokenHint(rawToken) {
const hint = $('githubTokenHint');
if (!hint) return;
const token = String(rawToken || '').trim();
const connected = !!token;
hint.className = `settings-inline-hint ${connected ? 'success' : 'error'}`;
hint.textContent = connected ? 'Verbunden' : 'Nicht verbunden';
}
function mapErrorMessage(message) {
const raw = String(message || '').toLowerCase();
if (!raw) return 'Unbekannter Fehler';
if (raw.includes('401') || raw.includes('unauthorized') || raw.includes('authentifizierung')) {
return 'Authentifizierung fehlgeschlagen. Bitte Token prüfen.';
}
if (raw.includes('403') || raw.includes('forbidden') || raw.includes('zugriff verweigert')) {
return 'Zugriff verweigert. Bitte Token-Berechtigungen prüfen.';
}
if (raw.includes('404') || raw.includes('not found') || raw.includes('nicht gefunden')) {
return 'Server oder Ressource nicht gefunden. URL/Repo prüfen.';
}
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
return 'Server nicht erreichbar. DNS, IPv4/IPv6 und Port prüfen.';
}
if (raw.includes('timeout') || raw.includes('econnaborted') || raw.includes('zeitueberschreitung') || raw.includes('zeitüberschreitung')) {
return 'Zeitüberschreitung bei der Verbindung. Bitte erneut versuchen.';
}
if (raw.includes('ungueltige') || raw.includes('ungültige') || raw.includes('invalid') || raw.includes('url')) {
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000';
}
return String(message);
}
function setStatus(txt) {
const s = $('status');
if (s) s.innerText = txt || '';
}
/* -------------------------
TOAST NOTIFICATIONS
------------------------- */
function showToast(message, type = 'info', duration = 4000) {
const container = (() => {
let c = $('toastContainer');
if (!c) {
c = document.createElement('div');
c.id = 'toastContainer';
c.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
z-index: 99999;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
`;
document.body.appendChild(c);
}
return c;
})();
const colors = {
error: { bg: 'rgba(239,68,68,0.15)', border: '#ef4444', icon: '✗' },
success: { bg: 'rgba(34,197,94,0.15)', border: '#22c55e', icon: '✓' },
info: { bg: 'rgba(0,212,255,0.12)', border: '#00d4ff', icon: '' },
warning: { bg: 'rgba(245,158,11,0.15)', border: '#f59e0b', icon: '⚠' },
};
const c = colors[type] || colors.info;
const toast = document.createElement('div');
toast.style.cssText = `
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background: ${c.bg};
border: 1px solid ${c.border};
border-left: 3px solid ${c.border};
border-radius: 10px;
backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
color: #fff;
font-size: 13px;
font-weight: 500;
max-width: 360px;
pointer-events: auto;
cursor: pointer;
opacity: 0;
transform: translateX(20px);
transition: opacity 220ms ease, transform 220ms ease;
line-height: 1.4;
`;
const iconEl = document.createElement('span');
iconEl.style.cssText = `color: ${c.border}; font-weight: 700; font-size: 15px; flex-shrink: 0; margin-top: 1px;`;
iconEl.textContent = c.icon;
const msgEl = document.createElement('span');
msgEl.textContent = message;
toast.appendChild(iconEl);
toast.appendChild(msgEl);
container.appendChild(toast);
// Einblenden
requestAnimationFrame(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateX(0)';
});
const dismiss = () => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
setTimeout(() => toast.remove(), 220);
};
toast.addEventListener('click', dismiss);
setTimeout(dismiss, duration);
}
// Kurzformen
function showError(msg) {
const friendly = mapErrorMessage(msg);
updateSettingsHealth({ lastError: friendly });
setStatus(friendly);
showToast(friendly, 'error');
logActivity('error', friendly);
}
function showSuccess(msg) { setStatus(msg); showToast(msg, 'success', 3000); logActivity('info', msg); }
function showWarning(msg) { setStatus(msg); showToast(msg, 'warning'); logActivity('warning', msg); }
function showInfo(msg) { setStatus(msg); showToast(msg, 'info', 2500); }
function normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
function fuzzyScoreToken(token, text) {
if (!token || !text) return 0;
const t = normalizeSearchText(text);
const q = normalizeSearchText(token);
if (!q) return 0;
// Direkter Treffer bekommt starken Score
const idx = t.indexOf(q);
if (idx >= 0) {
let score = 120 - Math.min(idx, 60);
if (t.startsWith(q)) score += 20;
return score;
}
// Fuzzy: alle Zeichen in Reihenfolge vorhanden
let ti = 0;
let gaps = 0;
for (let qi = 0; qi < q.length; qi++) {
const ch = q[qi];
const found = t.indexOf(ch, ti);
if (found === -1) return 0;
if (found > ti) gaps += (found - ti);
ti = found + 1;
}
return Math.max(8, 80 - gaps * 2);
}
function getRepoCardSearchScore(card, query) {
const raw = normalizeSearchText(query).trim();
if (!raw) return 1;
const tokens = raw.split(/\s+/).filter(Boolean);
const fields = [
card.dataset.searchName || '',
card.dataset.searchOwner || '',
card.dataset.searchFull || '',
card.dataset.searchLanguage || '',
card.dataset.searchTopics || '',
card.dataset.searchDescription || ''
];
let total = 0;
for (const token of tokens) {
let best = 0;
for (const field of fields) {
best = Math.max(best, fuzzyScoreToken(token, field));
}
if (best === 0) return 0;
total += best;
}
return total;
}
function applyRepoFuzzyFilter(grid, searchInput, searchMetaEl) {
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.item-card'));
const query = (searchInput?.value || '').trim();
const isOwnerMatch = (card) => {
const owner = String(card.dataset.searchOwner || '').toLowerCase();
const isShared = String(card.dataset.shared || '') === 'true';
if (activeRepoOwnerFilter === 'all') return true;
if (activeRepoOwnerFilter === 'mine') {
if (!currentGiteaUsername) return true;
return owner === String(currentGiteaUsername).toLowerCase();
}
if (activeRepoOwnerFilter === 'shared') {
return isShared;
}
if (activeRepoOwnerFilter.startsWith('owner:')) {
const target = activeRepoOwnerFilter.slice('owner:'.length).toLowerCase();
return owner === target;
}
return true;
};
if (!query) {
cards.forEach(card => {
card.style.display = isOwnerMatch(card) ? 'flex' : 'none';
card.style.order = '';
});
if (searchMetaEl) {
const visibleCount = cards.filter(card => isOwnerMatch(card)).length;
searchMetaEl.textContent = `${visibleCount} Repositories`;
}
return;
}
const ranked = cards
.filter(card => isOwnerMatch(card))
.map(card => ({ card, score: getRepoCardSearchScore(card, query) }))
.filter(entry => entry.score > 0)
.sort((a, b) => b.score - a.score || a.card.dataset.searchName.localeCompare(b.card.dataset.searchName));
const visibleSet = new Set(ranked.map(entry => entry.card));
cards.forEach(card => {
card.style.display = visibleSet.has(card) ? 'flex' : 'none';
});
ranked.forEach(entry => grid.appendChild(entry.card));
if (searchMetaEl) {
const label = ranked.length === 1 ? 'Treffer' : 'Treffer';
searchMetaEl.textContent = `${ranked.length} ${label} für "${query}"`;
}
}
function isRepoWritable(repo, currentUsername) {
const owner = String(repo?.owner?.login || repo?.owner?.username || '').toLowerCase();
const me = String(currentUsername || '').toLowerCase();
if (owner && me && owner === me) return true;
const perms = repo?.permissions || repo?.permission || {};
if (perms.admin === true) return true;
if (perms.push === true) return true;
return false;
}
function buildRepoWebUrl(owner, repoName) {
const urlInput = $('giteaURL');
const normalized = normalizeAndValidateGiteaUrl(urlInput?.value || '');
if (!normalized.ok || !normalized.value) return null;
return `${normalized.value}/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`;
}
// Löschen-Bestätigung als Toast (ersetzt confirm())
function showDeleteConfirm(message, onConfirm) {
const container = (() => {
let c = $('toastContainer');
if (!c) { c = document.createElement('div'); c.id = 'toastContainer'; c.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;'; document.body.appendChild(c); }
return c;
})();
const toast = document.createElement('div');
toast.style.cssText = `
padding: 14px 16px;
background: rgba(239,68,68,0.15);
border: 1px solid #ef4444;
border-left: 3px solid #ef4444;
border-radius: 10px;
backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
color: #fff;
font-size: 13px;
max-width: 360px;
pointer-events: auto;
opacity: 0;
transform: translateX(20px);
transition: opacity 220ms ease, transform 220ms ease;
`;
const msgEl = document.createElement('div');
msgEl.style.cssText = 'font-weight:600;margin-bottom:10px;';
msgEl.textContent = '🗑️ ' + message;
const btns = document.createElement('div');
btns.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Abbrechen';
cancelBtn.style.cssText = 'padding:5px 12px;border-radius:6px;border:1px solid rgba(255,255,255,0.2);background:transparent;color:#fff;cursor:pointer;font-size:12px;';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = 'Löschen';
confirmBtn.style.cssText = 'padding:5px 12px;border-radius:6px;border:none;background:#ef4444;color:#fff;cursor:pointer;font-size:12px;font-weight:600;';
btns.appendChild(cancelBtn);
btns.appendChild(confirmBtn);
toast.appendChild(msgEl);
toast.appendChild(btns);
container.appendChild(toast);
requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; });
const dismiss = () => { toast.style.opacity = '0'; toast.style.transform = 'translateX(20px)'; setTimeout(() => toast.remove(), 220); };
cancelBtn.addEventListener('click', dismiss);
confirmBtn.addEventListener('click', () => { dismiss(); onConfirm(); });
setTimeout(dismiss, 8000);
}
/* -------------------------
PROGRESS UI
------------------------- */
function ensureProgressUI() {
if ($('folderProgressContainer')) return;
const container = document.createElement('div');
container.id = 'folderProgressContainer';
container.style.cssText = `
position: fixed;
left: 50%;
top: 12px;
transform: translateX(-50%);
z-index: 10000;
width: 480px;
max-width: 90%;
padding: 12px 16px;
background: rgba(20,20,30,0.98);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
color: #fff;
font-family: sans-serif;
display: none;
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 212, 255, 0.2);
`;
const text = document.createElement('div');
text.id = 'folderProgressText';
text.style.cssText = `
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #00d4ff;
`;
container.appendChild(text);
const barWrap = document.createElement('div');
barWrap.style.cssText = `
width: 100%;
height: 12px;
background: rgba(255,255,255,0.1);
border-radius: 6px;
overflow: hidden;
position: relative;
`;
const bar = document.createElement('div');
bar.id = 'folderProgressBar';
bar.style.cssText = `
width: 0%;
height: 100%;
background: linear-gradient(90deg, #00d4ff, #8b5cf6);
transition: width 200ms ease-out;
border-radius: 6px;
`;
barWrap.appendChild(bar);
container.appendChild(barWrap);
document.body.appendChild(container);
}
function showProgress(percent, text) {
ensureProgressUI();
const container = $('folderProgressContainer');
const bar = $('folderProgressBar');
const txt = $('folderProgressText');
if (txt) txt.innerText = text || '';
if (bar) bar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
if (container) container.style.display = 'block';
}
function hideProgress() {
const container = $('folderProgressContainer');
if (container) {
setTimeout(() => {
container.style.display = 'none';
}, 500);
}
}
/* -------------------------
ADVANCED FILE EDITOR - WITH TABS, UNDO/REDO, AUTO-SAVE, LINE NUMBERS
------------------------- */
// Editor State
let openTabs = {}; // { filePath: { name, content, originalContent, dirty, icon, history, historyIndex } }
let currentActiveTab = null;
let autoSaveTimer = null;
let autoSaveInterval = 3000; // 3 sekunden
// Initialize editor
function initEditor() {
const textarea = $('fileEditorContent');
if (!textarea) return;
textarea.addEventListener('input', () => {
updateCurrentTab();
updateLineNumbers();
updateEditorStats();
triggerAutoSave();
});
textarea.addEventListener('scroll', () => {
const lineNumbers = $('lineNumbers');
if (lineNumbers) lineNumbers.scrollTop = textarea.scrollTop;
});
textarea.addEventListener('keydown', (e) => {
// Ctrl+Z - Undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undoChange();
}
// Ctrl+Shift+Z or Ctrl+Y - Redo
if (((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) ||
((e.ctrlKey || e.metaKey) && e.key === 'y')) {
e.preventDefault();
redoChange();
}
// Tab insertion
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + '\t' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 1;
updateCurrentTab();
updateLineNumbers();
}
});
}
// Add new tab
function addTab(filePath, fileName, content, isGitea = false, owner = null, repo = null, platform = null) {
openTabs[filePath] = {
name: fileName,
content: content,
originalContent: content,
dirty: false,
icon: getFileIcon(fileName),
isGitea,
owner,
repo,
platform: platform || (isGitea ? currentState.platform : null),
history: [content],
historyIndex: 0
};
currentActiveTab = filePath;
renderTabs();
updateEditor(); // Kann async sein
}
// Remove tab
function removeTab(filePath) {
delete openTabs[filePath];
if (currentActiveTab === filePath) {
const paths = Object.keys(openTabs);
currentActiveTab = paths.length > 0 ? paths[0] : null;
}
if (Object.keys(openTabs).length === 0) {
closeFileEditor();
} else {
renderTabs();
updateEditor();
}
}
// Switch tab
function switchTab(filePath) {
if (openTabs[filePath]) {
currentActiveTab = filePath;
renderTabs();
updateEditor(); // Kann async sein, aber wir warten nicht
}
}
// Render tabs
function renderTabs() {
const tabsContainer = $('fileEditorTabs');
if (!tabsContainer) return;
tabsContainer.innerHTML = '';
Object.entries(openTabs).forEach(([filePath, tab]) => {
const tabEl = document.createElement('div');
tabEl.className = `editor-tab ${currentActiveTab === filePath ? 'active' : ''}`;
const nameEl = document.createElement('div');
nameEl.className = 'editor-tab-name';
const iconEl = document.createElement('span');
iconEl.textContent = tab.icon;
const nameSpan = document.createElement('span');
nameSpan.textContent = tab.name;
if (tab.dirty) {
const dirtyEl = document.createElement('span');
dirtyEl.className = 'editor-tab-dirty';
dirtyEl.textContent = '●';
nameEl.appendChild(iconEl);
nameEl.appendChild(nameSpan);
nameEl.appendChild(dirtyEl);
} else {
nameEl.appendChild(iconEl);
nameEl.appendChild(nameSpan);
}
const closeBtn = document.createElement('button');
closeBtn.className = 'editor-tab-close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (tab.dirty) {
const ok = await showActionConfirmModal({
title: 'Ungespeicherte Aenderungen',
message: `${tab.name} hat ungespeicherte Aenderungen. Wirklich schliessen?`,
confirmText: 'Schliessen',
danger: true
});
if (!ok) return;
}
removeTab(filePath);
});
tabEl.appendChild(nameEl);
tabEl.appendChild(closeBtn);
tabEl.addEventListener('click', () => switchTab(filePath));
tabsContainer.appendChild(tabEl);
});
}
// Update current tab content
function updateCurrentTab() {
if (!currentActiveTab) return;
const textarea = $('fileEditorContent');
const content = textarea.value;
const tab = openTabs[currentActiveTab];
if (!tab) return;
tab.content = content;
tab.dirty = (content !== tab.originalContent);
renderTabs();
}
// Update editor display
async function updateEditor() {
if (!currentActiveTab || !openTabs[currentActiveTab]) return;
const tab = openTabs[currentActiveTab];
const textarea = $('fileEditorContent');
const imagePreview = $('imagePreview');
// Prüfe ob es eine Bilddatei ist
const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(currentActiveTab);
if (isImage) {
// Zeige Bild statt Textarea
if (textarea) textarea.classList.add('hidden');
if (imagePreview) {
imagePreview.classList.remove('hidden');
let imgSrc = '';
if (tab.isGitea) {
// Gitea-Bild: Lade via API
try {
const filePath = currentActiveTab.replace(`gitea://${tab.owner}/${tab.repo}/`, '');
const response = await window.electronAPI.readGiteaFile({
owner: tab.owner,
repo: tab.repo,
path: filePath,
ref: getDefaultBranch(tab.owner, tab.repo),
platform: tab.platform || 'gitea'
});
if (response.ok) {
// Content ist Base64 Text, konvertiere zu Data URL
const imageData = response.content;
const ext = filePath.split('.').pop().toLowerCase();
const mimeType = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml'
}[ext] || 'image/png';
imgSrc = `data:${mimeType};base64,${imageData}`;
}
} catch (error) {
console.error('Error loading Gitea image:', error);
imagePreview.innerHTML = '<div style="color: var(--text-muted); text-align: center;">Fehler beim Laden des Bildes</div>';
return;
}
} else {
// Lokale Datei
imgSrc = 'file:///' + currentActiveTab.replace(/\\/g, '/');
}
// Erstelle Bild-Element mit verbesserter Darstellung
const img = document.createElement('img');
img.src = imgSrc;
img.alt = tab.name;
img.style.cssText = `
max-width: 100%;
max-height: 85vh;
width: auto;
height: auto;
display: block;
margin: 0 auto;
object-fit: contain;
cursor: zoom-in;
`;
// Click zum Zoomen (Original-Größe)
let isZoomed = false;
img.onclick = function() {
if (isZoomed) {
img.style.maxWidth = '100%';
img.style.maxHeight = '85vh';
img.style.cursor = 'zoom-in';
isZoomed = false;
} else {
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.style.cursor = 'zoom-out';
isZoomed = true;
}
};
img.onerror = function() {
imagePreview.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 40px;">Bild konnte nicht geladen werden</div>';
};
// Container für zentrierte Anzeige
imagePreview.innerHTML = '';
imagePreview.style.cssText = `
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
overflow: auto;
padding: 20px;
`;
imagePreview.appendChild(img);
}
} else {
// Zeige Text Editor
if (textarea) {
textarea.classList.remove('hidden');
textarea.value = tab.content;
updateLineNumbers();
updateEditorStats();
}
if (imagePreview) imagePreview.classList.add('hidden');
}
updateTabInfo();
}
// Update tab info header
function updateTabInfo() {
const tab = openTabs[currentActiveTab];
if (!tab) return;
$('fileEditorName').textContent = tab.name;
$('fileEditorIcon').textContent = tab.icon;
const pathText = tab.isGitea ? `Gitea: ${tab.owner}/${tab.repo}/${currentActiveTab}` : `Pfad: ${currentActiveTab}`;
$('fileEditorPath').textContent = pathText;
const lines = tab.content.split('\n').length;
const bytes = new Blob([tab.content]).size;
$('fileEditorStats').textContent = `${lines} Zeilen • ${bytes} Bytes`;
}
// Update line numbers
function updateLineNumbers() {
const textarea = $('fileEditorContent');
const lineNumbers = $('lineNumbers');
if (!textarea || !lineNumbers) return;
const lines = textarea.value.split('\n').length;
let html = '';
for (let i = 1; i <= lines; i++) {
html += i + '\n';
}
lineNumbers.textContent = html;
lineNumbers.scrollTop = textarea.scrollTop;
}
// Update editor stats (cursor position)
function updateEditorStats() {
const textarea = $('fileEditorContent');
if (!textarea) return;
const lines = textarea.value.split('\n').length;
const startPos = textarea.selectionStart;
const textBeforeCursor = textarea.value.substring(0, startPos);
const line = textBeforeCursor.split('\n').length;
const col = startPos - textBeforeCursor.lastIndexOf('\n');
$('fileEditorCursor').textContent = `Zeile ${line}, Spalte ${col}`;
}
// Undo
function undoChange() {
if (!currentActiveTab) return;
const tab = openTabs[currentActiveTab];
if (tab.historyIndex > 0) {
tab.historyIndex--;
const textarea = $('fileEditorContent');
textarea.value = tab.history[tab.historyIndex];
updateCurrentTab();
updateLineNumbers();
updateEditorStats();
}
}
// Redo
function redoChange() {
if (!currentActiveTab) return;
const tab = openTabs[currentActiveTab];
if (tab.historyIndex < tab.history.length - 1) {
tab.historyIndex++;
const textarea = $('fileEditorContent');
textarea.value = tab.history[tab.historyIndex];
updateCurrentTab();
updateLineNumbers();
updateEditorStats();
}
}
// Push to history
function pushToHistory(content) {
if (!currentActiveTab) return;
const tab = openTabs[currentActiveTab];
// Remove any redo history
tab.history = tab.history.slice(0, tab.historyIndex + 1);
tab.history.push(content);
tab.historyIndex++;
// Limit history to 50 items
if (tab.history.length > 50) {
tab.history.shift();
tab.historyIndex--;
}
}
// Auto-Save
function triggerAutoSave() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
saveCurrentFile(true);
}, autoSaveInterval);
}
function showAutoSaveIndicator() {
const indicator = $('autoSaveStatus');
if (indicator) {
indicator.style.display = 'inline';
setTimeout(() => {
indicator.style.display = 'none';
}, 2000);
}
}
async function closeFileEditor() {
// Überprüfe auf ungespeicherte Änderungen
const unsaved = Object.entries(openTabs).filter(([_, tab]) => tab.dirty);
if (unsaved.length > 0) {
const ok = await showActionConfirmModal({
title: 'Ungespeicherte Aenderungen',
message: `${unsaved.length} Datei(en) haben ungespeicherte Aenderungen. Wirklich schliessen?`,
confirmText: 'Schliessen',
danger: true
});
if (!ok) return;
}
openTabs = {};
currentActiveTab = null;
clearTimeout(autoSaveTimer);
const modal = $('fileEditorModal');
if (modal) modal.classList.add('hidden');
}
async function openFileEditor(filePath, fileName) {
try {
console.log('🔍 Opening file:', filePath);
// Wenn bereits offen, nur switchen
if (openTabs[filePath]) {
switchTab(filePath);
const modal = $('fileEditorModal');
if (modal) modal.classList.remove('hidden');
return;
}
// Prüfe ob es eine Bilddatei ist
const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(fileName);
if (isImage) {
// Für Bilder brauchen wir keinen Content zu lesen
addTab(filePath, fileName, '', false, null, null);
} else {
// Lese Text-Datei
const response = await window.electronAPI.readFile({ path: filePath });
if (response.ok) {
addTab(filePath, fileName, response.content);
} else {
await showInfoModal('Datei konnte nicht geoeffnet werden', `Fehler: ${response.error || 'Unbekannter Fehler'}`, true);
return;
}
}
const modal = $('fileEditorModal');
if (modal) {
modal.classList.remove('hidden');
initEditor();
$('fileEditorContent').focus();
}
setStatus(`Editiere: ${fileName}`);
console.log('✅ File opened');
} catch (error) {
console.error('Error opening file:', error);
await showInfoModal('Datei konnte nicht geoeffnet werden', 'Fehler beim Oeffnen der Datei.', true);
}
}
async function openGiteaFileInEditor(owner, repo, filePath, fileName) {
try {
console.log('🔍 Loading Gitea file:', owner, repo, filePath);
setStatus('Lädt Datei...');
// Wenn bereits offen, nur switchen
const vPath = `gitea://${owner}/${repo}/${filePath}`;
if (openTabs[vPath]) {
switchTab(vPath);
const modal = $('fileEditorModal');
if (modal) modal.classList.remove('hidden');
return;
}
// Lade Datei-Content vom Gitea/GitHub Handler
const response = await window.electronAPI.readGiteaFile({
owner,
repo,
path: filePath,
ref: getDefaultBranch(owner, repo),
platform: currentState.platform
});
if (response.ok) {
addTab(vPath, fileName, response.content, true, owner, repo, currentState.platform);
const modal = $('fileEditorModal');
if (modal) {
modal.classList.remove('hidden');
initEditor();
$('fileEditorContent').focus();
}
setStatus(`Editiere: ${fileName}`);
console.log('✅ Gitea file opened');
} else {
await showInfoModal('Datei konnte nicht geladen werden', `Fehler: ${response.error || 'Unbekannter Fehler'}`, true);
showError('Fehler beim Laden der Datei');
}
} catch (error) {
console.error('Error opening Gitea file:', error);
await showInfoModal('Datei konnte nicht geoeffnet werden', 'Fehler beim Oeffnen der Datei.', true);
showError('Fehler');
}
}
// Farbige Icons pro Dateityp (emoji + Farb-Overlay via CSS-Klassen)
const FILE_ICONS = {
// Web
js: { icon: '📄', color: '#f7df1e', label: 'JS' },
jsx: { icon: '📄', color: '#61dafb', label: 'JSX' },
ts: { icon: '📄', color: '#3178c6', label: 'TS' },
tsx: { icon: '📄', color: '#3178c6', label: 'TSX' },
html: { icon: '📄', color: '#e34c26', label: 'HTML' },
css: { icon: '📄', color: '#264de4', label: 'CSS' },
scss: { icon: '📄', color: '#cd6799', label: 'SCSS' },
vue: { icon: '📄', color: '#42b883', label: 'VUE' },
svelte:{ icon: '📄', color: '#ff3e00', label: 'SVE' },
// Backend
py: { icon: '📄', color: '#3572a5', label: 'PY' },
java: { icon: '📄', color: '#b07219', label: 'JAVA' },
rb: { icon: '📄', color: '#701516', label: 'RB' },
php: { icon: '📄', color: '#4f5d95', label: 'PHP' },
go: { icon: '📄', color: '#00add8', label: 'GO' },
rs: { icon: '📄', color: '#dea584', label: 'RS' },
cs: { icon: '📄', color: '#178600', label: 'C#' },
cpp: { icon: '📄', color: '#f34b7d', label: 'C++' },
c: { icon: '📄', color: '#555555', label: 'C' },
// Config
json: { icon: '📄', color: '#fbc02d', label: 'JSON' },
yaml: { icon: '📄', color: '#cb171e', label: 'YAML' },
yml: { icon: '📄', color: '#cb171e', label: 'YAML' },
toml: { icon: '📄', color: '#9c4221', label: 'TOML' },
env: { icon: '📄', color: '#ecd53f', label: 'ENV' },
xml: { icon: '📄', color: '#f60', label: 'XML' },
// Docs
md: { icon: '📄', color: '#083fa1', label: 'MD' },
txt: { icon: '📄', color: '#888', label: 'TXT' },
pdf: { icon: '📄', color: '#e53935', label: 'PDF' },
// Shell
sh: { icon: '📄', color: '#89e051', label: 'SH' },
bat: { icon: '📄', color: '#c1f12e', label: 'BAT' },
// Images
png: { icon: '🖼️', color: '#4caf50', label: 'PNG' },
jpg: { icon: '🖼️', color: '#4caf50', label: 'JPG' },
jpeg: { icon: '🖼️', color: '#4caf50', label: 'JPG' },
gif: { icon: '🖼️', color: '#4caf50', label: 'GIF' },
svg: { icon: '🖼️', color: '#ff9800', label: 'SVG' },
webp: { icon: '🖼️', color: '#4caf50', label: 'WEBP' },
// Archives
zip: { icon: '📦', color: '#ff9800', label: 'ZIP' },
tar: { icon: '📦', color: '#ff9800', label: 'TAR' },
gz: { icon: '📦', color: '#ff9800', label: 'GZ' },
};
function getFileIcon(fileName) {
const ext = fileName.split('.').pop()?.toLowerCase();
const info = FILE_ICONS[ext];
if (!info) return '📄';
return info.icon;
}
// Gibt ein DOM-Element für das Explorer-Icon zurück (mit farbigem Badge wenn aktiviert)
function makeFileIconEl(fileName, isDir = false) {
const wrapper = document.createElement('div');
wrapper.className = 'item-icon';
if (isDir) {
wrapper.textContent = '📁';
return wrapper;
}
const ext = fileName.split('.').pop()?.toLowerCase();
const info = featureColoredIcons ? FILE_ICONS[ext] : null;
wrapper.textContent = info ? info.icon : '📄';
if (info) {
const badge = document.createElement('span');
badge.className = 'file-type-badge';
badge.textContent = info.label;
badge.style.background = info.color;
// Helligkeit prüfen für Textfarbe
const hex = info.color.replace('#','');
const r = parseInt(hex.slice(0,2)||'88',16);
const g = parseInt(hex.slice(2,4)||'88',16);
const b = parseInt(hex.slice(4,6)||'88',16);
const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
badge.style.color = lum > 0.55 ? '#111' : '#fff';
wrapper.appendChild(badge);
}
return wrapper;
}
/* -------------------------
SEARCH & REPLACE
------------------------- */
function toggleSearch() {
const searchBar = $('searchBar');
if (searchBar.classList.contains('hidden')) {
searchBar.classList.remove('hidden');
$('searchInput').focus();
} else {
searchBar.classList.add('hidden');
}
}
function performSearch() {
const searchTerm = $('searchInput').value;
const textarea = $('fileEditorContent');
if (!searchTerm || !textarea) return;
const text = textarea.value;
const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const matches = [...text.matchAll(regex)];
$('searchInfo').textContent = matches.length > 0 ? `${matches.length} gefunden` : '0 gefunden';
if (matches.length > 0) {
const firstMatch = matches[0];
textarea.setSelectionRange(firstMatch.index, firstMatch.index + firstMatch[0].length);
textarea.focus();
}
}
function replaceOnce() {
const searchTerm = $('searchInput').value;
const replaceTerm = $('replaceInput').value;
const textarea = $('fileEditorContent');
if (!searchTerm || !textarea) return;
const text = textarea.value;
const newText = text.replace(new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'), replaceTerm);
textarea.value = newText;
pushToHistory(newText);
updateCurrentTab();
updateLineNumbers();
performSearch();
}
function replaceAll() {
const searchTerm = $('searchInput').value;
const replaceTerm = $('replaceInput').value;
const textarea = $('fileEditorContent');
if (!searchTerm || !textarea) return;
const text = textarea.value;
const newText = text.replace(new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), replaceTerm);
textarea.value = newText;
pushToHistory(newText);
updateCurrentTab();
updateLineNumbers();
performSearch();
}
async function saveCurrentFile(isAutoSave = false) {
if (!currentActiveTab) return;
const tab = openTabs[currentActiveTab];
// Prüfe ob es eine Bilddatei ist
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(currentActiveTab)) {
await showInfoModal('Nicht bearbeitbar', 'Bilder koennen nicht bearbeitet werden.');
return;
}
const textarea = $('fileEditorContent');
const content = textarea.value;
if (!isAutoSave) setStatus('Speichert...');
try {
let response;
// Prüfe ob es eine Gitea/GitHub-Datei ist
if (tab.isGitea) {
response = await window.electronAPI.writeGiteaFile({
owner: tab.owner,
repo: tab.repo,
path: currentActiveTab.replace(`gitea://${tab.owner}/${tab.repo}/`, ''),
content: content,
ref: getDefaultBranch(tab.owner, tab.repo),
platform: tab.platform || 'gitea'
});
} else {
// Lokale Datei
response = await window.electronAPI.writeFile({
path: currentActiveTab,
content: content
});
}
if (response.ok && !response.queued) {
tab.originalContent = content;
tab.dirty = false;
// Push current state to history
pushToHistory(content);
renderTabs();
if (isAutoSave) {
showAutoSaveIndicator();
} else {
setStatus(`✓ Gespeichert: ${tab.name}`);
}
console.log('✅ File saved');
} else if (response.ok && response.queued) {
tab.originalContent = content;
tab.dirty = false;
pushToHistory(content);
renderTabs();
showWarning(response.message || 'Änderung in Retry-Queue gelegt und wird später hochgeladen.');
updateRetryQueueBadge(retryQueueCount + 1);
} else {
await showInfoModal('Speichern fehlgeschlagen', `Fehler: ${response.error || 'Unbekannter Fehler'}`, true);
}
} catch (error) {
console.error('Error saving file:', error);
await showInfoModal('Speichern fehlgeschlagen', 'Fehler beim Speichern.', true);
}
}
function updateEditorStats() {
const textarea = $('fileEditorContent');
if (!textarea) return;
const lines = textarea.value.split('\n').length;
const startPos = textarea.selectionStart;
const textBeforeCursor = textarea.value.substring(0, startPos);
const line = textBeforeCursor.split('\n').length;
const col = startPos - textBeforeCursor.lastIndexOf('\n');
$('fileEditorCursor').textContent = `Zeile ${line}, Spalte ${col}`;
}
/* -------------------------
MARKDOWN PARSER
------------------------- */
function parseMarkdownToHTML(markdown) {
if (!markdown) return '';
// Immer escapen: verhindert Script-/HTML-Injection aus Release-Texten.
let html = String(markdown)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Convert markdown patterns
// Headings: ### Title → <h3>Title</h3>
html = html.replace(/^###### (.*?)$/gm, '<h6>$1</h6>');
html = html.replace(/^##### (.*?)$/gm, '<h5>$1</h5>');
html = html.replace(/^#### (.*?)$/gm, '<h4>$1</h4>');
html = html.replace(/^### (.*?)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1>$1</h1>');
// Horizontal rule: --- or *** or ___
html = html.replace(/^\-{3,}$/gm, '<hr>');
html = html.replace(/^\*{3,}$/gm, '<hr>');
html = html.replace(/^_{3,}$/gm, '<hr>');
// Bold: **text** or __text__
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
// Italic: *text* or _text_ (but not within words)
html = html.replace(/\s\*(.*?)\*\s/g, ' <em>$1</em> ');
html = html.replace(/\s_(.*?)_\s/g, ' <em>$1</em> ');
// Convert line breaks to <br>
html = html.replace(/\n/g, '<br>');
// Wrap plain text in paragraphs (text not already in tags)
let lines = html.split('<br>');
lines = lines.map(line => {
line = line.trim();
if (line && !line.match(/^</) && line.length > 0) {
// Only wrap if not already a tag
if (!line.match(/^<(h[1-6]|hr|p|div|ul|ol|li|em|strong|b|i)/)) {
return '<p>' + line + '</p>';
}
}
return line;
});
html = lines.join('<br>');
return html;
}
/* -------------------------
NAVIGATION & UI UPDATES
------------------------- */
function updateNavigationUI() {
const btnBack = $('btnBack');
if (!btnBack) return;
// Back Button zeigen, wenn wir in einem Repo oder tief in Ordnern sind
if (currentState.view === 'gitea-repo' ||
(currentState.view === 'gitea-list' && currentState.path !== '')) {
btnBack.classList.remove('hidden');
} else {
btnBack.classList.add('hidden');
}
}
/* -------------------------
GITEA CORE LOGIK (GRID)
------------------------- */
async function loadGiteaRepos(preloadedData = null, requestId = null) {
const activeRequestId = requestId || ++repoLoadRequestId;
currentState.view = 'gitea-list';
currentState.path = '';
updateNavigationUI();
// Verstecke Commits & Releases-Buttons in Repo-Liste
const btnCommits = $('btnCommits');
const btnReleases = $('btnReleases');
if (btnCommits) btnCommits.classList.add('hidden');
if (btnReleases) btnReleases.classList.add('hidden');
// WICHTIG: Grid-Layout zurücksetzen
const grid = $('explorerGrid');
if (grid) {
grid.style.gridTemplateColumns = '';
}
setStatus('Loading repos...');
if (!preloadedData) updateSettingsHealth({ lastError: '-' });
try {
let res;
if (preloadedData) {
res = preloadedData;
} else {
res = await window.electronAPI.listGiteaRepos();
if (activeRequestId !== repoLoadRequestId) return;
if (!res.ok) {
showError('Failed to load repos: ' + (res.error || 'Unknown error'));
updateSettingsHealth({ api: 'Fehler', auth: 'Fehler' });
return;
}
updateSettingsHealth({ api: 'Erreichbar', auth: 'OK', lastError: '-' });
}
currentGiteaRepos = Array.isArray(res.repos) ? res.repos : [];
if (!preloadedData) {
try {
const cachedName = getCachedUsername('gitea');
if (cachedName) {
currentGiteaUsername = cachedName;
} else {
const meRes = await window.electronAPI.getGiteaCurrentUser?.();
if (activeRequestId !== repoLoadRequestId) return;
currentGiteaUsername = meRes?.ok ? (meRes.user?.login || meRes.user?.username || '') : '';
setCachedUsername('gitea', currentGiteaUsername);
}
} catch (_) {
currentGiteaUsername = '';
}
}
const grid = $('explorerGrid');
if (!grid) return;
grid.innerHTML = '';
if (!res.repos || res.repos.length === 0) {
renderFavHistorySidebar([]);
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--text-muted);">Keine Repositories gefunden</div>';
setStatus('No repositories found');
return;
}
// --- Fuzzy-Suchfeld für Repositories ---
const ownerTabsWrap = document.createElement('div');
ownerTabsWrap.className = 'repo-owner-tabs';
ownerTabsWrap.style.cssText = 'grid-column: 1/-1; margin-bottom: 12px;';
const ownerCounts = new Map();
for (const r of currentGiteaRepos) {
const owner = (r?.owner?.login || r?.owner?.username || '').trim();
if (!owner) continue;
ownerCounts.set(owner, (ownerCounts.get(owner) || 0) + 1);
}
const myOwner = String(currentGiteaUsername || '').trim();
const tabDefs = [
{ key: 'mine', label: 'Meine', count: myOwner ? (ownerCounts.get(myOwner) || 0) : currentGiteaRepos.length },
{ key: 'all', label: 'Alle', count: currentGiteaRepos.length }
];
Array.from(ownerCounts.keys())
.filter(o => !myOwner || o.toLowerCase() !== myOwner.toLowerCase())
.sort((a, b) => a.localeCompare(b, 'de'))
.forEach(owner => tabDefs.push({ key: `owner:${owner}`, label: owner, count: ownerCounts.get(owner) || 0 }));
if (activeRepoOwnerFilter === 'mine' && tabDefs[0].count === 0) {
activeRepoOwnerFilter = 'all';
}
if (activeRepoOwnerFilter === 'shared') {
activeRepoOwnerFilter = 'all';
}
const makeTab = (tab) => {
const btn = document.createElement('button');
btn.className = 'repo-owner-tab' + (activeRepoOwnerFilter === tab.key ? ' active' : '');
btn.textContent = String(tab.label || '');
btn.appendChild(document.createTextNode(' '));
const countSpan = document.createElement('span');
countSpan.textContent = String(tab.count || 0);
btn.appendChild(countSpan);
btn.onclick = () => {
activeRepoOwnerFilter = tab.key;
ownerTabsWrap.querySelectorAll('.repo-owner-tab').forEach(el => {
el.classList.toggle('active', el === btn);
});
applyRepoFuzzyFilter(grid, searchInput, null);
};
return btn;
};
tabDefs.forEach(tab => ownerTabsWrap.appendChild(makeTab(tab)));
grid.appendChild(ownerTabsWrap);
const searchContainer = document.createElement('div');
searchContainer.className = 'repo-search-wrap';
searchContainer.style.cssText = 'grid-column: 1/-1; margin-bottom: 20px;';
const searchTop = document.createElement('div');
searchTop.className = 'repo-search-top';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = '🔍 Fuzzy-Suche: Name, Owner, Sprache, Topics...';
searchInput.className = 'repo-search-input';
searchInput.style.cssText = `
width: 100%;
padding: 12px 16px;
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
outline: none;
box-sizing: border-box;
`;
const searchClearBtn = document.createElement('button');
searchClearBtn.className = 'secondary repo-search-clear';
searchClearBtn.textContent = '✕';
searchClearBtn.title = 'Suche leeren';
searchClearBtn.onclick = () => {
searchInput.value = '';
applyRepoFuzzyFilter(grid, searchInput, null);
searchInput.focus();
};
// Search Focus Effekt
searchInput.addEventListener('focus', () => {
searchInput.style.borderColor = 'var(--accent-primary)';
});
searchInput.addEventListener('blur', () => {
searchInput.style.borderColor = 'rgba(255, 255, 255, 0.1)';
});
searchTop.appendChild(searchInput);
searchTop.appendChild(searchClearBtn);
searchContainer.appendChild(searchTop);
grid.appendChild(searchContainer);
const heatmapHost = document.createElement('div');
heatmapHost.id = 'repoActivityHeatmapHost';
heatmapHost.style.cssText = 'grid-column: 1/-1; margin-bottom: 18px;';
grid.appendChild(heatmapHost);
renderActivityHeatmap(heatmapHost);
loadRemoteHeatmapData(false).finally(() => {
if (activeRequestId !== repoLoadRequestId) return;
renderActivityHeatmap(heatmapHost);
});
// Fuzzy Search Logic
searchInput.addEventListener('input', (e) => {
applyRepoFuzzyFilter(grid, e.target, null);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchInput.value = '';
applyRepoFuzzyFilter(grid, searchInput, null);
}
});
if (!repoSearchHotkeyBound) {
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
if (currentState.view !== 'gitea-list') return;
const input = document.querySelector('.repo-search-input');
if (!input) return;
e.preventDefault();
input.focus();
input.select();
}
});
repoSearchHotkeyBound = true;
}
// ── Sidebar links einblenden ──
renderFavHistorySidebar(res.repos);
repoPrivacyByFullName = {};
repoTopicsByFullName = {};
const knownTopicSet = new Set();
res.repos.forEach(repo => {
let owner = (repo.owner && (repo.owner.login || repo.owner.username)) || null;
let repoName = repo.name;
let cloneUrl = repo.clone_url || repo.clone_url_ssh;
repoPrivacyByFullName[`${owner || ''}/${repoName || ''}`] = !!repo.private;
const repoTopics = Array.isArray(repo.topics) ? repo.topics : [];
repoTopicsByFullName[`${owner || ''}/${repoName || ''}`] = repoTopics;
repoTopics.forEach(t => {
const s = String(t || '').trim();
if (s) knownTopicSet.add(s);
});
// default_branch speichern (main ODER master je nach Repo)
const defaultBranch = repo.default_branch || 'HEAD';
repoDefaultBranches[`${owner}/${repoName}`] = defaultBranch;
const card = document.createElement('div');
card.className = 'item-card';
card.style.position = 'relative';
card.dataset.cloneUrl = cloneUrl;
card.dataset.owner = owner;
card.dataset.repo = repoName;
card.dataset.searchName = repoName || '';
card.dataset.searchOwner = owner || '';
card.dataset.searchFull = `${owner || ''}/${repoName || ''}`;
card.dataset.searchLanguage = repo.language || '';
card.dataset.searchTopics = Array.isArray(repo.topics) ? repo.topics.join(' ') : '';
card.dataset.searchDescription = repo.description || '';
const ownerLower = String(owner || '').toLowerCase();
const myOwnerLower = String(currentGiteaUsername || '').toLowerCase();
const sharedByOthers = !!ownerLower && !!myOwnerLower && ownerLower !== myOwnerLower;
card.dataset.shared = sharedByOthers ? 'true' : 'false';
card.dataset.searchSharedOwner = sharedByOthers ? `geteilt von ${owner}` : '';
const writable = isRepoWritable(repo, currentGiteaUsername);
const readOnly = !writable;
card.dataset.readOnly = readOnly ? 'true' : 'false';
// Stern-Button (nur wenn Favoriten-Feature aktiv)
if (featureFavorites) {
const starBtn = document.createElement('button');
starBtn.className = 'fav-star-btn' + (isFavorite(owner, repoName) ? ' active' : '');
starBtn.title = isFavorite(owner, repoName) ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen';
starBtn.textContent = isFavorite(owner, repoName) ? '⭐' : '☆';
starBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await toggleFavorite(owner, repoName, cloneUrl);
});
card.appendChild(starBtn);
}
const iconEl = document.createElement('div');
iconEl.className = 'item-icon';
if (repo.avatar_url) {
const avatarImg = document.createElement('img');
avatarImg.src = repo.avatar_url;
avatarImg.className = 'repo-avatar-img';
avatarImg.alt = '';
avatarImg.onerror = () => { iconEl.textContent = '📦'; avatarImg.remove(); };
iconEl.appendChild(avatarImg);
} else {
iconEl.textContent = '📦';
}
card.appendChild(iconEl);
const nameEl = document.createElement('div');
nameEl.className = 'item-name';
nameEl.textContent = repoName;
card.appendChild(nameEl);
if (sharedByOthers) {
const sharedByEl = document.createElement('div');
sharedByEl.className = 'item-submeta';
sharedByEl.textContent = `geteilt von ${owner}`;
card.appendChild(sharedByEl);
}
if (readOnly) {
const roBadge = document.createElement('div');
roBadge.className = 'repo-size-badge';
roBadge.style.top = '10px';
roBadge.style.right = '42px';
roBadge.style.background = 'rgba(59,130,246,0.16)';
roBadge.style.borderColor = 'rgba(59,130,246,0.45)';
roBadge.textContent = 'Nur Ansicht';
card.appendChild(roBadge);
}
// Repo-Größe anzeigen
if (repo.size != null) {
const sizeEl = document.createElement('div');
sizeEl.className = 'repo-size-badge';
const kb = repo.size;
sizeEl.textContent = kb >= 1024
? `${(kb / 1024).toFixed(1)} MB`
: `${kb} KB`;
card.appendChild(sizeEl);
}
// --- Nativer Drag Start (Download) ---
card.draggable = true;
card.addEventListener('dragstart', async (ev) => {
ev.preventDefault();
setStatus(`Preparing download for ${repoName}...`);
showProgress(0, `Preparing ${repoName}...`);
try {
const resDrag = await window.electronAPI.prepareDownloadDrag({
owner,
repo: repoName,
path: ''
});
if (resDrag.ok) {
window.electronAPI.startNativeDrag(resDrag.tempPath);
setStatus('Ready to drag');
} else {
showError('Download preparation failed');
}
} catch (error) {
console.error('Drag preparation error:', error);
showError('Error preparing download');
} finally {
hideProgress();
}
});
// --- Nativer Drop (Upload in Repo Root) ---
card.addEventListener('dragover', (ev) => {
ev.preventDefault();
ev.stopPropagation();
card.classList.add('drag-target');
});
card.addEventListener('dragleave', (ev) => {
ev.preventDefault();
ev.stopPropagation();
card.classList.remove('drag-target');
});
card.addEventListener('drop', async (ev) => {
ev.preventDefault();
ev.stopPropagation();
card.classList.remove('drag-target');
const files = ev.dataTransfer.files;
try {
window.electronAPI.debugToMain('log', 'repoCard:drop:event', {
owner,
repo: repoName,
fileCount: files ? files.length : 0,
itemCount: ev.dataTransfer?.items ? ev.dataTransfer.items.length : 0
});
} catch (_) {}
if (!files || files.length === 0) {
showWarning("Keine Dateien zum Upload gefunden.");
return;
}
const paths = extractDroppedPaths(files);
if (paths.length === 0) {
try {
window.electronAPI.debugToMain('warn', 'repoCard:drop:no-paths', {
fileNames: Array.from(files).map(f => f.name)
});
} catch (_) {}
showError('Dateipfade konnten nicht gelesen werden (Sandbox/Drag-Quelle). Bitte Upload über Kontextmenü testen.');
return;
}
console.log('[UPLOAD_DEBUG][renderer] repoCard:drop', {
owner,
repo: repoName,
branch: getDefaultBranch(owner, repoName),
paths
});
setStatus(`Starte Upload von ${paths.length} Elementen...`);
try {
const res = await uploadDroppedPaths({
paths,
owner,
repo: repoName,
destPath: '',
cloneUrl,
branch: getDefaultBranch(owner, repoName)
});
console.log('[UPLOAD_DEBUG][renderer] repoCard:dropResult', res);
if (!res.ok) {
showError('Fehler: ' + (res.error || 'Upload fehlgeschlagen'));
setStatus('Upload fehlgeschlagen');
} else {
setStatus('Upload abgeschlossen');
}
} catch (err) {
console.error('Kritischer Upload Fehler:', err);
setStatus('Upload fehlgeschlagen');
}
hideProgress();
});
card.onclick = () => {
addToRecent(owner, repoName, cloneUrl);
loadRepoContents(owner, repoName, '');
};
card.oncontextmenu = (ev) => showRepoContextMenu(ev, owner, repoName, cloneUrl, card, !!repo.private, repo);
grid.appendChild(card);
});
repoKnownTopics = Array.from(knownTopicSet).sort((a, b) => a.localeCompare(b, 'de'));
repoKnownTopicsLoadedAt = Date.now();
applyRepoFuzzyFilter(grid, searchInput, null);
setStatus(`Loaded ${res.repos.length} repos`);
} catch (error) {
console.error('Error loading repos:', error);
showError('Error loading repositories');
updateSettingsHealth({ api: 'Fehler', auth: 'Unbekannt' });
}
}
async function loadRepoContents(owner, repo, path) {
// Projekttitel als Gravur unterhalb der Toolbar setzen (nur Repo-Name)
const gravurTitle = document.getElementById('project-gravur-title');
if (gravurTitle) {
gravurTitle.textContent = repo;
}
currentState.view = 'gitea-repo';
currentState.owner = owner;
currentState.repo = repo;
currentState.path = path;
updateNavigationUI();
// Zeige Commits & Releases-Buttons wenn wir in einem Repo sind
const btnCommits = $('btnCommits');
const btnReleases = $('btnReleases');
if (btnCommits) {
btnCommits.classList.remove('hidden');
btnCommits.onclick = () => loadCommitHistory(owner, repo, getDefaultBranch(owner, repo));
}
if (btnReleases) {
btnReleases.classList.remove('hidden');
btnReleases.onclick = () => loadRepoReleases(owner, repo);
}
// WICHTIG: Grid-Layout zurücksetzen
const grid = $('explorerGrid');
if (grid) {
grid.style.gridTemplateColumns = '';
}
setStatus(`Loading: /${path || 'root'}`);
const ref = getDefaultBranch(owner, repo);
const platform = currentState.platform;
try {
const res = await window.electronAPI.getGiteaRepoContents({
owner,
repo,
path,
ref,
platform
});
if (!res.ok) {
// Bei Fehler Gravur zurücksetzen
const gravurTitle = document.getElementById('project-gravur-title');
if (gravurTitle) {
gravurTitle.textContent = '';
}
showError('Error: ' + (res.error || 'Unknown error'));
return;
}
// Wenn zur Übersicht gewechselt wird, Gravur zurücksetzen
function resetProjectGravurTitle() {
const gravurTitle = document.getElementById('project-gravur-title');
if (gravurTitle) gravurTitle.textContent = '';
}
// Nach dem Laden der Repo-Liste oder beim Klick auf "Zurück" rufe resetProjectGravurTitle() auf
const origLoadRepos = loadRepos;
loadRepos = function(...args) {
resetProjectGravurTitle();
return origLoadRepos.apply(this, args);
};
const grid = $('explorerGrid');
if (!grid) return;
grid.innerHTML = '';
if (!res.items || res.items.length === 0) {
const emptyMsg = res.empty
? '📭 Leeres Repository — noch keine Commits'
: '📂 Leerer Ordner';
const emptyEl = document.createElement('div');
emptyEl.style.cssText = 'grid-column: 1/-1; text-align: center; padding: 40px; color: var(--text-muted);';
emptyEl.textContent = emptyMsg;
grid.appendChild(emptyEl);
setStatus(res.empty ? 'Leeres Repository' : 'Leerer Ordner');
return;
}
res.items.forEach(item => {
const card = document.createElement('div');
card.className = 'item-card';
// Farbiges Icon-Element
const iconEl = makeFileIconEl(item.name, item.type === 'dir');
const nameEl = document.createElement('div');
nameEl.className = 'item-name';
nameEl.textContent = item.name;
card.appendChild(iconEl);
card.appendChild(nameEl);
// lastSelectedItem tracken
card.addEventListener('click', () => { lastSelectedItem = { type: 'gitea', item, owner, repo }; });
// Drag für Files und Folders
if (item.type === 'dir') {
card.draggable = true;
card.addEventListener('dragstart', async (ev) => {
ev.preventDefault();
showProgress(0, `Preparing ${item.name}...`);
try {
const resDrag = await window.electronAPI.prepareDownloadDrag({
owner,
repo,
path: item.path
});
if (resDrag.ok) {
window.electronAPI.startNativeDrag(resDrag.tempPath);
}
} catch (error) {
console.error('Drag error:', error);
} finally {
hideProgress();
}
});
}
// Drop in Ordner
card.addEventListener('dragover', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (item.type === 'dir') card.classList.add('drag-target');
});
card.addEventListener('dragleave', (ev) => {
ev.preventDefault();
ev.stopPropagation();
card.classList.remove('drag-target');
});
card.addEventListener('drop', async (ev) => {
ev.preventDefault();
ev.stopPropagation();
card.classList.remove('drag-target');
if (item.type !== 'dir') return;
const files = ev.dataTransfer.files;
if (!files || files.length === 0) return;
const paths = extractDroppedPaths(files);
if (paths.length === 0) {
showError('Dateipfade konnten nicht gelesen werden (Sandbox/Drag-Quelle).');
return;
}
const targetPath = item.path;
try {
const res = await uploadDroppedPaths({
paths,
owner,
repo,
destPath: targetPath,
branch: getDefaultBranch(owner, repo)
});
if (!res.ok) {
showError('Upload error: ' + (res.error || 'Unbekannter Fehler'));
}
} catch (error) {
console.error('Upload error:', error);
showError('Upload failed');
}
hideProgress();
loadRepoContents(owner, repo, path);
});
if (item.type === 'dir') {
card.onclick = (e) => {
if (e.ctrlKey || e.metaKey) {
if (selectedItems.has(item.path)) { selectedItems.delete(item.path); card.classList.remove('selected'); }
else { selectedItems.add(item.path); card.classList.add('selected'); }
return;
}
selectedItems.clear();
document.querySelectorAll('.item-card.selected').forEach(c => c.classList.remove('selected'));
loadRepoContents(owner, repo, item.path);
};
} else {
card.onclick = (e) => {
if (e.ctrlKey || e.metaKey) {
if (selectedItems.has(item.path)) { selectedItems.delete(item.path); card.classList.remove('selected'); }
else { selectedItems.add(item.path); card.classList.add('selected'); }
return;
}
selectedItems.clear();
document.querySelectorAll('.item-card.selected').forEach(c => c.classList.remove('selected'));
openGiteaFileInEditor(owner, repo, item.path, item.name);
};
}
card.oncontextmenu = (ev) => showGiteaItemContextMenu(ev, item, owner, repo);
grid.appendChild(card);
});
setStatus(`Loaded ${res.items.length} items`);
} catch (error) {
console.error('Error loading repo contents:', error);
showError('Error loading contents');
}
}
/* -------------------------
LOKALE LOGIK
------------------------- */
async function selectLocalFolder() {
try {
const folder = await window.electronAPI.selectFolder();
if (!folder) return;
selectedFolder = folder;
setStatus('Local: ' + folder);
currentState.view = 'local';
updateNavigationUI();
await refreshLocalTree(folder);
await loadBranches(folder);
} catch (error) {
console.error('Error selecting folder:', error);
showError('Error selecting folder');
}
}
async function refreshLocalTree(folder) {
try {
const res = await window.electronAPI.getFileTree({
folder,
exclude: ['node_modules', '.git'],
maxDepth: 5
});
currentLocalProjects = Array.isArray(res.tree)
? res.tree.filter(node => node && node.isDirectory)
: [];
const grid = $('explorerGrid');
if (!grid) return;
grid.innerHTML = '';
if (!res.ok) {
showError('Error loading local files');
return;
}
if (!res.tree || res.tree.length === 0) {
currentLocalProjects = [];
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--text-muted);">Keine Dateien gefunden</div>';
return;
}
res.tree.forEach(node => {
const card = document.createElement('div');
card.className = 'item-card';
card.dataset.path = node.path;
// Farbiges Icon + Name
const nodeIconEl = makeFileIconEl(node.name, node.isDirectory);
const nodeNameEl = document.createElement('div');
nodeNameEl.className = 'item-name';
nodeNameEl.textContent = node.name;
card.appendChild(nodeIconEl);
card.appendChild(nodeNameEl);
// lastSelectedItem tracken
card.addEventListener('click', () => { lastSelectedItem = { type: 'local', node }; });
card.onclick = async (e) => {
if (e.ctrlKey || e.metaKey) {
// Mehrfachauswahl
if (selectedItems.has(node.path)) {
selectedItems.delete(node.path);
card.classList.remove('selected');
} else {
selectedItems.add(node.path);
card.classList.add('selected');
}
return;
}
// Normale Auswahl
selectedItems.clear();
grid.querySelectorAll('.item-card.selected').forEach(c => c.classList.remove('selected'));
if (!node.isDirectory) {
openFileEditor(node.path, node.name);
}
};
card.oncontextmenu = (ev) => showLocalItemContextMenu(ev, node);
grid.appendChild(card);
});
} catch (error) {
console.error('Error refreshing tree:', error);
showError('Error loading file tree');
}
}
/* -------------------------
GIT ACTIONS
------------------------- */
async function pushLocalFolder() {
if (!selectedFolder) {
await showActionConfirmModal({
title: 'Lokaler Ordner fehlt',
message: 'Bitte waehle zuerst einen lokalen Ordner aus.',
confirmText: 'OK',
danger: false
});
return;
}
// Commit-Nachricht abfragen
const message = await showCommitMessageModal();
if (message === null) return; // Abgebrochen
const branch = $('branchSelect')?.value || 'main';
const repoName = $('repoName')?.value;
const platform = $('platform')?.value;
setStatus('Pushing...');
showProgress(0, 'Starting push...');
try {
const res = await window.electronAPI.pushProject({
folder: selectedFolder,
branch,
repoName,
platform,
commitMessage: message
});
if (res.ok) {
showSuccess('Upload erfolgreich ✓');
} else {
showError('Upload fehlgeschlagen: ' + (res.error || 'Unbekannter Fehler'));
}
} catch (error) {
console.error('Push error:', error);
showError('Upload fehlgeschlagen');
} finally {
hideProgress();
}
}
// Modal für Commit-Nachricht
function showCommitMessageModal() {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal';
modal.style.zIndex = '99999';
const card = document.createElement('div');
card.className = 'modalContent card';
card.style.maxWidth = '500px';
const title = document.createElement('h2');
title.textContent = '💬 Commit-Nachricht';
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group';
const label = document.createElement('label');
label.textContent = 'Was wurde geändert?';
const input = document.createElement('input');
input.id = 'commitMsgInput';
input.type = 'text';
input.placeholder = 'z.B. Fix: Button-Farbe angepasst';
input.style.fontSize = '15px';
input.autocomplete = 'off';
inputGroup.appendChild(label);
inputGroup.appendChild(input);
const quickBtns = document.createElement('div');
quickBtns.id = 'commitQuickBtns';
quickBtns.style.cssText = 'margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px;';
['🐛 Fix Bug', '✨ Neues Feature', '📝 Dokumentation', '♻️ Refactoring', '🚀 Release'].forEach((text) => {
const btn = document.createElement('button');
btn.className = 'commit-quick-btn';
btn.style.cssText = `
background: var(--bg-tertiary);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
`;
btn.textContent = text;
quickBtns.appendChild(btn);
});
const modalButtons = document.createElement('div');
modalButtons.className = 'modal-buttons';
modalButtons.style.marginTop = '20px';
const okButton = document.createElement('button');
okButton.id = 'btnCommitOk';
okButton.className = 'accent-btn';
okButton.textContent = '⬆️ Pushen';
const cancelButton = document.createElement('button');
cancelButton.id = 'btnCommitCancel';
cancelButton.className = 'secondary';
cancelButton.textContent = 'Abbrechen';
modalButtons.appendChild(okButton);
modalButtons.appendChild(cancelButton);
card.appendChild(title);
card.appendChild(inputGroup);
card.appendChild(quickBtns);
card.appendChild(modalButtons);
modal.appendChild(card);
document.body.appendChild(modal);
input.focus();
// Quick-Buttons
modal.querySelectorAll('.commit-quick-btn').forEach(btn => {
btn.onclick = () => {
input.value = btn.textContent.trim();
input.focus();
};
});
modal.querySelector('#btnCommitOk').onclick = () => {
const val = input.value.trim() || 'Update via Git Manager GUI';
modal.remove();
resolve(val);
};
modal.querySelector('#btnCommitCancel').onclick = () => {
modal.remove();
resolve(null);
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') modal.querySelector('#btnCommitOk').click();
if (e.key === 'Escape') modal.querySelector('#btnCommitCancel').click();
});
});
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function showSyncConfirmModal({ title = 'Bestaetigen', message = '', confirmText = 'Fortfahren', details = [] }) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal';
const card = document.createElement('div');
card.className = 'card';
card.style.width = 'min(520px, 92vw)';
const titleEl = document.createElement('h2');
titleEl.style.marginBottom = '8px';
titleEl.textContent = String(title || 'Bestaetigen');
const messageEl = document.createElement('p');
messageEl.style.cssText = 'margin: 0; color: var(--text-secondary); line-height: 1.5;';
messageEl.textContent = String(message || '');
card.appendChild(titleEl);
card.appendChild(messageEl);
if (Array.isArray(details)) {
const rows = details.filter(d => d && d.label);
if (rows.length > 0) {
const detailBlock = document.createElement('div');
detailBlock.style.cssText = 'margin-top:12px;padding:10px 12px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);background:linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));';
const detailTitle = document.createElement('div');
detailTitle.style.cssText = 'font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:#b9c9e8;margin-bottom:4px;';
detailTitle.textContent = 'Was wird uebernommen';
detailBlock.appendChild(detailTitle);
rows.forEach((d) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;justify-content:space-between;gap:10px;padding:6px 0;border-bottom:1px dashed rgba(255,255,255,0.08);';
const left = document.createElement('span');
left.style.cssText = 'color:var(--text-secondary);font-size:12px;';
left.textContent = String(d.label || '');
const right = document.createElement('strong');
right.style.cssText = 'color:var(--text-primary);font-size:12px;text-align:right;word-break:break-word;';
right.textContent = String(d.value || '');
row.appendChild(left);
row.appendChild(right);
detailBlock.appendChild(row);
});
card.appendChild(detailBlock);
}
}
const btnRow = document.createElement('div');
btnRow.className = 'modal-buttons';
btnRow.style.marginTop = '18px';
const confirmBtn = document.createElement('button');
confirmBtn.id = 'btnSyncConfirm';
confirmBtn.className = 'accent-btn';
confirmBtn.textContent = String(confirmText || 'Fortfahren');
const cancelBtn = document.createElement('button');
cancelBtn.id = 'btnSyncCancel';
cancelBtn.className = 'secondary';
cancelBtn.textContent = 'Abbrechen';
btnRow.appendChild(confirmBtn);
btnRow.appendChild(cancelBtn);
card.appendChild(btnRow);
modal.appendChild(card);
const close = (result) => {
modal.remove();
resolve(!!result);
};
document.body.appendChild(modal);
modal.querySelector('#btnSyncConfirm').addEventListener('click', () => close(true));
modal.querySelector('#btnSyncCancel').addEventListener('click', () => close(false));
modal.addEventListener('click', (e) => {
if (e.target === modal) close(false);
});
});
}
async function loadBranches(folder) {
try {
const res = await window.electronAPI.getBranches({ folder });
const sel = $('branchSelect');
if (!sel) return;
sel.innerHTML = '';
if (res.ok && res.branches) {
res.branches.forEach(b => {
const option = document.createElement('option');
option.value = b;
option.textContent = b;
sel.appendChild(option);
});
}
} catch (error) {
console.error('Error loading branches:', error);
}
}
async function loadCommitLogs(folder) {
try {
const res = await window.electronAPI.getCommitLogs({ folder });
const container = $('logs');
if (!container) return;
container.innerHTML = '';
if (res.ok && res.logs) {
res.logs.forEach(l => {
const d = document.createElement('div');
d.className = 'log-item';
d.innerText = l;
container.appendChild(d);
});
}
} catch (error) {
console.error('Error loading logs:', error);
}
}
/* -------------------------
CONTEXT MENÜS
------------------------- */
function showRepoContextMenu(ev, owner, repoName, cloneUrl, element, isPrivate = false, repoMeta = null) {
ev.preventDefault();
ev.stopPropagation();
const old = $('ctxMenu');
if (old) old.remove();
const menu = document.createElement('div');
menu.id = 'ctxMenu';
menu.className = 'context-menu';
menu.style.left = ev.clientX + 'px';
menu.style.top = ev.clientY + 'px';
const createMenuItem = (icon, text, onClick, color = null) => {
const item = document.createElement('div');
item.className = 'context-item';
item.textContent = `${icon} ${text}`;
if (color) item.style.color = color;
item.onclick = onClick;
return item;
};
const addSep = () => {
const sep = document.createElement('div');
sep.style.cssText = 'height:1px;background:rgba(255,255,255,0.08);margin:4px 0;';
menu.appendChild(sep);
};
menu.appendChild(createMenuItem('📂', 'Repository öffnen', () => {
menu.remove();
addToRecent(owner, repoName, cloneUrl);
loadRepoContents(owner, repoName, '');
}));
addSep();
const writable = isRepoWritable(repoMeta || {}, currentGiteaUsername);
// ── Projektbild ändern ──
if (writable && currentState.platform === 'gitea') {
menu.appendChild(createMenuItem('🖼️', 'Projektbild ändern', () => {
menu.remove();
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async (e) => {
const file = e.target.files[0];
fileInput.remove();
if (!file) return;
const reader = new FileReader();
reader.onload = async (ev) => {
const creds = await window.electronAPI.loadCredentials();
if (!creds?.giteaToken || !creds?.giteaURL) {
showError('Gitea Token oder URL fehlt. Bitte zuerst in den Einstellungen speichern.');
return;
}
showProgress(30, `Bild für "${repoName}" hochladen…`);
const result = await window.electronAPI.updateGiteaRepoAvatar({
token: creds.giteaToken,
url: creds.giteaURL,
owner,
repo: repoName,
imageBase64: ev.target.result
});
hideProgress();
if (result?.ok) {
showSuccess(`Projektbild für "${repoName}" aktualisiert`);
loadGiteaRepos();
} else {
showError('Upload fehlgeschlagen: ' + (result?.error || 'Unbekannter Fehler'));
}
};
reader.readAsDataURL(file);
};
fileInput.click();
}));
}
const fullName = `${owner || ''}/${repoName || ''}`;
const currentTopics = repoTopicsByFullName[fullName] || [];
if (writable) {
menu.appendChild(createMenuItem('🏷️', 'Tags bearbeiten', async () => {
menu.remove();
await editRepoTopics(owner, repoName, currentTopics);
}));
}
menu.appendChild(createMenuItem('🔗', 'Clone-URL kopieren', async () => {
menu.remove();
const res = await window.electronAPI.copyToClipboard(cloneUrl || '');
if (res?.ok) showInfo('Clone-URL kopiert');
else showError('Clone-URL konnte nicht kopiert werden');
}));
const isOwnRepo = String(owner || '').toLowerCase() === String(currentGiteaUsername || '').toLowerCase();
if (isOwnRepo) {
const isGiteaView = currentState.platform === 'gitea';
const syncLabel = isGiteaView ? 'Gitea -> GitHub synchronisieren' : 'GitHub -> Gitea synchronisieren';
const syncIcon = isGiteaView ? '🔄' : '🔁';
menu.appendChild(createMenuItem(syncIcon, syncLabel, async () => {
menu.remove();
if (isGiteaView) {
const ok = await showSyncConfirmModal({
title: 'Sync bestaetigen',
message: `Projekt "${repoName}" von Gitea nach GitHub synchronisieren?`,
confirmText: 'Jetzt synchronisieren',
details: [
{ label: 'Richtung', value: 'Gitea -> GitHub' },
{ label: 'Repository', value: `${owner}/${repoName}` },
{ label: 'Sichtbarkeit', value: isPrivate ? 'Privat' : 'Oeffentlich' },
{ label: 'Beschreibung', value: (repoMeta?.description || '-').slice(0, 160) },
{ label: 'Topics', value: (Array.isArray(repoMeta?.topics) && repoMeta.topics.length > 0) ? repoMeta.topics.join(', ') : '-' }
]
});
if (!ok) return;
showProgress(20, `Synchronisiere ${owner}/${repoName} nach GitHub...`);
const res = await window.electronAPI.syncRepoToGitHub({
owner,
repo: repoName,
cloneUrl,
isPrivate,
description: repoMeta?.description || '',
homepage: repoMeta?.website || repoMeta?.homepage || '',
topics: Array.isArray(repoMeta?.topics) ? repoMeta.topics : []
});
hideProgress();
if (res?.ok) {
const createdText = res.repoCreated ? ' (neu erstellt)' : ' (bereits vorhanden)';
showSuccess(`Sync erfolgreich: ${res.githubRepo}${createdText}`);
setStatus(`GitHub Sync OK: ${res.githubRepo}`);
} else {
showError('GitHub Sync fehlgeschlagen: ' + (res?.error || 'Unbekannter Fehler'));
}
} else {
const ok = await showSyncConfirmModal({
title: 'Sync bestaetigen',
message: `Projekt "${repoName}" von GitHub nach Gitea synchronisieren?`,
confirmText: 'Jetzt synchronisieren',
details: [
{ label: 'Richtung', value: 'GitHub -> Gitea' },
{ label: 'Repository', value: `${owner}/${repoName}` },
{ label: 'Sichtbarkeit', value: isPrivate ? 'Privat' : 'Oeffentlich' },
{ label: 'Beschreibung', value: (repoMeta?.description || '-').slice(0, 160) },
{ label: 'Topics', value: (Array.isArray(repoMeta?.topics) && repoMeta.topics.length > 0) ? repoMeta.topics.join(', ') : '-' }
]
});
if (!ok) return;
const creds = await window.electronAPI.loadCredentials();
if (!creds?.giteaToken || !creds?.giteaURL) {
showError('Gitea Token oder URL fehlt. Bitte in den Einstellungen speichern.');
return;
}
showProgress(20, `Synchronisiere ${owner}/${repoName} nach Gitea...`);
const res = await window.electronAPI.migrateRepoToGitea({
cloneUrl,
repoName,
description: repoMeta?.description || `Synced from GitHub ${owner}/${repoName}`,
isPrivate: !!isPrivate,
authToken: creds.githubToken || '',
authUsername: 'x-access-token'
});
hideProgress();
if (res?.ok) {
showSuccess(`Sync erfolgreich: Gitea/${repoName}`);
setStatus(`Gitea Sync OK: ${repoName}`);
} else {
showError('Gitea Sync fehlgeschlagen: ' + (res?.error || 'Unbekannter Fehler'));
}
}
}));
}
menu.appendChild(createMenuItem('📋', 'owner/repo kopieren', async () => {
menu.remove();
const res = await window.electronAPI.copyToClipboard(`${owner}/${repoName}`);
if (res?.ok) showInfo('owner/repo kopiert');
else showError('owner/repo konnte nicht kopiert werden');
}));
menu.appendChild(createMenuItem('🌐', 'Im Browser öffnen', async () => {
menu.remove();
const url = buildRepoWebUrl(owner, repoName);
if (!url) {
showWarning('Gitea-URL fehlt oder ist ungültig. Bitte in den Settings prüfen.');
return;
}
const res = await window.electronAPI.openExternalUrl(url);
if (res?.ok) showInfo('Repository im Browser geöffnet');
else showError('Browser konnte nicht geöffnet werden');
}));
if (writable) {
menu.appendChild(createMenuItem(
isPrivate ? '🌍' : '🔒',
isPrivate ? 'Öffentlich machen' : 'Privat machen',
async () => {
menu.remove();
await toggleRepoVisibility(owner, repoName, isPrivate);
}
));
}
addSep();
// ── Favorit ──
if (featureFavorites) {
const isFav = isFavorite(owner, repoName);
const favItem = createMenuItem(
isFav ? '⭐' : '☆',
isFav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen',
async () => {
menu.remove();
await toggleFavorite(owner, repoName, cloneUrl);
loadGiteaRepos();
},
isFav ? '#f59e0b' : null
);
menu.appendChild(favItem);
addSep();
}
const uploadItem = createMenuItem('🚀', 'Folder hier hochladen', async () => {
menu.remove();
try {
const sel = await window.electronAPI.selectFolder();
if (sel) {
showProgress(0, 'Upload...');
await window.electronAPI.uploadAndPush({
localFolder: sel,
owner,
repo: repoName,
destPath: '',
cloneUrl,
branch: getDefaultBranch(owner, repoName)
});
hideProgress();
setStatus('Upload complete');
}
} catch (error) {
console.error('Upload error:', error);
hideProgress();
showError('Upload failed');
}
});
const deleteItem = createMenuItem('🗑️', 'Repo löschen', async () => {
menu.remove();
const ok = await showActionConfirmModal({
title: 'Repository loeschen',
message: `Delete ${repoName}?`,
confirmText: 'Loeschen',
danger: true
});
if (ok) {
try {
const res = await window.electronAPI.deleteGiteaRepo({ owner, repo: repoName, platform: currentState.platform });
if (res.ok) {
element.remove();
showSuccess('Repository deleted');
} else {
showError('Delete failed: ' + res.error);
}
} catch (error) {
console.error('Delete error:', error);
showError('Delete failed');
}
}
}, '#ef4444');
if (writable) {
menu.appendChild(uploadItem);
menu.appendChild(deleteItem);
}
document.body.appendChild(menu);
setTimeout(() => {
document.addEventListener('click', () => menu.remove(), { once: true });
}, 10);
}
function showGiteaItemContextMenu(ev, item, owner, repo) {
ev.preventDefault();
ev.stopPropagation();
const old = $('ctxMenu');
if (old) old.remove();
const menu = document.createElement('div');
menu.id = 'ctxMenu';
menu.className = 'context-menu';
// Menü positionieren (nicht außerhalb des Fensters)
const menuW = 220, menuH = 360;
const x = Math.min(ev.clientX, window.innerWidth - menuW);
const y = Math.min(ev.clientY, window.innerHeight - menuH);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
const addSep = () => {
const s = document.createElement('div');
s.style.cssText = 'height:1px;background:rgba(255,255,255,0.1);margin:4px 0;';
menu.appendChild(s);
};
const addItem = (icon, text, onClick, color = null) => {
const el = document.createElement('div');
el.className = 'context-item';
el.textContent = `${icon} ${text}`;
if (color) el.style.color = color;
el.onclick = () => { menu.remove(); onClick(); };
menu.appendChild(el);
};
// Mehrfachauswahl-Info
if (selectedItems.size > 1 && selectedItems.has(item.path)) {
const infoEl = document.createElement('div');
infoEl.style.cssText = 'padding:8px 14px;font-size:11px;color:var(--accent-primary);font-weight:600;';
infoEl.textContent = `${selectedItems.size} Elemente ausgewählt`;
menu.appendChild(infoEl);
addSep();
addItem('🗑️', `Alle ${selectedItems.size} löschen`, async () => {
const ok = await showActionConfirmModal({
title: 'Mehrfach-Loeschen',
message: `${selectedItems.size} Elemente wirklich loeschen?`,
confirmText: 'Loeschen',
danger: true
});
if (!ok) return;
showProgress(0, 'Lösche...');
let done = 0;
for (const p of selectedItems) {
const isGithub = currentState.platform === 'github';
await window.electronAPI.deleteFile({ path: p, owner, repo, isGitea: !isGithub, isGithub, ref: getDefaultBranch(owner, repo) });
done++;
showProgress(Math.round((done / selectedItems.size) * 100), `Lösche ${done}/${selectedItems.size}`);
}
selectedItems.clear();
hideProgress();
setStatus('Bulk-Delete abgeschlossen');
loadRepoContents(owner, repo, currentState.path);
}, '#ef4444');
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
return;
}
// --- ÖFFNEN ---
addItem(
item.type === 'dir' ? '📂' : '✏️',
item.type === 'dir' ? 'Öffnen' : 'Im Editor öffnen',
() => {
if (item.type === 'dir') loadRepoContents(owner, repo, item.path);
else openGiteaFileInEditor(owner, repo, item.path, item.name);
}
);
addSep();
// --- NEU ERSTELLEN (immer sichtbar) ---
addItem('📄', 'Neue Datei erstellen', () => showNewGiteaItemModal(owner, repo, item.type === 'dir' ? item.path : currentState.path, 'file'));
addItem('📁', 'Neuen Ordner erstellen', () => showNewGiteaItemModal(owner, repo, item.type === 'dir' ? item.path : currentState.path, 'folder'));
addSep();
// --- UMBENENNEN ---
addItem('✏️', 'Umbenennen', () => showGiteaRenameModal(item, owner, repo));
// --- CUT & PASTE ---
addItem('✂️', 'Ausschneiden (Cut)', () => {
clipboard = { item: { ...item, owner, repo, isGitea: currentState.platform !== 'github', isGithub: currentState.platform === 'github' }, action: 'cut' };
setStatus(`✂️ "${item.name}" ausgeschnitten — Zielordner öffnen und Einfügen wählen`);
});
if (clipboard.item && clipboard.item.isGitea && item.type === 'dir') {
addItem('📋', `Einfügen: "${clipboard.item.name}"`, async () => {
await pasteGiteaItem(owner, repo, item.path);
});
}
addSep();
// --- DOWNLOAD ---
if (item.type === 'file') {
addItem('📥', 'Herunterladen', async () => {
const res = await window.electronAPI.downloadGiteaFile({ owner, repo, path: item.path });
setStatus(res.ok ? `Gespeichert: ${res.savedTo}` : 'Download fehlgeschlagen');
});
} else {
addItem('📥', 'Ordner herunterladen', async () => {
showProgress(0, `Lade ${item.name}...`);
const res = await window.electronAPI.downloadGiteaFolder({ owner, repo, path: item.path });
hideProgress();
setStatus(res.ok ? `Gespeichert: ${res.savedTo}` : 'Download fehlgeschlagen');
});
}
addSep();
// --- LÖSCHEN ---
addItem('🗑️', 'Löschen', async () => {
const ok = await showActionConfirmModal({
title: 'Element loeschen',
message: `"${item.name}" wirklich loeschen?`,
confirmText: 'Loeschen',
danger: true
});
if (!ok) return;
showProgress(0, `Lösche ${item.name}...`);
const isGithub = currentState.platform === 'github';
const res = await window.electronAPI.deleteFile({ path: item.path, owner, repo, isGitea: !isGithub, isGithub, ref: getDefaultBranch(owner, repo) });
hideProgress();
if (res && res.ok) {
setStatus(`${item.name} gelöscht`);
loadRepoContents(owner, repo, currentState.path);
} else {
showError('Löschen fehlgeschlagen: ' + (res?.error || ''));
await showInfoModal('Loeschen fehlgeschlagen', 'Loeschen fehlgeschlagen:\n' + (res?.error || 'Unbekannter Fehler'), true);
}
}, '#ef4444');
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
}
/* -------------------------
LOKALES KONTEXT-MENÜ
------------------------- */
function showLocalItemContextMenu(ev, node) {
ev.preventDefault();
ev.stopPropagation();
const old = $('ctxMenu');
if (old) old.remove();
const menu = document.createElement('div');
menu.id = 'ctxMenu';
menu.className = 'context-menu';
const menuW = 220, menuH = 360;
const x = Math.min(ev.clientX, window.innerWidth - menuW);
const y = Math.min(ev.clientY, window.innerHeight - menuH);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
const addSep = () => {
const s = document.createElement('div');
s.style.cssText = 'height:1px;background:rgba(255,255,255,0.1);margin:4px 0;';
menu.appendChild(s);
};
const addItem = (icon, text, onClick, color = null) => {
const el = document.createElement('div');
el.className = 'context-item';
el.textContent = `${icon} ${text}`;
if (color) el.style.color = color;
el.onclick = () => { menu.remove(); onClick(); };
menu.appendChild(el);
};
// Mehrfachauswahl-Bulk-Delete
if (selectedItems.size > 1 && selectedItems.has(node.path)) {
const infoEl = document.createElement('div');
infoEl.style.cssText = 'padding:8px 14px;font-size:11px;color:var(--accent-primary);font-weight:600;';
infoEl.textContent = `${selectedItems.size} Elemente ausgewählt`;
menu.appendChild(infoEl);
addSep();
addItem('🗑️', `Alle ${selectedItems.size} löschen`, async () => {
const ok = await showActionConfirmModal({
title: 'Mehrfach-Loeschen',
message: `${selectedItems.size} Elemente wirklich loeschen?`,
confirmText: 'Loeschen',
danger: true
});
if (!ok) return;
for (const p of selectedItems) {
await window.electronAPI.deleteFile({ path: p });
}
selectedItems.clear();
setStatus('Bulk-Delete abgeschlossen');
if (selectedFolder) refreshLocalTree(selectedFolder);
}, '#ef4444');
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
return;
}
// --- ÖFFNEN ---
if (!node.isDirectory) {
addItem('✏️', 'Im Editor öffnen', () => openFileEditor(node.path, node.name));
addSep();
}
// --- NEU ERSTELLEN ---
const targetDir = node.isDirectory ? node.path : require('path').dirname(node.path);
addItem('📄', 'Neue Datei erstellen', () => showNewLocalItemModal(targetDir, 'file'));
addItem('📁', 'Neuen Ordner erstellen', () => showNewLocalItemModal(targetDir, 'folder'));
addSep();
// --- UMBENENNEN ---
addItem('✏️', 'Umbenennen', () => showLocalRenameModal(node));
// --- CUT & PASTE ---
addItem('✂️', 'Ausschneiden (Cut)', () => {
clipboard = { item: { ...node, isLocal: true }, action: 'cut' };
setStatus(`✂️ "${node.name}" ausgeschnitten`);
});
if (clipboard.item && clipboard.item.isLocal && node.isDirectory) {
addItem('📋', `Einfügen: "${clipboard.item.name}"`, async () => {
await pasteLocalItem(node.path);
});
}
addSep();
// --- DOWNLOAD / KOPIEREN ---
addItem('📥', 'Kopieren nach...', async () => {
const destFolder = await window.electronAPI.selectFolder();
if (!destFolder) return;
const res = await window.electronAPI.copyLocalItem({ src: node.path, destDir: destFolder });
setStatus(res?.ok ? `Kopiert nach: ${destFolder}` : 'Kopieren fehlgeschlagen: ' + (res?.error || ''));
});
addSep();
// --- LÖSCHEN ---
addItem('🗑️', 'Löschen', async () => {
const ok = await showActionConfirmModal({
title: 'Element loeschen',
message: `"${node.name}" wirklich loeschen?`,
confirmText: 'Loeschen',
danger: true
});
if (!ok) return;
const res = await window.electronAPI.deleteFile({ path: node.path });
if (res && res.ok) {
setStatus(`${node.name} gelöscht`);
if (selectedFolder) refreshLocalTree(selectedFolder);
} else {
showError('Löschen fehlgeschlagen: ' + (res?.error || ''));
}
}, '#ef4444');
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
}
/* =========================================
MODAL HELPER FUNKTIONEN
========================================= */
// Gitea: Umbenennen
function showGiteaRenameModal(item, owner, repo) {
showInputModal({
title: `✏️ Umbenennen`,
label: 'Neuer Name',
defaultValue: item.name,
confirmText: 'Umbenennen',
onConfirm: async (newName) => {
if (!newName || newName === item.name) return;
setStatus('Umbenennen...');
const parentPath = item.path.split('/').slice(0, -1).join('/');
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
const res = await window.electronAPI.renameGiteaItem({
owner, repo,
oldPath: item.path,
newPath,
isDir: item.type === 'dir'
});
if (res?.ok) {
setStatus(`Umbenannt in "${newName}"`);
loadRepoContents(owner, repo, currentState.path);
} else {
await showInfoModal('Umbenennen fehlgeschlagen', 'Umbenennen fehlgeschlagen:\n' + (res?.error || 'Unbekannter Fehler'), true);
showError('Fehler beim Umbenennen');
}
}
});
}
// Gitea: Neue Datei / Ordner
function showNewGiteaItemModal(owner, repo, parentPath, type) {
showInputModal({
title: type === 'file' ? '📄 Neue Datei' : '📁 Neuer Ordner',
label: type === 'file' ? 'Dateiname (z.B. README.md)' : 'Ordnername',
defaultValue: '',
confirmText: 'Erstellen',
onConfirm: async (name) => {
if (!name) return;
const targetPath = parentPath ? `${parentPath}/${name}` : name;
const res = await window.electronAPI.createGiteaItem({
owner, repo,
path: targetPath,
type
});
if (res?.ok) {
setStatus(`"${name}" erstellt`);
loadRepoContents(owner, repo, currentState.path);
} else {
await showInfoModal('Erstellen fehlgeschlagen', 'Erstellen fehlgeschlagen:\n' + (res?.error || ''), true);
}
}
});
}
// Lokal: Umbenennen
function showLocalRenameModal(node) {
showInputModal({
title: `✏️ Umbenennen`,
label: 'Neuer Name',
defaultValue: node.name,
confirmText: 'Umbenennen',
onConfirm: async (newName) => {
if (!newName || newName === node.name) return;
const res = await window.electronAPI.renameLocalItem({ oldPath: node.path, newName });
if (res?.ok) {
setStatus(`Umbenannt in "${newName}"`);
if (selectedFolder) refreshLocalTree(selectedFolder);
} else {
await showInfoModal('Umbenennen fehlgeschlagen', 'Umbenennen fehlgeschlagen:\n' + (res?.error || ''), true);
}
}
});
}
// Lokal: Neue Datei / Ordner
function showNewLocalItemModal(parentDir, type) {
showInputModal({
title: type === 'file' ? '📄 Neue Datei' : '📁 Neuer Ordner',
label: type === 'file' ? 'Dateiname (z.B. README.md)' : 'Ordnername',
defaultValue: '',
confirmText: 'Erstellen',
onConfirm: async (name) => {
if (!name) return;
const res = await window.electronAPI.createLocalItem({ parentDir, name, type });
if (res?.ok) {
setStatus(`"${name}" erstellt`);
if (selectedFolder) refreshLocalTree(selectedFolder);
} else {
await showInfoModal('Erstellen fehlgeschlagen', 'Erstellen fehlgeschlagen:\n' + (res?.error || ''), true);
}
}
});
}
// Gitea: Einfügen nach Cut
async function pasteGiteaItem(owner, repo, destFolderPath) {
if (!clipboard.item || !clipboard.item.isGitea) return;
const src = clipboard.item;
const newPath = destFolderPath ? `${destFolderPath}/${src.name}` : src.name;
setStatus(`Verschiebe "${src.name}"...`);
showProgress(0, `Verschiebe...`);
const res = await window.electronAPI.renameGiteaItem({
owner, repo,
oldPath: src.path,
newPath,
isDir: src.type === 'dir'
});
hideProgress();
if (res?.ok) {
clipboard = { item: null, action: null };
setStatus(`"${src.name}" verschoben`);
loadRepoContents(owner, repo, currentState.path);
} else {
await showInfoModal('Verschieben fehlgeschlagen', 'Verschieben fehlgeschlagen:\n' + (res?.error || ''), true);
showError('Fehler beim Verschieben');
}
}
// Lokal: Einfügen nach Cut
async function pasteLocalItem(destDir) {
if (!clipboard.item || !clipboard.item.isLocal) return;
const src = clipboard.item;
setStatus(`Verschiebe "${src.name}"...`);
const res = await window.electronAPI.moveLocalItem({ srcPath: src.path, destDir });
if (res?.ok) {
clipboard = { item: null, action: null };
setStatus(`"${src.name}" verschoben`);
if (selectedFolder) refreshLocalTree(selectedFolder);
} else {
await showInfoModal('Verschieben fehlgeschlagen', 'Verschieben fehlgeschlagen:\n' + (res?.error || ''), true);
}
}
// Generic Input Modal
function showInputModal({ title, label, defaultValue, confirmText, onConfirm }) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.style.zIndex = '99999';
const card = document.createElement('div');
card.className = 'modalContent card';
card.style.maxWidth = '420px';
const titleEl = document.createElement('h2');
titleEl.textContent = String(title || '');
const group = document.createElement('div');
group.className = 'input-group';
const labelEl = document.createElement('label');
labelEl.textContent = String(label || '');
const inputEl = document.createElement('input');
inputEl.id = 'inputModalField';
inputEl.type = 'text';
inputEl.value = String(defaultValue || '');
inputEl.autocomplete = 'off';
group.appendChild(labelEl);
group.appendChild(inputEl);
const buttons = document.createElement('div');
buttons.className = 'modal-buttons';
buttons.style.marginTop = '16px';
const okBtn = document.createElement('button');
okBtn.id = 'inputModalOk';
okBtn.className = 'accent-btn';
okBtn.textContent = String(confirmText || 'Bestätigen');
const cancelBtn = document.createElement('button');
cancelBtn.id = 'inputModalCancel';
cancelBtn.className = 'secondary';
cancelBtn.textContent = 'Abbrechen';
buttons.appendChild(okBtn);
buttons.appendChild(cancelBtn);
card.appendChild(titleEl);
card.appendChild(group);
card.appendChild(buttons);
modal.appendChild(card);
document.body.appendChild(modal);
const input = modal.querySelector('#inputModalField');
input.focus();
input.select();
modal.querySelector('#inputModalOk').onclick = () => {
const val = input.value.trim();
modal.remove();
onConfirm(val);
};
modal.querySelector('#inputModalCancel').onclick = () => modal.remove();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') modal.querySelector('#inputModalOk').click();
if (e.key === 'Escape') modal.querySelector('#inputModalCancel').click();
});
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
}
function showActionConfirmModal({ title, message, confirmText = 'Bestätigen', danger = false }) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal confirm-modal';
modal.style.zIndex = '99999';
const card = document.createElement('div');
card.className = 'modalContent card confirm-modal-content';
const titleEl = document.createElement('h2');
titleEl.className = 'confirm-modal-title';
titleEl.textContent = `${danger ? '🗑️' : ''} ${title || 'Bestätigung'}`;
const msgEl = document.createElement('p');
msgEl.className = 'confirm-modal-message';
msgEl.textContent = String(message || '');
const btnWrap = document.createElement('div');
btnWrap.className = 'modal-buttons confirm-modal-buttons';
const okBtn = document.createElement('button');
okBtn.id = 'actionConfirmOk';
okBtn.className = `confirm-modal-btn ${danger ? 'confirm-modal-btn--danger' : 'confirm-modal-btn--primary'}`;
okBtn.textContent = String(confirmText || 'Bestätigen');
const cancelBtn = document.createElement('button');
cancelBtn.id = 'actionConfirmCancel';
cancelBtn.className = 'confirm-modal-btn confirm-modal-btn--secondary';
cancelBtn.textContent = 'Abbrechen';
btnWrap.appendChild(okBtn);
btnWrap.appendChild(cancelBtn);
card.appendChild(titleEl);
card.appendChild(msgEl);
card.appendChild(btnWrap);
modal.appendChild(card);
document.body.appendChild(modal);
const okBtnNode = modal.querySelector('#actionConfirmOk');
const cancelBtnNode = modal.querySelector('#actionConfirmCancel');
if (okBtnNode) okBtnNode.focus();
const closeWith = (result) => {
modal.remove();
resolve(result);
};
if (okBtnNode) okBtnNode.onclick = () => closeWith(true);
if (cancelBtnNode) cancelBtnNode.onclick = () => closeWith(false);
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeWith(false);
if (e.key === 'Enter') closeWith(true);
});
modal.onclick = (e) => {
if (e.target === modal) closeWith(false);
};
});
}
function showInfoModal(title, message, danger = false) {
return showActionConfirmModal({
title,
message,
confirmText: 'OK',
danger
});
}
/* -------------------------
HELPER FUNCTIONS
------------------------- */
function ppathBasename(p) {
try {
return p.split(/[\\/]/).pop();
} catch (_) {
return p;
}
}
async function previewGiteaFile(owner, repo, filePath) {
try {
const res = await window.electronAPI.getGiteaFileContent({
owner,
repo,
path: filePath,
ref: getDefaultBranch(owner, repo),
platform: currentState.platform
});
if (res.ok) {
setStatus(`Previewed: ${filePath}`);
} else {
showError('Preview failed');
}
} catch (error) {
console.error('Preview error:', error);
showError('Preview failed');
}
}
async function createRepoHandler() {
const name = $('repoName')?.value?.trim();
if (!name) {
showWarning('Repository-Name ist erforderlich.');
return;
}
const check = await validateRepoNameLive(name);
if (!check.ok || check.blocking) {
showError('Bitte den Repository-Namen korrigieren.');
return;
}
if (check.similar && check.similar.length > 0) {
const proceed = await showActionConfirmModal({
title: 'Aehnliche Namen gefunden',
message: `Aehnliche Repository-Namen gefunden: ${check.similar.slice(0, 3).join(', ')}\n\nTrotzdem erstellen?`,
confirmText: 'Trotzdem erstellen',
danger: false
});
if (!proceed) return;
}
setStatus('Creating repository...');
try {
const res = await window.electronAPI.createRepo({
name,
platform: $('platform').value,
license: $('licenseSelect')?.value || '',
autoInit: $('createReadme')?.checked || true
});
if (res.ok) {
$('repoActionModal')?.classList.add('hidden');
showSuccess('Repository created');
loadGiteaRepos();
} else {
showError('Create failed: ' + (res.error || 'Unknown error'));
}
} catch (error) {
console.error('Create repo error:', error);
showError('Create failed');
}
}
/* -------------------------
GLOBALER DROP-HANDLER FÜR REPO-ANSICHT
------------------------- */
/* -------------------------
HINTERGRUND KONTEXT-MENÜ (Rechtsklick auf leere Fläche)
------------------------- */
function setupBackgroundContextMenu() {
// Listener auf #main statt explorerGrid, da Grid kleiner als sichtbarer Bereich sein kann
const mainEl = $('main');
if (!mainEl) return;
mainEl.addEventListener('contextmenu', (ev) => {
// Nicht auslösen wenn auf eine Karte oder interaktives Element geklickt wird
if (ev.target.closest('.item-card, .release-card, .commit-card, .fav-chip, .fav-star-btn, button, input, textarea, select, a')) return;
// Nur in Repo- oder Lokal-Ansicht
if (currentState.view !== 'gitea-repo' && currentState.view !== 'local') return;
ev.preventDefault();
ev.stopPropagation();
const old = $('ctxMenu');
if (old) old.remove();
const menu = document.createElement('div');
menu.id = 'ctxMenu';
menu.className = 'context-menu';
const menuW = 220, menuH = 160;
const x = Math.min(ev.clientX, window.innerWidth - menuW);
const y = Math.min(ev.clientY, window.innerHeight - menuH);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Aktuelle Pfad-Info als Header
const header = document.createElement('div');
header.style.cssText = 'padding:8px 14px 4px;font-size:11px;color:var(--text-muted);letter-spacing:0.5px;';
const currentPath = currentState.view === 'gitea-repo'
? (currentState.path || 'Root')
: (selectedFolder ? selectedFolder.split(/[\\/]/).pop() : 'Lokal');
header.textContent = `📂 ${currentPath}`;
menu.appendChild(header);
const sep = document.createElement('div');
sep.style.cssText = 'height:1px;background:rgba(255,255,255,0.1);margin:4px 0;';
menu.appendChild(sep);
const addItem = (icon, text, onClick) => {
const el = document.createElement('div');
el.className = 'context-item';
el.textContent = `${icon} ${text}`;
el.onclick = () => { menu.remove(); onClick(); };
menu.appendChild(el);
};
if (currentState.view === 'gitea-repo') {
const { owner, repo, path: currentPath } = currentState;
addItem('📄', 'Neue Datei erstellen', () =>
showNewGiteaItemModal(owner, repo, currentPath, 'file')
);
addItem('📁', 'Neuen Ordner erstellen', () =>
showNewGiteaItemModal(owner, repo, currentPath, 'folder')
);
// Einfügen: gleiche Quelle ODER Cross-Paste von Lokal
if (clipboard.item) {
const sep2 = document.createElement('div');
sep2.style.cssText = 'height:1px;background:rgba(255,255,255,0.1);margin:4px 0;';
menu.appendChild(sep2);
if (clipboard.item.isGitea) {
addItem('📋', `Einfügen: "${clipboard.item.name}"`, () =>
pasteGiteaItem(owner, repo, currentPath)
);
} else if (clipboard.item.isLocal) {
addItem('📋', `⬆️ Von Lokal einfügen: "${clipboard.item.name}"`, async () => {
showProgress(0, `Lade "${clipboard.item.name}" hoch...`);
try {
await window.electronAPI.uploadAndPush({
localFolder: clipboard.item.path,
owner, repo,
destPath: currentPath,
branch: getDefaultBranch(owner, repo)
});
showSuccess(`"${clipboard.item.name}" nach Gitea kopiert`);
loadRepoContents(owner, repo, currentState.path);
} catch(e) { showError('Cross-Paste fehlgeschlagen'); }
finally { hideProgress(); }
});
}
}
} else if (currentState.view === 'local' && selectedFolder) {
addItem('📄', 'Neue Datei erstellen', () =>
showNewLocalItemModal(selectedFolder, 'file')
);
addItem('📁', 'Neuen Ordner erstellen', () =>
showNewLocalItemModal(selectedFolder, 'folder')
);
if (clipboard.item) {
const sep2 = document.createElement('div');
sep2.style.cssText = 'height:1px;background:rgba(255,255,255,0.1);margin:4px 0;';
menu.appendChild(sep2);
if (clipboard.item.isLocal) {
addItem('📋', `Einfügen: "${clipboard.item.name}"`, () =>
pasteLocalItem(selectedFolder)
);
} else if (clipboard.item.isGitea) {
addItem('📋', `⬇️ Von Gitea einfügen: "${clipboard.item.name}"`, async () => {
showProgress(0, `Lade "${clipboard.item.name}" herunter...`);
try {
await window.electronAPI.downloadGiteaFolder({
owner: clipboard.item.owner,
repo: clipboard.item.repo,
giteaPath: clipboard.item.path,
localPath: selectedFolder
});
showSuccess(`"${clipboard.item.name}" nach Lokal kopiert`);
refreshLocalTree(selectedFolder);
} catch(e) { showError('Cross-Paste fehlgeschlagen'); }
finally { hideProgress(); }
});
}
}
}
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', () => menu.remove(), { once: true }), 10);
});
}
function setupGlobalDropZone() {
const main = $('main');
if (!main) return;
// Visual feedback beim Drag über das Fenster
let dragCounter = 0;
main.addEventListener('dragenter', (ev) => {
// Nur in Repo-Ansicht aktiv
if (currentState.view !== 'gitea-repo') return;
dragCounter++;
if (dragCounter === 1) {
main.classList.add('drop-active');
}
});
main.addEventListener('dragleave', (ev) => {
if (currentState.view !== 'gitea-repo') return;
dragCounter--;
if (dragCounter === 0) {
main.classList.remove('drop-active');
}
});
main.addEventListener('dragover', (ev) => {
if (currentState.view !== 'gitea-repo') return;
ev.preventDefault();
});
main.addEventListener('drop', async (ev) => {
if (currentState.view !== 'gitea-repo') return;
ev.preventDefault();
ev.stopPropagation();
dragCounter = 0;
main.classList.remove('drop-active');
const files = ev.dataTransfer.files;
if (!files || files.length === 0) {
showWarning("Keine Dateien zum Upload gefunden.");
return;
}
// Upload in aktuellen Pfad
const owner = currentState.owner;
const repo = currentState.repo;
const targetPath = currentState.path || '';
const paths = extractDroppedPaths(files);
if (paths.length === 0) {
showError('Dateipfade konnten nicht gelesen werden (Sandbox/Drag-Quelle).');
return;
}
setStatus(`Uploading ${paths.length} items to /${targetPath || 'root'}...`);
try {
const res = await uploadDroppedPaths({
paths,
owner,
repo,
destPath: targetPath,
branch: getDefaultBranch(owner, repo)
});
if (!res.ok) {
console.error('Upload error:', res.error);
showError('Error: ' + (res.error || 'Upload failed'));
}
} catch (err) {
console.error('Critical upload error:', err);
showError('Upload failed');
}
hideProgress();
// Refresh current view
setTimeout(() => {
loadRepoContents(owner, repo, targetPath);
}, 1000);
});
}
/* -------------------------
INITIALISIERUNG
------------------------- */
window.addEventListener('DOMContentLoaded', async () => {
initializePlatformSelection();
// Favoriten & Verlauf vorladen
await loadFavoritesAndRecent();
renderSettingsHealth();
// Prevent default drag/drop on document (except in repo view)
document.addEventListener('dragover', e => {
if (currentState.view !== 'gitea-repo') {
e.preventDefault();
}
});
document.addEventListener('drop', e => {
try {
window.electronAPI.debugToMain('log', 'document:drop', {
currentView: currentState.view,
fileCount: e.dataTransfer?.files ? e.dataTransfer.files.length : 0,
itemCount: e.dataTransfer?.items ? e.dataTransfer.items.length : 0
});
} catch (_) {}
if (currentState.view !== 'gitea-repo') {
e.preventDefault();
}
});
// Load credentials and auto-login if available
try {
const creds = await window.electronAPI.loadCredentials();
const credentialStatus = await window.electronAPI.getCredentialsStatus();
if (creds) {
// Fülle Settings-Felder
if ($('githubToken')) $('githubToken').value = creds.githubToken || '';
if ($('giteaToken')) $('giteaToken').value = creds.giteaToken || '';
if ($('giteaURL')) $('giteaURL').value = creds.giteaURL || '';
renderGithubTokenHint(creds.githubToken || '');
renderGiteaUrlHint(creds.giteaURL || '', creds.giteaToken || '');
// Avatar anzeigen
if (creds.avatarB64) {
const img = $('settingsAvatarImg');
const ph = $('settingsAvatarPlaceholder');
if (img) { img.src = creds.avatarB64; img.style.display = 'block'; }
if (ph) ph.style.display = 'none';
}
const checkedUrl = normalizeAndValidateGiteaUrl(creds.giteaURL || '');
updateSettingsHealth({
url: checkedUrl.ok && checkedUrl.value ? 'Gültig' : (checkedUrl.ok ? 'Leer' : 'Ungültig'),
api: creds.giteaURL ? 'Unbekannt' : 'Nicht konfiguriert',
auth: creds.giteaToken ? 'Token vorhanden' : 'Kein Token',
latency: '-',
version: '-'
});
// Feature-Flags aus gespeicherten Einstellungen
if (typeof creds.featureFavorites === 'boolean') featureFavorites = creds.featureFavorites;
if (typeof creds.featureRecent === 'boolean') featureRecent = creds.featureRecent;
if (typeof creds.compactMode === 'boolean') compactMode = creds.compactMode;
if (typeof creds.featureColoredIcons === 'boolean') featureColoredIcons = creds.featureColoredIcons;
document.body.classList.toggle('compact-mode', compactMode);
// Autostart-Status vom System lesen (Quelle der Wahrheit)
try {
const autostartRes = await window.electronAPI.getAutostart();
if (autostartRes && typeof autostartRes.enabled === 'boolean') {
featureAutostart = autostartRes.enabled;
}
} catch (_) {}
// Collapse-Zustand wiederherstellen
if (typeof creds.favCollapsedFavorites === 'boolean') favSectionCollapsed.favorites = creds.favCollapsedFavorites;
if (typeof creds.favCollapsedRecent === 'boolean') favSectionCollapsed.recent = creds.favCollapsedRecent;
// Settings-Checkboxen befüllen
const cbFav = $('settingFavorites');
const cbRec = $('settingRecent');
const cbCompact = $('settingCompact');
if (cbFav) cbFav.checked = featureFavorites;
if (cbRec) cbRec.checked = featureRecent;
if (cbCompact) cbCompact.checked = compactMode;
const cbColorIcons = $('settingColoredIcons');
if (cbColorIcons) cbColorIcons.checked = featureColoredIcons;
const cbAutostart = $('settingAutostart');
if (cbAutostart) cbAutostart.checked = featureAutostart;
// Standard beim App-Start: zuerst "Meine" Repositories anzeigen.
activeRepoOwnerFilter = 'mine';
// AUTO-LOGIN: je nach ausgewaehlter Plattform nur mit passenden Credentials laden
const selectedPlatform = ($('platform')?.value || currentState.platform || 'gitea');
const canAutoload = selectedPlatform === 'github'
? !!creds.githubToken
: !!(creds.giteaToken && creds.giteaURL);
if (canAutoload) {
setStatus('Lade deine Projekte...');
setTimeout(() => {
loadRepos();
}, 350);
} else {
setStatus('Bereit - bitte Settings konfigurieren');
}
} else {
setStatus('Bereit - bitte Settings konfigurieren');
renderGithubTokenHint('');
renderGiteaUrlHint('', '');
updateSettingsHealth({
url: 'Nicht konfiguriert',
api: 'Nicht konfiguriert',
auth: 'Kein Token',
latency: '-',
version: '-',
lastError: '-'
});
if (credentialStatus && credentialStatus.reason === 'safeStorage-decrypt-failed') {
showError('Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden. Bitte GitHub- und Gitea-Token neu eingeben und speichern.');
updateSettingsHealth({
auth: 'Nicht lesbar',
lastError: 'Credentials nicht entschlüsselbar'
});
} else if (credentialStatus && credentialStatus.reason === 'safeStorage-unavailable') {
showError('Die aktuelle Sitzung kann die gespeicherten Zugangsdaten nicht über safeStorage lesen. Bitte erneut eingeben und speichern.');
updateSettingsHealth({
auth: 'Nicht lesbar',
lastError: 'safeStorage nicht verfügbar'
});
}
}
} catch (error) {
console.error('Error loading credentials:', error);
showError('Fehler beim Laden der Einstellungen');
}
// Rest of Event Handlers... (bleibt unverändert)
// Event Handlers
if ($('btnLoadGiteaRepos')) {
$('btnLoadGiteaRepos').onclick = loadRepos;
}
if ($('btnSelectFolder')) {
$('btnSelectFolder').onclick = selectLocalFolder;
}
if ($('btnPush')) {
$('btnPush').onclick = pushLocalFolder;
}
if ($('btnCreateRepo')) {
$('btnCreateRepo').onclick = createRepoHandler;
}
if ($('btnBack')) {
$('btnBack').onclick = () => {
if (currentState.view === 'gitea-repo') {
if (currentState.path === '' || currentState.path === '/') {
loadGiteaRepos();
} else {
const parts = currentState.path.split('/').filter(p => p);
parts.pop();
loadRepoContents(currentState.owner, currentState.repo, parts.join('/'));
}
}
};
}
// Modal controls
if ($('btnWinMinimize')) $('btnWinMinimize').onclick = () => window.electronAPI.windowMinimize();
if ($('btnWinMaximize')) $('btnWinMaximize').onclick = () => window.electronAPI.windowMaximize();
if ($('btnWinClose')) $('btnWinClose').onclick = () => window.electronAPI.windowClose();
if ($('btnSettings')) {
$('btnSettings').onclick = () => {
$('settingsModal').classList.remove('hidden');
$('settingsWatermarkCard')?.classList.add('hidden');
renderSettingsHealth();
requestAnimationFrame(syncSettingsPanelHeights);
};
}
if ($('btnSettingsWatermark') && $('settingsWatermarkCard')) {
$('btnSettingsWatermark').onclick = (e) => {
e.stopPropagation();
$('settingsWatermarkCard').classList.toggle('hidden');
};
$('settingsWatermarkCard').addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', (e) => {
if ($('settingsModal')?.classList.contains('hidden')) return;
if ($('settingsWatermarkCard')?.classList.contains('hidden')) return;
const target = e.target;
if ($('btnSettingsWatermark')?.contains(target)) return;
if ($('settingsWatermarkCard')?.contains(target)) return;
$('settingsWatermarkCard')?.classList.add('hidden');
});
}
window.addEventListener('resize', syncSettingsPanelHeights);
if ($('btnBatchActions')) {
$('btnBatchActions').onclick = () => {
$('batchActionModal')?.classList.remove('hidden');
updateBatchActionFields();
scheduleBatchCloneValidation();
};
}
if ($('btnOpenActivityLog')) {
$('btnOpenActivityLog').onclick = () => {
$('activityLogModal')?.classList.remove('hidden');
renderActivityLog();
};
}
if ($('btnCloseActivityLog')) {
$('btnCloseActivityLog').onclick = () => $('activityLogModal')?.classList.add('hidden');
}
if ($('activityFilterLevel')) {
$('activityFilterLevel').addEventListener('change', renderActivityLog);
}
if ($('btnClearActivityLog')) {
$('btnClearActivityLog').onclick = () => {
activityEntries = [];
renderActivityLog();
refreshActivityHeatmapIfVisible();
};
}
if ($('btnCloseBatchAction')) {
$('btnCloseBatchAction').onclick = () => $('batchActionModal')?.classList.add('hidden');
}
if ($('batchActionType')) {
$('batchActionType').addEventListener('change', updateBatchActionFields);
$('batchActionType').addEventListener('change', scheduleBatchCloneValidation);
updateBatchActionFields();
}
if ($('repoName')) {
$('repoName').addEventListener('input', scheduleRepoNameValidation);
$('repoName').addEventListener('blur', () => validateRepoNameLive($('repoName')?.value || ''));
}
if ($('batchRepoList')) {
$('batchRepoList').addEventListener('input', scheduleBatchCloneValidation);
}
if ($('batchCloneTarget')) {
$('batchCloneTarget').addEventListener('input', scheduleBatchCloneValidation);
}
if ($('btnSelectBatchCloneTarget')) {
$('btnSelectBatchCloneTarget').onclick = async () => {
const folder = await window.electronAPI.selectFolder();
if (folder && $('batchCloneTarget')) {
$('batchCloneTarget').value = folder;
scheduleBatchCloneValidation();
}
};
}
if ($('btnRunBatchAction')) {
$('btnRunBatchAction').onclick = async () => {
const action = $('batchActionType')?.value || 'refresh';
const repos = parseBatchRepoInput($('batchRepoList')?.value || '');
if (repos.length === 0) {
showWarning('Bitte mindestens ein Repository im Format owner/repo eintragen.');
return;
}
const options = {
cloneTargetDir: $('batchCloneTarget')?.value || '',
tag: $('batchTagName')?.value || '',
name: $('batchReleaseName')?.value || '',
body: $('batchReleaseBody')?.value || ''
};
if (action === 'clone' && !options.cloneTargetDir) {
showWarning('Bitte zuerst einen Zielordner für Clone wählen.');
return;
}
if (action === 'clone') {
const safeToClone = await validateBatchCloneCollisions(true);
if (!safeToClone) {
showError('Clone abgebrochen: Zielordner-Konflikt erkannt.');
return;
}
}
if ((action === 'create-tag' || action === 'create-release') && !String(options.tag).trim()) {
showWarning('Bitte einen Tag eintragen.');
return;
}
const btn = $('btnRunBatchAction');
const old = btn.textContent;
btn.disabled = true;
btn.textContent = 'Läuft...';
logActivity('info', `Batch gestartet: ${action} (${repos.length} Repos)`);
try {
const res = await window.electronAPI.runBatchRepoAction({ action, repos, options });
if (!res.ok) {
showError(res.error || 'Batch-Aktion fehlgeschlagen');
return;
}
const summary = res.summary || { total: repos.length, success: 0, failed: 0 };
if (summary.failed > 0) {
showWarning(`Batch beendet: ${summary.success}/${summary.total} erfolgreich, ${summary.failed} fehlgeschlagen.`);
} else {
showSuccess(`Batch erfolgreich: ${summary.success}/${summary.total}`);
}
(res.results || []).forEach(r => {
if (r.ok) logActivity('info', `${r.repo}: ${r.message || 'OK'}`);
else logActivity('error', `${r.repo}: ${r.error || 'Fehler'}`);
});
} catch (error) {
showError(error && error.message ? error.message : String(error));
} finally {
btn.disabled = false;
btn.textContent = old;
}
};
}
if ($('btnRetryQueueNow')) {
$('btnRetryQueueNow').onclick = async () => {
try {
const res = await window.electronAPI.processRetryQueueNow();
if (res.ok) {
showSuccess(`Queue verarbeitet: ${res.succeeded || 0} erfolgreich, ${res.failed || 0} verworfen.`);
} else {
showWarning(res.error || 'Queue konnte nicht verarbeitet werden');
}
} catch (e) {
showError(e && e.message ? e.message : String(e));
}
};
}
if ($('btnRetryQueueRefresh')) {
$('btnRetryQueueRefresh').onclick = async () => {
try {
const res = await window.electronAPI.processRetryQueueNow();
if (res.ok) {
showSuccess(`Queue verarbeitet: ${res.succeeded || 0} erfolgreich, ${res.failed || 0} verworfen.`);
} else {
showWarning(res.error || 'Queue konnte nicht verarbeitet werden');
}
} catch (e) {
showError(e && e.message ? e.message : String(e));
}
};
}
if ($('giteaURL')) {
$('giteaURL').addEventListener('input', (e) => {
const raw = e.target.value;
renderGiteaUrlHint(raw, $('giteaToken')?.value || '');
const checked = normalizeAndValidateGiteaUrl(raw);
updateSettingsHealth({
url: checked.ok && checked.value ? 'Gültig' : (checked.ok ? 'Leer' : 'Ungültig')
});
});
}
if ($('giteaToken')) {
$('giteaToken').addEventListener('input', (e) => {
renderGiteaUrlHint($('giteaURL')?.value || '', e.target.value || '');
});
}
if ($('githubToken')) {
$('githubToken').addEventListener('input', (e) => {
renderGithubTokenHint(e.target.value || '');
});
}
if ($('btnTestGiteaConnection')) {
$('btnTestGiteaConnection').onclick = async () => {
const token = $('giteaToken')?.value || '';
const rawUrl = $('giteaURL')?.value || '';
const checked = normalizeAndValidateGiteaUrl(rawUrl);
if (!checked.ok) {
showError(checked.error);
updateSettingsHealth({ url: 'Ungültig', api: 'Unbekannt', auth: token ? 'Token vorhanden' : 'Kein Token' });
return;
}
if (!checked.value) {
showWarning('Bitte zuerst eine Gitea URL eintragen.');
return;
}
setStatus('Teste Gitea-Verbindung...');
const btn = $('btnTestGiteaConnection');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Teste...';
try {
const res = await window.electronAPI.testGiteaConnection({
token,
url: checked.value,
timeout: 8000
});
if (!res.ok) {
showError('Gitea: Nicht verbunden');
updateSettingsHealth({
url: 'Gültig',
api: 'Fehler',
auth: token ? 'Fehler' : 'Kein Token',
latency: '-',
version: '-'
});
return;
}
const result = res.result || {};
const checks = result.checks || {};
const metrics = result.metrics || {};
const server = result.server || {};
updateSettingsHealth({
url: 'Gültig',
api: checks.apiReachable ? 'Erreichbar' : 'Fehler',
auth: checks.authProvided ? (checks.authOk ? 'OK' : 'Fehler') : 'Kein Token',
latency: metrics.latencyMs ? `${metrics.latencyMs} ms` : '-',
version: server.version || '-',
lastError: '-'
});
if (result.ok) showSuccess('Gitea: Verbunden');
else showError('Gitea: Nicht verbunden');
} catch (error) {
console.error('test-gitea-connection error:', error);
showError('Gitea: Nicht verbunden');
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
};
}
if ($('btnTestGithubConnection')) {
$('btnTestGithubConnection').onclick = async () => {
const token = $('githubToken')?.value || '';
if (!token) {
showError('GitHub: Nicht verbunden');
return;
}
setStatus('Teste GitHub-Verbindung...');
const btn = $('btnTestGithubConnection');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Teste...';
try {
const res = await window.electronAPI.testGithubConnection({ token });
if (!res.ok) {
showError('GitHub: Nicht verbunden');
return;
}
showSuccess('GitHub: Verbunden');
updateSettingsHealth({ auth: 'OK', lastError: '-' });
} catch (error) {
console.error('test-github-connection error:', error);
showError('GitHub: Nicht verbunden');
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
};
}
if ($('btnCloseSettings')) {
$('btnCloseSettings').onclick = () => {
$('settingsWatermarkCard')?.classList.add('hidden');
$('settingsModal').classList.add('hidden');
};
}
if ($('btnOpenRepoActions')) {
$('btnOpenRepoActions').onclick = () => {
$('repoActionModal').classList.remove('hidden');
scheduleRepoNameValidation();
};
}
if ($('btnCloseRepoActions')) {
$('btnCloseRepoActions').onclick = () => {
$('repoActionModal').classList.add('hidden');
};
}
// ── Migration Modal ──
if ($('btnOpenMigration')) {
$('btnOpenMigration').onclick = () => {
$('migrationModal').classList.remove('hidden');
};
}
if ($('btnCloseMigration')) {
$('btnCloseMigration').onclick = () => {
$('migrationModal').classList.add('hidden');
$('migrationStatus').classList.add('hidden');
$('migrationStatus').textContent = '';
};
}
if ($('migrateCloneUrl')) {
$('migrateCloneUrl').addEventListener('input', () => {
// Repo-Name aus URL automatisch ableiten
const url = $('migrateCloneUrl').value.trim();
const match = url.match(/\/([^/]+?)(\.git)?$/);
if (match && !$('migrateRepoName').value) {
$('migrateRepoName').value = match[1];
}
});
}
if ($('btnStartMigration')) {
$('btnStartMigration').onclick = async () => {
const cloneUrl = $('migrateCloneUrl')?.value.trim();
const repoName = $('migrateRepoName')?.value.trim();
if (!cloneUrl) { showError('Bitte eine Quell-URL eingeben.'); return; }
if (!repoName) { showError('Bitte einen Repository-Namen eingeben.'); return; }
const statusEl = $('migrationStatus');
const btn = $('btnStartMigration');
btn.disabled = true;
btn.textContent = '⏳ Migration läuft…';
statusEl.className = 'migration-status migration-status--running';
statusEl.textContent = `⏳ Migriere "${repoName}" von ${cloneUrl}`;
statusEl.classList.remove('hidden');
const result = await window.electronAPI.migrateRepoToGitea({
cloneUrl,
repoName,
description: $('migrateDescription')?.value.trim() || '',
isPrivate: $('migratePrivate')?.checked || false,
authUsername: $('migrateAuthUsername')?.value.trim() || '',
authToken: $('migrateAuthToken')?.value.trim() || ''
});
btn.disabled = false;
btn.textContent = '📥 Migration starten';
if (result?.ok) {
statusEl.className = 'migration-status migration-status--ok';
statusEl.textContent = `✅ "${repoName}" wurde erfolgreich migriert!`;
showSuccess(`Repository "${repoName}" migriert`);
// Felder leeren
$('migrateCloneUrl').value = '';
$('migrateRepoName').value = '';
$('migrateDescription').value = '';
$('migrateAuthUsername').value = '';
$('migrateAuthToken').value = '';
$('migratePrivate').checked = false;
loadGiteaRepos();
} else {
statusEl.className = 'migration-status migration-status--error';
statusEl.textContent = `❌ Fehler: ${result?.error || 'Unbekannter Fehler'}`;
showError('Migration fehlgeschlagen: ' + (result?.error || ''));
}
};
}
try {
const queue = await window.electronAPI.getRetryQueue();
if (queue && queue.ok) {
updateRetryQueueBadge(queue.size || 0);
logActivity('info', `Retry-Queue geladen (${queue.size || 0} Einträge)`);
}
} catch (_) {}
// Avatar-Picker
if ($('settingsAvatarWrap')) {
$('settingsAvatarWrap').onclick = () => $('settingsAvatarInput')?.click();
}
if ($('settingsAvatarInput')) {
$('settingsAvatarInput').onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = $('settingsAvatarImg');
const ph = $('settingsAvatarPlaceholder');
if (img) { img.src = ev.target.result; img.style.display = 'block'; }
if (ph) ph.style.display = 'none';
};
reader.readAsDataURL(file);
};
}
// Avatar direkt auf Gitea hochladen
if ($('btnUploadAvatar')) {
$('btnUploadAvatar').onclick = async () => {
const btn = $('btnUploadAvatar');
const img = $('settingsAvatarImg');
const avatarB64 = img && img.style.display !== 'none' ? img.src : null;
if (!avatarB64 || !avatarB64.startsWith('data:')) {
showError('Kein Profilbild ausgewählt. Bitte zuerst ein Bild auswählen.');
return;
}
const token = $('giteaToken')?.value || '';
const url = $('giteaURL')?.value || '';
if (!token || !url) {
showError('Gitea Token und URL müssen eingetragen sein.');
return;
}
btn.disabled = true;
btn.textContent = '⏳ Wird hochgeladen…';
try {
const result = await window.electronAPI.updateGiteaAvatar({ token, url, imageBase64: avatarB64 });
if (result && result.ok) {
btn.textContent = '✅ Aktualisiert';
setTimeout(() => { btn.textContent = '📤 Auf Gitea aktualisieren'; btn.disabled = false; }, 2500);
} else {
showError('Upload fehlgeschlagen: ' + (result?.error || 'Unbekannter Fehler'));
btn.textContent = '📤 Auf Gitea aktualisieren';
btn.disabled = false;
}
} catch (err) {
showError('Upload fehlgeschlagen: ' + err.message);
btn.textContent = '📤 Auf Gitea aktualisieren';
btn.disabled = false;
}
};
}
if ($('btnSaveSettings')) {
$('btnSaveSettings').onclick = async () => {
try {
// Feature-Flags aus Checkboxen lesen
const cbFav = $('settingFavorites');
const cbRec = $('settingRecent');
const cbCompact = $('settingCompact');
featureFavorites = cbFav ? cbFav.checked : true;
featureRecent = cbRec ? cbRec.checked : true;
compactMode = cbCompact ? cbCompact.checked : false;
const cbColorIcons2 = $('settingColoredIcons');
featureColoredIcons = cbColorIcons2 ? cbColorIcons2.checked : true;
document.body.classList.toggle('compact-mode', compactMode);
const cbAutostart2 = $('settingAutostart');
const newAutostart = cbAutostart2 ? cbAutostart2.checked : false;
if (newAutostart !== featureAutostart) {
featureAutostart = newAutostart;
await window.electronAPI.setAutostart(featureAutostart);
}
const checkedUrl = normalizeAndValidateGiteaUrl($('giteaURL').value);
if (!checkedUrl.ok) {
showError(checkedUrl.error);
return;
}
const credentialStatus = await window.electronAPI.getCredentialsStatus();
const githubTokenValue = $('githubToken').value;
const giteaTokenValue = $('giteaToken').value;
if (
credentialStatus &&
(credentialStatus.reason === 'safeStorage-decrypt-failed' || credentialStatus.reason === 'safeStorage-unavailable') &&
!String(githubTokenValue || '').trim() &&
!String(giteaTokenValue || '').trim() &&
!String(checkedUrl.value || '').trim()
) {
showError('Gespeicherte Zugangsdaten sind nicht lesbar. Bitte Token und URL neu eingeben, bevor du speicherst.');
return;
}
const existingCreds = await window.electronAPI.loadCredentials() || {};
// aktuell ausgewählten Avatar-Base64 aus dem img-Element lesen
const avatarImg = $('settingsAvatarImg');
const avatarB64 = (avatarImg && avatarImg.src && avatarImg.style.display !== 'none')
? avatarImg.src
: (existingCreds.avatarB64 || null);
const data = {
...existingCreds,
githubToken: githubTokenValue,
giteaToken: giteaTokenValue,
giteaURL: checkedUrl.value,
avatarB64,
featureFavorites,
featureRecent,
compactMode,
featureColoredIcons,
favCollapsedFavorites: favSectionCollapsed.favorites,
favCollapsedRecent: favSectionCollapsed.recent
};
await window.electronAPI.saveCredentials(data);
// Avatar automatisch zu Gitea pushen
if (avatarB64 && avatarB64.startsWith('data:') && data.giteaToken && data.giteaURL) {
const btnSave = $('btnSaveSettings');
if (btnSave) btnSave.textContent = '⏳ Bild wird hochgeladen…';
try {
const avatarResult = await window.electronAPI.updateGiteaAvatar({
token: data.giteaToken,
url: data.giteaURL,
imageBase64: avatarB64
});
if (avatarResult && !avatarResult.ok) {
console.error('Avatar-Upload Fehler:', avatarResult.error);
showError('Profilbild konnte nicht hochgeladen werden: ' + (avatarResult.error || 'Unbekannter Fehler'));
}
} catch (avatarErr) {
console.error('Avatar-Upload Exception:', avatarErr);
showError('Profilbild-Upload fehlgeschlagen: ' + avatarErr.message);
} finally {
if (btnSave) btnSave.textContent = 'Speichern';
}
}
$('settingsModal').classList.add('hidden');
showSuccess('Settings saved');
renderGithubTokenHint(data.githubToken || '');
renderGiteaUrlHint(checkedUrl.value, data.giteaToken || '');
updateSettingsHealth({
url: checkedUrl.value ? 'Gültig' : 'Leer',
auth: data.giteaToken ? 'Token vorhanden' : 'Kein Token',
lastError: '-'
});
// Ansicht aktualisieren falls Feature-Flags geändert
loadGiteaRepos();
} catch (error) {
console.error('Error saving settings:', error);
showError('Save failed');
}
};
}
// FILE EDITOR EVENT LISTENERS
if ($('btnCloseEditor')) {
$('btnCloseEditor').onclick = closeFileEditor;
}
if ($('btnEditorSave')) {
$('btnEditorSave').onclick = () => saveCurrentFile(false);
}
if ($('btnEditorSearch')) {
$('btnEditorSearch').onclick = toggleSearch;
}
if ($('btnReplace')) {
$('btnReplace').onclick = replaceOnce;
}
if ($('btnReplaceAll')) {
$('btnReplaceAll').onclick = replaceAll;
}
if ($('btnCloseSearch')) {
$('btnCloseSearch').onclick = () => {
$('searchBar').classList.add('hidden');
};
}
if ($('searchInput')) {
$('searchInput').addEventListener('input', performSearch);
$('searchInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
performSearch();
}
});
}
if ($('btnDiscardEdit')) {
$('btnDiscardEdit').onclick = () => {
const tab = openTabs[currentActiveTab];
if (tab) {
tab.content = tab.originalContent;
tab.dirty = false;
tab.history = [tab.originalContent];
tab.historyIndex = 0;
updateEditor();
}
};
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
// Ctrl+S - Save
if (e.key === 's') {
e.preventDefault();
if (currentActiveTab) {
saveCurrentFile(false);
}
}
// Ctrl+F - Search
if (e.key === 'f') {
e.preventDefault();
if (currentActiveTab) {
toggleSearch();
}
}
// Ctrl+H - Replace
if (e.key === 'h') {
e.preventDefault();
if (currentActiveTab) {
toggleSearch();
$('replaceInput').focus();
}
}
}
// ESC - Close search
if (e.key === 'Escape') {
if (!$('searchBar').classList.contains('hidden')) {
$('searchBar').classList.add('hidden');
}
}
// F2 - Umbenennen
if (e.key === 'F2' && lastSelectedItem && !currentActiveTab) {
e.preventDefault();
if (lastSelectedItem.type === 'gitea') {
showGiteaRenameModal(lastSelectedItem.item, lastSelectedItem.owner, lastSelectedItem.repo);
} else if (lastSelectedItem.type === 'local') {
showLocalRenameModal(lastSelectedItem.node);
}
}
// Entf - Löschen mit Bestätigungs-Toast
if (e.key === 'Delete' && lastSelectedItem && !currentActiveTab) {
e.preventDefault();
if (lastSelectedItem.type === 'gitea') {
const { item, owner, repo } = lastSelectedItem;
showDeleteConfirm(`"${item.name}" wirklich löschen?`, async () => {
const res = await window.electronAPI.deleteFile({ path: item.path, owner, repo, isGitea: true });
if (res?.ok) { showSuccess(`"${item.name}" gelöscht`); loadRepoContents(owner, repo, currentState.path); lastSelectedItem = null; }
else showError('Löschen fehlgeschlagen: ' + (res?.error || ''));
});
} else if (lastSelectedItem.type === 'local') {
const { node } = lastSelectedItem;
showDeleteConfirm(`"${node.name}" wirklich löschen?`, async () => {
const res = await window.electronAPI.deleteFile({ path: node.path });
if (res?.ok) { showSuccess(`"${node.name}" gelöscht`); if (selectedFolder) refreshLocalTree(selectedFolder); lastSelectedItem = null; }
else showError('Löschen fehlgeschlagen: ' + (res?.error || ''));
});
}
}
});
// Progress listeners
window.electronAPI.onFolderUploadProgress(p => {
showProgress(p.percent, `Upload: ${p.processed}/${p.total}`);
});
window.electronAPI.onFolderDownloadProgress(p => {
showProgress(p.percent, `Download: ${p.processed}/${p.total}`);
});
if (window.electronAPI.onRetryQueueUpdated) {
window.electronAPI.onRetryQueueUpdated((payload) => {
const size = payload && typeof payload.size === 'number' ? payload.size : 0;
updateRetryQueueBadge(size);
if (payload && payload.event === 'queued' && payload.item) {
const p = payload.item.payload || {};
logActivity('warning', `Queue: ${p.owner}/${p.repo}/${p.path} wurde eingeplant`);
} else if (payload && payload.event === 'processed') {
logActivity('info', `Queue-Retry: ${payload.succeeded || 0} erfolgreich, ${payload.failed || 0} verworfen`);
}
});
}
if (window.electronAPI.onBatchActionProgress) {
window.electronAPI.onBatchActionProgress((payload) => {
if (!payload) return;
if (payload.status === 'running') {
logActivity('info', `Batch ${payload.action}: ${payload.repo} (${payload.index}/${payload.total})`);
} else if (payload.status === 'error') {
logActivity('error', `Batch ${payload.action}: ${payload.repo} - ${payload.error || 'Fehler'}`);
}
});
}
// Setup globalen Drop-Handler für Repo-Ansicht
setupGlobalDropZone();
setupBackgroundContextMenu();
setStatus('Ready');
initUpdater(); // Updater initialisieren
updateNavigationUI();
});
/* ================================
RELEASE MANAGEMENT UI FUNCTIONS
Füge dies zu renderer.js hinzu
================================ */
let currentReleaseView = {
owner: null,
repo: null
};
/* -------------------------
RELEASES LADEN & ANZEIGEN
------------------------- */
async function loadRepoReleases(owner, repo) {
currentReleaseView.owner = owner;
currentReleaseView.repo = repo;
currentReleaseView.platform = currentState.platform;
setStatus('Loading releases...');
try {
const res = await window.electronAPI.listReleases({ owner, repo, platform: currentState.platform });
if (!res.ok) {
showError('Error loading releases: ' + res.error);
return;
}
const grid = $('explorerGrid');
if (!grid) return;
// Header mit "New Release" Button
grid.innerHTML = '';
const releaseHeader = document.createElement('div');
releaseHeader.style.cssText = 'grid-column: 1/-1; display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;';
const releaseTitle = document.createElement('h2');
releaseTitle.style.cssText = 'margin: 0; color: var(--text-primary);';
releaseTitle.textContent = `📦 Releases für ${repo}`;
const newBtn = document.createElement('button');
newBtn.className = 'btn-new-release';
newBtn.style.cssText = `
background: var(--accent-gradient);
color: #000;
border: none;
padding: 10px 20px;
border-radius: var(--radius-md);
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
`;
newBtn.textContent = '🚀 New Release';
newBtn.onclick = () => {
console.log('New Release button clicked');
showCreateReleaseModal(owner, repo);
};
releaseHeader.appendChild(releaseTitle);
releaseHeader.appendChild(newBtn);
grid.appendChild(releaseHeader);
if (!res.releases || res.releases.length === 0) {
// WICHTIG: appendChild statt innerHTML +=, um Event-Listener zu erhalten
const emptyMsg = document.createElement('div');
emptyMsg.style.cssText = 'grid-column: 1/-1; text-align: center; padding: 60px; color: var(--text-muted); font-size: 16px;';
emptyMsg.textContent = '📭 Noch keine Releases veröffentlicht';
grid.appendChild(emptyMsg);
setStatus('No releases');
return;
}
// Releases als Cards darstellen
res.releases.forEach((release, index) => {
const card = createReleaseCard(release, index === 0);
grid.appendChild(card);
});
setStatus(`${res.releases.length} release(s) loaded`);
} catch (error) {
console.error('Error loading releases:', error);
showError('Failed to load releases');
}
}
function createReleaseCard(release, isLatest) {
const card = document.createElement('div');
card.className = 'release-card';
card.style.cssText = `
grid-column: 1/-1;
background: var(--bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
transition: all var(--transition-normal);
`;
// Header mit Tag und Badges
const header = document.createElement('div');
header.style.cssText = 'display: flex; gap: 10px; align-items: center; margin-bottom: 12px;';
const tag = document.createElement('span');
tag.textContent = release.tag_name;
tag.style.cssText = `
background: var(--accent-gradient);
color: #000;
padding: 6px 16px;
border-radius: 20px;
font-weight: 700;
font-size: 14px;
`;
header.appendChild(tag);
if (isLatest) {
const latestBadge = document.createElement('span');
latestBadge.textContent = 'LATEST';
latestBadge.style.cssText = `
background: var(--success);
color: #000;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
`;
header.appendChild(latestBadge);
}
if (release.prerelease) {
const preBadge = document.createElement('span');
preBadge.textContent = 'PRE-RELEASE';
preBadge.style.cssText = `
background: var(--warning);
color: #000;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
`;
header.appendChild(preBadge);
}
if (release.draft) {
const draftBadge = document.createElement('span');
draftBadge.textContent = 'DRAFT';
draftBadge.style.cssText = `
background: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
`;
header.appendChild(draftBadge);
}
card.appendChild(header);
// Title
const title = document.createElement('h3');
title.textContent = release.name || release.tag_name;
title.style.cssText = 'margin: 0 0 12px 0; color: var(--text-primary); font-size: 20px;';
card.appendChild(title);
// Body (Release Notes)
if (release.body) {
const body = document.createElement('div');
body.className = 'release-body';
body.innerHTML = parseMarkdownToHTML(release.body);
card.appendChild(body);
}
// Assets
if (release.assets && release.assets.length > 0) {
const assetsContainer = document.createElement('div');
assetsContainer.style.cssText = `
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
`;
const assetsTitle = document.createElement('div');
assetsTitle.textContent = '📦 Assets';
assetsTitle.style.cssText = 'font-weight: 600; margin-bottom: 12px; color: var(--text-primary);';
assetsContainer.appendChild(assetsTitle);
release.assets.forEach(asset => {
const assetItem = document.createElement('div');
assetItem.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-sm);
margin-bottom: 8px;
`;
const assetName = document.createElement('span');
assetName.textContent = `📎 ${asset.name}`;
assetName.style.cssText = 'color: var(--text-primary);';
const assetSize = document.createElement('span');
assetSize.textContent = formatBytes(asset.size || 0);
assetSize.style.cssText = 'color: var(--text-muted); font-size: 12px; margin-left: 12px;';
const downloadBtn = document.createElement('button');
downloadBtn.textContent = '⬇️ Download';
downloadBtn.style.cssText = `
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
`;
downloadBtn.onclick = () => {
if (asset.browser_download_url) {
window.open(asset.browser_download_url, '_blank');
}
};
const deleteAssetBtn = document.createElement('button');
deleteAssetBtn.textContent = '🗑️';
deleteAssetBtn.style.cssText = `
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
padding: 6px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
margin-left: 8px;
`;
deleteAssetBtn.onclick = async () => {
const ok = await showActionConfirmModal({
title: 'Asset loeschen',
message: `Delete asset "${asset.name}"?`,
confirmText: 'Loeschen',
danger: true
});
if (ok) {
const res = await window.electronAPI.deleteReleaseAsset({
owner: currentReleaseView.owner,
repo: currentReleaseView.repo,
assetId: asset.id
});
if (res.ok) {
assetItem.remove();
setStatus('Asset deleted');
}
}
};
const leftSide = document.createElement('div');
leftSide.style.cssText = 'display: flex; align-items: center; gap: 12px;';
leftSide.appendChild(assetName);
leftSide.appendChild(assetSize);
const rightSide = document.createElement('div');
rightSide.style.cssText = 'display: flex; gap: 8px;';
rightSide.appendChild(downloadBtn);
rightSide.appendChild(deleteAssetBtn);
assetItem.appendChild(leftSide);
assetItem.appendChild(rightSide);
assetsContainer.appendChild(assetItem);
});
card.appendChild(assetsContainer);
}
// Meta Info
const meta = document.createElement('div');
meta.style.cssText = `
display: flex;
gap: 20px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-muted);
font-size: 12px;
`;
const date = new Date(release.created_at);
const dateStr = date.toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const dateMeta = document.createElement('span');
dateMeta.textContent = `📅 ${dateStr}`;
const authorMeta = document.createElement('span');
authorMeta.textContent = `👤 ${release.author?.login || 'Unknown'}`;
meta.appendChild(dateMeta);
meta.appendChild(authorMeta);
card.appendChild(meta);
// Action Buttons
const actions = document.createElement('div');
actions.style.cssText = 'display: flex; gap: 12px; margin-top: 16px;';
const downloadArchiveBtn = document.createElement('button');
downloadArchiveBtn.textContent = '📦 Download ZIP';
downloadArchiveBtn.style.cssText = `
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
`;
downloadArchiveBtn.onclick = async () => {
const res = await window.electronAPI.downloadReleaseArchive({
owner: currentReleaseView.owner,
repo: currentReleaseView.repo,
tag: release.tag_name
});
if (res.ok) {
setStatus(`Downloaded to ${res.savedTo}`);
}
};
const addAssetBtn = document.createElement('button');
addAssetBtn.textContent = '📎 Add Asset';
addAssetBtn.style.cssText = `
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
`;
addAssetBtn.onclick = () => showUploadAssetDialog(release);
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '🗑️ Delete';
deleteBtn.style.cssText = `
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
margin-left: auto;
`;
deleteBtn.onclick = async () => {
const ok = await showActionConfirmModal({
title: 'Release loeschen',
message: `Delete release "${release.name || release.tag_name}"?`,
confirmText: 'Loeschen',
danger: true
});
if (ok) {
const res = await window.electronAPI.deleteRelease({
owner: currentReleaseView.owner,
repo: currentReleaseView.repo,
releaseId: release.id,
platform: currentReleaseView.platform || currentState.platform
});
if (res.ok) {
card.remove();
setStatus('Release deleted');
}
}
};
actions.appendChild(downloadArchiveBtn);
actions.appendChild(addAssetBtn);
actions.appendChild(deleteBtn);
card.appendChild(actions);
return card;
}
/* -------------------------
CREATE RELEASE MODAL (MIT DATEI-UPLOAD)
------------------------- */
function showCreateReleaseModal(owner, repo) {
const modal = document.createElement('div');
modal.className = 'modal';
const card = document.createElement('div');
card.className = 'card';
card.style.cssText = 'width: 600px; max-width: 90vw;';
const title = document.createElement('h2');
title.textContent = '🚀 Neues Release erstellen';
card.appendChild(title);
const tagGroup = document.createElement('div');
tagGroup.className = 'input-group';
const tagLabel = document.createElement('label');
tagLabel.textContent = 'Tag Version *';
const tagInput = document.createElement('input');
tagInput.id = 'releaseTag';
tagInput.type = 'text';
tagInput.placeholder = 'v1.0.0';
tagGroup.appendChild(tagLabel);
tagGroup.appendChild(tagInput);
card.appendChild(tagGroup);
const nameGroup = document.createElement('div');
nameGroup.className = 'input-group';
const nameLabel = document.createElement('label');
nameLabel.textContent = 'Release Name';
const nameInput = document.createElement('input');
nameInput.id = 'releaseName';
nameInput.type = 'text';
nameInput.placeholder = 'Version 1.0.0';
nameGroup.appendChild(nameLabel);
nameGroup.appendChild(nameInput);
card.appendChild(nameGroup);
const bodyGroup = document.createElement('div');
bodyGroup.className = 'input-group';
const bodyLabel = document.createElement('label');
bodyLabel.textContent = 'Release Notes';
const bodyInput = document.createElement('textarea');
bodyInput.id = 'releaseBody';
bodyInput.rows = 8;
bodyInput.placeholder = '## Was ist neu?\n\n- Feature 1\n- Feature 2\n- Bug Fixes';
bodyInput.style.cssText = 'width: 100%; padding: 12px; border-radius: var(--radius-md); border: 1px solid rgba(255, 255, 255, 0.1); background: var(--bg-tertiary); color: var(--text-primary); font-family: monospace; resize: vertical;';
bodyGroup.appendChild(bodyLabel);
bodyGroup.appendChild(bodyInput);
card.appendChild(bodyGroup);
const targetGroup = document.createElement('div');
targetGroup.className = 'input-group';
const targetLabel = document.createElement('label');
targetLabel.textContent = 'Target Branch';
const targetInput = document.createElement('input');
targetInput.id = 'releaseTarget';
targetInput.type = 'text';
targetInput.value = 'main';
targetInput.placeholder = 'main';
targetGroup.appendChild(targetLabel);
targetGroup.appendChild(targetInput);
card.appendChild(targetGroup);
const assetGroup = document.createElement('div');
assetGroup.className = 'input-group';
const assetLabel = document.createElement('label');
assetLabel.textContent = 'Release Asset (Optional)';
const assetRow = document.createElement('div');
assetRow.style.cssText = 'display: flex; gap: 10px;';
const assetInput = document.createElement('input');
assetInput.id = 'releaseAssetInput';
assetInput.type = 'text';
assetInput.readOnly = true;
assetInput.placeholder = 'Keine Datei gewählt';
assetInput.style.cssText = 'flex: 1; padding: 10px; border-radius: var(--radius-md); border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.2); color: var(--text-muted); cursor: not-allowed;';
const assetBtn = document.createElement('button');
assetBtn.id = 'btnSelectReleaseAsset';
assetBtn.type = 'button';
assetBtn.textContent = '📎 Datei wählen';
assetBtn.style.cssText = 'padding: 10px 20px; border-radius: var(--radius-md); background: var(--bg-secondary); color: var(--text-primary); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; font-weight: 600;';
assetRow.appendChild(assetInput);
assetRow.appendChild(assetBtn);
assetGroup.appendChild(assetLabel);
assetGroup.appendChild(assetRow);
card.appendChild(assetGroup);
const optionsGroup = document.createElement('div');
optionsGroup.className = 'input-group';
optionsGroup.style.cssText = 'display: flex; gap: 20px;';
const preLabel = document.createElement('label');
preLabel.style.cssText = 'display: flex; align-items: center; gap: 8px; cursor: pointer;';
const preCheck = document.createElement('input');
preCheck.type = 'checkbox';
preCheck.id = 'releasePrerelease';
preLabel.appendChild(preCheck);
preLabel.appendChild(document.createTextNode(' Pre-Release'));
const draftLabel = document.createElement('label');
draftLabel.style.cssText = 'display: flex; align-items: center; gap: 8px; cursor: pointer;';
const draftCheck = document.createElement('input');
draftCheck.type = 'checkbox';
draftCheck.id = 'releaseDraft';
draftLabel.appendChild(draftCheck);
draftLabel.appendChild(document.createTextNode(' Draft (nicht veröffentlichen)'));
optionsGroup.appendChild(preLabel);
optionsGroup.appendChild(draftLabel);
card.appendChild(optionsGroup);
const modalButtons = document.createElement('div');
modalButtons.className = 'modal-buttons';
const createBtn = document.createElement('button');
createBtn.id = 'btnCreateRelease';
createBtn.textContent = 'Erstellen & Veröffentlichen';
const cancelBtn = document.createElement('button');
cancelBtn.id = 'btnCancelRelease';
cancelBtn.textContent = 'Abbrechen';
modalButtons.appendChild(createBtn);
modalButtons.appendChild(cancelBtn);
card.appendChild(modalButtons);
modal.appendChild(card);
document.body.appendChild(modal);
// Variable zum Speichern des gewählten Dateipfads
let selectedAssetPath = null;
// Event Listener: Datei auswählen
$('btnSelectReleaseAsset').onclick = async () => {
try {
const res = await window.electronAPI.selectFile();
if (res.ok && res.files && res.files.length > 0) {
selectedAssetPath = res.files[0];
const fileName = selectedAssetPath.split(/[\\/]/).pop();
$('releaseAssetInput').value = fileName;
$('releaseAssetInput').style.color = 'var(--text-primary)';
$('releaseAssetInput').style.borderColor = 'var(--accent-primary)';
}
} catch (error) {
console.error('Fehler beim Auswählen der Datei:', error);
await showInfoModal('Dateidialog', 'Konnte Dateidialog nicht oeffnen.', true);
}
};
// Event Listener: Release erstellen
$('btnCreateRelease').onclick = async () => {
const tag = $('releaseTag').value.trim();
const name = $('releaseName').value.trim() || tag;
const body = $('releaseBody').value.trim();
const target = $('releaseTarget').value.trim() || 'main';
const prerelease = $('releasePrerelease').checked;
const draft = $('releaseDraft').checked;
if (!tag) {
await showInfoModal('Eingabe fehlt', 'Tag Version ist erforderlich!');
return;
}
setStatus('Creating release...');
try {
// 1. Release erstellen
const res = await window.electronAPI.createRelease({
owner,
repo,
tag_name: tag,
name,
body,
target_commitish: target,
prerelease,
draft,
platform: currentState.platform
});
if (res.ok) {
// 2. Falls eine Datei ausgewählt wurde, direkt hochladen
if (selectedAssetPath) {
setStatus('Release erstellt. Lade Datei hoch...');
showProgress(50, 'Uploading Asset...');
try {
const fileName = selectedAssetPath.split(/[\\/]/).pop();
const uploadRes = await window.electronAPI.uploadReleaseAsset({
owner,
repo,
releaseId: res.release.id,
filePath: selectedAssetPath,
fileName
});
if (uploadRes.ok) {
setStatus(`Release "${tag}" und Asset erstellt!`);
} else {
console.error('Asset Upload fehlgeschlagen:', uploadRes.error);
await showInfoModal('Asset Upload fehlgeschlagen', `Release erstellt, aber Asset Upload fehlgeschlagen: ${uploadRes.error || 'Unbekannter Fehler'}`, true);
showWarning('Release erstellt (Upload Fehler)');
}
} catch (uploadErr) {
console.error('Upload error:', uploadErr);
await showInfoModal('Upload fehlgeschlagen', 'Release erstellt, aber Fehler beim Hochladen der Datei.', true);
showWarning('Release erstellt (Upload Fehler)');
} finally {
hideProgress();
}
} else {
setStatus('Release created!');
}
modal.remove();
loadRepoReleases(owner, repo); // Liste neu laden
} else {
showError('Failed: ' + res.error);
await showInfoModal('Release erstellen fehlgeschlagen', 'Fehler beim Erstellen des Releases: ' + (res.error || 'Unbekannter Fehler'), true);
}
} catch (error) {
console.error('Create release error:', error);
showError('Create failed');
await showInfoModal('Unerwarteter Fehler', 'Ein unerwarteter Fehler ist aufgetreten.', true);
}
};
$('btnCancelRelease').onclick = () => modal.remove();
// Close on background click
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
}
/* -------------------------
UPLOAD ASSET DIALOG
------------------------- */
async function showUploadAssetDialog(release) {
try {
const res = await window.electronAPI.selectFile();
if (!res.ok || !res.files || res.files.length === 0) {
return;
}
const filePath = res.files[0];
const fileName = filePath.split(/[\\/]/).pop();
setStatus(`Uploading ${fileName}...`);
showProgress(0, `Uploading ${fileName}...`);
const uploadRes = await window.electronAPI.uploadReleaseAsset({
owner: currentReleaseView.owner,
repo: currentReleaseView.repo,
releaseId: release.id,
filePath,
fileName
});
hideProgress();
if (uploadRes.ok) {
setStatus('Asset uploaded!');
// Reload releases to show new asset
loadRepoReleases(currentReleaseView.owner, currentReleaseView.repo);
} else {
showError('Upload failed: ' + uploadRes.error);
}
} catch (error) {
console.error('Upload asset error:', error);
hideProgress();
showError('Upload failed');
}
}
/* -------------------------
HELPER FUNCTIONS
------------------------- */
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
/* -------------------------
INTEGRATION IN REPO VIEW
Füge "Releases" Tab zum Repo hinzu
------------------------- */
// Modifiziere die loadRepoContents Funktion um einen Releases-Button hinzuzufügen:
// Nach dem Laden eines Repos, zeige einen Button "View Releases" an
/* ================================
COMMIT HISTORY VISUALIZATION UI
Füge dies zu renderer.js hinzu
================================ */
let currentCommitView = {
owner: null,
repo: null,
branch: 'HEAD',
commits: [],
selectedCommit: null
};
/* -------------------------
COMMIT HISTORY LADEN
------------------------- */
async function loadCommitHistory(owner, repo, branch = 'main') {
currentCommitView.owner = owner;
currentCommitView.repo = repo;
currentCommitView.branch = branch;
currentCommitView.platform = currentState.platform;
setStatus('Loading commit history...');
try {
const res = await window.electronAPI.getCommits({
owner,
repo,
branch,
limit: 100,
platform: currentState.platform
});
if (!res.ok) {
showError('Error loading commits: ' + res.error);
return;
}
currentCommitView.commits = res.commits;
renderCommitHistoryView();
setStatus(`${res.commits.length} commits loaded`);
} catch (error) {
console.error('Error loading commit history:', error);
showError('Failed to load commits');
}
}
function renderCommitHistoryView() {
const grid = $('explorerGrid');
if (!grid) return;
grid.innerHTML = '';
grid.style.gridTemplateColumns = '1fr';
// Header mit Search und Branch-Selector
const header = document.createElement('div');
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
`;
const title = document.createElement('h2');
title.style.cssText = 'margin: 0; color: var(--text-primary); display: flex; align-items: center; gap: 12px;';
title.textContent = '📊 Commit History';
const titleMeta = document.createElement('span');
titleMeta.style.cssText = 'font-size: 14px; color: var(--text-muted); font-weight: 400;';
titleMeta.textContent = `${currentCommitView.repo} / ${currentCommitView.branch}`;
title.appendChild(titleMeta);
const searchWrap = document.createElement('div');
searchWrap.style.cssText = 'display: flex; gap: 12px; flex: 1; max-width: 600px;';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.id = 'commitSearch';
searchInput.placeholder = '🔍 Search commits (message, author)...';
searchInput.style.cssText = `
flex: 1;
padding: 10px 16px;
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
`;
const clearBtn = document.createElement('button');
clearBtn.id = 'btnClearSearch';
clearBtn.textContent = 'Clear';
clearBtn.style.cssText = `
padding: 10px 16px;
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
font-weight: 600;
`;
searchWrap.appendChild(searchInput);
searchWrap.appendChild(clearBtn);
header.appendChild(title);
header.appendChild(searchWrap);
grid.appendChild(header);
// Search-Handler
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
handleCommitSearch(e.target.value);
}, 300);
});
clearBtn.onclick = () => {
searchInput.value = '';
renderCommitTimeline(currentCommitView.commits);
};
// Timeline Container
const timelineContainer = document.createElement('div');
timelineContainer.id = 'commitTimeline';
timelineContainer.style.cssText = `
position: relative;
max-width: 100%;
`;
grid.appendChild(timelineContainer);
// Initial render
renderCommitTimeline(currentCommitView.commits);
}
function renderCommitTimeline(commits) {
const container = $('commitTimeline');
if (!container) return;
container.innerHTML = '';
if (!commits || commits.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 60px; color: var(--text-muted); font-size: 16px;">📭 No commits found</div>';
return;
}
// Timeline mit Cards
commits.forEach((commit, index) => {
const card = createCommitCard(commit, index);
container.appendChild(card);
});
}
function createCommitCard(commit, index) {
const card = document.createElement('div');
card.className = 'commit-card';
card.dataset.sha = commit.sha;
const isEven = index % 2 === 0;
card.style.cssText = `
position: relative;
padding-left: 60px;
margin-bottom: 32px;
cursor: pointer;
transition: all var(--transition-normal);
`;
// Timeline dot
const dot = document.createElement('div');
dot.style.cssText = `
position: absolute;
left: 18px;
top: 0;
width: 16px;
height: 16px;
background: var(--accent-primary);
border: 3px solid var(--bg-primary);
border-radius: 50%;
z-index: 2;
box-shadow: 0 0 0 4px var(--bg-tertiary);
`;
card.appendChild(dot);
// Timeline line
if (index < currentCommitView.commits.length - 1) {
const line = document.createElement('div');
line.style.cssText = `
position: absolute;
left: 25px;
top: 16px;
width: 2px;
height: calc(100% + 32px);
background: linear-gradient(180deg, var(--accent-primary) 0%, rgba(0, 212, 255, 0.2) 100%);
z-index: 1;
`;
card.appendChild(line);
}
// Content card
const content = document.createElement('div');
content.className = 'commit-content';
content.style.cssText = `
background: var(--bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
transition: all var(--transition-normal);
`;
// Commit message
const message = commit.commit?.message || commit.message || 'No message';
const shortMessage = message.split('\n')[0]; // First line only
const messageEl = document.createElement('div');
messageEl.style.cssText = `
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
line-height: 1.4;
`;
messageEl.textContent = shortMessage;
content.appendChild(messageEl);
// Meta info
const meta = document.createElement('div');
meta.style.cssText = `
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 12px;
`;
const author = commit.commit?.author?.name || commit.author?.login || 'Unknown';
const date = new Date(commit.commit?.author?.date || commit.created_at);
const dateStr = formatRelativeTime(date);
const sha = commit.sha?.substring(0, 7) || '???????';
const authorMeta = document.createElement('span');
authorMeta.style.cssText = 'display: flex; align-items: center; gap: 6px;';
authorMeta.appendChild(document.createTextNode('👤 '));
const authorStrong = document.createElement('strong');
authorStrong.textContent = author;
authorMeta.appendChild(authorStrong);
const dateMeta = document.createElement('span');
dateMeta.style.cssText = 'display: flex; align-items: center; gap: 6px;';
dateMeta.textContent = `🕐 ${dateStr}`;
const shaMeta = document.createElement('span');
shaMeta.style.cssText = 'display: flex; align-items: center; gap: 6px; font-family: monospace; background: rgba(255,255,255,0.05); padding: 2px 8px; border-radius: 4px;';
shaMeta.textContent = `#${sha}`;
meta.appendChild(authorMeta);
meta.appendChild(dateMeta);
meta.appendChild(shaMeta);
content.appendChild(meta);
// Stats (if available)
if (commit.stats) {
const stats = document.createElement('div');
stats.style.cssText = `
display: flex;
gap: 16px;
font-size: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
`;
const addStat = document.createElement('span');
addStat.style.color = 'var(--success)';
addStat.textContent = `+${commit.stats.additions || 0}`;
const delStat = document.createElement('span');
delStat.style.color = 'var(--danger)';
delStat.textContent = `-${commit.stats.deletions || 0}`;
const totalStat = document.createElement('span');
totalStat.style.color = 'var(--text-muted)';
totalStat.textContent = `${commit.stats.total || 0} changes`;
stats.appendChild(addStat);
stats.appendChild(delStat);
stats.appendChild(totalStat);
content.appendChild(stats);
}
card.appendChild(content);
// Hover effect
card.addEventListener('mouseenter', () => {
content.style.borderColor = 'var(--accent-primary)';
content.style.transform = 'translateX(4px)';
content.style.boxShadow = 'var(--shadow-md)';
});
card.addEventListener('mouseleave', () => {
content.style.borderColor = 'rgba(255, 255, 255, 0.1)';
content.style.transform = 'translateX(0)';
content.style.boxShadow = 'none';
});
// Click to show details
card.onclick = () => showCommitDetails(commit);
return card;
}
/* -------------------------
COMMIT DETAILS & DIFF VIEWER
------------------------- */
async function showCommitDetails(commit) {
currentCommitView.selectedCommit = commit;
const modal = document.createElement('div');
modal.className = 'modal commit-modal';
const card = document.createElement('div');
card.className = 'card';
card.style.cssText = 'width: 90vw; max-width: 1200px; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column;';
const header = document.createElement('div');
header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;';
const title = document.createElement('h2');
title.style.margin = '0';
title.textContent = '📋 Commit Details';
const closeBtn = document.createElement('button');
closeBtn.id = 'btnCloseCommitModal';
closeBtn.style.cssText = 'background: transparent; border: none; font-size: 24px; cursor: pointer; color: var(--text-muted);';
closeBtn.textContent = '✕';
header.appendChild(title);
header.appendChild(closeBtn);
const scroller = document.createElement('div');
scroller.style.cssText = 'overflow-y: auto; flex: 1;';
const detailsContent = document.createElement('div');
detailsContent.id = 'commitDetailsContent';
detailsContent.style.paddingBottom = '20px';
const loading = document.createElement('div');
loading.style.cssText = 'text-align: center; padding: 40px; color: var(--text-muted);';
loading.textContent = 'Loading commit details...';
detailsContent.appendChild(loading);
scroller.appendChild(detailsContent);
card.appendChild(header);
card.appendChild(scroller);
modal.appendChild(card);
document.body.appendChild(modal);
$('btnCloseCommitModal').onclick = () => modal.remove();
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
// Load commit details
await loadCommitDetailsContent(commit);
}
async function loadCommitDetailsContent(commit) {
const container = $('commitDetailsContent');
if (!container) return;
try {
// Check if this is local git or Gitea repo
let diffRes, filesRes;
if (selectedFolder) {
// Local Git repository
const details = await window.electronAPI.getLocalCommitDetails({
folderPath: selectedFolder,
sha: commit.sha || commit.hash
});
diffRes = { ok: true, diff: details?.diff || '' };
filesRes = { ok: true, files: details?.fileChanges?.files || [], stats: { additions: details?.fileChanges?.insertions || 0, deletions: details?.fileChanges?.deletions || 0 } };
} else {
// Remote repository (Gitea or GitHub)
const [diff, files] = await Promise.all([
window.electronAPI.getCommitDiff({
owner: currentCommitView.owner,
repo: currentCommitView.repo,
sha: commit.sha,
platform: currentCommitView.platform || currentState.platform
}),
window.electronAPI.getCommitFiles({
owner: currentCommitView.owner,
repo: currentCommitView.repo,
sha: commit.sha,
platform: currentCommitView.platform || currentState.platform
})
]);
diffRes = diff;
filesRes = files;
}
container.innerHTML = '';
// Commit info header
const header = document.createElement('div');
header.style.cssText = `
background: var(--bg-tertiary);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
margin-bottom: 24px;
`;
const message = commit.commit?.message || commit.message || 'No message';
const author = commit.commit?.author?.name || commit.author?.login || 'Unknown';
const email = commit.commit?.author?.email || '';
const date = new Date(commit.commit?.author?.date || commit.created_at);
const sha = commit.sha || '';
const titleEl = document.createElement('h3');
titleEl.style.cssText = 'margin: 0 0 16px 0; font-size: 20px; line-height: 1.4;';
titleEl.textContent = message;
const metaRow = document.createElement('div');
metaRow.style.cssText = 'display: flex; gap: 24px; font-size: 14px; color: var(--text-muted); flex-wrap: wrap;';
const authorEl = document.createElement('span');
authorEl.appendChild(document.createTextNode('👤 '));
const authorStrong = document.createElement('strong');
authorStrong.textContent = author;
authorEl.appendChild(authorStrong);
if (email) {
authorEl.appendChild(document.createTextNode(` <${email}>`));
}
const dateEl = document.createElement('span');
dateEl.textContent = `🕐 ${date.toLocaleString()}`;
const shaEl = document.createElement('span');
shaEl.style.cssText = 'font-family: monospace; background: rgba(255,255,255,0.05); padding: 4px 12px; border-radius: 6px;';
shaEl.textContent = sha.substring(0, 7);
metaRow.appendChild(authorEl);
metaRow.appendChild(dateEl);
metaRow.appendChild(shaEl);
header.appendChild(titleEl);
header.appendChild(metaRow);
container.appendChild(header);
// File changes summary
if (filesRes.ok && filesRes.files && Array.isArray(filesRes.files) && filesRes.files.length > 0) {
const filesHeader = document.createElement('div');
filesHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
`;
const filesTitle = document.createElement('h4');
filesTitle.style.cssText = 'margin: 0; display: flex; align-items: center; gap: 8px;';
filesTitle.textContent = `📁 Changed Files (${filesRes.files.length})`;
const filesStats = document.createElement('div');
filesStats.style.cssText = 'display: flex; gap: 16px; font-size: 13px;';
const additionsEl = document.createElement('span');
additionsEl.style.color = 'var(--success)';
additionsEl.textContent = `+${filesRes.stats?.additions || 0}`;
const deletionsEl = document.createElement('span');
deletionsEl.style.color = 'var(--danger)';
deletionsEl.textContent = `-${filesRes.stats?.deletions || 0}`;
filesStats.appendChild(additionsEl);
filesStats.appendChild(deletionsEl);
filesHeader.appendChild(filesTitle);
filesHeader.appendChild(filesStats);
container.appendChild(filesHeader);
// File list
const fileList = document.createElement('div');
fileList.style.cssText = 'margin-bottom: 24px;';
filesRes.files.forEach(file => {
const fileItem = document.createElement('div');
fileItem.style.cssText = `
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-sm);
margin-bottom: 4px;
font-size: 13px;
`;
const changeType = file.changes === file.insertions ? 'added' :
file.changes === file.deletions ? 'deleted' : 'modified';
const icon = changeType === 'added' ? '🆕' :
changeType === 'deleted' ? '🗑️' : '📝';
const fileNameEl = document.createElement('span');
fileNameEl.style.cssText = 'font-family: monospace; color: var(--text-primary);';
fileNameEl.textContent = `${icon} ${String(file.file || '')}`;
const fileStatsEl = document.createElement('span');
fileStatsEl.style.color = 'var(--text-muted)';
const fileAddEl = document.createElement('span');
fileAddEl.style.color = 'var(--success)';
fileAddEl.textContent = `+${file.insertions}`;
const fileDelEl = document.createElement('span');
fileDelEl.style.cssText = 'color: var(--danger); margin-left: 8px;';
fileDelEl.textContent = `-${file.deletions}`;
fileStatsEl.appendChild(fileAddEl);
fileStatsEl.appendChild(fileDelEl);
fileItem.appendChild(fileNameEl);
fileItem.appendChild(fileStatsEl);
fileList.appendChild(fileItem);
});
container.appendChild(fileList);
}
// Diff viewer
if (diffRes.ok && diffRes.diff) {
const diffHeader = document.createElement('h4');
diffHeader.textContent = '📝 Changes (Diff)';
diffHeader.style.marginBottom = '12px';
container.appendChild(diffHeader);
const diffContainer = document.createElement('div');
diffContainer.className = 'diff-viewer';
diffContainer.style.cssText = `
background: #1e1e1e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
padding: 16px;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
max-height: 600px;
overflow-y: auto;
`;
diffContainer.innerHTML = formatDiff(diffRes.diff);
container.appendChild(diffContainer);
}
} catch (error) {
console.error('Error loading commit details:', error);
console.error('Stack:', error.stack);
console.error('selectedFolder:', selectedFolder);
console.error('commit:', commit);
const errorMsg = error.message || String(error);
const isLocalGit = selectedFolder ? 'Local Git' : 'Gitea';
container.innerHTML = '';
const errorWrap = document.createElement('div');
errorWrap.style.cssText = 'text-align: center; padding: 40px; color: var(--danger);';
const errorTitle = document.createElement('p');
errorTitle.textContent = '❌ Error loading commit details';
const errorDetails = document.createElement('p');
errorDetails.style.cssText = 'font-size: 12px; color: var(--text-muted); margin-top: 12px;';
errorDetails.textContent = `Source: ${isLocalGit} | Error: ${errorMsg}`;
errorWrap.appendChild(errorTitle);
errorWrap.appendChild(errorDetails);
container.appendChild(errorWrap);
}
}
function formatDiff(diffText) {
const lines = diffText.split('\n');
let html = '';
lines.forEach(line => {
let color = '#d4d4d4';
let bgColor = 'transparent';
if (line.startsWith('+++') || line.startsWith('---')) {
color = '#569cd6'; // Blue
} else if (line.startsWith('+')) {
color = '#4ec9b0'; // Green
bgColor = 'rgba(78, 201, 176, 0.1)';
} else if (line.startsWith('-')) {
color = '#f48771'; // Red
bgColor = 'rgba(244, 135, 113, 0.1)';
} else if (line.startsWith('@@')) {
color = '#c586c0'; // Purple
} else if (line.startsWith('diff')) {
color = '#dcdcaa'; // Yellow
}
html += `<div style="color: ${color}; background: ${bgColor}; padding: 2px 4px;">${escapeHtml(line)}</div>`;
});
return html;
}
/* -------------------------
COMMIT SEARCH
------------------------- */
async function handleCommitSearch(query) {
if (!query || query.trim().length === 0) {
renderCommitTimeline(currentCommitView.commits);
return;
}
setStatus('Searching commits...');
try {
const res = await window.electronAPI.searchCommits({
owner: currentCommitView.owner,
repo: currentCommitView.repo,
branch: currentCommitView.branch,
query: query.trim(),
platform: currentCommitView.platform || currentState.platform
});
if (res.ok) {
renderCommitTimeline(res.commits);
setStatus(`Found ${res.commits.length} commits`);
} else {
setStatus('Search failed');
}
} catch (error) {
console.error('Search error:', error);
setStatus('Search error');
}
}
/* -------------------------
HELPER FUNCTIONS
------------------------- */
function formatRelativeTime(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) > 1 ? 's' : ''} ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`;
return `${Math.floor(diffDays / 365)} year${Math.floor(diffDays / 365) > 1 ? 's' : ''} ago`;
}
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* -------------------------
EVENT LISTENERS
------------------------- */
// File Editor Event Listeners
setTimeout(() => {
// Buttons
const btnClose = $('btnCloseEditor');
const btnSave = $('btnEditorSave');
const btnSearch = $('btnEditorSearch');
const btnDiscard = $('btnDiscardEdit');
const btnFileActions = $('btnFileActions');
const modal = $('fileEditorModal');
// Close button
if (btnClose) btnClose.addEventListener('click', closeFileEditor);
// Save button
if (btnSave) btnSave.addEventListener('click', saveCurrentFile);
// Search button
if (btnSearch) btnSearch.addEventListener('click', toggleSearch);
// Discard button
if (btnDiscard) btnDiscard.addEventListener('click', closeFileEditor);
// File actions menu
if (btnFileActions) {
btnFileActions.addEventListener('click', (e) => {
const menu = $('fileActionsMenu');
if (menu) {
menu.classList.toggle('hidden');
const rect = btnFileActions.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.right = '20px';
}
});
}
// Search & Replace
const searchInput = $('searchInput');
if (searchInput) {
searchInput.addEventListener('keyup', performSearch);
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
replaceOnce();
}
});
}
const btnReplace = $('btnReplace');
if (btnReplace) btnReplace.addEventListener('click', replaceOnce);
const btnReplaceAll = $('btnReplaceAll');
if (btnReplaceAll) btnReplaceAll.addEventListener('click', replaceAll);
const btnCloseSearch = $('btnCloseSearch');
if (btnCloseSearch) btnCloseSearch.addEventListener('click', () => {
const searchBar = $('searchBar');
if (searchBar) searchBar.classList.add('hidden');
});
// Textarea events
const textarea = $('fileEditorContent');
if (textarea) {
textarea.addEventListener('input', () => {
updateEditorContent(textarea.value);
});
textarea.addEventListener('scroll', () => {
const lineNumbers = $('lineNumbers');
if (lineNumbers) lineNumbers.scrollTop = textarea.scrollTop;
});
textarea.addEventListener('click', updateEditorStats);
textarea.addEventListener('keyup', updateEditorStats);
}
// Close modal on background click
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeFileEditor();
}
});
}
console.log('✅ Advanced editor event listeners registered');
}, 100);
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
const modal = $('fileEditorModal');
if (!modal || modal.classList.contains('hidden')) return;
// Ctrl+S / Cmd+S - Save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveCurrentFile();
}
// Ctrl+F / Cmd+F - Search
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
toggleSearch();
}
// Ctrl+H / Cmd+H - Replace
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
e.preventDefault();
toggleSearch();
$('replaceInput').focus();
}
// ESC - Close search bar if open
if (e.key === 'Escape') {
const searchBar = $('searchBar');
if (searchBar && !searchBar.classList.contains('hidden')) {
searchBar.classList.add('hidden');
}
}
});
/* ========================================
UPDATER FUNKTIONEN (Optimiert & Synchronisiert)
======================================== */
async function initUpdater() {
try {
const versionRes = await window.electronAPI.getAppVersion();
if (versionRes && versionRes.ok && $('appVersion')) {
$('appVersion').value = versionRes.version;
}
if (versionRes && versionRes.ok && $('watermarkVersion')) {
$('watermarkVersion').textContent = versionRes.version;
}
} catch (error) {
console.error('[Renderer] Fehler beim Laden der Version:', error);
}
if ($('watermarkCopyright')) {
$('watermarkCopyright').textContent = `© ${new Date().getFullYear()} M_Viper`;
}
// Manueller Check Button in Settings
if ($('btnCheckUpdates')) {
$('btnCheckUpdates').onclick = async () => {
const btn = $('btnCheckUpdates');
const originalHTML = btn.innerHTML;
btn.innerHTML = '⏳ Suche...';
btn.disabled = true;
try {
await window.electronAPI.checkForUpdates({ silent: false });
setStatus('Update-Suche abgeschlossen');
} catch (error) {
setStatus('Fehler bei der Update-Prüfung');
} finally {
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.disabled = false;
}, 1500);
}
};
}
}
// Event-Listener für das Update-Modal
if (window.electronAPI.onUpdateAvailable) {
window.electronAPI.onUpdateAvailable((info) => {
const modal = $('updateModal');
const versionInfo = $('updateVersionInfo');
const changelog = $('updateChangelog');
if (versionInfo) versionInfo.innerText = `Version ${info.version} verfügbar!`;
if (changelog) changelog.innerText = info.body || 'Keine Release-Notes vorhanden.';
if (modal) modal.classList.remove('hidden');
// Button: Jetzt installieren
const updateBtn = $('btnStartUpdate');
if (updateBtn) {
updateBtn.onclick = () => {
if (modal) modal.classList.add('hidden');
setStatus('Download gestartet...');
// Aufruf der korrekten Preload-Funktion
window.electronAPI.startUpdateDownload(info.asset);
};
}
// Button: Später
const ignoreBtn = $('btnIgnoreUpdate');
if (ignoreBtn) {
ignoreBtn.onclick = () => { if (modal) modal.classList.add('hidden'); };
}
});
}
if (window.electronAPI.onUpdateNotAvailable) {
window.electronAPI.onUpdateNotAvailable(() => {
showInfo('Du nutzt bereits die aktuelle Version.');
});
}
// AM ENDE DER DATEI: Initialisierung beim Start
document.addEventListener('DOMContentLoaded', () => {
// 1. Basis-Setup (Settings-Feld füllen etc.)
initUpdater();
// 2. AUTOMATISCHER UPDATE-CHECK BEIM START
// Etwas spaeter, damit initiales UI/Repo-Laden nicht ausgebremst wird.
setTimeout(() => {
window.electronAPI.checkForUpdates({ silent: true });
}, 12000);
});