Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f11730fc7 | |||
| e0827faf42 | |||
| 1041f39ced | |||
| 7a43d24a32 | |||
| d6968a4954 | |||
| 464d15464a | |||
| da47343b2e | |||
| d41865608f | |||
| e4b1215aa7 | |||
| 1d7b5e8d6e | |||
| e79c0f411d | |||
| 9da186e5d2 | |||
| a9f0728232 | |||
| edeb05f088 | |||
| 4b4d1520b9 | |||
| b8644b248f | |||
| 71ed9b7c67 | |||
| 78a94f2263 | |||
| b153431543 | |||
| 93ebe5aea9 | |||
| 1ca5856fe3 | |||
| e2dbacc77c | |||
| 925c214a1f | |||
| aafd5c3e66 | |||
| 68002a31df | |||
| ec72cd2a11 | |||
| cd739363ae | |||
| f064ffc8a2 | |||
| 6965503bb3 | |||
| 2a9812575c | |||
| 89272fb899 | |||
| 14d15dc355 | |||
| 2b2d6a8303 | |||
| b613e3bd73 | |||
| 62e4f9b85f | |||
| 7b68e92a95 | |||
| 83782d547f | |||
| f1acea14fa | |||
| 5513bdcef4 | |||
| f1f896ba65 | |||
| ca55bbdcae | |||
| e97e714826 | |||
| cb185d7714 | |||
| 4215429114 | |||
| 1195dd4c0e | |||
| a6585a856b | |||
| 5988d98d9a | |||
| 89dcd9f311 | |||
| 0366253134 | |||
| 853a93e142 | |||
| 24dd12c360 | |||
| f52b99f192 | |||
| 6ba774cb26 | |||
| f5542a6114 | |||
| 3c0c1e78dc | |||
| f041b3bc32 | |||
| 3ecb3125d3 | |||
| 272bd00c80 | |||
| 0e9c14b144 | |||
| a9c0c97287 | |||
| cdd7dc430f | |||
| 433c70d389 | |||
| d61c2b39e3 | |||
| 96c3a063cc | |||
| 9dd3696a09 | |||
| 4bef46201b | |||
| fd80283328 | |||
| 2dba14ca58 | |||
| 1391f092d8 | |||
| 88e2535e62 | |||
| de581d878f | |||
| a1f7c66f67 | |||
| 12dbdbb28e | |||
| 687a80924a | |||
| b22ea34032 | |||
| c7fb8ce0bf | |||
| b5f744db12 | |||
| 553333c623 | |||
| 13ee1b0339 | |||
| 3755796ef6 | |||
| e3dbc6c663 | |||
| 8613720a0b | |||
| 0f758616cf | |||
| b2ba2a09e2 | |||
| 2ef1e0df61 | |||
| fe0e7b7794 | |||
| c8e06bf576 | |||
| 7b480acd10 | |||
| 658b29368b | |||
| c64d40fbda | |||
| f6598cfb19 | |||
| 5787c4ca22 |
@@ -56,6 +56,6 @@ Bei Problemen oder Fragen kannst du ein Issue auf GitHub erstellen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Copyright © 2026 - M_Viper - Alle Rechte vorbehalten**
|
## Lizenz
|
||||||
|
|
||||||
Die unbefugte Vervielfältigung, Verbreitung oder Weitergabe dieses Plugins ist strafbar und wird rechtlich verfolgt.
|
Dieses Projekt ist Open-Source unter der [GPL Lizenz](LICENSE).
|
||||||
|
|||||||
686
package-lock.json
generated
686
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -1,27 +1,56 @@
|
|||||||
{
|
{
|
||||||
"name": "git-manager-gui",
|
"name": "git-manager-gui",
|
||||||
"version": "2.0.4",
|
"version": "2.0.9",
|
||||||
|
"description": "Git Manager GUI - Verwaltung von Git Repositories",
|
||||||
|
"author": "M_Viper",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"build": "electron-builder"
|
"build": "electron-builder",
|
||||||
|
"test": "node --test __tests__/*.test.js",
|
||||||
|
"pdf": "electron generate-pdf.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.5",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"simple-git": "^3.19.1"
|
"simple-git": "^3.32.3",
|
||||||
|
"archiver": "^6.0.0",
|
||||||
|
"unzipper": "^0.11.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"minimatch": "^3.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^26.2.0",
|
"electron": "^41.0.4",
|
||||||
"electron-builder": "^26.8.1"
|
"electron-builder": "^26.8.1"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
"asar": true,
|
||||||
"appId": "com.viper.gitmanager",
|
"appId": "com.viper.gitmanager",
|
||||||
"productName": "Git Manager GUI",
|
"productName": "Git Manager GUI",
|
||||||
"files": [
|
"files": [
|
||||||
"**/*",
|
"**/*",
|
||||||
"!node_modules/.cache",
|
"!node_modules/.cache",
|
||||||
"!**/*.map"
|
"!**/*.map",
|
||||||
|
"!**/.git",
|
||||||
|
"!**/.git/**",
|
||||||
|
"!repos/**",
|
||||||
|
"!data/**",
|
||||||
|
"!backup/**",
|
||||||
|
"!*.zip",
|
||||||
|
"!*.rar",
|
||||||
|
"!*.log"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
"repos/",
|
||||||
|
{
|
||||||
|
"from": "data",
|
||||||
|
"to": "data",
|
||||||
|
"filter": [
|
||||||
|
"**/*",
|
||||||
|
"!credentials.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "assets"
|
"buildResources": "assets"
|
||||||
|
|||||||
75
preload.js
75
preload.js
@@ -1,5 +1,5 @@
|
|||||||
// preload.js — expose IPC to renderer
|
// preload.js — expose IPC to renderer
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Lokale Datei-Operationen
|
// Lokale Datei-Operationen
|
||||||
@@ -20,6 +20,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Gitea Datei-Operationen
|
// Gitea Datei-Operationen
|
||||||
listGiteaRepos: (data) => ipcRenderer.invoke('list-gitea-repos', data),
|
listGiteaRepos: (data) => ipcRenderer.invoke('list-gitea-repos', data),
|
||||||
|
getGiteaCurrentUser: () => ipcRenderer.invoke('get-gitea-current-user'),
|
||||||
|
getGiteaUserHeatmap: (data) => ipcRenderer.invoke('get-gitea-user-heatmap', data),
|
||||||
|
|
||||||
|
// GitHub Datei-Operationen
|
||||||
|
listGithubRepos: (data) => ipcRenderer.invoke('list-github-repos', data),
|
||||||
|
getGithubCurrentUser: () => ipcRenderer.invoke('get-github-current-user'),
|
||||||
|
getGithubUserHeatmap: (data) => ipcRenderer.invoke('get-github-user-heatmap', data),
|
||||||
getGiteaRepoContents: (data) => ipcRenderer.invoke('get-gitea-repo-contents', data),
|
getGiteaRepoContents: (data) => ipcRenderer.invoke('get-gitea-repo-contents', data),
|
||||||
getGiteaFileContent: (data) => ipcRenderer.invoke('get-gitea-file-content', data),
|
getGiteaFileContent: (data) => ipcRenderer.invoke('get-gitea-file-content', data),
|
||||||
readGiteaFile: (data) => ipcRenderer.invoke('read-gitea-file', data),
|
readGiteaFile: (data) => ipcRenderer.invoke('read-gitea-file', data),
|
||||||
@@ -32,16 +39,42 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Repository & Git Management
|
// Repository & Git Management
|
||||||
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),
|
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),
|
||||||
loadCredentials: () => ipcRenderer.invoke('load-credentials'),
|
loadCredentials: () => ipcRenderer.invoke('load-credentials'),
|
||||||
|
getCredentialsStatus: () => ipcRenderer.invoke('get-credentials-status'),
|
||||||
|
testGiteaConnection: (data) => ipcRenderer.invoke('test-gitea-connection', data),
|
||||||
|
testGithubConnection: (data) => ipcRenderer.invoke('test-github-connection', data),
|
||||||
|
updateGiteaAvatar: (data) => ipcRenderer.invoke('update-gitea-avatar', data),
|
||||||
|
updateGiteaRepoAvatar: (data) => ipcRenderer.invoke('update-gitea-repo-avatar', data),
|
||||||
|
updateGiteaRepoVisibility: (data) => ipcRenderer.invoke('update-gitea-repo-visibility', data),
|
||||||
|
updateGiteaRepoTopics: (data) => ipcRenderer.invoke('update-gitea-repo-topics', data),
|
||||||
|
getGiteaTopicsCatalog: () => ipcRenderer.invoke('get-gitea-topics-catalog'),
|
||||||
|
migrateRepoToGitea: (data) => ipcRenderer.invoke('migrate-repo-to-gitea', data),
|
||||||
createRepo: (data) => ipcRenderer.invoke('create-repo', data),
|
createRepo: (data) => ipcRenderer.invoke('create-repo', data),
|
||||||
pushProject: (data) => ipcRenderer.invoke('push-project', data),
|
pushProject: (data) => ipcRenderer.invoke('push-project', data),
|
||||||
getBranches: (data) => ipcRenderer.invoke('getBranches', data),
|
getBranches: (data) => ipcRenderer.invoke('getBranches', data),
|
||||||
getCommitLogs: (data) => ipcRenderer.invoke('getCommitLogs', data),
|
getCommitLogs: (data) => ipcRenderer.invoke('getCommitLogs', data),
|
||||||
uploadAndPush: (data) => ipcRenderer.invoke('upload-and-push', data),
|
uploadAndPush: (data) => ipcRenderer.invoke('upload-and-push', data),
|
||||||
deleteGiteaRepo: (data) => ipcRenderer.invoke('delete-gitea-repo', data),
|
deleteGiteaRepo: (data) => ipcRenderer.invoke('delete-gitea-repo', data),
|
||||||
|
syncRepoToGitHub: (data) => ipcRenderer.invoke('sync-repo-to-github', data),
|
||||||
|
runBatchRepoAction: (data) => ipcRenderer.invoke('run-batch-repo-action', data),
|
||||||
|
validateRepoName: (data) => ipcRenderer.invoke('validate-repo-name', data),
|
||||||
|
checkCloneTargetCollisions: (data) => ipcRenderer.invoke('check-clone-target-collisions', data),
|
||||||
|
|
||||||
|
// Offline/Retry Queue
|
||||||
|
getRetryQueue: () => ipcRenderer.invoke('get-retry-queue'),
|
||||||
|
processRetryQueueNow: () => ipcRenderer.invoke('process-retry-queue-now'),
|
||||||
|
removeRetryQueueItem: (data) => ipcRenderer.invoke('remove-retry-queue-item', data),
|
||||||
|
|
||||||
// Drag & Drop
|
// Drag & Drop
|
||||||
prepareDownloadDrag: (data) => ipcRenderer.invoke('prepare-download-drag', data),
|
prepareDownloadDrag: (data) => ipcRenderer.invoke('prepare-download-drag', data),
|
||||||
startNativeDrag: (filePath) => ipcRenderer.send('ondragstart', filePath),
|
startNativeDrag: (filePath) => ipcRenderer.send('ondragstart', filePath),
|
||||||
|
getPathType: (filePath) => ipcRenderer.invoke('get-path-type', filePath),
|
||||||
|
getPathForFile: (file) => {
|
||||||
|
try {
|
||||||
|
return webUtils.getPathForFile(file) || '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Release Management
|
// Release Management
|
||||||
listReleases: (data) => ipcRenderer.invoke('list-releases', data),
|
listReleases: (data) => ipcRenderer.invoke('list-releases', data),
|
||||||
@@ -74,7 +107,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
saveRecent: (data) => ipcRenderer.invoke('save-recent', data),
|
saveRecent: (data) => ipcRenderer.invoke('save-recent', data),
|
||||||
|
|
||||||
// === UPDATER APIs ===
|
// === UPDATER APIs ===
|
||||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
checkForUpdates: (options) => ipcRenderer.invoke('check-for-updates', options || {}),
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
|
||||||
// Triggert den tatsächlichen Download des Assets
|
// Triggert den tatsächlichen Download des Assets
|
||||||
@@ -88,6 +121,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeListener('update-available', listener);
|
return () => ipcRenderer.removeListener('update-available', listener);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onUpdateNotAvailable: (cb) => {
|
||||||
|
const listener = (event, info) => cb(info);
|
||||||
|
ipcRenderer.on('update-not-available', listener);
|
||||||
|
return () => ipcRenderer.removeListener('update-not-available', listener);
|
||||||
|
},
|
||||||
|
|
||||||
onUpdateProgress: (cb) => {
|
onUpdateProgress: (cb) => {
|
||||||
const listener = (event, percent) => cb(percent);
|
const listener = (event, percent) => cb(percent);
|
||||||
ipcRenderer.on('update-progress', listener);
|
ipcRenderer.on('update-progress', listener);
|
||||||
@@ -110,5 +149,35 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
|
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
|
||||||
ipcRenderer.on('folder-download-progress', listener);
|
ipcRenderer.on('folder-download-progress', listener);
|
||||||
return () => ipcRenderer.removeListener('folder-download-progress', listener);
|
return () => ipcRenderer.removeListener('folder-download-progress', listener);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
onRetryQueueUpdated: (cb) => {
|
||||||
|
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
|
||||||
|
ipcRenderer.on('retry-queue-updated', listener);
|
||||||
|
return () => ipcRenderer.removeListener('retry-queue-updated', listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
onBatchActionProgress: (cb) => {
|
||||||
|
const listener = (event, payload) => { try { cb(payload); } catch (_) {} };
|
||||||
|
ipcRenderer.on('batch-action-progress', listener);
|
||||||
|
return () => ipcRenderer.removeListener('batch-action-progress', listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Window Controls
|
||||||
|
windowMinimize: () => ipcRenderer.send('window-minimize'),
|
||||||
|
windowMaximize: () => ipcRenderer.send('window-maximize'),
|
||||||
|
windowClose: () => ipcRenderer.send('window-close'),
|
||||||
|
|
||||||
|
// Autostart
|
||||||
|
setAutostart: (enable) => ipcRenderer.invoke('set-autostart', enable),
|
||||||
|
getAutostart: () => ipcRenderer.invoke('get-autostart'),
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||||
|
openExternalUrl: (url) => ipcRenderer.invoke('open-external-url', url),
|
||||||
|
debugToMain: (level, message, payload) => ipcRenderer.send('renderer-debug-log', { level, message, payload }),
|
||||||
|
|
||||||
|
// Debugging & Diagnostics
|
||||||
|
getDebugInfo: () => ipcRenderer.invoke('get-debug-info'),
|
||||||
|
clearCache: (type) => ipcRenderer.invoke('clear-cache', type || 'all')
|
||||||
});
|
});
|
||||||
238
renderer/App.jsx
238
renderer/App.jsx
@@ -4,39 +4,42 @@ import Settings from './Settings.jsx';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [folder, setFolder] = useState('');
|
const [folder, setFolder] = useState('');
|
||||||
const [repoName, setRepoName] = useState('');
|
const [repoName, setRepoName] = useState('');
|
||||||
const [platform, setPlatform] = useState('github');
|
const [platform, setPlatform] = useState('gitea');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('Bereit');
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [branches, setBranches] = useState([]);
|
const [branches, setBranches] = useState([]);
|
||||||
const [selectedBranch, setSelectedBranch] = useState('master');
|
const [selectedBranch, setSelectedBranch] = useState('main');
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
async function selectFolder() {
|
async function selectFolder() {
|
||||||
const selected = await window.electronAPI.selectFolder();
|
const selected = await window.electronAPI.selectFolder();
|
||||||
if (selected) setFolder(selected);
|
if (!selected) return;
|
||||||
// Branches laden
|
setFolder(selected);
|
||||||
if (selected) {
|
const branchList = await window.electronAPI.getBranches({ folder: selected });
|
||||||
const branchList = await window.electronAPI.getBranches({ folder: selected });
|
setBranches(branchList);
|
||||||
setBranches(branchList);
|
if (branchList.includes('main')) setSelectedBranch('main');
|
||||||
if (branchList.includes('master')) setSelectedBranch('master');
|
else if (branchList.includes('master')) setSelectedBranch('master');
|
||||||
}
|
else if (branchList.length > 0) setSelectedBranch(branchList[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRepoHandler() {
|
async function createRepoHandler() {
|
||||||
if (!repoName) return alert('Repo Name required!');
|
if (!repoName) return alert('Repo-Name erforderlich!');
|
||||||
setStatus('Creating repository...');
|
setStatus('Repository wird erstellt…');
|
||||||
const result = await window.electronAPI.createRepo({ name: repoName, platform });
|
const result = await window.electronAPI.createRepo({ name: repoName, platform });
|
||||||
setStatus(result ? 'Repository created!' : 'Failed to create repository.');
|
setStatus(result ? 'Repository erstellt!' : 'Fehler beim Erstellen des Repositories.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushProjectHandler() {
|
async function pushProjectHandler() {
|
||||||
if (!folder) return alert('Select a project folder first!');
|
if (!folder) return alert('Bitte zuerst einen Projektordner auswählen!');
|
||||||
setStatus('Pushing project...');
|
setStatus('Projekt wird gepusht…');
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
const onProgress = (p) => setProgress(p); // Callback für Fortschritt
|
const result = await window.electronAPI.pushProject({
|
||||||
const result = await window.electronAPI.pushProject({ folder, branch: selectedBranch, onProgress });
|
folder,
|
||||||
setStatus(result ? 'Project pushed!' : 'Failed to push project.');
|
branch: selectedBranch,
|
||||||
|
onProgress: (p) => setProgress(p),
|
||||||
|
});
|
||||||
|
setStatus(result ? 'Projekt gepusht!' : 'Push fehlgeschlagen.');
|
||||||
if (result) {
|
if (result) {
|
||||||
const logList = await window.electronAPI.getCommitLogs({ folder });
|
const logList = await window.electronAPI.getCommitLogs({ folder });
|
||||||
setLogs(logList);
|
setLogs(logList);
|
||||||
@@ -44,61 +47,168 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<div id="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||||
<h1>Git Manager GUI - High-End</h1>
|
|
||||||
<button onClick={() => setShowSettings(!showSettings)}>Settings</button>
|
|
||||||
{showSettings && <Settings />}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
{/* ── Toolbar ── */}
|
||||||
<label>Platform:</label>
|
<div id="toolbar">
|
||||||
<select value={platform} onChange={e => setPlatform(e.target.value)}>
|
<div className="toolbar-row toolbar-row--top">
|
||||||
<option value="github">GitHub</option>
|
<div className="toolbar-brand">
|
||||||
<option value="gitea">Gitea</option>
|
<div className="toolbar-brand-mark">
|
||||||
</select>
|
<img src="./icon.png" alt="Git Manager Logo" className="toolbar-brand-logo" />
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-brand-copy">
|
||||||
|
<span className="toolbar-kicker">Workspace Control</span>
|
||||||
|
<strong>Git Manager Explorer Pro</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-top-actions">
|
||||||
|
<div className="tool-group tool-group--quick-actions">
|
||||||
|
<button onClick={createRepoHandler} title="Neues Repository erstellen">🚀 New Repo</button>
|
||||||
|
<button onClick={pushProjectHandler} title="Projekt pushen">⬆️ Push</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tool-group tool-group--utility">
|
||||||
|
<button onClick={() => setShowSettings(true)} title="Einstellungen">⚙️ Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-status-wrap">
|
||||||
|
<span className="status-dot" aria-hidden="true" />
|
||||||
|
<span className="status">{status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-row toolbar-row--bottom">
|
||||||
|
<div className="tool-group tool-group--workspace">
|
||||||
|
<button className="accent-btn" onClick={selectFolder} title="Lokalen Ordner öffnen">
|
||||||
|
📂 Open Local
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tool-group tool-group--repo">
|
||||||
|
<div className="platform-switch" role="tablist" aria-label="Plattform auswählen">
|
||||||
|
{['gitea', 'github'].map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
className={`platform-option${platform === p ? ' active' : ''}`}
|
||||||
|
data-platform={p}
|
||||||
|
aria-pressed={platform === p}
|
||||||
|
onClick={() => setPlatform(p)}
|
||||||
|
>
|
||||||
|
{p === 'gitea' ? 'Gitea' : 'GitHub'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{branches.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedBranch}
|
||||||
|
onChange={e => setSelectedBranch(e.target.value)}
|
||||||
|
title="Branch auswählen"
|
||||||
|
>
|
||||||
|
{branches.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
{/* ── Main ── */}
|
||||||
<button onClick={selectFolder}>Select Project Folder</button>
|
<main id="main">
|
||||||
<span style={{ marginLeft: 10 }}>{folder}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
{/* Fortschrittsbalken */}
|
||||||
<label>Branch:</label>
|
{progress > 0 && progress < 100 && (
|
||||||
<select value={selectedBranch} onChange={e => setSelectedBranch(e.target.value)}>
|
<div className="input-group" style={{ marginBottom: 20 }}>
|
||||||
{branches.map(b => <option key={b} value={b}>{b}</option>)}
|
<label>Fortschritt</label>
|
||||||
</select>
|
<progress value={progress} max="100" style={{ width: '100%', height: 8, borderRadius: 4 }} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
{/* Repo erstellen / Ordner */}
|
||||||
<input
|
<div className="card" style={{ marginBottom: 20 }}>
|
||||||
type="text"
|
<h2>📁 Projekt & Repository</h2>
|
||||||
placeholder="Repository Name"
|
|
||||||
value={repoName}
|
|
||||||
onChange={e => setRepoName(e.target.value)} />
|
|
||||||
<button onClick={createRepoHandler} style={{ marginLeft: 10 }}>Create Repo</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
<div className="input-group">
|
||||||
<button onClick={pushProjectHandler}>Push / Update Project</button>
|
<label>Lokaler Projektordner</label>
|
||||||
</div>
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={folder}
|
||||||
|
placeholder="Noch kein Ordner ausgewählt…"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button onClick={selectFolder} style={{ flex: '0 0 auto' }}>📂 Auswählen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 10 }}>
|
<div className="input-group">
|
||||||
<label>Progress:</label>
|
<label>Repository Name</label>
|
||||||
<progress value={progress} max="100" style={{ width: '100%' }} />
|
<input
|
||||||
</div>
|
type="text"
|
||||||
|
placeholder="mein-projekt"
|
||||||
|
value={repoName}
|
||||||
|
onChange={e => setRepoName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
|
||||||
<strong>Status: </strong>{status}
|
<button className="accent-btn" onClick={createRepoHandler}>🚀 Repo erstellen</button>
|
||||||
</div>
|
<button onClick={pushProjectHandler}>⬆️ Push / Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
{/* Commit-Logs */}
|
||||||
<h3>Commit Logs:</h3>
|
{logs.length > 0 && (
|
||||||
<ul>
|
<div className="card">
|
||||||
{logs.map((log, i) => (
|
<h2>📊 Commit-Verlauf</h2>
|
||||||
<li key={i}>{log}</li>
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
))}
|
{logs.map((log, i) => (
|
||||||
</ul>
|
<li
|
||||||
</div>
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(140,173,255,0.08)',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logs.length === 0 && !folder && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: 60,
|
||||||
|
gap: 16,
|
||||||
|
opacity: 0.55,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 64, filter: 'drop-shadow(0 8px 16px rgba(88,213,255,0.2))' }}>📂</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 15, textAlign: 'center' }}>
|
||||||
|
Öffne einen lokalen Ordner oder lade Repos über die Toolbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* ── Settings-Modal ── */}
|
||||||
|
{showSettings && (
|
||||||
|
<div id="settingsModal" className="modal" onClick={e => { if (e.target === e.currentTarget) setShowSettings(false); }}>
|
||||||
|
<Settings onClose={() => setShowSettings(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings({ onClose }) {
|
||||||
const [githubToken, setGithubToken] = useState('');
|
const [githubToken, setGithubToken] = useState('');
|
||||||
const [giteaToken, setGiteaToken] = useState('');
|
const [giteaToken, setGiteaToken] = useState('');
|
||||||
const [giteaURL, setGiteaURL] = useState('');
|
const [giteaURL, setGiteaURL] = useState('');
|
||||||
|
const [avatarB64, setAvatarB64] = useState(null);
|
||||||
|
const [savedOk, setSavedOk] = useState(false);
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
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 Gitea-URL. Beispiel für IPv6: http://[2001:db8::1]:3000',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Die Gitea-URL muss mit http:// oder https:// beginnen.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, value: value.replace(/\/$/, '') };
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI.loadCredentials().then(data => {
|
window.electronAPI.loadCredentials().then(data => {
|
||||||
@@ -11,31 +39,145 @@ export default function Settings() {
|
|||||||
setGithubToken(data.githubToken || '');
|
setGithubToken(data.githubToken || '');
|
||||||
setGiteaToken(data.giteaToken || '');
|
setGiteaToken(data.giteaToken || '');
|
||||||
setGiteaURL(data.giteaURL || '');
|
setGiteaURL(data.giteaURL || '');
|
||||||
|
setAvatarB64(data.avatarB64 || null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const save = () => {
|
function handleAvatarFileChange(e) {
|
||||||
window.electronAPI.saveCredentials({ githubToken, giteaToken, giteaURL });
|
const file = e.target.files[0];
|
||||||
alert('Settings saved securely!');
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setAvatarB64(ev.target.result);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const checkedUrl = normalizeAndValidateGiteaUrl(giteaURL);
|
||||||
|
if (!checkedUrl.ok) {
|
||||||
|
alert(checkedUrl.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.electronAPI.saveCredentials({
|
||||||
|
githubToken,
|
||||||
|
giteaToken,
|
||||||
|
giteaURL: checkedUrl.value,
|
||||||
|
avatarB64: avatarB64 || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avatar automatisch zu Gitea pushen, wenn Token + URL vorhanden
|
||||||
|
if (avatarB64 && giteaToken && checkedUrl.value) {
|
||||||
|
setAvatarUploading(true);
|
||||||
|
const result = await window.electronAPI.updateGiteaAvatar({
|
||||||
|
token: giteaToken,
|
||||||
|
url: checkedUrl.value,
|
||||||
|
imageBase64: avatarB64
|
||||||
|
});
|
||||||
|
setAvatarUploading(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn('Avatar-Upload fehlgeschlagen:', result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavedOk(true);
|
||||||
|
setTimeout(() => setSavedOk(false), 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<div className="modalContent card settings-modal-content">
|
||||||
<h2>Settings</h2>
|
|
||||||
<div>
|
<div className="settings-header">
|
||||||
<label>GitHub Token:</label>
|
<div className="settings-header-inner">
|
||||||
<input type="password" value={githubToken} onChange={e => setGithubToken(e.target.value)} />
|
<div className="settings-avatar-wrap" onClick={() => fileInputRef.current?.click()} title="Profilbild ändern">
|
||||||
|
{avatarB64
|
||||||
|
? <img src={avatarB64} alt="Avatar" className="settings-avatar-img" />
|
||||||
|
: <div className="settings-avatar-placeholder">👤</div>
|
||||||
|
}
|
||||||
|
<div className="settings-avatar-overlay">✏️</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleAvatarFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="settings-eyebrow">Konfiguration</div>
|
||||||
|
<h2>⚙️ Einstellungen</h2>
|
||||||
|
<p className="settings-subtitle">
|
||||||
|
Zugangsdaten für GitHub und Gitea hinterlegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label>Gitea Token:</label>
|
<div className="settings-layout">
|
||||||
<input type="password" value={giteaToken} onChange={e => setGiteaToken(e.target.value)} />
|
<div className="settings-column settings-column--left">
|
||||||
|
<section className="settings-panel settings-panel--credentials">
|
||||||
|
<div className="settings-panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>Zugangsdaten</h3>
|
||||||
|
<p>API-Zugriffe für GitHub und Gitea konfigurieren.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-fields-grid">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="react-githubToken">GitHub Token</label>
|
||||||
|
<input
|
||||||
|
id="react-githubToken"
|
||||||
|
type="password"
|
||||||
|
placeholder="ghp_…"
|
||||||
|
value={githubToken}
|
||||||
|
onChange={e => setGithubToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="react-giteaToken">Gitea Token</label>
|
||||||
|
<input
|
||||||
|
id="react-giteaToken"
|
||||||
|
type="password"
|
||||||
|
placeholder="Token hier einfügen"
|
||||||
|
value={giteaToken}
|
||||||
|
onChange={e => setGiteaToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group input-group--wide">
|
||||||
|
<label htmlFor="react-giteaURL">Gitea URL</label>
|
||||||
|
<input
|
||||||
|
id="react-giteaURL"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://gitea.example.com"
|
||||||
|
value={giteaURL}
|
||||||
|
onChange={e => setGiteaURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-connection-tools">
|
||||||
|
<div className="settings-inline-hint">
|
||||||
|
Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label>Gitea URL:</label>
|
<div className="modal-buttons settings-modal-actions">
|
||||||
<input type="text" value={giteaURL} onChange={e => setGiteaURL(e.target.value)} />
|
<button
|
||||||
|
className="accent-btn"
|
||||||
|
onClick={save}
|
||||||
|
disabled={avatarUploading}
|
||||||
|
style={savedOk ? { background: 'var(--success)', borderColor: 'var(--success)' } : {}}
|
||||||
|
>
|
||||||
|
{avatarUploading ? '⏳ Bild wird hochgeladen…' : savedOk ? '✅ Gespeichert' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button className="secondary" onClick={onClose}>Abbrechen</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={save} style={{ marginTop: 10 }}>Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
renderer/icon.png
Normal file
BIN
renderer/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -4,97 +4,288 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Git Manager Explorer Pro</title>
|
<title>Git Manager Explorer Pro</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: http:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';">
|
||||||
|
<link rel="icon" type="image/png" href="./icon.png">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="toolbar">
|
<!-- Titelbalken-Streifen -->
|
||||||
<div class="tool-group">
|
<div id="titlebar-strip">
|
||||||
<button id="btnSettings" title="Einstellungen">⚙️ Settings</button>
|
<div class="titlebar-strip-brand">
|
||||||
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
|
<img src="./icon.png" alt="" class="titlebar-strip-icon">
|
||||||
<button id="btnSelectFolder" class="accent-btn" title="Lokalen Ordner öffnen">📂 Open Local</button>
|
<span class="titlebar-strip-title">Git Manager Explorer Pro</span>
|
||||||
<button id="btnLoadGiteaRepos" class="accent-btn" title="Gitea Repositories laden">🌐 Load Gitea</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="win-controls" aria-label="Fenster-Steuerung">
|
||||||
<div class="tool-group">
|
<button class="win-btn win-btn--minimize" id="btnWinMinimize" title="Minimieren">
|
||||||
<select id="platform" title="Plattform auswählen">
|
<svg width="10" height="1" viewBox="0 0 10 1"><rect width="10" height="1" fill="currentColor"/></svg>
|
||||||
<option value="gitea" selected>Gitea</option>
|
</button>
|
||||||
<option value="github">GitHub</option>
|
<button class="win-btn win-btn--maximize" id="btnWinMaximize" title="Maximieren">
|
||||||
</select>
|
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" rx="1" fill="none" stroke="currentColor"/></svg>
|
||||||
<button id="btnOpenRepoActions" title="Neues Repository erstellen">🚀 New Repo</button>
|
</button>
|
||||||
<button id="btnPush" title="Projekt pushen">⬆️ Push</button>
|
<button class="win-btn win-btn--close" id="btnWinClose" title="Schließen">
|
||||||
<button id="btnCommits" class="hidden" title="Commit History anzeigen">📊 Commits</button>
|
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
<button id="btnReleases" class="hidden" title="Releases anzeigen">📦 Releases</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span id="status" class="status">Bereit</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="main">
|
<div id="toolbar">
|
||||||
<div id="explorerGrid" class="explorer-grid">
|
<div class="toolbar-row toolbar-row--top">
|
||||||
|
<div class="toolbar-brand" aria-label="App Kopfbereich">
|
||||||
|
<div class="toolbar-brand-mark">
|
||||||
|
<img src="./icon.png" alt="Git Manager Logo" class="toolbar-brand-logo">
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-brand-copy">
|
||||||
|
<span class="toolbar-kicker">Workspace Control</span>
|
||||||
|
<strong>Git Manager Explorer Pro</strong>
|
||||||
|
</div>
|
||||||
|
<span id="project-toolbar-title" class="toolbar-project-title"></span>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
<div class="toolbar-top-actions">
|
||||||
|
<div class="tool-group tool-group--quick-actions">
|
||||||
|
<button id="btnOpenRepoActions" title="Neues Repository erstellen">🚀 New Repo</button>
|
||||||
|
<button id="btnOpenMigration" title="Repository von GitHub/GitLab zu Gitea migrieren">📥 Migrieren</button>
|
||||||
|
<button id="btnPush" title="Projekt pushen">⬆️ Push</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-group tool-group--utility">
|
||||||
|
<span class="tool-group-title">Steuerung</span>
|
||||||
|
<button id="btnSettings" title="Einstellungen">⚙️ Settings</button>
|
||||||
|
<button id="btnBatchActions" title="Batch-Aktionen">🧩 Batch</button>
|
||||||
|
<button id="btnOpenActivityLog" title="Aktivitätsprotokoll">📝 Activity</button>
|
||||||
|
<button id="btnRetryQueueNow" class="secondary" title="Retry-Queue jetzt verarbeiten">🔁 Queue (0)</button>
|
||||||
|
<button id="btnBack" class="secondary hidden" title="Zurück">⬅️ Zurück</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-status-wrap">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span id="status" class="status">Bereit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row toolbar-row--bottom">
|
||||||
|
<div class="tool-group tool-group--workspace">
|
||||||
|
<span class="tool-group-title">Quelle</span>
|
||||||
|
<button id="btnSelectFolder" class="accent-btn" title="Lokalen Ordner öffnen">📂 Open Local</button>
|
||||||
|
<button id="btnLoadGiteaRepos" class="accent-btn" title="Projekte der gewählten Plattform laden">🌐 Load Projekte</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-group tool-group--repo">
|
||||||
|
<span class="tool-group-title">Repository</span>
|
||||||
|
<input id="platform" type="hidden" value="gitea">
|
||||||
|
<div class="platform-switch" role="tablist" aria-label="Plattform auswählen">
|
||||||
|
<button type="button" class="platform-option active" data-platform="gitea" aria-pressed="true">Gitea</button>
|
||||||
|
<button type="button" class="platform-option" data-platform="github" aria-pressed="false">GitHub</button>
|
||||||
|
</div>
|
||||||
|
<button id="btnCommits" class="hidden" title="Commit History anzeigen">📊 Commits</button>
|
||||||
|
<button id="btnReleases" class="hidden" title="Releases anzeigen">📦 Releases</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-gravur-bar"><span id="project-gravur-title" class="project-gravur-title"></span></div>
|
||||||
|
<div class="project-gravur-separator"></div>
|
||||||
|
<div id="contentArea" class="content-area">
|
||||||
|
<aside id="favHistorySidebar" class="fav-history-sidebar" aria-label="Favoriten und Verlauf"></aside>
|
||||||
|
<main id="main">
|
||||||
|
<div id="explorerGrid" class="explorer-grid"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="settingsModal" class="modal hidden">
|
<div id="settingsModal" class="modal hidden">
|
||||||
<div class="modalContent card">
|
<div class="modalContent card settings-modal-content">
|
||||||
<h2>⚙️ Einstellungen</h2>
|
<button id="btnSettingsWatermark" class="settings-watermark-btn" title="Projektinformationen anzeigen" aria-label="Projektinformationen anzeigen">ⓘ</button>
|
||||||
|
<div id="settingsWatermarkCard" class="settings-watermark-card hidden" role="dialog" aria-label="Projektinformationen">
|
||||||
<div class="input-group">
|
<h4>Projektinformationen</h4>
|
||||||
<label>GitHub Token</label>
|
<div class="settings-watermark-row"><span>Ersteller:</span><strong>M_Viper</strong></div>
|
||||||
<input id="githubToken" type="password" placeholder="ghp_...">
|
<div class="settings-watermark-row"><span>Webseite:</span><a href="https://m-viper.de" target="_blank" rel="noopener noreferrer">https://m-viper.de</a></div>
|
||||||
|
<div class="settings-watermark-row"><span>Discord:</span><a id="watermarkDiscord" href="https://discord.com/invite/FdRs4BRd8D" target="_blank" rel="noopener noreferrer">discord.com/invite/FdRs4BRd8D</a></div>
|
||||||
|
<div class="settings-watermark-row"><span>E-Mail:</span><a id="watermarkMail" href="mailto:admin@m-viper.de">admin@m-viper.de</a></div>
|
||||||
|
<div class="settings-watermark-row"><span>Version:</span><strong id="watermarkVersion">-</strong></div>
|
||||||
|
<div class="settings-watermark-row"><span>Copyright:</span><strong id="watermarkCopyright">-</strong></div>
|
||||||
|
<div class="settings-watermark-row"><span>Projekt:</span><strong>Git Manager Explorer Pro</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="settings-header">
|
||||||
<label>Gitea Token</label>
|
<div class="settings-header-inner">
|
||||||
<input id="giteaToken" type="password" placeholder="Token hier einfügen">
|
<div class="settings-avatar-wrap" id="settingsAvatarWrap" title="Profilbild ändern">
|
||||||
</div>
|
<img id="settingsAvatarImg" class="settings-avatar-img" src="" alt="Avatar" style="display:none;">
|
||||||
|
<div id="settingsAvatarPlaceholder" class="settings-avatar-placeholder">👤</div>
|
||||||
<div class="input-group">
|
<div class="settings-avatar-overlay">✏️</div>
|
||||||
<label>Gitea URL</label>
|
<input id="settingsAvatarInput" type="file" accept="image/*" style="display:none;">
|
||||||
<input id="giteaURL" type="text" placeholder="https://gitea.example.com">
|
</div>
|
||||||
</div>
|
<button id="btnUploadAvatar" class="settings-avatar-upload-btn" title="Gespeichertes Bild jetzt auf Gitea hochladen">📤 Auf Gitea aktualisieren</button>
|
||||||
|
<div>
|
||||||
<div class="input-group" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1);">
|
<div class="settings-eyebrow">Konfiguration</div>
|
||||||
<label style="margin-bottom: 12px;">Übersicht</label>
|
<h2>⚙️ Einstellungen</h2>
|
||||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
<p class="settings-subtitle">Alle wichtigen Optionen auf einer Seite: Zugangsdaten, Verbindungscheck, Darstellung und Updates.</p>
|
||||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
|
</div>
|
||||||
<input type="checkbox" id="settingFavorites" checked>
|
|
||||||
<span>⭐ Favoriten-Bereich anzeigen</span>
|
|
||||||
</label>
|
|
||||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
|
|
||||||
<input type="checkbox" id="settingRecent" checked>
|
|
||||||
<span>🕐 Zuletzt geöffnet anzeigen</span>
|
|
||||||
</label>
|
|
||||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
|
|
||||||
<input type="checkbox" id="settingCompact">
|
|
||||||
<span>⊞ Kompakt-Modus (kleinere Karten)</span>
|
|
||||||
</label>
|
|
||||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: normal;">
|
|
||||||
<input type="checkbox" id="settingColoredIcons" checked>
|
|
||||||
<span>🎨 Farbige Datei-Icons</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group" style="margin-top: 30px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1);">
|
<div class="settings-layout">
|
||||||
<label>App Version</label>
|
<div class="settings-column settings-column--left">
|
||||||
<div style="display: flex; gap: 12px; align-items: center;">
|
<section class="settings-panel settings-panel--credentials">
|
||||||
<input id="appVersion" type="text" readonly style="flex: 1; background: rgba(255,255,255,0.05); cursor: not-allowed;">
|
<div class="settings-panel-header">
|
||||||
<button id="btnCheckUpdates" style="
|
<div>
|
||||||
background: linear-gradient(135deg, #00d4ff, #8b5cf6);
|
<h3>Zugangsdaten</h3>
|
||||||
color: #000;
|
<p>API-Zugriffe für GitHub und Gitea konfigurieren.</p>
|
||||||
border: none;
|
</div>
|
||||||
padding: 10px 20px;
|
</div>
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
<div class="settings-credentials-grid">
|
||||||
cursor: pointer;
|
<article class="settings-auth-card settings-auth-card--github">
|
||||||
white-space: nowrap;
|
<div class="settings-auth-card-header">
|
||||||
">🔄 Nach Updates suchen</button>
|
<h4>GitHub</h4>
|
||||||
|
<button id="btnTestGithubConnection" class="secondary" type="button">🔌 Verbindung testen</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-group settings-auth-input">
|
||||||
|
<label for="githubToken">GitHub Token</label>
|
||||||
|
<input id="githubToken" type="password" placeholder="ghp_...">
|
||||||
|
</div>
|
||||||
|
<div class="settings-auth-spacer" aria-hidden="true">GitHub benötigt keine Server-URL</div>
|
||||||
|
<div id="githubTokenHint" class="settings-inline-hint">Hinweis: Der Token wird direkt über api.github.com geprüft.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-auth-card settings-auth-card--gitea">
|
||||||
|
<div class="settings-auth-card-header">
|
||||||
|
<h4>Gitea</h4>
|
||||||
|
<button id="btnTestGiteaConnection" class="secondary" type="button">🔌 Verbindung testen</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-group settings-auth-input">
|
||||||
|
<label for="giteaToken">Gitea Token</label>
|
||||||
|
<input id="giteaToken" type="password" placeholder="Token hier einfügen">
|
||||||
|
</div>
|
||||||
|
<div class="input-group settings-auth-input">
|
||||||
|
<label for="giteaURL">Gitea URL</label>
|
||||||
|
<input id="giteaURL" type="text" placeholder="https://gitea.example.com">
|
||||||
|
</div>
|
||||||
|
<div id="giteaUrlHint" class="settings-inline-hint">Hinweis: IPv6 mit Klammern eingeben, z.B. http://[2001:db8::1]:3000</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel settings-panel--display">
|
||||||
|
<div class="settings-panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>Darstellung</h3>
|
||||||
|
<p>Übersicht und Explorer an deinen Arbeitsstil anpassen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-list">
|
||||||
|
<label class="settings-toggle-row" for="settingFavorites">
|
||||||
|
<span class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-title">⭐ Favoriten-Bereich anzeigen</span>
|
||||||
|
<span class="settings-toggle-desc">Pinnt wichtige Repositories und Ordner sichtbar im Kopfbereich.</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="settingFavorites" checked>
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row" for="settingRecent">
|
||||||
|
<span class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-title">🕐 Zuletzt geöffnet anzeigen</span>
|
||||||
|
<span class="settings-toggle-desc">Zeigt deine letzten Projekte direkt in der Übersicht an.</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="settingRecent" checked>
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row" for="settingCompact">
|
||||||
|
<span class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-title">⊞ Kompakt-Modus</span>
|
||||||
|
<span class="settings-toggle-desc">Verdichtet Karten und Abstände für kleinere Fenster.</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="settingCompact">
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row" for="settingColoredIcons">
|
||||||
|
<span class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-title">🎨 Farbige Datei-Icons</span>
|
||||||
|
<span class="settings-toggle-desc">Setzt stärkere Dateityp-Farben für schnellere Orientierung.</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="settingColoredIcons" checked>
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-column settings-column--right">
|
||||||
|
<section class="settings-panel settings-panel--health">
|
||||||
|
<div class="settings-panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>Verbindungsstatus</h3>
|
||||||
|
<p>Direkt sehen, ob URL, API und Auth sauber antworten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-health-box">
|
||||||
|
<div class="settings-health-row"><span>URL</span><strong id="healthUrl">Unbekannt</strong></div>
|
||||||
|
<div class="settings-health-row"><span>API</span><strong id="healthApi">Unbekannt</strong></div>
|
||||||
|
<div class="settings-health-row"><span>Auth</span><strong id="healthAuth">Unbekannt</strong></div>
|
||||||
|
<div class="settings-health-row"><span>Latenz</span><strong id="healthLatency">-</strong></div>
|
||||||
|
<div class="settings-health-row"><span>Server</span><strong id="healthVersion">-</strong></div>
|
||||||
|
<div class="settings-health-row"><span>Letzter Fehler</span><strong id="healthLastError">-</strong></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel settings-panel--system">
|
||||||
|
<div class="settings-panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>System</h3>
|
||||||
|
<p>Verhalten beim Windows-Start steuern.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toggle-list">
|
||||||
|
<label class="settings-toggle-row" for="settingAutostart">
|
||||||
|
<span class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-title">🚀 Mit Windows starten</span>
|
||||||
|
<span class="settings-toggle-desc">Startet die App automatisch beim Anmelden, minimiert im System-Tray.</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="settingAutostart">
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel settings-panel--app">
|
||||||
|
<div class="settings-panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>App & Updates</h3>
|
||||||
|
<p>Version prüfen und neue Releases direkt anstoßen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-version-card">
|
||||||
|
<div class="input-group input-group--wide settings-version-field">
|
||||||
|
<label for="appVersion">App Version</label>
|
||||||
|
<input id="appVersion" class="settings-readonly-input" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
<button id="btnCheckUpdates" class="settings-update-btn">🔄 Nach Updates suchen</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-buttons">
|
<div class="modal-buttons settings-modal-actions">
|
||||||
<button id="btnSaveSettings">Speichern</button>
|
<button id="btnSaveSettings" class="accent-btn">Speichern</button>
|
||||||
<button id="btnCloseSettings" class="secondary">Abbrechen</button>
|
<button id="btnCloseSettings" class="secondary">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +298,7 @@
|
|||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Repository Name</label>
|
<label>Repository Name</label>
|
||||||
<input id="repoName" type="text" placeholder="mein-projekt">
|
<input id="repoName" type="text" placeholder="mein-projekt">
|
||||||
|
<div id="repoNameValidationHint" class="settings-inline-hint">Name prüfen: Duplikate, ähnliche Namen und ungültige Zeichen werden erkannt.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -147,6 +339,134 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Migration Modal -->
|
||||||
|
<div id="migrationModal" class="modal hidden">
|
||||||
|
<div class="modalContent card migration-modal-content">
|
||||||
|
<h2>📥 Repository migrieren</h2>
|
||||||
|
<p class="settings-subtitle">Ein Repository von GitHub, GitLab oder einer anderen Git-Quelle auf deine Gitea-Instanz kopieren.</p>
|
||||||
|
|
||||||
|
<div class="migration-fields">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="migrateCloneUrl">Quell-URL (Clone-URL)</label>
|
||||||
|
<input id="migrateCloneUrl" type="text" placeholder="https://github.com/benutzer/repo.git">
|
||||||
|
<div class="settings-inline-hint">Beispiel: https://github.com/M_Viper/NexTrade.git</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="migrateRepoName">Neuer Repository-Name auf Gitea</label>
|
||||||
|
<input id="migrateRepoName" type="text" placeholder="NexTrade">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="migrateDescription">Beschreibung (optional)</label>
|
||||||
|
<input id="migrateDescription" type="text" placeholder="">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="migration-row-split">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="migrateAuthUsername">Auth-Benutzername (bei privaten Repos)</label>
|
||||||
|
<input id="migrateAuthUsername" type="text" placeholder="GitHub-Benutzername">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="migrateAuthToken">Auth-Token (bei privaten Repos)</label>
|
||||||
|
<input id="migrateAuthToken" type="password" placeholder="ghp_…">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row" style="margin-top:4px;" for="migratePrivate">
|
||||||
|
<span class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-title">🔒 Privates Repository</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="migratePrivate">
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="migrationStatus" class="migration-status hidden"></div>
|
||||||
|
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="btnStartMigration" class="accent-btn">📥 Migration starten</button>
|
||||||
|
<button id="btnCloseMigration" class="secondary">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="batchActionModal" class="modal hidden">
|
||||||
|
<div class="modalContent card">
|
||||||
|
<h2>🧩 Batch-Aktionen</h2>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Aktion</label>
|
||||||
|
<select id="batchActionType">
|
||||||
|
<option value="refresh">Repos aktualisieren</option>
|
||||||
|
<option value="clone">Repos klonen</option>
|
||||||
|
<option value="create-tag">Tag erstellen</option>
|
||||||
|
<option value="create-release">Release erstellen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Repositories (pro Zeile: owner/repo)</label>
|
||||||
|
<textarea id="batchRepoList" class="batch-textarea" placeholder="M_Viper/ProjektA M_Viper/ProjektB"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="batchCloneGroup" class="input-group hidden">
|
||||||
|
<label>Zielordner für Clone</label>
|
||||||
|
<div class="batch-inline-row">
|
||||||
|
<input id="batchCloneTarget" type="text" readonly placeholder="Bitte Zielordner auswählen">
|
||||||
|
<button id="btnSelectBatchCloneTarget" class="secondary">📁 Wählen</button>
|
||||||
|
</div>
|
||||||
|
<div id="batchCloneValidationHint" class="settings-inline-hint">Kollisionsprüfung aktiv: vorhandene Zielordner und Namenskonflikte werden angezeigt.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="batchTagGroup" class="input-group hidden">
|
||||||
|
<label>Tag</label>
|
||||||
|
<input id="batchTagName" type="text" placeholder="v1.0.0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="batchReleaseNameGroup" class="input-group hidden">
|
||||||
|
<label>Release-Name</label>
|
||||||
|
<input id="batchReleaseName" type="text" placeholder="Release v1.0.0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="batchReleaseBodyGroup" class="input-group hidden">
|
||||||
|
<label>Release-Text</label>
|
||||||
|
<textarea id="batchReleaseBody" class="batch-textarea" placeholder="Changelog..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="btnRunBatchAction" class="accent-btn">Ausführen</button>
|
||||||
|
<button id="btnCloseBatchAction" class="secondary">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="activityLogModal" class="modal hidden">
|
||||||
|
<div class="modalContent card">
|
||||||
|
<h2>📝 Aktivitätsprotokoll</h2>
|
||||||
|
|
||||||
|
<div class="activity-toolbar">
|
||||||
|
<select id="activityFilterLevel">
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warning">Warn</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
<button id="btnRetryQueueRefresh" class="secondary">🔁 Queue jetzt retry</button>
|
||||||
|
<button id="btnClearActivityLog" class="secondary">🧹 Log leeren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="activityQueueInfo" class="activity-queue-info">Retry-Queue: 0</div>
|
||||||
|
<div id="activityLogList" class="activity-log-list"></div>
|
||||||
|
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="btnCloseActivityLog" class="secondary">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="fileEditorModal" class="modal hidden">
|
<div id="fileEditorModal" class="modal hidden">
|
||||||
<div class="file-editor-card">
|
<div class="file-editor-card">
|
||||||
<div class="file-editor-header">
|
<div class="file-editor-header">
|
||||||
@@ -197,7 +517,7 @@
|
|||||||
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 20px;">
|
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 20px;">
|
||||||
<div style="font-size: 3rem; filter: drop-shadow(0 0 10px var(--accent-primary));">🚀</div>
|
<div style="font-size: 3rem; filter: drop-shadow(0 0 10px var(--accent-primary));">🚀</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin: 0; background: var(--accent-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Update verfügbar!</h2>
|
<h2 style="margin: 0; background: var(--accent-gradient); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Update verfügbar!</h2>
|
||||||
<p id="updateVersionInfo" style="color: var(--text-secondary); margin: 5px 0 0 0; font-family: monospace;"></p>
|
<p id="updateVersionInfo" style="color: var(--text-secondary); margin: 5px 0 0 0; font-family: monospace;"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
3995
renderer/renderer.js
3995
renderer/renderer.js
File diff suppressed because it is too large
Load Diff
2162
renderer/style.css
2162
renderer/style.css
File diff suppressed because it is too large
Load Diff
183
src/backup/BackupManager.js
Normal file
183
src/backup/BackupManager.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* BackupManager — Orchestrates backup operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
const archiver = require('archiver')
|
||||||
|
const { createReadStream, createWriteStream } = require('fs')
|
||||||
|
const { mkdir } = require('fs').promises
|
||||||
|
const path = require('path')
|
||||||
|
const { Transform } = require('stream')
|
||||||
|
|
||||||
|
class BackupManager {
|
||||||
|
constructor(provider) {
|
||||||
|
this.provider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a backup from a project folder
|
||||||
|
* @param {string} projectPath - Path to project folder
|
||||||
|
* @param {string} repoName - Repository name (used for filenames)
|
||||||
|
* @returns {Promise<{filename, size, timestamp}>}
|
||||||
|
*/
|
||||||
|
async createBackup(projectPath, repoName) {
|
||||||
|
try {
|
||||||
|
// Create ZIP buffer
|
||||||
|
const buffer = await this._createZip(projectPath)
|
||||||
|
const timestamp = this._getTimestamp()
|
||||||
|
const filename = `${repoName}-backup-${timestamp}.zip`
|
||||||
|
|
||||||
|
// Upload to provider
|
||||||
|
const result = await this.provider.uploadBackup(buffer, filename)
|
||||||
|
|
||||||
|
// Cleanup old backups
|
||||||
|
await this._cleanupOldBackups(repoName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
size: result.size || buffer.length,
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Backup creation failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all backups for a repository
|
||||||
|
* @param {string} repoName - Repository name
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async listBackups(repoName) {
|
||||||
|
try {
|
||||||
|
const backups = await this.provider.listBackups()
|
||||||
|
return backups
|
||||||
|
.filter(b => b.name.startsWith(repoName))
|
||||||
|
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to list backups: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a backup to a target folder
|
||||||
|
* @param {string} repoName - Repository name
|
||||||
|
* @param {string} filename - Backup filename
|
||||||
|
* @param {string} targetPath - Target folder path
|
||||||
|
*/
|
||||||
|
async restoreBackup(repoName, filename, targetPath) {
|
||||||
|
try {
|
||||||
|
// Download backup
|
||||||
|
const buffer = await this.provider.downloadBackup(filename)
|
||||||
|
|
||||||
|
// Extract ZIP
|
||||||
|
await this._extractZip(buffer, targetPath)
|
||||||
|
|
||||||
|
return { ok: true, restored: filename }
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Restore failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup
|
||||||
|
* @param {string} filename - Backup filename
|
||||||
|
*/
|
||||||
|
async deleteBackup(filename) {
|
||||||
|
try {
|
||||||
|
await this.provider.deleteBackup(filename)
|
||||||
|
return { ok: true }
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Delete failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PRIVATE METHODS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ZIP buffer from project folder
|
||||||
|
* Excludes: .git, node_modules, dist, build, .env
|
||||||
|
*/
|
||||||
|
async _createZip(projectPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const output = []
|
||||||
|
const archive = archiver('zip', { zlib: { level: 5 } })
|
||||||
|
|
||||||
|
archive.on('data', chunk => output.push(chunk))
|
||||||
|
archive.on('end', () => resolve(Buffer.concat(output)))
|
||||||
|
archive.on('error', reject)
|
||||||
|
|
||||||
|
// Add files with exclusions
|
||||||
|
archive.glob('**/*', {
|
||||||
|
cwd: projectPath,
|
||||||
|
ignore: [
|
||||||
|
'.git/**',
|
||||||
|
'.git',
|
||||||
|
'node_modules/**',
|
||||||
|
'node_modules',
|
||||||
|
'dist/**',
|
||||||
|
'dist',
|
||||||
|
'build/**',
|
||||||
|
'build',
|
||||||
|
'.env',
|
||||||
|
'.env.local',
|
||||||
|
'.env.*.local',
|
||||||
|
'*.log',
|
||||||
|
'data/backups/**'
|
||||||
|
],
|
||||||
|
dot: true
|
||||||
|
})
|
||||||
|
|
||||||
|
archive.finalize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract ZIP buffer to target folder
|
||||||
|
*/
|
||||||
|
async _extractZip(buffer, targetPath) {
|
||||||
|
const unzipper = require('unzipper')
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { Readable } = require('stream')
|
||||||
|
const stream = Readable.from(buffer)
|
||||||
|
|
||||||
|
stream
|
||||||
|
.pipe(unzipper.Extract({ path: targetPath }))
|
||||||
|
.on('close', resolve)
|
||||||
|
.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ISO timestamp (YYYYMMDD-HHMMSS format)
|
||||||
|
*/
|
||||||
|
_getTimestamp() {
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}${month}${day}-${hours}${minutes}${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old backups (keep only last N versions)
|
||||||
|
*/
|
||||||
|
async _cleanupOldBackups(repoName, maxVersions = 5) {
|
||||||
|
try {
|
||||||
|
const backups = await this.listBackups(repoName)
|
||||||
|
|
||||||
|
for (let i = maxVersions; i < backups.length; i++) {
|
||||||
|
await this.provider.deleteBackup(backups[i].name)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Silently ignore cleanup errors
|
||||||
|
console.warn(`Cleanup warning: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BackupManager
|
||||||
59
src/backup/BackupProvider.js
Normal file
59
src/backup/BackupProvider.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* BackupProvider — Abstract base class for backup providers
|
||||||
|
* All providers must implement these methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
class BackupProvider {
|
||||||
|
/**
|
||||||
|
* Authenticate with the backup service
|
||||||
|
* @param {Object} credentials - Provider-specific credentials
|
||||||
|
*/
|
||||||
|
async authenticate(credentials) {
|
||||||
|
throw new Error('authenticate() not implemented in ' + this.constructor.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a backup file
|
||||||
|
* @param {Buffer} buffer - File content
|
||||||
|
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
|
||||||
|
* @returns {Promise<{size: number}>}
|
||||||
|
*/
|
||||||
|
async uploadBackup(buffer, filename) {
|
||||||
|
throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all backups for a repository
|
||||||
|
* @returns {Promise<Array>} Array of {name, size, date}
|
||||||
|
*/
|
||||||
|
async listBackups() {
|
||||||
|
throw new Error('listBackups() not implemented in ' + this.constructor.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a specific backup
|
||||||
|
* @param {string} filename - Filename to download
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
async downloadBackup(filename) {
|
||||||
|
throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup file
|
||||||
|
* @param {string} filename - Filename to delete
|
||||||
|
*/
|
||||||
|
async deleteBackup(filename) {
|
||||||
|
throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to backup service
|
||||||
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
||||||
|
*/
|
||||||
|
async testConnection() {
|
||||||
|
throw new Error('testConnection() not implemented in ' + this.constructor.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BackupProvider
|
||||||
84
src/backup/LocalProvider.js
Normal file
84
src/backup/LocalProvider.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* LocalProvider — Backup provider for local folders
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs').promises
|
||||||
|
const BackupProvider = require('./BackupProvider')
|
||||||
|
|
||||||
|
class LocalProvider extends BackupProvider {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.basePath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(credentials) {
|
||||||
|
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
|
||||||
|
if (!basePath) {
|
||||||
|
throw new Error('Lokaler Backup-Ordner fehlt')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.basePath = path.resolve(basePath)
|
||||||
|
await fs.mkdir(this.basePath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection() {
|
||||||
|
if (!this.basePath) return { ok: false, error: 'Not authenticated' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(this.basePath)
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return { ok: false, error: 'Pfad ist kein Ordner' }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadBackup(buffer, filename) {
|
||||||
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const target = path.join(this.basePath, filename)
|
||||||
|
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||||
|
await fs.writeFile(target, buffer)
|
||||||
|
return { size: buffer.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBackups() {
|
||||||
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const entries = await fs.readdir(this.basePath, { withFileTypes: true })
|
||||||
|
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
|
||||||
|
|
||||||
|
const backups = []
|
||||||
|
for (const f of files) {
|
||||||
|
const full = path.join(this.basePath, f.name)
|
||||||
|
const stat = await fs.stat(full)
|
||||||
|
backups.push({
|
||||||
|
name: f.name,
|
||||||
|
size: stat.size,
|
||||||
|
date: stat.mtime.toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
|
return backups
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadBackup(filename) {
|
||||||
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const file = path.join(this.basePath, filename)
|
||||||
|
return fs.readFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(filename) {
|
||||||
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const file = path.join(this.basePath, filename)
|
||||||
|
await fs.unlink(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LocalProvider
|
||||||
File diff suppressed because it is too large
Load Diff
293
src/utils/helpers.js
Normal file
293
src/utils/helpers.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Gemeinsame Utility-Funktionen für Git Manager GUI
|
||||||
|
* - Branch Handling
|
||||||
|
* - API Error Handling
|
||||||
|
* - Standardisiertes Logging
|
||||||
|
* - Caching
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const ppath = require('path');
|
||||||
|
|
||||||
|
// ===== LOGGING SYSTEM =====
|
||||||
|
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
|
||||||
|
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
|
||||||
|
let logQueue = [];
|
||||||
|
const MAX_LOG_BUFFER = 100;
|
||||||
|
|
||||||
|
function formatLog(level, context, message, details = null) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
level: levelStr,
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
pid: process.pid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLog(logEntry) {
|
||||||
|
logQueue.push(logEntry);
|
||||||
|
if (logQueue.length > MAX_LOG_BUFFER) {
|
||||||
|
logQueue.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch in Console schreiben
|
||||||
|
const { level, timestamp, context, message, details } = logEntry;
|
||||||
|
const prefix = `[${timestamp}] [${level}] [${context}]`;
|
||||||
|
|
||||||
|
if (level === 'ERROR' && details?.error) {
|
||||||
|
console.error(prefix, message, details.error);
|
||||||
|
} else if (level === 'WARN') {
|
||||||
|
console.warn(prefix, message, details ? JSON.stringify(details) : '');
|
||||||
|
} else if (level !== 'DEBUG' || process.env.DEBUG) {
|
||||||
|
console.log(prefix, message, details ? JSON.stringify(details) : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
|
||||||
|
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
|
||||||
|
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
|
||||||
|
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
|
||||||
|
getRecent: (count = 20) => logQueue.slice(-count),
|
||||||
|
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== BRANCH HANDLING =====
|
||||||
|
const BRANCH_DEFAULTS = {
|
||||||
|
gitea: 'main',
|
||||||
|
github: 'main'
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
|
||||||
|
const value = String(branch || '').trim();
|
||||||
|
|
||||||
|
// HEAD sollte immer zu Standard konvertiert werden
|
||||||
|
if (value.toLowerCase() === 'head') {
|
||||||
|
return BRANCH_DEFAULTS[platform] || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung: nur sichere Git-Referenzen
|
||||||
|
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BRANCH_DEFAULTS[platform] || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeBranch(branch) {
|
||||||
|
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ERROR HANDLING =====
|
||||||
|
const ERROR_CODES = {
|
||||||
|
NETWORK: 'NETWORK_ERROR',
|
||||||
|
AUTH_FAILED: 'AUTH_FAILED',
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
VALIDATION: 'VALIDATION_ERROR',
|
||||||
|
RATE_LIMIT: 'RATE_LIMIT',
|
||||||
|
SERVER_ERROR: 'SERVER_ERROR',
|
||||||
|
UNKNOWN: 'UNKNOWN_ERROR'
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
|
||||||
|
if (!error) {
|
||||||
|
return { code: defaultCode, message: 'Unknown error', statusCode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axios-style error
|
||||||
|
if (error.response) {
|
||||||
|
const status = error.response.status;
|
||||||
|
const data = error.response.data;
|
||||||
|
let code = defaultCode;
|
||||||
|
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
code = ERROR_CODES.AUTH_FAILED;
|
||||||
|
} else if (status === 404) {
|
||||||
|
code = ERROR_CODES.NOT_FOUND;
|
||||||
|
} else if (status === 429) {
|
||||||
|
code = ERROR_CODES.RATE_LIMIT;
|
||||||
|
} else if (status >= 500) {
|
||||||
|
code = ERROR_CODES.SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: data?.message || error.message || `HTTP ${status}`,
|
||||||
|
statusCode: status,
|
||||||
|
rawMessage: data?.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network error
|
||||||
|
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
|
||||||
|
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
|
||||||
|
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: ERROR_CODES.UNKNOWN,
|
||||||
|
message: error.message || String(error),
|
||||||
|
statusCode: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorForUser(error, context = 'Operation') {
|
||||||
|
const parsed = parseApiError(error);
|
||||||
|
const messages = {
|
||||||
|
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
|
||||||
|
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
|
||||||
|
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
|
||||||
|
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
|
||||||
|
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
|
||||||
|
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
userMessage: messages[parsed.code],
|
||||||
|
technicalMessage: parsed.message,
|
||||||
|
code: parsed.code,
|
||||||
|
details: parsed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CACHING SYSTEM =====
|
||||||
|
class Cache {
|
||||||
|
constructor(ttl = 300000) { // 5 min default
|
||||||
|
this.store = new Map();
|
||||||
|
this.ttl = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value, customTtl = null) {
|
||||||
|
const expiry = Date.now() + (customTtl || this.ttl);
|
||||||
|
this.store.set(key, { value, expiry });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
const item = this.store.get(key);
|
||||||
|
if (!item) return null;
|
||||||
|
if (Date.now() > item.expiry) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(keyPattern) {
|
||||||
|
for (const [key] of this.store) {
|
||||||
|
if (key.includes(keyPattern)) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size() {
|
||||||
|
return this.store.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Caches
|
||||||
|
const caches = {
|
||||||
|
repos: new Cache(600000), // 10 min
|
||||||
|
fileTree: new Cache(300000), // 5 min
|
||||||
|
api: new Cache(120000) // 2 min
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== PARALLEL OPERATIONS =====
|
||||||
|
async function runParallel(operations, concurrency = 4, onProgress = null) {
|
||||||
|
const results = new Array(operations.length);
|
||||||
|
let completed = 0;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (index < operations.length) {
|
||||||
|
const i = index++;
|
||||||
|
try {
|
||||||
|
results[i] = { ok: true, result: await operations[i]() };
|
||||||
|
} catch (e) {
|
||||||
|
results[i] = { ok: false, error: e };
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
if (onProgress) {
|
||||||
|
try { onProgress(completed, operations.length); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RETRY LOGIC =====
|
||||||
|
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
if (attempt < maxAttempts - 1) {
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== FILE OPERATIONS =====
|
||||||
|
function ensureDirectory(dirPath) {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadFile(filePath, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return defaultValue;
|
||||||
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeWriteFile(filePath, content) {
|
||||||
|
try {
|
||||||
|
ensureDirectory(ppath.dirname(filePath));
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== EXPORTS =====
|
||||||
|
module.exports = {
|
||||||
|
logger,
|
||||||
|
normalizeBranch,
|
||||||
|
isSafeBranch,
|
||||||
|
parseApiError,
|
||||||
|
formatErrorForUser,
|
||||||
|
ERROR_CODES,
|
||||||
|
Cache,
|
||||||
|
caches,
|
||||||
|
runParallel,
|
||||||
|
retryWithBackoff,
|
||||||
|
ensureDirectory,
|
||||||
|
safeReadFile,
|
||||||
|
safeWriteFile,
|
||||||
|
LOG_LEVELS
|
||||||
|
};
|
||||||
182
updater.js
182
updater.js
@@ -3,9 +3,11 @@ const { app, shell } = require('electron');
|
|||||||
const https = require('https');
|
const https = require('https');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const GITEA_API_URL = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/Git-Manager-Gui/releases';
|
const GITEA_API_URL = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/Git-Manager-Gui/releases';
|
||||||
|
const TRUSTED_UPDATE_HOST = 'git.viper.ipv64.net';
|
||||||
|
|
||||||
class Updater {
|
class Updater {
|
||||||
constructor(mainWindow) {
|
constructor(mainWindow) {
|
||||||
@@ -31,15 +33,28 @@ class Updater {
|
|||||||
console.log(`[Updater] Version-Check: Server(${serverVer}) vs Lokal(${localVer})`);
|
console.log(`[Updater] Version-Check: Server(${serverVer}) vs Lokal(${localVer})`);
|
||||||
|
|
||||||
if (this.compareVersions(serverVer, localVer) > 0) {
|
if (this.compareVersions(serverVer, localVer) > 0) {
|
||||||
|
const asset = this.findAsset(latestRelease.assets);
|
||||||
|
const checksumAsset = this.findChecksumAsset(latestRelease.assets, asset);
|
||||||
|
const expectedSha256 = this.extractChecksumFromReleaseBody(latestRelease.body, asset?.name);
|
||||||
|
|
||||||
console.log("[Updater] Update verfügbar. Sende Daten an Renderer...");
|
console.log("[Updater] Update verfügbar. Sende Daten an Renderer...");
|
||||||
this.mainWindow.webContents.send('update-available', {
|
this.mainWindow.webContents.send('update-available', {
|
||||||
version: serverVer,
|
version: serverVer,
|
||||||
body: latestRelease.body,
|
body: latestRelease.body,
|
||||||
url: latestRelease.html_url,
|
url: latestRelease.html_url,
|
||||||
asset: this.findAsset(latestRelease.assets)
|
asset: asset ? {
|
||||||
|
...asset,
|
||||||
|
checksumUrl: checksumAsset ? checksumAsset.browser_download_url : null,
|
||||||
|
expectedSha256
|
||||||
|
} : null
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("[Updater] Anwendung ist auf dem neuesten Stand.");
|
console.log("[Updater] Anwendung ist auf dem neuesten Stand.");
|
||||||
|
if (!silent) {
|
||||||
|
this.mainWindow.webContents.send('update-not-available', {
|
||||||
|
version: localVer
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Updater] Fehler beim Update-Check:', error);
|
console.error('[Updater] Fehler beim Update-Check:', error);
|
||||||
@@ -77,7 +92,118 @@ class Updater {
|
|||||||
findAsset(assets) {
|
findAsset(assets) {
|
||||||
if (!assets) return null;
|
if (!assets) return null;
|
||||||
const ext = process.platform === 'win32' ? '.exe' : '.AppImage';
|
const ext = process.platform === 'win32' ? '.exe' : '.AppImage';
|
||||||
return assets.find(a => a.name.toLowerCase().endsWith(ext));
|
return assets.find(a => {
|
||||||
|
const name = String(a?.name || '').toLowerCase();
|
||||||
|
// Leerzeichen im Namen erlauben!
|
||||||
|
const validName = /^[a-z0-9._\- ]+$/i.test(name);
|
||||||
|
return validName && name.endsWith(ext);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findChecksumAsset(assets, targetAsset) {
|
||||||
|
if (!Array.isArray(assets) || !targetAsset?.name) return null;
|
||||||
|
const targetLower = String(targetAsset.name).toLowerCase();
|
||||||
|
|
||||||
|
const exactCandidates = [
|
||||||
|
`${targetLower}.sha256`,
|
||||||
|
`${targetLower}.sha256sum`,
|
||||||
|
`${targetLower}.sha512`,
|
||||||
|
`${targetLower}.sha512sum`
|
||||||
|
];
|
||||||
|
|
||||||
|
const exact = assets.find(a => exactCandidates.includes(String(a?.name || '').toLowerCase()));
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
return assets.find(a => {
|
||||||
|
const name = String(a?.name || '').toLowerCase();
|
||||||
|
return name.includes('checksum') || name.includes('checksums') || name.endsWith('.sha256') || name.endsWith('.sha256sum');
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractChecksumFromReleaseBody(body, fileName) {
|
||||||
|
const text = String(body || '');
|
||||||
|
const target = String(fileName || '').trim();
|
||||||
|
if (!text || !target) return null;
|
||||||
|
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const targetLower = target.toLowerCase();
|
||||||
|
for (const line of lines) {
|
||||||
|
const normalized = String(line || '').trim();
|
||||||
|
if (!normalized) continue;
|
||||||
|
const match = normalized.match(/\b([a-fA-F0-9]{64})\b/);
|
||||||
|
if (!match) continue;
|
||||||
|
if (normalized.toLowerCase().includes(targetLower)) {
|
||||||
|
return match[1].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadText(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isTrustedDownloadUrl(url)) {
|
||||||
|
reject(new Error('Unsichere Checksum-URL blockiert.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => {
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
|
return resolve(this.downloadText(res.headers.location || ''));
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`Checksum-Download fehlgeschlagen: HTTP ${res.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk.toString('utf8'));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
extractChecksumFromText(text, fileName) {
|
||||||
|
const lines = String(text || '').split(/\r?\n/);
|
||||||
|
const targetLower = String(fileName || '').toLowerCase();
|
||||||
|
for (const line of lines) {
|
||||||
|
const normalized = String(line || '').trim();
|
||||||
|
if (!normalized) continue;
|
||||||
|
const hashMatch = normalized.match(/\b([a-fA-F0-9]{64})\b/);
|
||||||
|
if (!hashMatch) continue;
|
||||||
|
if (!targetLower || normalized.toLowerCase().includes(targetLower)) {
|
||||||
|
return hashMatch[1].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeFileSha256(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('data', chunk => hash.update(chunk));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex').toLowerCase()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveExpectedSha256(asset) {
|
||||||
|
const expectedFromAsset = String(asset?.expectedSha256 || '').trim().toLowerCase();
|
||||||
|
if (/^[a-f0-9]{64}$/.test(expectedFromAsset)) return expectedFromAsset;
|
||||||
|
|
||||||
|
const checksumUrl = String(asset?.checksumUrl || '').trim();
|
||||||
|
if (!checksumUrl) return null;
|
||||||
|
|
||||||
|
const checksumText = await this.downloadText(checksumUrl);
|
||||||
|
return this.extractChecksumFromText(checksumText, asset?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
isTrustedDownloadUrl(rawUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(rawUrl || ''));
|
||||||
|
return parsed.protocol === 'https:' && parsed.hostname === TRUSTED_UPDATE_HOST;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,16 +215,40 @@ class Updater {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isTrustedDownloadUrl(asset.browser_download_url)) {
|
||||||
|
console.error('[Updater] Unsichere Download-URL blockiert.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tempPath = path.join(app.getPath('temp'), asset.name);
|
const tempPath = path.join(app.getPath('temp'), asset.name);
|
||||||
console.log(`[Updater] Download gestartet: ${asset.name} -> ${tempPath}`);
|
console.log(`[Updater] Download gestartet: ${asset.name} -> ${tempPath}`);
|
||||||
|
|
||||||
|
let expectedSha256 = null;
|
||||||
|
try {
|
||||||
|
expectedSha256 = await this.resolveExpectedSha256(asset);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Updater] Konnte erwartete Checksumme nicht laden:', e?.message || e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedSha256) {
|
||||||
|
console.error('[Updater] Kein SHA-256-Checksum-Wert gefunden. Update wurde aus Sicherheitsgruenden blockiert.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const file = fs.createWriteStream(tempPath);
|
const file = fs.createWriteStream(tempPath);
|
||||||
|
|
||||||
const download = (url) => {
|
const download = (url) => {
|
||||||
|
if (!this.isTrustedDownloadUrl(url)) {
|
||||||
|
console.error('[Updater] Unsicherer Redirect/Download blockiert.');
|
||||||
|
fs.unlink(tempPath, () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => {
|
https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => {
|
||||||
// Handle Redirects
|
// Handle Redirects
|
||||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
return download(res.headers.location);
|
return download(res.headers.location || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
@@ -108,9 +258,22 @@ class Updater {
|
|||||||
|
|
||||||
res.pipe(file);
|
res.pipe(file);
|
||||||
|
|
||||||
file.on('finish', () => {
|
file.on('finish', async () => {
|
||||||
file.close();
|
file.close();
|
||||||
console.log("[Updater] Download abgeschlossen. Initialisiere entkoppelten Installer...");
|
try {
|
||||||
|
const actualSha256 = await this.computeFileSha256(tempPath);
|
||||||
|
if (actualSha256 !== expectedSha256) {
|
||||||
|
console.error('[Updater] Checksum-Validierung fehlgeschlagen. Installation wurde blockiert.');
|
||||||
|
fs.unlink(tempPath, () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (verifyErr) {
|
||||||
|
console.error('[Updater] Checksum-Validierung konnte nicht ausgeführt werden:', verifyErr?.message || verifyErr);
|
||||||
|
fs.unlink(tempPath, () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Updater] Download und Checksum-Validierung abgeschlossen. Initialisiere entkoppelten Installer...");
|
||||||
this.installAndQuit(tempPath);
|
this.installAndQuit(tempPath);
|
||||||
});
|
});
|
||||||
}).on('error', (err) => {
|
}).on('error', (err) => {
|
||||||
@@ -129,15 +292,14 @@ class Updater {
|
|||||||
console.log(`[Updater] Bereite Installation vor: ${filePath}`);
|
console.log(`[Updater] Bereite Installation vor: ${filePath}`);
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
// Wir nutzen spawn mit detached: true, damit der Installer weiterläuft,
|
|
||||||
// wenn der Hauptprozess (Electron) beendet wird.
|
|
||||||
try {
|
try {
|
||||||
const child = spawn('cmd.exe', ['/c', 'start', '""', filePath], {
|
const child = spawn(filePath, [], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore'
|
stdio: 'ignore',
|
||||||
|
shell: false
|
||||||
});
|
});
|
||||||
|
|
||||||
child.unref(); // Trennt die Referenz zum Installer
|
child.unref();
|
||||||
|
|
||||||
console.log("[Updater] Installer-Prozess entkoppelt gestartet. App schließt in 2 Sek...");
|
console.log("[Updater] Installer-Prozess entkoppelt gestartet. App schließt in 2 Sek...");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user