diff --git a/src/App.tsx b/src/App.tsx index 340ee69..85695e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { SettingsTabId } from './components/layout/sidebar/AppSidebar.types'; import { I18nProvider } from './i18n'; import { useGlobalKeyboardShortcuts } from './hooks/useGlobalKeyboardShortcuts'; import { CommandPalette, PaletteCommand } from './components/CommandPalette'; +import { AppStateContext, AppContextValue } from './contexts/AppStateContext'; import './index.css'; const SIDEBAR_MIN_WIDTH = 180; @@ -106,16 +107,30 @@ const App: React.FC = () => { }, [getSidebarMaxWidth]); const paletteCommands: PaletteCommand[] = [ + // Navigation { id: 'tab-repos', label: tr('Lokale Repos', 'Local repos'), keywords: ['local', 'repos', 'lokal'], action: () => state.setActiveTab('localRepos') }, { id: 'tab-repo', label: tr('Repository-Ansicht', 'Repository view'), keywords: ['repo', 'branch', 'commits'], action: () => state.setActiveTab('repo') }, { id: 'tab-github', label: tr('GitHub', 'GitHub'), keywords: ['github', 'pr', 'pull request'], action: () => state.setActiveTab('github') }, { id: 'tab-settings', label: tr('Einstellungen', 'Settings'), keywords: ['settings', 'preferences'], action: () => state.setActiveTab('settings') }, + // Remote { id: 'fetch', label: tr('Fetch (Remote aktualisieren)', 'Fetch (refresh remote)'), keywords: ['fetch', 'remote', 'sync'], action: () => state.refreshRemoteState(true) }, { id: 'pull', label: tr('Pull', 'Pull'), keywords: ['pull', 'download'], action: () => state.runGitCommand(['pull'], tr('Erfolgreich gepullt.', 'Pull completed successfully.')) }, { id: 'pull-rebase', label: tr('Pull --rebase', 'Pull --rebase'), keywords: ['pull', 'rebase'], action: () => state.runGitCommand(['pull', '--rebase'], tr('Pull mit Rebase abgeschlossen.', 'Pull with rebase completed.')) }, { id: 'push', label: tr('Push', 'Push'), keywords: ['push', 'upload'], action: () => state.runGitCommand(['push'], tr('Erfolgreich gepusht.', 'Push completed successfully.')) }, { id: 'push-force', label: tr('Push --force-with-lease', 'Push --force-with-lease'), keywords: ['push', 'force'], action: () => state.runGitCommand(['push', '--force-with-lease'], tr('Force-Push abgeschlossen.', 'Force push completed.')) }, + // Branches + { id: 'branch-create', label: tr('Branch erstellen...', 'Create branch...'), keywords: ['branch', 'new', 'erstellen'], action: () => { state.setActiveTab('repo'); state.setIsCreatingBranch(true); state.setNewBranchName(''); setTimeout(() => state.newBranchInputRef.current?.focus(), 100); } }, + // Stash + { id: 'stash-push', label: tr('Stash erstellen', 'Create stash'), keywords: ['stash', 'save', 'speichern'], action: () => state.runGitCommand(['stash', 'push', '-m', 'Quick stash'], tr('Stash erstellt.', 'Stash created.')) }, + { id: 'stash-pop', label: tr('Letzten Stash anwenden (pop)', 'Apply last stash (pop)'), keywords: ['stash', 'pop', 'apply', 'anwenden'], action: () => state.runGitCommand(['stash', 'pop'], tr('Stash angewendet.', 'Stash applied.')) }, + // Merge / Rebase + { id: 'merge-abort', label: tr('Merge abbrechen', 'Abort merge'), keywords: ['merge', 'abort', 'abbrechen'], action: () => state.runGitCommand(['mergeAbort'], tr('Merge abgebrochen.', 'Merge aborted.')) }, + { id: 'merge-continue', label: tr('Merge fortsetzen', 'Continue merge'), keywords: ['merge', 'continue', 'fortsetzen'], action: () => state.runGitCommand(['mergeContinue'], tr('Merge fortgesetzt.', 'Merge continued.')) }, + { id: 'rebase-abort', label: tr('Rebase abbrechen', 'Abort rebase'), keywords: ['rebase', 'abort', 'abbrechen'], action: () => state.runGitCommand(['rebaseAbort'], tr('Rebase abgebrochen.', 'Rebase aborted.')) }, + { id: 'rebase-continue', label: tr('Rebase fortsetzen', 'Continue rebase'), keywords: ['rebase', 'continue', 'fortsetzen'], action: () => state.runGitCommand(['rebaseContinue'], tr('Rebase fortgesetzt.', 'Rebase continued.')) }, + // Repo { id: 'open-folder', label: tr('Repository öffnen...', 'Open repository...'), keywords: ['open', 'folder', 'öffnen'], action: () => state.handleOpenFolder() }, + { id: 'add-remote', label: tr('Remote hinzufügen...', 'Add remote...'), keywords: ['remote', 'add', 'hinzufügen'], action: () => { state.setActiveTab('repo'); state.handleAddRemote(); } }, ]; useGlobalKeyboardShortcuts({ @@ -124,287 +139,293 @@ const App: React.FC = () => { onOpenCommandPalette: () => setIsPaletteOpen(true), }); + const ctxValue: AppContextValue = { + // ── AppSidebarProps ──────────────────────────────────────────────────── + activeTab: state.activeTab, + setActiveTab: state.setActiveTab, + + activeRepo: state.activeRepo, + openRepos: state.openRepos, + repoMeta: state.repoMeta, + repoSortBy: state.repoSortBy, + onSetRepoSortBy: state.setRepoSortBy, + onToggleRepoPin: state.handleToggleRepoPin, + onOpenFolder: state.handleOpenFolder, + onSwitchRepo: state.handleSwitchRepo, + onCloseRepo: state.handleCloseRepo, + isRepoPanelCollapsed: state.isRepoPanelCollapsed, + onToggleRepoPanelCollapsed: state.toggleRepoPanelCollapsed, + + remoteSync: state.remoteSync, + isGitActionRunning: state.isGitActionRunning, + onRefreshRemoteQuick: () => state.refreshRemoteState(true), + + branches: state.branches, + isCreatingBranch: state.isCreatingBranch, + newBranchName: state.newBranchName, + newBranchInputRef: state.newBranchInputRef, + onSetCreatingBranch: state.setIsCreatingBranch, + onSetNewBranchName: state.setNewBranchName, + onCreateBranch: state.handleCreateBranch, + onCheckoutBranch: (name) => state.runGitCommand(['checkout', name], tr(`Ausgecheckt: ${name}`, `Checked out: ${name}`)), + onSetBranchContextMenu: state.setBranchContextMenu, + isBranchPanelCollapsed: state.isBranchPanelCollapsed, + onToggleBranchPanelCollapsed: state.toggleBranchPanelCollapsed, + + tags: state.tags, + onCreateTag: state.handleCreateTag, + onPushTags: state.handlePushTags, + onDeleteTag: state.handleDeleteTag, + isTagPanelCollapsed: state.isTagPanelCollapsed, + onToggleTagPanelCollapsed: state.toggleTagPanelCollapsed, + + remotes: state.remotes, + remoteStatus: state.remoteStatus, + remoteOnlyBranchesCount: state.remoteOnlyBranches.length, + remoteOnlyBranches: state.remoteOnlyBranches.map((branch) => branch.name), + onAddRemote: state.handleAddRemote, + onRemoveRemote: state.handleRemoveRemote, + onRenameRemote: state.handleRenameRemote, + onSetRemoteUrl: state.handleSetRemoteUrl, + onRefreshRemote: () => state.refreshRemoteState(true), + onSetUpstreamForCurrentBranch: state.handleSetUpstreamForCurrentBranch, + onCheckoutRemoteBranch: state.handleCheckoutRemoteBranch, + onMergeRemoteBranch: state.handleMergeBranch, + isRemotePanelCollapsed: state.isRemotePanelCollapsed, + onToggleRemotePanelCollapsed: state.toggleRemotePanelCollapsed, + + submodules: state.submodules, + onSubmoduleInitUpdate: state.handleSubmoduleInitUpdate, + onSubmoduleSync: state.handleSubmoduleSync, + onOpenSubmodule: state.handleOpenSubmodule, + isSubmodulePanelCollapsed: state.isSubmodulePanelCollapsed, + onToggleSubmodulePanelCollapsed: state.toggleSubmodulePanelCollapsed, + + hasRemoteOrigin: state.hasRemoteOrigin, + isConnectingGithubRepo: state.isConnectingGithubRepo, + connectError: state.connectError, + newRepoName: state.newRepoName, + setNewRepoName: state.setNewRepoName, + newRepoDescription: state.newRepoDescription, + setNewRepoDescription: state.setNewRepoDescription, + newRepoPrivate: state.newRepoPrivate, + setNewRepoPrivate: state.setNewRepoPrivate, + onCreateGithubRepoForCurrent: state.handleCreateGithubRepoForCurrent, + + isAuthenticated: state.isAuthenticated, + tokenInput: state.tokenInput, + setTokenInput: state.setTokenInput, + isAuthenticating: state.isAuthenticating, + authError: state.authError, + setAuthError: state.setAuthError, + onTokenLogin: state.handleTokenLogin, + oauthConfigured: state.oauthConfigured, + deviceFlow: state.deviceFlow, + isDeviceFlowRunning: state.isDeviceFlowRunning, + deviceFlowError: state.deviceFlowError, + onStartDeviceFlowLogin: state.handleStartDeviceFlowLogin, + onCancelDeviceFlow: state.handleCancelDeviceFlow, + isWebFlowRunning: state.isWebFlowRunning, + webFlowError: state.webFlowError, + onStartWebFlowLogin: state.handleStartWebFlowLogin, + selectedGithubAuthHelpMethod, + onSelectGithubAuthHelpMethod: setSelectedGithubAuthHelpMethod, + + githubUser: state.githubUser, + githubRepos: state.githubRepos, + githubRepoSearch: state.githubRepoSearch, + setGithubRepoSearch: state.setGithubRepoSearch, + githubReposHasMore: state.githubReposHasMore, + isLoadingGithubRepos: state.isLoadingGithubRepos, + isLoadingMoreGithubRepos: state.isLoadingMoreGithubRepos, + loadMoreGithubRepos: state.loadMoreGithubRepos, + refreshGithubRepos: state.refreshGithubRepos, + onLogout: state.handleLogout, + onClone: state.handleClone, + isCloning: state.isCloning, + + prOwnerRepo: state.prOwnerRepo, + prFilter: state.prFilter, + setPrFilter: state.setPrFilter, + prLoading: state.prLoading, + pullRequests: state.pullRequests, + prCiByNumber: state.prCiByNumber, + onOpenPR: state.handleOpenPR, + onCopyPRUrl: state.handleCopyPRUrl, + onCheckoutPR: state.handleCheckoutPR, + onMergePR: state.handleMergePR, + + showCreatePR: state.showCreatePR, + setShowCreatePR: state.setShowCreatePR, + currentBranch: state.currentBranch, + setNewPRHead: state.setNewPRHead, + newPRTitle: state.newPRTitle, + setNewPRTitle: state.setNewPRTitle, + newPRBody: state.newPRBody, + setNewPRBody: state.setNewPRBody, + newPRHead: state.newPRHead, + setNewPRHeadInput: state.setNewPRHead, + newPRBase: state.newPRBase, + setNewPRBase: state.setNewPRBase, + onCreatePR: state.handleCreatePR, + + releaseForm: state.releaseForm, + setReleaseForm: state.setReleaseForm, + releaseSubmitting: state.releaseSubmitting, + releaseError: state.releaseError, + releaseSuccess: state.releaseSuccess, + onCreateRelease: state.handleCreateRelease, + + settings: state.settings, + onUpdateSettings: state.handleUpdateSettings, + jobs: state.jobs, + onClearJobs: state.clearJobs, + settingsTab, + onSelectSettingsTab: setSettingsTab, + + // ── AppContextValue extras ───────────────────────────────────────────── + onClearGithubAuthHelpMethod: () => setSelectedGithubAuthHelpMethod(null), + onResetLayout: resetLayout, + + activeGitActionLabel: state.activeGitActionLabel, + + selectedCommit: state.selectedCommit, + setSelectedCommit: state.setSelectedCommit, + refreshTrigger: state.refreshTrigger, + triggerRefresh: state.triggerRefresh, + showSecondaryHistory: state.settings.showSecondaryHistory, + + onFetch: () => state.refreshRemoteState(true), + onPull: () => state.runGitCommand(['pull'], tr('Erfolgreich gepullt.', 'Pull completed successfully.'), tr('Pull wird ausgefuehrt...', 'Running pull...')), + onPullRebase: () => state.runGitCommand(['pull', '--rebase'], tr('Erfolgreich mit Rebase gepullt.', 'Pull with rebase completed successfully.'), tr('Pull --rebase wird ausgefuehrt...', 'Running pull --rebase...')), + onPullFfOnly: () => state.runGitCommand(['pull', '--ff-only'], tr('Erfolgreich mit ff-only gepullt.', 'Pull with ff-only completed successfully.'), tr('Pull --ff-only wird ausgefuehrt...', 'Running pull --ff-only...')), + onPullNoFf: () => state.runGitCommand(['pull', '--no-ff'], tr('Erfolgreich mit --no-ff gepullt.', 'Pull with --no-ff completed.'), tr('Pull --no-ff wird ausgefuehrt...', 'Running pull --no-ff...')), + onPush: () => state.runGitCommand(['push'], tr('Erfolgreich gepusht.', 'Push completed successfully.'), tr('Push wird ausgefuehrt...', 'Running push...')), + onPushForceWithLease: () => state.runGitCommand(['push', '--force-with-lease'], tr('Erfolgreich mit force-with-lease gepusht.', 'Push with force-with-lease completed successfully.'), tr('Push --force-with-lease wird ausgefuehrt...', 'Running push --force-with-lease...')), + onPushSetUpstream: () => { const b = state.currentBranch; if (b) void state.runGitCommand(['push', '-u', 'origin', b], tr(`Branch "${b}" gepusht & Upstream gesetzt.`, `Pushed "${b}" & set upstream.`), 'Push -u...'); }, + onMergeBranch: state.handleMergeBranch, + onOpenRepoWorkspace: () => state.setActiveTab('repo'), + + showReleaseCreator: state.showReleaseCreator, + onOpenReleaseCreator: state.openReleaseCreator, + onCloseReleaseCreator: state.closeReleaseCreator, + releaseContextLoading: state.releaseContextLoading, + releaseContextError: state.releaseContextError, + releaseContext: state.releaseContext, + onRefreshReleaseContext: state.refreshReleaseContext, + onGenerateReleaseNotes: state.generateReleaseNotesWithAI, + releaseNotesGenerating: state.releaseNotesGenerating, + releaseNotesLanguage: state.releaseNotesLanguage, + setReleaseNotesLanguage: state.setReleaseNotesLanguage, + + autoOpenConflictResolverPath: state.autoOpenConflictResolverPath, + onAutoOpenConflictResolverConsumed: state.clearAutoOpenConflictResolverPath, + onOpenConflictResolverForPath: state.openConflictResolverForPath, + }; + return ( -
- state.refreshRemoteState(true)} - branches={state.branches} - isCreatingBranch={state.isCreatingBranch} - newBranchName={state.newBranchName} - newBranchInputRef={state.newBranchInputRef} - onSetCreatingBranch={state.setIsCreatingBranch} - onSetNewBranchName={state.setNewBranchName} - onCreateBranch={state.handleCreateBranch} - onCheckoutBranch={(name) => state.runGitCommand(['checkout', name], tr(`Ausgecheckt: ${name}`, `Checked out: ${name}`))} - onSetBranchContextMenu={state.setBranchContextMenu} - isBranchPanelCollapsed={state.isBranchPanelCollapsed} - onToggleBranchPanelCollapsed={state.toggleBranchPanelCollapsed} - tags={state.tags} - onCreateTag={state.handleCreateTag} - onPushTags={state.handlePushTags} - onDeleteTag={state.handleDeleteTag} - isTagPanelCollapsed={state.isTagPanelCollapsed} - onToggleTagPanelCollapsed={state.toggleTagPanelCollapsed} - remotes={state.remotes} - remoteStatus={state.remoteStatus} - remoteOnlyBranchesCount={state.remoteOnlyBranches.length} - remoteOnlyBranches={state.remoteOnlyBranches.map(branch => branch.name)} - onAddRemote={state.handleAddRemote} - onRemoveRemote={state.handleRemoveRemote} - onRefreshRemote={() => state.refreshRemoteState(true)} - onSetUpstreamForCurrentBranch={state.handleSetUpstreamForCurrentBranch} - onCheckoutRemoteBranch={state.handleCheckoutRemoteBranch} - onMergeRemoteBranch={state.handleMergeBranch} - isRemotePanelCollapsed={state.isRemotePanelCollapsed} - onToggleRemotePanelCollapsed={state.toggleRemotePanelCollapsed} - submodules={state.submodules} - onSubmoduleInitUpdate={state.handleSubmoduleInitUpdate} - onSubmoduleSync={state.handleSubmoduleSync} - onOpenSubmodule={state.handleOpenSubmodule} - isSubmodulePanelCollapsed={state.isSubmodulePanelCollapsed} - onToggleSubmodulePanelCollapsed={state.toggleSubmodulePanelCollapsed} - hasRemoteOrigin={state.hasRemoteOrigin} - isConnectingGithubRepo={state.isConnectingGithubRepo} - connectError={state.connectError} - newRepoName={state.newRepoName} - setNewRepoName={state.setNewRepoName} - newRepoDescription={state.newRepoDescription} - setNewRepoDescription={state.setNewRepoDescription} - newRepoPrivate={state.newRepoPrivate} - setNewRepoPrivate={state.setNewRepoPrivate} - onCreateGithubRepoForCurrent={state.handleCreateGithubRepoForCurrent} - isAuthenticated={state.isAuthenticated} - tokenInput={state.tokenInput} - setTokenInput={state.setTokenInput} - isAuthenticating={state.isAuthenticating} - authError={state.authError} - setAuthError={state.setAuthError} - onTokenLogin={state.handleTokenLogin} - oauthConfigured={state.oauthConfigured} - deviceFlow={state.deviceFlow} - isDeviceFlowRunning={state.isDeviceFlowRunning} - deviceFlowError={state.deviceFlowError} - onStartDeviceFlowLogin={state.handleStartDeviceFlowLogin} - onCancelDeviceFlow={state.handleCancelDeviceFlow} - isWebFlowRunning={state.isWebFlowRunning} - webFlowError={state.webFlowError} - onStartWebFlowLogin={state.handleStartWebFlowLogin} - selectedGithubAuthHelpMethod={selectedGithubAuthHelpMethod} - onSelectGithubAuthHelpMethod={setSelectedGithubAuthHelpMethod} - githubUser={state.githubUser} - githubRepos={state.githubRepos} - githubRepoSearch={state.githubRepoSearch} - setGithubRepoSearch={state.setGithubRepoSearch} - githubReposHasMore={state.githubReposHasMore} - isLoadingGithubRepos={state.isLoadingGithubRepos} - isLoadingMoreGithubRepos={state.isLoadingMoreGithubRepos} - loadMoreGithubRepos={state.loadMoreGithubRepos} - refreshGithubRepos={state.refreshGithubRepos} - onLogout={state.handleLogout} - onClone={state.handleClone} - isCloning={state.isCloning} - prOwnerRepo={state.prOwnerRepo} - prFilter={state.prFilter} - setPrFilter={state.setPrFilter} - prLoading={state.prLoading} - pullRequests={state.pullRequests} - prCiByNumber={state.prCiByNumber} - onOpenPR={state.handleOpenPR} - onCopyPRUrl={state.handleCopyPRUrl} - onCheckoutPR={state.handleCheckoutPR} - onMergePR={state.handleMergePR} - showCreatePR={state.showCreatePR} - setShowCreatePR={state.setShowCreatePR} - currentBranch={state.currentBranch} - setNewPRHead={state.setNewPRHead} - newPRTitle={state.newPRTitle} - setNewPRTitle={state.setNewPRTitle} - newPRBody={state.newPRBody} - setNewPRBody={state.setNewPRBody} - newPRHead={state.newPRHead} - setNewPRHeadInput={state.setNewPRHead} - newPRBase={state.newPRBase} - setNewPRBase={state.setNewPRBase} - onCreatePR={state.handleCreatePR} - releaseForm={state.releaseForm} - setReleaseForm={state.setReleaseForm} - releaseSubmitting={state.releaseSubmitting} - releaseError={state.releaseError} - releaseSuccess={state.releaseSuccess} - onCreateRelease={state.handleCreateRelease} - settings={state.settings} - onUpdateSettings={state.handleUpdateSettings} - jobs={state.jobs} - onClearJobs={state.clearJobs} - settingsTab={settingsTab} - onSelectSettingsTab={setSettingsTab} - /> - -
- - setSelectedGithubAuthHelpMethod(null)} - activeRepo={state.activeRepo} - currentBranch={state.currentBranch} - branches={state.branches} - onMergeBranch={state.handleMergeBranch} - remoteSync={state.remoteSync} - remoteStatus={state.remoteStatus} - isGitActionRunning={state.isGitActionRunning} - activeGitActionLabel={state.activeGitActionLabel} - selectedCommit={state.selectedCommit} - setSelectedCommit={state.setSelectedCommit} - refreshTrigger={state.refreshTrigger} - triggerRefresh={state.triggerRefresh} - showSecondaryHistory={state.settings.showSecondaryHistory} - onFetch={() => state.refreshRemoteState(true)} - onPull={() => state.runGitCommand(['pull'], tr('Erfolgreich gepullt.', 'Pull completed successfully.'), tr('Pull wird ausgefuehrt...', 'Running pull...'))} - onPullRebase={() => state.runGitCommand(['pull', '--rebase'], tr('Erfolgreich mit Rebase gepullt.', 'Pull with rebase completed successfully.'), tr('Pull --rebase wird ausgefuehrt...', 'Running pull --rebase...'))} - onPullFfOnly={() => state.runGitCommand(['pull', '--ff-only'], tr('Erfolgreich mit ff-only gepullt.', 'Pull with ff-only completed successfully.'), tr('Pull --ff-only wird ausgefuehrt...', 'Running pull --ff-only...'))} - onPush={() => state.runGitCommand(['push'], tr('Erfolgreich gepusht.', 'Push completed successfully.'), tr('Push wird ausgefuehrt...', 'Running push...'))} - onPushForceWithLease={() => state.runGitCommand(['push', '--force-with-lease'], tr('Erfolgreich mit force-with-lease gepusht.', 'Push with force-with-lease completed successfully.'), tr('Push --force-with-lease wird ausgefuehrt...', 'Running push --force-with-lease...'))} - onPushTags={() => state.runGitCommand(['push', '--tags'], tr('Tags erfolgreich gepusht.', 'Tags pushed successfully.'), tr('Push --tags wird ausgefuehrt...', 'Running push --tags...'))} - onOpenRepoWorkspace={() => state.setActiveTab('repo')} - settings={state.settings} - onUpdateSettings={state.handleUpdateSettings} - jobs={state.jobs} - onClearJobs={state.clearJobs} - settingsTab={settingsTab} - onResetLayout={resetLayout} - showReleaseCreator={state.showReleaseCreator} - onOpenReleaseCreator={state.openReleaseCreator} - onCloseReleaseCreator={state.closeReleaseCreator} - prOwnerRepo={state.prOwnerRepo} - releaseForm={state.releaseForm} - setReleaseForm={state.setReleaseForm} - releaseSubmitting={state.releaseSubmitting} - releaseError={state.releaseError} - releaseSuccess={state.releaseSuccess} - onCreateRelease={state.handleCreateRelease} - releaseContextLoading={state.releaseContextLoading} - releaseContextError={state.releaseContextError} - releaseContext={state.releaseContext} - onRefreshReleaseContext={state.refreshReleaseContext} - onGenerateReleaseNotes={state.generateReleaseNotesWithAI} - releaseNotesGenerating={state.releaseNotesGenerating} - releaseNotesLanguage={state.releaseNotesLanguage} - setReleaseNotesLanguage={state.setReleaseNotesLanguage} - autoOpenConflictResolverPath={state.autoOpenConflictResolverPath} - onAutoOpenConflictResolverConsumed={state.clearAutoOpenConflictResolverPath} - onOpenConflictResolverForPath={state.openConflictResolverForPath} - /> - - {state.gitActionToasts.length > 0 && ( -
- {state.gitActionToasts.map((t) => ( -
state.dismissToast(t.id)} - title={tr('Klicken zum Schließen', 'Click to dismiss')} - > - {t.isError ? '✕' : '✓'} - {t.msg} -
- ))} -
- )} - - state.runGitCommand(['checkout', branch], tr(`Ausgecheckt: ${branch}`, `Checked out: ${branch}`))} - onMerge={state.handleMergeBranch} - onRename={state.handleRenameBranch} - onDelete={state.handleDeleteBranch} - /> - - {state.confirmDialog && state.confirmDialog.variant === 'confirm' && ( - +
+ + +
+ + + + {state.gitActionToasts.length > 0 && ( +
+ {state.gitActionToasts.map((t) => ( +
state.dismissToast(t.id)} + title={tr('Klicken zum Schließen', 'Click to dismiss')} + > + {t.isError ? '✕' : '✓'} + {t.msg} +
+ ))} +
+ )} + + state.runGitCommand(['checkout', branch], tr(`Ausgecheckt: ${branch}`, `Checked out: ${branch}`))} + onMerge={state.handleMergeBranch} + onRename={state.handleRenameBranch} + onDelete={state.handleDeleteBranch} /> - )} - - {state.confirmDialog && state.confirmDialog.variant === 'danger' && ( - + )} + + {state.confirmDialog && state.confirmDialog.variant === 'danger' && ( + + )} + + {state.inputDialog && ( + + )} + + { + state.setIsCloning(false); + state.triggerRefresh(); + }} /> - )} - - {state.inputDialog && ( - setIsPaletteOpen(false)} /> - )} - - { - state.setIsCloning(false); - state.triggerRefresh(); - }} - /> - - setIsPaletteOpen(false)} - /> -
+
+ ); }; diff --git a/src/components/CommitGraph.tsx b/src/components/CommitGraph.tsx index 4d07f7a..ac45591 100644 --- a/src/components/CommitGraph.tsx +++ b/src/components/CommitGraph.tsx @@ -149,6 +149,8 @@ export const CommitGraph: React.FC = ({ line: tr('-L Zeilenbereich', '-L line range'), }), [tr]); const logContainerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [containerHeight, setContainerHeight] = useState(800); const { layout, workingTreeStatus, @@ -170,6 +172,17 @@ export const CommitGraph: React.FC = ({ }, }); + useEffect(() => { + if (!layout) return; + const container = logContainerRef.current?.parentElement; + if (!container) return; + const onScroll = () => setScrollTop(container.scrollTop); + setScrollTop(container.scrollTop); + setContainerHeight(container.clientHeight); + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, [layout]); + useEffect(() => { try { const raw = localStorage.getItem(FORENSIC_PATH_HISTORY_STORAGE_KEY); @@ -774,7 +787,17 @@ export const CommitGraph: React.FC = ({ return
{tr('Bitte waehle ein Repository aus, um den Graphen zu sehen.', 'Please select a repository to view the graph.')}
; } if (loading) { - return ; + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ); } if (!layout || layout.nodes.length === 0) { return ( @@ -793,6 +816,16 @@ export const CommitGraph: React.FC = ({ const graphWidth = Math.max((layout.maxLane + 1) * LANE_WIDTH + GRAPH_PADDING * 2, 60); const totalHeight = (layout.nodes.length + workingTreeRowOffset) * ROW_HEIGHT; const laneX = (lane: number) => GRAPH_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2; + + const OVERSCAN = 8; + const visibleStartIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT - workingTreeRowOffset) - OVERSCAN); + const visibleEndIdx = Math.min(layout.nodes.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT - workingTreeRowOffset) + OVERSCAN); + const visibleNodes = layout.nodes.slice(visibleStartIdx, visibleEndIdx); + const topSpacerHeight = visibleStartIdx * ROW_HEIGHT; + const bottomSpacerHeight = Math.max(0, (layout.nodes.length - visibleEndIdx) * ROW_HEIGHT); + const visibleEdges = layout.edges.filter( + (e) => Math.min(e.fromRow, e.toRow) <= visibleEndIdx && Math.max(e.fromRow, e.toRow) >= visibleStartIdx + ); const nodeByHash = new Map(layout.nodes.map(node => [node.commit.hash, node])); const headNode = layout.nodes.find(node => ( node.commit.refs.some(ref => ref.startsWith('HEAD ->') || ref === 'HEAD') @@ -1040,7 +1073,7 @@ export const CommitGraph: React.FC = ({ /> )} - {layout.edges.map((edge, i) => ( + {visibleEdges.map((edge, i) => ( = ({ strokeDasharray={edge.kind === 'merge' ? '4 4' : undefined} /> ))} - {layout.edges.map((edge, i) => ( + {visibleEdges.map((edge, i) => ( = ({ strokeDasharray={edge.kind === 'merge' ? '4 4' : undefined} /> ))} - {layout.nodes.map((node) => { + {visibleNodes.map((node) => { const cx = laneX(node.lane); const cy = (node.row + workingTreeRowOffset) * ROW_HEIGHT + ROW_HEIGHT / 2; const isSelected = selectedHash === node.commit.hash; @@ -1154,7 +1187,8 @@ export const CommitGraph: React.FC = ({
)} - {layout.nodes.map((node) => { + {topSpacerHeight > 0 && ); })} + {bottomSpacerHeight > 0 && - {isLoading &&
{tr('Diff wird geladen...', 'Loading diff...')}
} + {isLoading && ( +
+ {Array.from({ length: 10 }).map((_, i) => { + const isAdd = i % 5 === 1; + const isDel = i % 5 === 3; + return ( +
+
+
+
+ ); + })} +
+ )} {error && !isLoading &&
{error}
} {!isLoading && !error && !diffText.trim() &&
{tr('Keine Unterschiede vorhanden.', 'No differences found.')}
} @@ -437,7 +458,19 @@ export const DiffViewer: React.FC = ({ repoPath, request, onClo
{isTooLarge && (
- {tr('Großer Diff erkannt. Anzeige wurde aus Performance-Gründen gekürzt.', 'Large diff detected. Output was truncated for performance.')} + + {tr( + `Großer Diff: ${diffText.split('\n').length.toLocaleString()} Zeilen – Anzeige auf ${MAX_RENDER_LINES.toLocaleString()} Zeilen gekürzt.`, + `Large diff: ${diffText.split('\n').length.toLocaleString()} lines – display truncated to ${MAX_RENDER_LINES.toLocaleString()} lines.` + )} + +
)} diff --git a/src/components/StagingArea.tsx b/src/components/StagingArea.tsx index 462d100..73308b9 100644 --- a/src/components/StagingArea.tsx +++ b/src/components/StagingArea.tsx @@ -1,38 +1,26 @@ -import React, { useEffect, useLayoutEffect, useState, useCallback, useMemo, useRef } from 'react'; -import { FileEntry, parseGitStatusDetailed } from '../utils/gitParsing'; +import React, { useCallback, useState } from 'react'; +import { FileEntry } from '../utils/gitParsing'; import { useToastQueue } from '../hooks/useToastQueue'; import { Confirm } from './Confirm'; import { DangerConfirm } from './DangerConfirm'; import { Input } from './Input'; -import { DiffRequest } from '../types/diff'; -import { normalizeMergeConflictFileContent } from '../utils/conflictLineGutter'; import { ConflictResolverPanel } from './staging-area/ConflictResolverPanel'; import { StashPanel } from './staging-area/StashPanel'; +import { useFileOperations } from './staging-area/useFileOperations'; +import { useCommitForm } from './staging-area/useCommitForm'; +import { useAiCommit } from './staging-area/useAiCommit'; +import { useConflictResolver } from './staging-area/useConflictResolver'; import type { ConfirmDialogState, - ConflictEditorState, - ConflictResolutionChoice, - DiffStats, FileSection, - GitStatusWithConflicts, InputDialogState, StagingAreaProps, - StagingContextMenuState, } from './staging-area/types'; import { - EMPTY_DIFF_STATS, - basename, - buildConflictResolution, - countConflictMarkerLines, - detectLineEnding, dirname, extensionPattern, formatDiffStats, getStatusInfo, - parseConflictBlocks, - parseConflictEntries, - parseNumstatStats, - replaceConflictBlock, toGitPath, } from './staging-area/utils'; @@ -46,119 +34,11 @@ export const StagingArea: React.FC = ({ initialConflictPath = null, settings, }) => { - const [status, setStatus] = useState(null); - const [commitMsg, setCommitMsg] = useState(''); - const [isCommitting, setIsCommitting] = useState(false); + const { toast, setToast } = useToastQueue(3000); const [confirmDialog, setConfirmDialog] = useState(null); const [inputDialog, setInputDialog] = useState(null); - const { toast, setToast } = useToastQueue(3000); - const [searchQuery, setSearchQuery] = useState(''); - const [activeFilter, setActiveFilter] = useState<'all' | 'staged' | 'unstaged' | 'untracked' | 'conflicts'>('all'); - const [amendCommit, setAmendCommit] = useState(false); - const [signoffCommit, setSignoffCommit] = useState(false); - const [commitDescription, setCommitDescription] = useState(''); - const [stagedStats, setStagedStats] = useState(EMPTY_DIFF_STATS); - const [unstagedStats, setUnstagedStats] = useState(EMPTY_DIFF_STATS); - const [contextMenu, setContextMenu] = useState(null); - 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); - const [conflictEditor, setConflictEditor] = useState(null); - const [isConflictEditorLoading, setIsConflictEditorLoading] = useState(false); - const [selectedConflictBlockIndex, setSelectedConflictBlockIndex] = useState(0); - /** Konfliktblock-Anzahl pro Datei (Git zaehlt nur Dateien mit UU etc.) */ - 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 aiConfig = { - enabled: Boolean(settings.aiAutoCommitEnabled), - provider: settings.aiProvider, - model: settings.aiProvider === 'gemini' ? (settings.geminiModel || '') : (settings.ollamaModel || ''), - }; - const isConflictOnly = viewMode === 'conflictOnly'; - - const refresh = useCallback(async () => { - if (!repoPath || !window.electronAPI) return; - try { - const statusRequest = window.electronAPI.runGitCommand('statusPorcelain'); - const stagedDiffRequest = window.electronAPI.runGitCommand('diff', '--numstat', '--cached'); - const unstagedDiffRequest = window.electronAPI.runGitCommand('diff', '--numstat'); - - const [statusResult, stagedResult, unstagedResult] = await Promise.all([ - statusRequest, - stagedDiffRequest, - unstagedDiffRequest, - ]); - - 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(() => { - setSignoffCommit(Boolean(settings.commitSignoffByDefault)); - }, [settings.commitSignoffByDefault]); - - useEffect(() => { - if (settings.commitTemplate && !commitMsg.trim()) { - setCommitMsg(settings.commitTemplate); - } - }, [settings.commitTemplate, commitMsg]); - - const git = 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 }); - } - }; + const isConflictOnly = viewMode === 'conflictOnly'; const closeConfirmDialog = useCallback(() => setConfirmDialog(null), []); const executeConfirmDialog = useCallback(async () => { @@ -176,756 +56,67 @@ export const StagingArea: React.FC = ({ await action(values); }, [inputDialog]); - 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; - }, []); - 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 openFileContextMenu = (event: React.MouseEvent, entry: FileEntry, section: FileSection) => { - event.preventDefault(); - event.stopPropagation(); - setContextMenu({ x: event.clientX, y: event.clientY, entry, section }); - }; - - const addIgnoreRule = 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 }); - } - }; - const stageFile = (f: string) => git(['add', '--', f], `${basename(f)} gestaged`); - const unstageFile = (f: string) => git(['reset', 'HEAD', '--', f], `${basename(f)} unstaged`); - const stageAll = () => git(['add', '.'], 'Alle Dateien gestaged'); - const stageAllUntracked = 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 }); - } - }; - const unstageAll = () => git(['reset', 'HEAD'], 'Alle Dateien unstaged'); - - const discardFile = (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), - }); - }; - - const discardAll = () => { - 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), - }); - }; - - const deleteUntracked = (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), - }); - }; - - const stashChanges = () => { - 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); - }, - }); - }; - - const stashPop = () => git(['stash', 'pop'], 'Stash angewendet', true); - - const showDiff = (filePath: string, staged: boolean) => { - const request: DiffRequest = { - source: staged ? 'staged' : 'unstaged', - path: filePath, - title: staged ? 'Staged Diff' : 'Unstaged Diff', - }; - onOpenDiff?.(request); - }; - - - const markConflictResolved = (filePath: string) => git(['conflictMarkResolved', filePath], `${basename(filePath)} als geloest markiert`); - - 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') { - const message = result.error || `Datei konnte nicht geladen werden: ${filePath}`; - setToast({ msg: message, 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) { - const message = error?.message || `Datei konnte nicht geladen werden: ${filePath}`; - setToast({ msg: message, 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 - ); - - 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]); - - /** Beim Wechseln des Konfliktblocks: in der manuellen Bearbeitung zur Startzeile scrollen */ - 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]); - - const isConflictEditorDirty = Boolean(conflictEditor && conflictEditor.content !== conflictEditor.originalContent); - - 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) { - const block = blocks[i]; - nextContent = replaceConflictBlock(nextContent, block, buildConflictResolution(block, 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 fileOps = useFileOperations({ repoPath, setToast, setConfirmDialog, setInputDialog, onRepoChanged, onOpenDiff }); + + const commitForm = useCommitForm({ + repoPath, + status: fileOps.status, + setToast, + refresh: fileOps.refresh, + onRepoChanged, + settings, + }); + + const aiCommit = useAiCommit({ + status: fileOps.status, + setToast, + refresh: fileOps.refresh, + onRepoChanged, + }); + + const conflicts = useConflictResolver({ + repoPath, + status: fileOps.status, + setToast, + setConfirmDialog, + git: fileOps.git, + refresh: fileOps.refresh, + onRepoChanged, + initialConflictPath, + isConflictOnly, + onOpenConflictResolver, + }); - - const markConflictResolvedAndSync = useCallback(async (filePath: string) => { - await markConflictResolved(filePath); - if (conflictEditor?.filePath === filePath) { - await openConflictEditor(filePath); - } - }, [conflictEditor, openConflictEditor]); - - 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]); - - 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) { - const message = error?.message || 'Konfliktdatei konnte nicht gespeichert werden.'; - - setConflictEditor((prev) => { - if (!prev || prev.filePath !== targetPath) return prev; - return { ...prev, isSaving: false }; - }); - setToast({ msg: message, isError: true }); - } - }, [conflictEditor, onRepoChanged, refresh, setToast]); - - const mergeContinue = () => git(['mergeContinue'], 'Merge fortgesetzt', true); - const mergeAbort = () => { - 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), - }); - }; - - const rebaseContinue = () => git(['rebaseContinue'], 'Rebase fortgesetzt', true); - const rebaseAbort = () => { - 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), - }); - }; - - const handleAiAutoCommit = 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) => `${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); - } - }; - - const handleCancelAiAutoCommit = 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 }); - } - }; - const handleCommit = 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) { - 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); - } + const aiConfig = { + enabled: Boolean(settings.aiAutoCommitEnabled), }; if (!repoPath) return null; - if (!status) return
Lade Status...
; + if (!fileOps.status) return
Lade Status...
; + const status = fileOps.status; const totalChanges = status.staged.length + status.unstaged.length + status.untracked.length + status.conflicts.length; const hasOpenConflicts = status.conflicts.length > 0; - const normalizedQuery = searchQuery.trim().toLowerCase(); + const normalizedQuery = fileOps.searchQuery.trim().toLowerCase(); const bySearch = (entries: T[]) => entries - .filter(entry => !normalizedQuery || entry.path.toLowerCase().includes(normalizedQuery)) + .filter((entry) => !normalizedQuery || entry.path.toLowerCase().includes(normalizedQuery)) .sort((a, b) => a.path.localeCompare(b.path)); - const visibleStaged = activeFilter === 'all' || activeFilter === 'staged' ? bySearch(status.staged) : []; - const visibleUnstaged = activeFilter === 'all' || activeFilter === 'unstaged' ? bySearch(status.unstaged) : []; - const visibleUntracked = activeFilter === 'all' || activeFilter === 'untracked' ? bySearch(status.untracked) : []; - const visibleConflicts = activeFilter === 'all' || activeFilter === 'conflicts' ? bySearch(status.conflicts) : []; + const visibleStaged = fileOps.activeFilter === 'all' || fileOps.activeFilter === 'staged' ? bySearch(status.staged) : []; + const visibleUnstaged = fileOps.activeFilter === 'all' || fileOps.activeFilter === 'unstaged' ? bySearch(status.unstaged) : []; + const visibleUntracked = fileOps.activeFilter === 'all' || fileOps.activeFilter === 'untracked' ? bySearch(status.untracked) : []; + const visibleConflicts = fileOps.activeFilter === 'all' || fileOps.activeFilter === 'conflicts' ? bySearch(status.conflicts) : []; const visibleTotal = visibleStaged.length + visibleUnstaged.length + visibleUntracked.length + visibleConflicts.length; - const blockCountForPath = (path: string) => { - if (conflictEditor?.filePath === path) { - return conflictBlocks.length; - } - return conflictBlockCountsByPath[path] ?? 0; - }; + const totalConflictBlocksInView = visibleConflicts.reduce((sum, f) => sum + conflicts.blockCountForPath(f.path), 0); + const totalConflictBlocksAll = status.conflicts.reduce((sum, f) => sum + conflicts.blockCountForPath(f.path), 0); - const totalConflictBlocksInView = visibleConflicts.reduce((sum, f) => sum + blockCountForPath(f.path), 0); - const totalConflictBlocksAll = status.conflicts.reduce((sum, f) => sum + blockCountForPath(f.path), 0); - const conflictPaths = [...new Set(status.conflicts.map((entry) => entry.path))].sort((a, b) => a.localeCompare(b)); - 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 contextEntry = contextMenu?.entry || null; + const contextEntry = fileOps.contextMenu?.entry || null; const contextDir = contextEntry ? dirname(contextEntry.path) : ''; const contextTopDir = contextDir.includes('/') ? contextDir.split('/')[0] : ''; const contextExtPattern = contextEntry ? extensionPattern(contextEntry.path) : null; - const navigateToPreviousConflict = 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); - }; - - const navigateToNextConflict = 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); - }; - - const onConflictEditorContentChange = (filePath: string, nextContent: string) => { - setConflictEditor((prev) => { - if (!prev || prev.filePath !== filePath) return prev; - return { ...prev, content: nextContent }; - }); - }; - const FileRow = ({ entry, section }: { entry: FileEntry; section: FileSection }) => { const statusCode = section === 'staged' ? entry.x : entry.y; const info = getStatusInfo(statusCode); @@ -934,31 +125,27 @@ export const StagingArea: React.FC = ({
{ - if (inspectSource) { - onSelectFileInspect?.(entry.path, inspectSource); - } - if (section !== 'untracked') { - showDiff(entry.path, section === 'staged'); - } + if (inspectSource) onSelectFileInspect?.(entry.path, inspectSource); + if (section !== 'untracked') fileOps.showDiff(entry.path, section === 'staged'); }} - onContextMenu={(e) => openFileContextMenu(e, entry, section)} + onContextMenu={(e) => fileOps.openFileContextMenu(e, entry, section)} > {statusCode} {entry.path}
{section === 'staged' && ( - + )} {section === 'unstaged' && ( <> - - + + )} {section === 'untracked' && ( <> - - + + )}
@@ -979,43 +166,43 @@ export const StagingArea: React.FC = ({ return (
{!isConflictOnly && ( -
- - - setSearchQuery(e.target.value)} - placeholder="Datei suchen..." - style={{ flex: 1, minWidth: '170px', padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-dark)', color: 'var(--text-primary)', fontSize: '0.76rem' }} - /> - {(['all', 'staged', 'unstaged', 'untracked', 'conflicts'] as const).map((filter) => ( - - ))} - - Staged {formatDiffStats(stagedStats)} - - - Unstaged {formatDiffStats(unstagedStats)} - -
- {visibleTotal > 0 && ( - - {visibleTotal} sichtbar +
+ + + fileOps.setSearchQuery(e.target.value)} + placeholder="Datei suchen..." + style={{ flex: 1, minWidth: '170px', padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-dark)', color: 'var(--text-primary)', fontSize: '0.76rem' }} + /> + {(['all', 'staged', 'unstaged', 'untracked', 'conflicts'] as const).map((filter) => ( + + ))} + + Staged {formatDiffStats(fileOps.stagedStats)} - )} -
+ + Unstaged {formatDiffStats(fileOps.unstagedStats)} + +
+ {visibleTotal > 0 && ( + + {visibleTotal} sichtbar + + )} +
)}
@@ -1025,70 +212,70 @@ export const StagingArea: React.FC = ({
)} - {visibleStaged.length > 0 && (
- - Alle} + - Alle} /> - {visibleStaged.map(f => )} + {visibleStaged.map((f) => )}
)} {visibleUnstaged.length > 0 && (
- - - + + } /> - {visibleUnstaged.map(f => )} + {visibleUnstaged.map((f) => )}
)} {visibleUntracked.length > 0 && (
+ Alle} + actions={} /> - {visibleUntracked.map(f => )} + {visibleUntracked.map((f) => )}
)}
@@ -1101,131 +288,113 @@ export const StagingArea: React.FC = ({ )} {!isConflictOnly && ( -
-