+
{tr('App-Updates', 'App updates')}
-
- {tr('Installierte Version', 'Installed version')}: {installedVersion}
-
-
-
- {tr('Status', 'Status')}: {updaterStatusLabel}
-
+
{tr('Version', 'Version')}: {installedVersion}
+
{tr('Status', 'Status')}: {updaterStatusLabel}
{updaterStatus?.availableVersion && (
-
- {tr('Verfuegbare Version', 'Available version')}: {updaterStatus.availableVersion}
-
+
{tr('Verfügbar', 'Available')}: {updaterStatus.availableVersion}
)}
-
{updaterStatus?.lastCheckedAt && (
-
- {tr('Zuletzt geprueft', 'Last checked')}: {new Date(updaterStatus.lastCheckedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-
+
{tr('Geprüft', 'Checked')}: {new Date(updaterStatus.lastCheckedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
)}
-
{updaterStatus?.state === 'downloading' && (
-
- {tr('Download', 'Download')}: {(updaterStatus.downloadPercent || 0).toFixed(1)}% ({formatBytes(updaterStatus.transferred)} / {formatBytes(updaterStatus.total)})
-
+
{tr('Download', 'Download')}: {(updaterStatus.downloadPercent || 0).toFixed(1)}% ({formatBytes(updaterStatus.transferred)} / {formatBytes(updaterStatus.total)})
)}
-
{updaterStatus?.releaseNotes && (
-
- {tr('Release Notes anzeigen', 'Show release notes')}
-
-
- {updaterStatus.releaseNotes}
-
+ {tr('Release Notes', 'Release notes')}
+ {updaterStatus.releaseNotes}
)}
+ {updaterStatus?.error &&
{updaterStatus.error}
}
+ {updaterMessage &&
{updaterMessage}
}
+ {!updaterSupported &&
{tr('Nur in installierten Builds verfügbar.', 'Only available in installed builds.')}
}
- {updaterStatus?.error && (
-
- {updaterStatus.error}
-
- )}
-
- {updaterMessage && (
-
- {updaterMessage}
-
- )}
-
- {!updaterSupported && (
-
- {tr('Auto-Updates sind nur in der installierten Produktions-App verfuegbar.', 'Auto updates are only available in installed production builds.')}
-
- )}
-
-
-
-
+ {/* ── Job Center ──────────────────────────────────────── */}
+
-
{tr('Job Center', 'Job center')}
+
{tr('Job Center', 'Job center')}
{tr('Leeren', 'Clear')}
{sortedJobs.length === 0 && (
-
{tr('Keine Jobs vorhanden.', 'No jobs available.')}
+
{tr('Keine Jobs vorhanden.', 'No jobs available.')}
)}
{sortedJobs.map((job) => (
-
-
- {job.operation}
- {job.status}
-
- {job.message && (
-
- {job.message}
-
- )}
-
- {new Date(job.timestamp).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
+
+
+ {job.operation}
+ {job.status}
+ {job.message &&
{job.message}
}
+
{new Date(job.timestamp).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
))}
diff --git a/src/components/layout/useAppState.ts b/src/components/layout/useAppState.ts
index c97a8a0..289b625 100644
--- a/src/components/layout/useAppState.ts
+++ b/src/components/layout/useAppState.ts
@@ -130,6 +130,7 @@ export const useAppState = () => {
try {
const next = await window.electronAPI.setSettings(partial);
setSettings(next);
+ setGitActionToast({ msg: tr('Einstellungen gespeichert.', 'Settings saved.'), isError: false });
} catch (e: any) {
setGitActionToast({ msg: e?.message || tr('Einstellungen konnten nicht gespeichert werden.', 'Could not save settings.'), isError: true });
}
@@ -207,7 +208,37 @@ export const useAppState = () => {
if (shouldScanPushSecrets) {
try {
- const scanResult = await window.electronAPI.scanPushSecrets();
+ const SCAN_TIMEOUT_MS = 15000;
+ const timeoutPromise = new Promise
((_, reject) =>
+ setTimeout(() => reject(new Error('__timeout__')), SCAN_TIMEOUT_MS)
+ );
+ let scanResult: Awaited>;
+ try {
+ scanResult = await Promise.race([window.electronAPI.scanPushSecrets(), timeoutPromise]);
+ } catch (timeoutErr: any) {
+ if (timeoutErr?.message === '__timeout__') {
+ setConfirmDialog({
+ variant: 'danger',
+ title: tr('Secret-Scan Timeout', 'Secret scan timed out'),
+ message: tr(
+ 'Der Secret-Scan hat zu lange gedauert (>15s) und wurde abgebrochen. Trotzdem pushen?',
+ 'The secret scan took too long (>15s) and was cancelled. Push anyway?',
+ ),
+ contextItems: [],
+ irreversible: false,
+ consequences: tr(
+ 'Ohne Secret-Scan könnten vertrauliche Daten gepusht werden.',
+ 'Without a secret scan, sensitive data could be pushed.',
+ ),
+ confirmLabel: tr('Trotzdem pushen', 'Push anyway'),
+ onConfirm: async () => {
+ await runGitCommand(args, successMsg, actionLabel, { ...options, skipSecretScan: true });
+ },
+ });
+ return false;
+ }
+ throw timeoutErr;
+ }
if (!scanResult.success) {
setGitActionToast({
msg: scanResult.error || tr('Secret-Scan vor Push fehlgeschlagen.', 'Secret scan before push failed.'),
@@ -809,6 +840,8 @@ export const useAppState = () => {
handlePushTags: repository.handlePushTags,
handleAddRemote: repository.handleAddRemote,
handleRemoveRemote: repository.handleRemoveRemote,
+ handleRenameRemote: repository.handleRenameRemote,
+ handleSetRemoteUrl: repository.handleSetRemoteUrl,
handleSubmoduleInitUpdate: repository.handleSubmoduleInitUpdate,
handleSubmoduleSync: repository.handleSubmoduleSync,
handleOpenSubmodule: repository.handleOpenSubmodule,
diff --git a/src/components/sidebar/RemotePanel.tsx b/src/components/sidebar/RemotePanel.tsx
index 5858ade..70a5ef7 100644
--- a/src/components/sidebar/RemotePanel.tsx
+++ b/src/components/sidebar/RemotePanel.tsx
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
-import { Globe, Plus, RefreshCw, X } from 'lucide-react';
+import { Globe, Plus, RefreshCw, X, Edit2 } from 'lucide-react';
import { GitMergeMode, RemoteInfo, RemoteSyncState } from '../../types/git';
import { DialogFrame } from '../DialogFrame';
import { useI18n } from '../../i18n';
@@ -13,6 +13,8 @@ type RemoteStatus = {
borderColor: string;
};
+type RemoteContextMenu = { x: number; y: number; remote: RemoteInfo } | null;
+
type Props = {
remotes: RemoteInfo[];
remoteSync: RemoteSyncState;
@@ -21,6 +23,8 @@ type Props = {
remoteOnlyBranches: string[];
onAddRemote: () => void;
onRemoveRemote: (name: string) => void;
+ onRenameRemote: (name: string) => void;
+ onSetRemoteUrl: (name: string, currentUrl: string) => void;
onRefreshRemote: () => void;
onSetUpstreamForCurrentBranch: () => void;
onCheckoutRemoteBranch: (remoteBranchName: string) => void;
@@ -39,6 +43,8 @@ export const RemotePanel: React.FC = ({
remoteOnlyBranches,
onAddRemote,
onRemoveRemote,
+ onRenameRemote,
+ onSetRemoteUrl,
onRefreshRemote,
onSetUpstreamForCurrentBranch,
onCheckoutRemoteBranch,
@@ -47,6 +53,7 @@ export const RemotePanel: React.FC = ({
onToggleCollapsed,
}) => {
const [isRemoteBranchesDialogOpen, setIsRemoteBranchesDialogOpen] = useState(false);
+ const [remoteCtxMenu, setRemoteCtxMenu] = useState(null);
const remoteOnlyPreview = remoteOnlyBranches.slice(0, 3);
const isHealthy = (remoteStatus.title === 'Remote ist aktuell' || remoteStatus.title === 'Remote is up to date') && remoteOnlyBranchesCount === 0 && !remoteSync.lastFetchError && remoteSync.hasUpstream;
const statusVariant: 'success' | 'warning' | 'danger' =
@@ -121,10 +128,15 @@ export const RemotePanel: React.FC = ({
{remotes.length > 0 ? (
{remotes.map(remote => (
-
+
{ e.preventDefault(); setRemoteCtxMenu({ x: e.clientX, y: e.clientY, remote }); }}
+ >
{remote.name}
{remote.url}
+ setRemoteCtxMenu({ x: 0, y: 0, remote })} className="icon-btn repo-close-btn" style={{ padding: '2px', opacity: 0 }} title={tr('Remote bearbeiten', 'Edit remote')}>
onRemoveRemote(remote.name)} className="icon-btn repo-close-btn" style={{ padding: '2px', opacity: 0 }} title={tr('Remote entfernen', 'Remove remote')}>
))}
@@ -133,6 +145,28 @@ export const RemotePanel: React.FC
= ({
{tr('Keine Remotes konfiguriert.', 'No remotes configured.')}
)}
+
+ {remoteCtxMenu && (
+
setRemoteCtxMenu(null)}>
+
0 ? { left: remoteCtxMenu.x, top: remoteCtxMenu.y } : { left: '50%', top: '50%', transform: 'translate(-50%,-50%)' }}
+ onClick={e => e.stopPropagation()}
+ >
+
{remoteCtxMenu.remote.name}
+
{ const r = remoteCtxMenu.remote; setRemoteCtxMenu(null); onRenameRemote(r.name); }}>
+ ✎ {tr('Umbenennen', 'Rename')}
+
+
{ const r = remoteCtxMenu.remote; setRemoteCtxMenu(null); onSetRemoteUrl(r.name, r.url); }}>
+ 🔗 {tr('URL ändern', 'Change URL')}
+
+
+
{ const r = remoteCtxMenu.remote; setRemoteCtxMenu(null); onRemoveRemote(r.name); }}>
+ ✕ {tr('Entfernen', 'Remove')}
+
+
+
+ )}
)}
diff --git a/src/components/staging-area/ConflictResolverPanel.tsx b/src/components/staging-area/ConflictResolverPanel.tsx
index 3b79b4e..91cc002 100644
--- a/src/components/staging-area/ConflictResolverPanel.tsx
+++ b/src/components/staging-area/ConflictResolverPanel.tsx
@@ -234,8 +234,26 @@ export const ConflictResolverPanel: React.FC
= ({
{isStructuredConflictViewLocked && (
-
- Konfliktmarker werden aktuell manuell geaendert. Die Vergleichsansicht ist temporaer pausiert, bis die Marker wieder konsistent sind.
+
+
+ ⚠ Unvollständige oder unbalancierte Konfliktmarker erkannt. Die Vergleichsansicht ist pausiert bis alle {'<<<<<<<'} / {'======='} / {'>>>>>>>'} Marker konsistent sind.
+
+
+ { void reloadActiveConflictEditor(); }}
+ title="Datei neu einlesen und Marker-Analyse wiederholen"
+ >
+ ↺ Neu laden
+
+ { void resetConflictEditorDraft(); }}
+ title="Alle manuellen Änderungen verwerfen und Originaldatei wiederherstellen"
+ >
+ ✕ Änderungen verwerfen
+
+
)}
diff --git a/src/components/staging-area/useAiCommit.ts b/src/components/staging-area/useAiCommit.ts
new file mode 100644
index 0000000..c167bc1
--- /dev/null
+++ b/src/components/staging-area/useAiCommit.ts
@@ -0,0 +1,157 @@
+import { useCallback, useEffect, useState } from 'react';
+import type { ToastMessage } from '../../types/git';
+import type { GitStatusWithConflicts } from './types';
+
+type Params = {
+ status: GitStatusWithConflicts | null;
+ setToast: (msg: ToastMessage | null) => void;
+ refresh: () => Promise
;
+ onRepoChanged?: () => void;
+};
+
+export const useAiCommit = ({ status, setToast, refresh, onRepoChanged }: Params) => {
+ const [isAiCommitting, setIsAiCommitting] = useState(false);
+ const [isAiJobRunning, setIsAiJobRunning] = useState(false);
+ const [aiProgressMessage, setAiProgressMessage] = useState(null);
+ const [aiPhase, setAiPhase] = useState('idle');
+ const [aiMode, setAiMode] = useState('normal');
+ const [aiLastCommit, setAiLastCommit] = useState(null);
+ const [aiRemainingFiles, setAiRemainingFiles] = useState(null);
+
+ useEffect(() => {
+ if (!window.electronAPI) return;
+
+ const unsubscribe = window.electronAPI.onJobEvent((event) => {
+ if (event.operation !== 'git:aiAutoCommit') return;
+
+ const details = event.details || {};
+ const phase = typeof details.phase === 'string' ? details.phase : null;
+ const mode = typeof details.mode === 'string' ? details.mode : null;
+ const lastCommit = typeof details.lastCommit === 'string' ? details.lastCommit : null;
+ const remaining = typeof details.remainingFiles === 'number' ? details.remainingFiles : null;
+
+ if (phase) setAiPhase(phase);
+ if (mode) setAiMode(mode);
+ if (lastCommit) setAiLastCommit(lastCommit);
+ if (remaining !== null) setAiRemainingFiles(remaining);
+
+ if (event.status === 'start' || event.status === 'progress') {
+ setIsAiJobRunning(true);
+ setAiProgressMessage(event.message || 'KI arbeitet...');
+ return;
+ }
+
+ if (event.status === 'done') {
+ setIsAiJobRunning(false);
+ setAiProgressMessage(event.message || 'KI Auto-Commit abgeschlossen.');
+ return;
+ }
+
+ if (event.status === 'failed') {
+ setIsAiJobRunning(false);
+ setAiProgressMessage(event.message || 'KI Auto-Commit fehlgeschlagen.');
+ return;
+ }
+
+ if (event.status === 'cancelled') {
+ setIsAiJobRunning(false);
+ setAiProgressMessage(event.message || 'KI Auto-Commit abgebrochen.');
+ }
+ });
+
+ return unsubscribe;
+ }, []);
+
+ const handleAiAutoCommit = useCallback(async () => {
+ if (!window.electronAPI || !status) return;
+
+ if (status.conflicts.length > 0) {
+ setToast({ msg: 'Bitte zuerst alle Konflikte aufloesen.', isError: true });
+ return;
+ }
+
+ if (status.staged.length + status.unstaged.length + status.untracked.length === 0) {
+ setToast({ msg: 'Keine Aenderungen fuer KI Auto-Commit vorhanden.', isError: true });
+ return;
+ }
+ setAiPhase('snapshot');
+ setAiMode('normal');
+ setAiLastCommit(null);
+ setAiRemainingFiles(status.staged.length + status.unstaged.length + status.untracked.length);
+ setIsAiJobRunning(true);
+ setAiProgressMessage('KI startet...');
+ setIsAiCommitting(true);
+ try {
+ const latestSettings = await window.electronAPI.getSettings();
+ const latestModel = latestSettings.aiProvider === 'gemini'
+ ? (latestSettings.geminiModel || '')
+ : (latestSettings.ollamaModel || '');
+
+ if (!latestSettings.aiAutoCommitEnabled) {
+ setToast({ msg: 'KI Auto-Commit ist in den Einstellungen deaktiviert.', isError: true });
+ return;
+ }
+
+ if (!latestModel.trim()) {
+ setToast({ msg: 'Bitte in den Einstellungen zuerst ein KI-Modell auswaehlen.', isError: true });
+ return;
+ }
+
+ const result = await window.electronAPI.runAiAutoCommit();
+ if (!result.success) {
+ setToast({ msg: result.error || 'KI Auto-Commit fehlgeschlagen.', isError: true });
+ return;
+ }
+
+ const commits = result.data.commits || [];
+ const warnings = result.data.warnings || [];
+ const diagnostics = result.data.diagnostics || [];
+
+ if (commits.length === 0) {
+ setToast({ msg: result.data.summary || 'KI hat keine Commits erstellt.', isError: false });
+ } else {
+ const list = commits.map((commit: { hash: string; subject: string }) => `${commit.hash} ${commit.subject}`).join(' | ');
+ const extra = warnings.length > 0 ? ` | Hinweise: ${warnings.length}` : '';
+ setToast({ msg: `KI Commit(s): ${list}${extra}`, isError: false });
+ }
+
+ if (diagnostics.length > 0) {
+ console.info('AI Auto-Commit diagnostics:', diagnostics);
+ }
+ if (onRepoChanged) onRepoChanged();
+ await refresh();
+ } catch (error: unknown) {
+ setToast({ msg: error instanceof Error ? error.message : 'KI Auto-Commit fehlgeschlagen.', isError: true });
+ } finally {
+ setIsAiCommitting(false);
+ setIsAiJobRunning(false);
+ }
+ }, [status, setToast, refresh, onRepoChanged]);
+
+ const handleCancelAiAutoCommit = useCallback(async () => {
+ if (!window.electronAPI) return;
+
+ try {
+ const result = await window.electronAPI.cancelAiAutoCommit();
+ if (result.success && result.canceled) {
+ setAiProgressMessage('Abbruch wird ausgefuehrt...');
+ } else {
+ setAiProgressMessage('Kein laufender KI Auto-Commit zum Abbrechen.');
+ }
+ } catch (error: unknown) {
+ setToast({ msg: error instanceof Error ? error.message : 'KI Auto-Commit konnte nicht abgebrochen werden.', isError: true });
+ }
+ }, [setToast]);
+
+ return {
+ isAiCommitting,
+ isAiJobRunning,
+ aiProgressMessage,
+ aiPhase,
+ aiMode,
+ aiLastCommit,
+ aiRemainingFiles,
+ handleAiAutoCommit,
+ handleCancelAiAutoCommit,
+ };
+};
diff --git a/src/components/staging-area/useCommitForm.ts b/src/components/staging-area/useCommitForm.ts
new file mode 100644
index 0000000..51c0add
--- /dev/null
+++ b/src/components/staging-area/useCommitForm.ts
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useState } from 'react';
+import type { AppSettingsDto } from '../../global';
+import type { ToastMessage } from '../../types/git';
+import type { GitStatusWithConflicts } from './types';
+
+type Params = {
+ repoPath: string | null;
+ status: GitStatusWithConflicts | null;
+ setToast: (msg: ToastMessage | null) => void;
+ refresh: () => Promise;
+ onRepoChanged?: () => void;
+ settings: AppSettingsDto;
+};
+
+export const useCommitForm = ({ repoPath, status, setToast, refresh, onRepoChanged, settings }: Params) => {
+ const [commitMsg, setCommitMsg] = useState('');
+ const [commitDescription, setCommitDescription] = useState('');
+ const [amendCommit, setAmendCommit] = useState(false);
+ const [signoffCommit, setSignoffCommit] = useState(false);
+ const [isCommitting, setIsCommitting] = useState(false);
+
+ useEffect(() => {
+ setSignoffCommit(Boolean(settings.commitSignoffByDefault));
+ }, [settings.commitSignoffByDefault]);
+
+ useEffect(() => {
+ if (settings.commitTemplate && !commitMsg.trim()) {
+ setCommitMsg(settings.commitTemplate);
+ }
+ }, [settings.commitTemplate, commitMsg]);
+
+ useEffect(() => {
+ if (!amendCommit || !repoPath || !window.electronAPI) return;
+ void window.electronAPI.runGitCommand('show', '--format=%B', '-s', 'HEAD').then((r) => {
+ if (r.success && typeof r.data === 'string') {
+ const lines = r.data.trimEnd().split('\n');
+ setCommitMsg(lines[0] || '');
+ setCommitDescription(lines.slice(2).join('\n'));
+ }
+ });
+ }, [amendCommit, repoPath]);
+
+ const handleCommit = useCallback(async () => {
+ if (!commitMsg.trim() || !window.electronAPI || !status) return;
+
+ if (status.conflicts.length > 0) {
+ setToast({ msg: 'Bitte zuerst alle Konflikte aufloesen.', isError: true });
+ return;
+ }
+
+ if (status.staged.length === 0 && !amendCommit) {
+ setToast({ msg: 'Bitte zuerst Dateien stagen.', isError: true });
+ return;
+ }
+
+ setIsCommitting(true);
+ try {
+ const commitArgs: string[] = ['commit'];
+ if (amendCommit) commitArgs.push('--amend');
+ if (signoffCommit) commitArgs.push('--signoff');
+ commitArgs.push('-m', commitMsg.trim());
+ if (commitDescription.trim()) {
+ commitArgs.push('-m', commitDescription.trim());
+ }
+ const r = await window.electronAPI.runGitCommand(commitArgs[0], ...commitArgs.slice(1));
+ if (r.success) {
+ setCommitMsg('');
+ setCommitDescription('');
+ setToast({ msg: 'Commit erfolgreich!', isError: false });
+ if (onRepoChanged) onRepoChanged();
+ await refresh();
+ } else {
+ setToast({ msg: r.error || 'Commit fehlgeschlagen', isError: true });
+ }
+ } catch (e: any) {
+ setToast({ msg: e.message, isError: true });
+ } finally {
+ setIsCommitting(false);
+ }
+ }, [commitMsg, commitDescription, amendCommit, signoffCommit, status, setToast, refresh, onRepoChanged]);
+
+ return {
+ commitMsg, setCommitMsg,
+ commitDescription, setCommitDescription,
+ amendCommit, setAmendCommit,
+ signoffCommit, setSignoffCommit,
+ isCommitting,
+ handleCommit,
+ };
+};
diff --git a/src/components/staging-area/useConflictResolver.ts b/src/components/staging-area/useConflictResolver.ts
new file mode 100644
index 0000000..ee30131
--- /dev/null
+++ b/src/components/staging-area/useConflictResolver.ts
@@ -0,0 +1,409 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { normalizeMergeConflictFileContent } from '../../utils/conflictLineGutter';
+import type { ToastMessage } from '../../types/git';
+import {
+ basename,
+ buildConflictResolution,
+ countConflictMarkerLines,
+ detectLineEnding,
+ parseConflictBlocks,
+ replaceConflictBlock,
+} from './utils';
+import type {
+ ConfirmDialogState,
+ ConflictEditorState,
+ ConflictResolutionChoice,
+ GitStatusWithConflicts,
+} from './types';
+
+type Params = {
+ repoPath: string | null;
+ status: GitStatusWithConflicts | null;
+ setToast: (msg: ToastMessage | null) => void;
+ setConfirmDialog: (d: ConfirmDialogState | null) => void;
+ git: (args: string[], msg: string, notify?: boolean) => Promise;
+ refresh: () => Promise;
+ onRepoChanged?: () => void;
+ initialConflictPath?: string | null;
+ isConflictOnly: boolean;
+ onOpenConflictResolver?: (filePath: string) => void;
+};
+
+export const useConflictResolver = ({
+ repoPath,
+ status,
+ setToast,
+ setConfirmDialog,
+ git,
+ refresh,
+ onRepoChanged,
+ initialConflictPath,
+ isConflictOnly,
+ onOpenConflictResolver,
+}: Params) => {
+ const [conflictEditor, setConflictEditor] = useState(null);
+ const [isConflictEditorLoading, setIsConflictEditorLoading] = useState(false);
+ const [selectedConflictBlockIndex, setSelectedConflictBlockIndex] = useState(0);
+ const [conflictBlockCountsByPath, setConflictBlockCountsByPath] = useState>({});
+ const [isConflictBlockCountPending, setIsConflictBlockCountPending] = useState(false);
+
+ const conflictManualScrollRef = useRef(null);
+ const autoOpenedConflictPathRef = useRef(null);
+ const appliedInitialConflictPathRef = useRef(null);
+ const autoScrollAnchorRef = useRef('');
+
+ const openConflictEditor = useCallback(async (filePath: string, initialBlockIndex = 0) => {
+ if (!window.electronAPI) return;
+ setIsConflictEditorLoading(true);
+ try {
+ const result = await window.electronAPI.readRepoFile(filePath);
+ if (!result.success || typeof result.data !== 'string') {
+ setToast({ msg: result.error || `Datei konnte nicht geladen werden: ${filePath}`, isError: true });
+ return;
+ }
+ const normalized = normalizeMergeConflictFileContent(result.data);
+ const parsedBlocks = parseConflictBlocks(normalized);
+ const requestedIndex = Number.isFinite(initialBlockIndex) ? Math.max(0, Math.floor(initialBlockIndex)) : 0;
+ const boundedIndex = parsedBlocks.length > 0 ? Math.min(requestedIndex, parsedBlocks.length - 1) : 0;
+ setConflictEditor({ filePath, originalContent: normalized, content: normalized, isSaving: false });
+ setSelectedConflictBlockIndex(boundedIndex);
+ } catch (error: any) {
+ setToast({ msg: error?.message || `Datei konnte nicht geladen werden: ${filePath}`, isError: true });
+ } finally {
+ setIsConflictEditorLoading(false);
+ }
+ }, [setToast]);
+
+ const reloadActiveConflictEditor = useCallback(async () => {
+ if (!conflictEditor) return;
+ await openConflictEditor(conflictEditor.filePath);
+ }, [conflictEditor, openConflictEditor]);
+
+ const conflictBlocks = useMemo(() => {
+ if (!conflictEditor) return [];
+ return parseConflictBlocks(conflictEditor.content);
+ }, [conflictEditor]);
+
+ const selectedConflictBlock = useMemo(() => {
+ if (conflictBlocks.length === 0) return null;
+ const safeIndex = Math.min(selectedConflictBlockIndex, conflictBlocks.length - 1);
+ return conflictBlocks[safeIndex] || null;
+ }, [conflictBlocks, selectedConflictBlockIndex]);
+
+ const conflictMarkerStats = useMemo(() => {
+ if (!conflictEditor) return { starts: 0, separators: 0, ends: 0 };
+ return countConflictMarkerLines(conflictEditor.content);
+ }, [conflictEditor]);
+
+ const hasRawConflictMarkers = conflictMarkerStats.starts + conflictMarkerStats.separators + conflictMarkerStats.ends > 0;
+ const hasBalancedConflictMarkers = (
+ conflictMarkerStats.starts === conflictMarkerStats.separators
+ && conflictMarkerStats.starts === conflictMarkerStats.ends
+ );
+ const isStructuredConflictViewLocked = hasRawConflictMarkers && (
+ !hasBalancedConflictMarkers || conflictBlocks.length !== conflictMarkerStats.starts
+ );
+ const isConflictEditorDirty = Boolean(conflictEditor && conflictEditor.content !== conflictEditor.originalContent);
+
+ useEffect(() => {
+ if (!repoPath || !window.electronAPI || !status?.conflicts?.length) {
+ setConflictBlockCountsByPath({});
+ setIsConflictBlockCountPending(false);
+ return;
+ }
+ let cancelled = false;
+ setIsConflictBlockCountPending(true);
+ const paths = [...new Set(status.conflicts.map((c) => c.path))].sort();
+ (async () => {
+ const next: Record = {};
+ try {
+ for (const path of paths) {
+ const r = await window.electronAPI.readRepoFile(path);
+ if (cancelled) return;
+ next[path] = r.success && typeof r.data === 'string'
+ ? parseConflictBlocks(normalizeMergeConflictFileContent(r.data)).length
+ : 0;
+ }
+ if (!cancelled) setConflictBlockCountsByPath(next);
+ } finally {
+ if (!cancelled) setIsConflictBlockCountPending(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [repoPath, status]);
+
+ useLayoutEffect(() => {
+ if (!selectedConflictBlock) return;
+ if (isStructuredConflictViewLocked) return;
+ if (!conflictEditor?.filePath) return;
+
+ const anchor = `${conflictEditor.filePath}::${selectedConflictBlockIndex}`;
+ if (autoScrollAnchorRef.current === anchor) return;
+ autoScrollAnchorRef.current = anchor;
+
+ const el = conflictManualScrollRef.current;
+ if (!el) return;
+ const line = selectedConflictBlock.startLine;
+ const run = () => {
+ const ta = el.querySelector('textarea.conflict-manual-textarea');
+ if (!(ta instanceof HTMLTextAreaElement)) return;
+ const lh = parseFloat(getComputedStyle(ta).lineHeight || '18');
+ const scrollTop = Math.max(0, (line - 1) * lh - 56);
+ el.scrollTop = scrollTop;
+ };
+ requestAnimationFrame(() => { requestAnimationFrame(run); });
+ }, [selectedConflictBlockIndex, conflictEditor?.filePath, selectedConflictBlock, isStructuredConflictViewLocked]);
+
+ useEffect(() => {
+ if (conflictBlocks.length === 0) {
+ if (selectedConflictBlockIndex !== 0) setSelectedConflictBlockIndex(0);
+ return;
+ }
+ if (selectedConflictBlockIndex > conflictBlocks.length - 1) {
+ setSelectedConflictBlockIndex(conflictBlocks.length - 1);
+ }
+ }, [conflictBlocks.length, selectedConflictBlockIndex]);
+
+ useEffect(() => {
+ if (onOpenConflictResolver && !isConflictOnly) return;
+ if (!status || status.conflicts.length === 0) {
+ autoOpenedConflictPathRef.current = null;
+ setConflictEditor(null);
+ setSelectedConflictBlockIndex(0);
+ return;
+ }
+ const activePath = conflictEditor?.filePath || null;
+ const activeStillConflicting = activePath ? status.conflicts.some((entry) => entry.path === activePath) : false;
+ if (activeStillConflicting) {
+ autoOpenedConflictPathRef.current = activePath;
+ return;
+ }
+ const nextPath = status.conflicts[0]?.path;
+ if (!nextPath) return;
+ if (!activePath && autoOpenedConflictPathRef.current === nextPath) return;
+ autoOpenedConflictPathRef.current = nextPath;
+ void openConflictEditor(nextPath);
+ }, [status, conflictEditor, openConflictEditor, onOpenConflictResolver, isConflictOnly]);
+
+ useEffect(() => {
+ if (!isConflictOnly) {
+ appliedInitialConflictPathRef.current = null;
+ return;
+ }
+ if (!initialConflictPath) return;
+ if (!status || status.conflicts.length === 0) return;
+ if (appliedInitialConflictPathRef.current === initialConflictPath) return;
+ if (!status.conflicts.some((entry) => entry.path === initialConflictPath)) return;
+ appliedInitialConflictPathRef.current = initialConflictPath;
+ if (conflictEditor?.filePath === initialConflictPath) return;
+ void openConflictEditor(initialConflictPath);
+ }, [isConflictOnly, initialConflictPath, status, conflictEditor?.filePath, openConflictEditor]);
+
+ const applyConflictChoiceToSelected = useCallback((choice: ConflictResolutionChoice) => {
+ if (!conflictEditor) return;
+ const blocks = parseConflictBlocks(conflictEditor.content);
+ if (blocks.length === 0) return;
+ const blockIndex = Math.min(selectedConflictBlockIndex, blocks.length - 1);
+ const block = blocks[blockIndex];
+ if (!block) return;
+ const nextContent = replaceConflictBlock(conflictEditor.content, block, buildConflictResolution(block, choice, detectLineEnding(conflictEditor.content)));
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== conflictEditor.filePath) return prev;
+ return { ...prev, content: nextContent };
+ });
+ const selectedLabel = choice === 'ours' ? 'Aktueller Stand' : choice === 'theirs' ? 'Eingehender Stand' : 'Beide Seiten';
+ setToast({ msg: `${selectedLabel} fuer Block ${blockIndex + 1} uebernommen.`, isError: false });
+ }, [conflictEditor, selectedConflictBlockIndex, setToast]);
+
+ const applyConflictChoiceToAll = useCallback((choice: ConflictResolutionChoice) => {
+ if (!conflictEditor) return;
+ const blocks = parseConflictBlocks(conflictEditor.content);
+ if (blocks.length === 0) return;
+ let nextContent = conflictEditor.content;
+ const lineEnding = detectLineEnding(conflictEditor.content);
+ for (let i = blocks.length - 1; i >= 0; i -= 1) {
+ nextContent = replaceConflictBlock(nextContent, blocks[i], buildConflictResolution(blocks[i], choice, lineEnding));
+ }
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== conflictEditor.filePath) return prev;
+ return { ...prev, content: nextContent };
+ });
+ setSelectedConflictBlockIndex(0);
+ const selectedLabel = choice === 'ours' ? 'Aktueller Stand' : choice === 'theirs' ? 'Eingehender Stand' : 'Beide Seiten';
+ setToast({ msg: `${selectedLabel} fuer alle Konfliktbloecke uebernommen.`, isError: false });
+ }, [conflictEditor, setToast]);
+
+ const markConflictResolved = useCallback((filePath: string) => git(['conflictMarkResolved', filePath], `${basename(filePath)} als geloest markiert`), [git]);
+
+ const markConflictResolvedAndSync = useCallback(async (filePath: string) => {
+ await markConflictResolved(filePath);
+ if (conflictEditor?.filePath === filePath) {
+ await openConflictEditor(filePath);
+ }
+ }, [conflictEditor, openConflictEditor, markConflictResolved]);
+
+ const resetConflictEditorDraft = useCallback(() => {
+ if (!conflictEditor) return;
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== conflictEditor.filePath) return prev;
+ return { ...prev, content: prev.originalContent };
+ });
+ setToast({ msg: 'Lokale Editor-Aenderungen verworfen.', isError: false });
+ }, [conflictEditor, setToast]);
+
+ const saveConflictEditor = useCallback(async (markResolvedAfterSave: boolean) => {
+ if (!window.electronAPI || !conflictEditor) return;
+ const pendingBlocks = parseConflictBlocks(conflictEditor.content);
+ if (markResolvedAfterSave && pendingBlocks.length > 0) {
+ setToast({ msg: 'Vor "Speichern + als geloest markieren" muessen alle Konfliktmarker entfernt sein.', isError: true });
+ return;
+ }
+ const targetPath = conflictEditor.filePath;
+ const targetContent = conflictEditor.content;
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== targetPath) return prev;
+ return { ...prev, isSaving: true };
+ });
+ try {
+ const writeResult = await window.electronAPI.writeRepoFile(targetPath, targetContent);
+ if (!writeResult.success) throw new Error(writeResult.error || 'Datei konnte nicht gespeichert werden.');
+ if (markResolvedAfterSave) {
+ const stageResult = await window.electronAPI.runGitCommand('conflictMarkResolved', targetPath);
+ if (!stageResult.success) throw new Error(stageResult.error || 'Datei konnte nicht als geloest markiert werden.');
+ }
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== targetPath) return prev;
+ return { ...prev, content: targetContent, originalContent: targetContent, isSaving: false };
+ });
+ setToast({ msg: markResolvedAfterSave ? `${basename(targetPath)} gespeichert + geloest` : `${basename(targetPath)} gespeichert`, isError: false });
+ if (onRepoChanged) onRepoChanged();
+ await refresh();
+ } catch (error: any) {
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== targetPath) return prev;
+ return { ...prev, isSaving: false };
+ });
+ setToast({ msg: error?.message || 'Konfliktdatei konnte nicht gespeichert werden.', isError: true });
+ }
+ }, [conflictEditor, onRepoChanged, refresh, setToast]);
+
+ const mergeContinue = useCallback(() => git(['mergeContinue'], 'Merge fortgesetzt', true), [git]);
+ const mergeAbort = useCallback(() => {
+ setConfirmDialog({
+ variant: 'danger',
+ title: 'Merge abbrechen?',
+ message: 'Der laufende Merge wird verworfen und auf den Zustand vor dem Merge zurueckgesetzt.',
+ contextItems: [{ label: 'Aktion', value: 'git merge --abort' }],
+ irreversible: true,
+ consequences: 'Alle noch nicht gesicherten Merge-Konfliktaufloesungen gehen verloren.',
+ confirmLabel: 'Merge abbrechen',
+ onConfirm: () => git(['mergeAbort'], 'Merge abgebrochen', true),
+ });
+ }, [setConfirmDialog, git]);
+
+ const rebaseContinue = useCallback(() => git(['rebaseContinue'], 'Rebase fortgesetzt', true), [git]);
+ const rebaseAbort = useCallback(() => {
+ setConfirmDialog({
+ variant: 'danger',
+ title: 'Rebase abbrechen?',
+ message: 'Der laufende Rebase wird verworfen und der vorherige Branch-Zustand wiederhergestellt.',
+ contextItems: [{ label: 'Aktion', value: 'git rebase --abort' }],
+ irreversible: true,
+ consequences: 'Alle noch nicht gesicherten Rebase-Aufloesungen gehen verloren.',
+ confirmLabel: 'Rebase abbrechen',
+ onConfirm: () => git(['rebaseAbort'], 'Rebase abgebrochen', true),
+ });
+ }, [setConfirmDialog, git]);
+
+ const onConflictEditorContentChange = useCallback((filePath: string, nextContent: string) => {
+ setConflictEditor((prev) => {
+ if (!prev || prev.filePath !== filePath) return prev;
+ return { ...prev, content: nextContent };
+ });
+ }, []);
+
+ // ── Derived navigation values (need status + editor state) ──────────────
+ const conflictPaths = useMemo(
+ () => status ? [...new Set(status.conflicts.map((e) => e.path))].sort((a, b) => a.localeCompare(b)) : [],
+ [status],
+ );
+ const safeSelectedConflictBlockIndex = conflictBlocks.length > 0
+ ? Math.min(selectedConflictBlockIndex, conflictBlocks.length - 1)
+ : 0;
+ const activeConflictFileIndex = conflictEditor ? conflictPaths.indexOf(conflictEditor.filePath) : -1;
+ const canUseStructuredConflictNavigation = Boolean(conflictEditor) && !isStructuredConflictViewLocked && conflictBlocks.length > 0;
+ const hasPreviousConflictTarget = canUseStructuredConflictNavigation && (
+ safeSelectedConflictBlockIndex > 0 || activeConflictFileIndex > 0
+ );
+ const hasNextConflictTarget = canUseStructuredConflictNavigation && (
+ safeSelectedConflictBlockIndex < conflictBlocks.length - 1
+ || (activeConflictFileIndex >= 0 && activeConflictFileIndex < conflictPaths.length - 1)
+ );
+
+ const navigateToPreviousConflict = useCallback(async () => {
+ if (!canUseStructuredConflictNavigation || !conflictEditor) return;
+ if (safeSelectedConflictBlockIndex > 0) {
+ setSelectedConflictBlockIndex((prev) => Math.max(prev - 1, 0));
+ return;
+ }
+ if (activeConflictFileIndex <= 0) return;
+ const previousPath = conflictPaths[activeConflictFileIndex - 1];
+ if (!previousPath) return;
+ await openConflictEditor(previousPath, Number.MAX_SAFE_INTEGER);
+ }, [canUseStructuredConflictNavigation, conflictEditor, safeSelectedConflictBlockIndex, activeConflictFileIndex, conflictPaths, openConflictEditor]);
+
+ const navigateToNextConflict = useCallback(async () => {
+ if (!canUseStructuredConflictNavigation || !conflictEditor) return;
+ if (safeSelectedConflictBlockIndex < conflictBlocks.length - 1) {
+ setSelectedConflictBlockIndex((prev) => prev + 1);
+ return;
+ }
+ if (activeConflictFileIndex < 0 || activeConflictFileIndex >= conflictPaths.length - 1) return;
+ const nextPath = conflictPaths[activeConflictFileIndex + 1];
+ if (!nextPath) return;
+ await openConflictEditor(nextPath, 0);
+ }, [canUseStructuredConflictNavigation, conflictEditor, safeSelectedConflictBlockIndex, conflictBlocks.length, activeConflictFileIndex, conflictPaths, openConflictEditor]);
+
+ const blockCountForPath = useCallback((path: string) => {
+ if (conflictEditor?.filePath === path) return conflictBlocks.length;
+ return conflictBlockCountsByPath[path] ?? 0;
+ }, [conflictEditor, conflictBlocks.length, conflictBlockCountsByPath]);
+
+ return {
+ conflictEditor,
+ setConflictEditor,
+ isConflictEditorLoading,
+ selectedConflictBlockIndex,
+ setSelectedConflictBlockIndex,
+ conflictBlockCountsByPath,
+ isConflictBlockCountPending,
+ conflictManualScrollRef,
+ conflictBlocks,
+ selectedConflictBlock,
+ conflictMarkerStats,
+ isStructuredConflictViewLocked,
+ isConflictEditorDirty,
+ openConflictEditor,
+ reloadActiveConflictEditor,
+ applyConflictChoiceToSelected,
+ applyConflictChoiceToAll,
+ markConflictResolvedAndSync,
+ resetConflictEditorDraft,
+ saveConflictEditor,
+ mergeContinue,
+ mergeAbort,
+ rebaseContinue,
+ rebaseAbort,
+ onConflictEditorContentChange,
+ // Navigation
+ conflictPaths,
+ safeSelectedConflictBlockIndex,
+ activeConflictFileIndex,
+ canUseStructuredConflictNavigation,
+ hasPreviousConflictTarget,
+ hasNextConflictTarget,
+ navigateToPreviousConflict,
+ navigateToNextConflict,
+ blockCountForPath,
+ };
+};
diff --git a/src/components/staging-area/useFileOperations.ts b/src/components/staging-area/useFileOperations.ts
new file mode 100644
index 0000000..803a76b
--- /dev/null
+++ b/src/components/staging-area/useFileOperations.ts
@@ -0,0 +1,259 @@
+import { useCallback, useEffect, useState } from 'react';
+import { FileEntry, parseGitStatusDetailed } from '../../utils/gitParsing';
+import type { DiffRequest } from '../../types/diff';
+import type { ToastMessage } from '../../types/git';
+import {
+ EMPTY_DIFF_STATS,
+ basename,
+ parseConflictEntries,
+ parseNumstatStats,
+} from './utils';
+import type {
+ ConfirmDialogState,
+ DiffStats,
+ FileSection,
+ GitStatusWithConflicts,
+ InputDialogState,
+ StagingContextMenuState,
+} from './types';
+
+type Params = {
+ repoPath: string | null;
+ setToast: (msg: ToastMessage | null) => void;
+ setConfirmDialog: (d: ConfirmDialogState | null) => void;
+ setInputDialog: (d: InputDialogState | null) => void;
+ onRepoChanged?: () => void;
+ onOpenDiff?: (request: DiffRequest) => void;
+};
+
+export const useFileOperations = ({
+ repoPath,
+ setToast,
+ setConfirmDialog,
+ setInputDialog,
+ onRepoChanged,
+ onOpenDiff,
+}: Params) => {
+ const [status, setStatus] = useState(null);
+ const [stagedStats, setStagedStats] = useState(EMPTY_DIFF_STATS);
+ const [unstagedStats, setUnstagedStats] = useState(EMPTY_DIFF_STATS);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeFilter, setActiveFilter] = useState<'all' | 'staged' | 'unstaged' | 'untracked' | 'conflicts'>('all');
+ const [contextMenu, setContextMenu] = useState(null);
+
+ const refresh = useCallback(async () => {
+ if (!repoPath || !window.electronAPI) return;
+ try {
+ const [statusResult, stagedResult, unstagedResult] = await Promise.all([
+ window.electronAPI.runGitCommand('statusPorcelain'),
+ window.electronAPI.runGitCommand('diff', '--numstat', '--cached'),
+ window.electronAPI.runGitCommand('diff', '--numstat'),
+ ]);
+
+ if (statusResult.success) {
+ const rawStatus = statusResult.data || '';
+ const parsed = parseGitStatusDetailed(rawStatus);
+ const conflicts = parseConflictEntries(rawStatus);
+ const conflictPathSet = new Set(conflicts.map((c) => c.path));
+ setStatus({
+ ...parsed,
+ conflicts,
+ staged: parsed.staged.filter((f) => !conflictPathSet.has(f.path)),
+ unstaged: parsed.unstaged.filter((f) => !conflictPathSet.has(f.path)),
+ });
+ }
+
+ setStagedStats(stagedResult.success ? parseNumstatStats(stagedResult.data || '') : EMPTY_DIFF_STATS);
+ setUnstagedStats(unstagedResult.success ? parseNumstatStats(unstagedResult.data || '') : EMPTY_DIFF_STATS);
+ } catch (e) {
+ console.error(e);
+ }
+ }, [repoPath]);
+
+ useEffect(() => {
+ if (!repoPath) {
+ setStatus(null);
+ setStagedStats(EMPTY_DIFF_STATS);
+ setUnstagedStats(EMPTY_DIFF_STATS);
+ return;
+ }
+ refresh();
+ const iv = setInterval(refresh, 3000);
+ window.addEventListener('focus', refresh);
+ return () => {
+ clearInterval(iv);
+ window.removeEventListener('focus', refresh);
+ };
+ }, [repoPath, refresh]);
+
+ useEffect(() => {
+ if (!contextMenu) return;
+ const close = () => setContextMenu(null);
+ const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setContextMenu(null); };
+ window.addEventListener('click', close);
+ window.addEventListener('contextmenu', close);
+ window.addEventListener('keydown', onKeyDown);
+ return () => {
+ window.removeEventListener('click', close);
+ window.removeEventListener('contextmenu', close);
+ window.removeEventListener('keydown', onKeyDown);
+ };
+ }, [contextMenu]);
+
+ const git = useCallback(async (args: string[], msg: string, notify = false) => {
+ if (!window.electronAPI) return;
+ try {
+ const r = await window.electronAPI.runGitCommand(args[0], ...args.slice(1));
+ if (r.success) {
+ setToast({ msg, isError: false });
+ if (notify && onRepoChanged) onRepoChanged();
+ await refresh();
+ } else {
+ setToast({ msg: r.error || 'Fehler', isError: true });
+ }
+ } catch (e: any) {
+ setToast({ msg: e.message, isError: true });
+ }
+ }, [setToast, onRepoChanged, refresh]);
+
+ const openFileContextMenu = useCallback((event: React.MouseEvent, entry: FileEntry, section: FileSection) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setContextMenu({ x: event.clientX, y: event.clientY, entry, section });
+ }, []);
+
+ const addIgnoreRule = useCallback(async (entry: FileEntry, section: FileSection, pattern: string) => {
+ if (!window.electronAPI) return;
+ const normalizedPattern = pattern.trim();
+ if (!normalizedPattern) return;
+ try {
+ const result = await window.electronAPI.addIgnoreRule(normalizedPattern);
+ if (!result.success) {
+ setToast({ msg: result.error || 'Konnte .gitignore nicht aktualisieren.', isError: true });
+ return;
+ }
+ if (section === 'staged' && entry.x === 'A') {
+ await window.electronAPI.runGitCommand('reset', 'HEAD', '--', entry.path);
+ }
+ setToast({ msg: result.added ? `Ignore-Regel hinzugefuegt: ${normalizedPattern}` : `Regel existiert bereits: ${normalizedPattern}`, isError: false });
+ if (onRepoChanged) onRepoChanged();
+ await refresh();
+ } catch (e: any) {
+ setToast({ msg: e.message || 'Konnte .gitignore nicht aktualisieren.', isError: true });
+ }
+ }, [setToast, onRepoChanged, refresh]);
+
+ const stageFile = useCallback((f: string) => git(['add', '--', f], `${basename(f)} gestaged`), [git]);
+ const unstageFile = useCallback((f: string) => git(['reset', 'HEAD', '--', f], `${basename(f)} unstaged`), [git]);
+ const stageAll = useCallback(() => git(['add', '.'], 'Alle Dateien gestaged'), [git]);
+ const unstageAll = useCallback(() => git(['reset', 'HEAD'], 'Alle Dateien unstaged'), [git]);
+
+ const stageAllUntracked = useCallback(async () => {
+ if (!window.electronAPI || !status || status.untracked.length === 0) return;
+ try {
+ for (const entry of status.untracked) {
+ const r = await window.electronAPI.runGitCommand('add', '--', entry.path);
+ if (!r.success) throw new Error(r.error || `Fehler beim Stagen von ${entry.path}`);
+ }
+ const count = status.untracked.length;
+ setToast({ msg: `${count} untracked Datei${count !== 1 ? 'en' : ''} gestaged`, isError: false });
+ await refresh();
+ } catch (e: any) {
+ setToast({ msg: e.message, isError: true });
+ }
+ }, [status, setToast, refresh]);
+
+ const discardFile = useCallback((f: string) => {
+ setConfirmDialog({
+ variant: 'danger',
+ title: 'Datei-Aenderungen verwerfen?',
+ message: 'Alle nicht gespeicherten Aenderungen dieser Datei werden verworfen.',
+ contextItems: [{ label: 'Datei', value: f }, { label: 'Bereich', value: 'Unstaged Working Tree' }],
+ irreversible: true,
+ consequences: 'Die verworfenen Zeilen koennen nicht aus Git wiederhergestellt werden.',
+ confirmLabel: 'Aenderungen verwerfen',
+ onConfirm: () => git(['checkout', '--', f], `${basename(f)} verworfen`, true),
+ });
+ }, [setConfirmDialog, git]);
+
+ const discardAll = useCallback(() => {
+ setConfirmDialog({
+ variant: 'danger',
+ title: 'Alle unstaged Aenderungen verwerfen?',
+ message: 'Alle lokalen unstaged Aenderungen werden auf den letzten Commit zurueckgesetzt.',
+ contextItems: [{ label: 'Umfang', value: 'Gesamtes Repository' }, { label: 'Betrifft', value: 'Nur unstaged Dateien' }],
+ irreversible: true,
+ consequences: 'Nicht gespeicherte Aenderungen gehen unwiderruflich verloren.',
+ confirmLabel: 'Alles verwerfen',
+ onConfirm: () => git(['checkout', '--', '.'], 'Alle Aenderungen verworfen', true),
+ });
+ }, [setConfirmDialog, git]);
+
+ const deleteUntracked = useCallback((f: string) => {
+ setConfirmDialog({
+ variant: 'danger',
+ title: 'Untracked Datei loeschen?',
+ message: 'Die Datei ist nicht versioniert und wird direkt vom Dateisystem entfernt.',
+ contextItems: [{ label: 'Datei', value: f }, { label: 'Git-Status', value: 'Untracked' }],
+ irreversible: true,
+ consequences: 'Die Datei ist danach ohne Backup nicht wiederherstellbar.',
+ confirmLabel: 'Datei loeschen',
+ onConfirm: () => git(['clean', '-f', '--', f], `${basename(f)} geloescht`, true),
+ });
+ }, [setConfirmDialog, git]);
+
+ const stashChanges = useCallback(() => {
+ setInputDialog({
+ title: 'Aenderungen stashen',
+ message: 'Optional eine Nachricht fuer den neuen Stash hinterlegen.',
+ fields: [{ id: 'message', label: 'Stash-Nachricht (optional)', placeholder: 'z.B. WIP: Feature XYZ' }],
+ contextItems: [{ label: 'Repository', value: repoPath ? basename(repoPath) : '(unbekannt)' }],
+ irreversible: false,
+ consequences: 'Aenderungen werden temporaer aus dem Working Tree entfernt und im Stash gespeichert.',
+ confirmLabel: 'Stash erstellen',
+ onSubmit: async (values) => {
+ const msg = (values.message || '').trim();
+ const args = msg ? ['stash', 'push', '-m', msg] : ['stash'];
+ await git(args, 'Aenderungen gestasht', true);
+ },
+ });
+ }, [setInputDialog, repoPath, git]);
+
+ const stashPop = useCallback(() => git(['stash', 'pop'], 'Stash angewendet', true), [git]);
+
+ const showDiff = useCallback((filePath: string, staged: boolean) => {
+ const request: DiffRequest = {
+ source: staged ? 'staged' : 'unstaged',
+ path: filePath,
+ title: staged ? 'Staged Diff' : 'Unstaged Diff',
+ };
+ onOpenDiff?.(request);
+ }, [onOpenDiff]);
+
+ return {
+ status,
+ refresh,
+ git,
+ stagedStats,
+ unstagedStats,
+ searchQuery,
+ setSearchQuery,
+ activeFilter,
+ setActiveFilter,
+ contextMenu,
+ setContextMenu,
+ openFileContextMenu,
+ addIgnoreRule,
+ stageFile,
+ unstageFile,
+ stageAll,
+ stageAllUntracked,
+ unstageAll,
+ discardFile,
+ discardAll,
+ deleteUntracked,
+ stashChanges,
+ stashPop,
+ showDiff,
+ };
+};
diff --git a/src/components/topbar/TopbarActions.tsx b/src/components/topbar/TopbarActions.tsx
index ec11b2b..754a94b 100644
--- a/src/components/topbar/TopbarActions.tsx
+++ b/src/components/topbar/TopbarActions.tsx
@@ -15,9 +15,11 @@ type Props = {
onPull: () => void;
onPullRebase: () => void;
onPullFfOnly: () => void;
+ onPullNoFf: () => void;
onPush: () => void;
onPushForceWithLease: () => void;
onPushTags: () => void;
+ onPushSetUpstream: () => void;
onMergeBranch: (branchName: string, mode: GitMergeMode) => void;
onStageCommit: () => void;
onOpenReleaseCreator: () => void;
@@ -40,9 +42,11 @@ export const TopbarActions: React.FC = ({
onPull,
onPullRebase,
onPullFfOnly,
+ onPullNoFf,
onPush,
onPushForceWithLease,
onPushTags,
+ onPushSetUpstream,
onMergeBranch,
onStageCommit,
onOpenReleaseCreator,
@@ -61,12 +65,17 @@ export const TopbarActions: React.FC = ({
hint: tr('Lokal neu auf Remote-Stand aufsetzen', 'Rebase local commits on top of remote'),
action: onPullRebase,
},
+ {
+ label: tr('Kein Fast-Forward (--no-ff)', 'No fast-forward (--no-ff)'),
+ hint: tr('Erzwingt einen Merge-Commit', 'Always creates a merge commit'),
+ action: onPullNoFf,
+ },
{
label: tr('Nur Fast-Forward', 'Fast-forward only'),
hint: tr('Abbruch bei Merge-Commit-Bedarf', 'Abort if a merge commit would be required'),
action: onPullFfOnly,
},
- ]), [onPullFfOnly, onPullRebase, tr]);
+ ]), [onPullFfOnly, onPullNoFf, onPullRebase, tr]);
const mergeCandidates = useMemo(() => {
const q = mergeQuery.trim().toLowerCase();
@@ -92,6 +101,11 @@ export const TopbarActions: React.FC = ({
]), [tr]);
const pushOptions = useMemo(() => ([
+ {
+ label: tr('Upstream setzen (-u)', 'Set upstream (-u)'),
+ hint: tr('Ersten Push + Tracking-Branch setzen', 'First push & set remote tracking branch'),
+ action: onPushSetUpstream,
+ },
{
label: tr('Force with lease', 'Force with lease'),
hint: tr('Sicheres Force-Push mit Lease-Pruefung', 'Safer force push with lease check'),
@@ -102,7 +116,7 @@ export const TopbarActions: React.FC = ({
hint: tr('Push inklusive lokaler Tags', 'Push including local tags'),
action: onPushTags,
},
- ]), [onPushForceWithLease, onPushTags, tr]);
+ ]), [onPushForceWithLease, onPushSetUpstream, onPushTags, tr]);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
diff --git a/src/contexts/AppStateContext.tsx b/src/contexts/AppStateContext.tsx
new file mode 100644
index 0000000..52d4f01
--- /dev/null
+++ b/src/contexts/AppStateContext.tsx
@@ -0,0 +1,61 @@
+import { createContext, useContext } from 'react';
+import { AppSidebarProps } from '../components/layout/sidebar/AppSidebar.types';
+import { GitHubReleaseContextDto } from '../global';
+import { GitMergeMode } from '../types/git';
+
+export type AppContextValue = AppSidebarProps & {
+ // Navigation & UI (local App.tsx state)
+ onClearGithubAuthHelpMethod: () => void;
+ onResetLayout: () => void;
+
+ // Git action status
+ activeGitActionLabel: string | null;
+
+ // Commit graph
+ selectedCommit: string | null;
+ setSelectedCommit: (hash: string | null) => void;
+ refreshTrigger: number;
+ triggerRefresh: () => void;
+ showSecondaryHistory: boolean;
+
+ // Branch merge (used by MainView/TopbarActions and CommitGraph)
+ onMergeBranch: (branchName: string, mode: GitMergeMode) => void;
+
+ // Remote sync actions
+ onFetch: () => void;
+ onPull: () => void;
+ onPullRebase: () => void;
+ onPullFfOnly: () => void;
+ onPullNoFf: () => void;
+ onPush: () => void;
+ onPushForceWithLease: () => void;
+ onPushTags: () => void;
+ onPushSetUpstream: () => void;
+ onOpenRepoWorkspace: () => void;
+
+ // Release creator
+ showReleaseCreator: boolean;
+ onOpenReleaseCreator: () => void;
+ onCloseReleaseCreator: () => void;
+ releaseContextLoading: boolean;
+ releaseContextError: string | null;
+ releaseContext: GitHubReleaseContextDto | null;
+ onRefreshReleaseContext: () => Promise;
+ onGenerateReleaseNotes: () => Promise;
+ releaseNotesGenerating: boolean;
+ releaseNotesLanguage: 'de' | 'en';
+ setReleaseNotesLanguage: (value: 'de' | 'en') => void;
+
+ // Conflict resolver
+ autoOpenConflictResolverPath?: string | null;
+ onAutoOpenConflictResolverConsumed?: () => void;
+ onOpenConflictResolverForPath?: (path: string) => void;
+};
+
+export const AppStateContext = createContext(null);
+
+export const useAppContext = (): AppContextValue => {
+ const ctx = useContext(AppStateContext);
+ if (!ctx) throw new Error('useAppContext must be used within AppStateContext.Provider');
+ return ctx;
+};
diff --git a/src/styles/commit-graph.css b/src/styles/commit-graph.css
index c89833e..075b2aa 100644
--- a/src/styles/commit-graph.css
+++ b/src/styles/commit-graph.css
@@ -1,4 +1,17 @@
-/* Commit Graph */
+/* Skeleton loading animation */
+@keyframes skeleton-shimmer {
+ 0% { background-position: -400px 0; }
+ 100% { background-position: 400px 0; }
+}
+
+.skeleton-line,
+.skeleton-circle {
+ background: linear-gradient(90deg, var(--bg-hover) 25%, var(--bg-panel) 50%, var(--bg-hover) 75%);
+ background-size: 800px 100%;
+ animation: skeleton-shimmer 1.4s ease-in-out infinite;
+}
+
+/* Commit Graph */
.commit-graph-container {
position: relative;
padding: 0;
diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css
index e98ffb9..cf1af88 100644
--- a/src/styles/diff-viewer.css
+++ b/src/styles/diff-viewer.css
@@ -140,6 +140,10 @@
}
.diff-large-warning {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
margin-bottom: 8px;
border: 1px solid var(--status-warning-border);
color: var(--status-warning);
@@ -148,6 +152,22 @@
padding: 7px 10px;
}
+.diff-large-warning-copy {
+ flex-shrink: 0;
+ background: transparent;
+ border: 1px solid var(--status-warning-border);
+ color: var(--status-warning);
+ border-radius: 4px;
+ padding: 2px 8px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.diff-large-warning-copy:hover {
+ background: var(--status-warning-border);
+ color: var(--bg-dark);
+}
+
.diff-file-header {
border: 1px solid var(--border-color);
border-radius: 8px;
diff --git a/src/styles/settings-and-sidebar.css b/src/styles/settings-and-sidebar.css
index 496ca95..5fa80fc 100644
--- a/src/styles/settings-and-sidebar.css
+++ b/src/styles/settings-and-sidebar.css
@@ -193,6 +193,80 @@
color: var(--text-accent);
}
+/* ── Settings Sidebar Compact Layout ─────────────────────────── */
+.ssc-root { display: flex; flex-direction: column; gap: 12px; }
+
+.ssc-section {
+ padding: 10px;
+ border-radius: 6px;
+ background-color: var(--bg-panel);
+ border: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.ssc-section-title {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.ssc-label {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ssc-label-inline {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+}
+
+.ssc-input {
+ padding: 6px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ background-color: var(--bg-dark);
+ color: var(--text-primary);
+ font-size: 0.78rem;
+}
+
+.ssc-input:focus { outline: 1px solid var(--accent-primary); }
+
+.ssc-hint {
+ font-size: 0.72rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.ssc-row { display: flex; gap: 8px; flex-wrap: wrap; }
+
+.ssc-job-item {
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 6px 8px;
+ background-color: var(--bg-dark);
+}
+
+.ssc-job-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.ssc-job-op { font-size: 0.76rem; color: var(--text-primary); }
+.ssc-job-status { font-size: 0.72rem; color: var(--text-secondary); }
+.ssc-job-status.failed { color: var(--status-danger); }
+.ssc-job-msg { margin-top: 4px; font-size: 0.72rem; color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; }
+.ssc-job-time { margin-top: 4px; font-size: 0.68rem; color: var(--text-secondary); }
+
/* Repo Cockpit */
.repo-cockpit {
display: flex;