diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 35293146a29..d1a45f98ef4 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -237,7 +237,7 @@ async function getConflictCompatibilityStatus( // rebase is still in progress but REBASE_HEAD is absent. The rebase-merge/ or // rebase-apply/ directory persists for the entire rebase, so checking it // catches the "rebase in progress, no conflicts on current step" case. -async function detectConflictOperation(worktreePath: string): Promise { +export async function detectConflictOperation(worktreePath: string): Promise { const gitDir = await resolveGitDir(worktreePath) const mergeHead = path.join(gitDir, 'MERGE_HEAD') const rebaseHead = path.join(gitDir, 'REBASE_HEAD') diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 32aec7c1314..3bdd4a39c98 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -9,6 +9,7 @@ import type { Store } from '../persistence' import type { DirEntry, GitBranchCompareResult, + GitConflictOperation, GitDiffResult, GitStatusResult, SearchOptions, @@ -17,6 +18,7 @@ import type { } from '../../shared/types' import { getStatus, + detectConflictOperation, getDiff, stageFile, unstageFile, @@ -333,6 +335,17 @@ export function registerFilesystemHandlers(store: Store): void { } ) + // Why: lightweight fs-only check for conflict operation state. Used to poll + // non-active worktrees so their "Rebasing"/"Merging" badges clear when the + // operation finishes, without running a full `git status`. + ipcMain.handle( + 'git:conflictOperation', + async (_event, args: { worktreePath: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return detectConflictOperation(worktreePath) + } + ) + ipcMain.handle( 'git:diff', async ( diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 1aab6fa01db..5276106a416 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -13,6 +13,7 @@ import type { UpdateStatus, DirEntry, GitBranchCompareResult, + GitConflictOperation, GitStatusEntry, GitDiffResult, SearchOptions, @@ -144,6 +145,7 @@ type FsApi = { type GitApi = { status: (args: { worktreePath: string }) => Promise + conflictOperation: (args: { worktreePath: string }) => Promise diff: (args: { worktreePath: string filePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 64c274725c3..56640ba7498 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -260,6 +260,8 @@ const api = { git: { status: (args: { worktreePath: string }): Promise => ipcRenderer.invoke('git:status', args), + conflictOperation: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('git:conflictOperation', args), diff: (args: { worktreePath: string; filePath: string; staged: boolean }): Promise => ipcRenderer.invoke('git:diff', args), branchCompare: (args: { worktreePath: string; baseRef: string }): Promise => diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 4c8083a2380..e50c7e7229a 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react' import { useAppStore } from '@/store' -import type { GitStatusResult } from '../../../../shared/types' +import type { GitConflictOperation, GitStatusResult } from '../../../../shared/types' const POLL_INTERVAL_MS = 3000 @@ -8,6 +8,8 @@ export function useGitStatusPolling(): void { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const setGitStatus = useAppStore((s) => s.setGitStatus) + const setConflictOperation = useAppStore((s) => s.setConflictOperation) + const conflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) const worktreePath = useMemo(() => { if (!activeWorktreeId) { @@ -22,6 +24,27 @@ export function useGitStatusPolling(): void { return null }, [activeWorktreeId, worktreesByRepo]) + // Why: build a list of non-active worktrees that still have a known conflict + // operation (merge/rebase/cherry-pick). These need lightweight polling so + // their sidebar badges clear when the operation finishes — the full git status + // poll only covers the active worktree. + const staleConflictWorktrees = useMemo(() => { + const result: { id: string; path: string }[] = [] + for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) { + if (worktreeId === activeWorktreeId || op === 'unknown') { + continue + } + for (const worktrees of Object.values(worktreesByRepo)) { + const wt = worktrees.find((w) => w.id === worktreeId) + if (wt) { + result.push({ id: wt.id, path: wt.path }) + break + } + } + } + return result + }, [conflictOperationByWorktree, activeWorktreeId, worktreesByRepo]) + const fetchStatus = useCallback(async () => { if (!activeWorktreeId || !worktreePath) { return @@ -39,4 +62,30 @@ export function useGitStatusPolling(): void { const intervalId = setInterval(() => void fetchStatus(), POLL_INTERVAL_MS) return () => clearInterval(intervalId) }, [fetchStatus]) + + // Why: poll conflict operation for non-active worktrees that have a stale + // non-unknown operation. This is a lightweight fs-only check (no git status) + // so it won't cause performance issues even with many worktrees. + useEffect(() => { + if (staleConflictWorktrees.length === 0) { + return + } + + const pollStale = async (): Promise => { + for (const { id, path } of staleConflictWorktrees) { + try { + const op = (await window.api.git.conflictOperation({ + worktreePath: path + })) as GitConflictOperation + setConflictOperation(id, op) + } catch { + // ignore — worktree may have been removed + } + } + } + + void pollStale() + const intervalId = setInterval(() => void pollStale(), POLL_INTERVAL_MS) + return () => clearInterval(intervalId) + }, [staleConflictWorktrees, setConflictOperation]) } diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index c36fa0de877..577963480ff 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { X, FileCode, GitCompareArrows, Copy } from 'lucide-react' +import { X, FileCode, GitCompareArrows, Copy, ShieldAlert } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -49,6 +49,7 @@ export default function EditorFileTab({ } const isDiff = file.mode === 'diff' + const isConflictReview = file.mode === 'conflict-review' const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) @@ -104,7 +105,11 @@ export default function EditorFileTab({ } }} > - {isDiff ? ( + {isConflictReview ? ( + + ) : isDiff ? ( diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 280d6fead7c..20bb09ad858 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -182,6 +182,9 @@ export type EditorSlice = { trackedConflictPathsByWorktree: Record> trackConflictPath: (worktreeId: string, path: string, conflictKind: GitConflictKind) => void setGitStatus: (worktreeId: string, status: GitStatusResult) => void + // Why: lightweight updater for conflict operation only, used to clear stale + // "Rebasing"/"Merging" badges on non-active worktrees without a full git status poll. + setConflictOperation: (worktreeId: string, operation: GitConflictOperation) => void gitBranchChangesByWorktree: Record gitBranchCompareSummaryByWorktree: Record gitBranchCompareRequestKeyByWorktree: Record @@ -969,6 +972,35 @@ export const createEditorSlice: StateCreator = (s : { ...s.trackedConflictPathsByWorktree, [worktreeId]: currentTracked } } }), + setConflictOperation: (worktreeId, operation) => + set((s) => { + const prev = s.gitConflictOperationByWorktree[worktreeId] ?? 'unknown' + if (prev === operation) { + return s + } + // Why: when the operation clears (transitions to 'unknown') on a non-active + // worktree, we also need to clear tracked conflict paths — same as the + // full setGitStatus handler does for the active worktree. + const nextTracked = + operation === 'unknown' && prev !== 'unknown' + ? {} + : s.trackedConflictPathsByWorktree[worktreeId] + const trackedUnchanged = nextTracked === s.trackedConflictPathsByWorktree[worktreeId] + return { + gitConflictOperationByWorktree: { + ...s.gitConflictOperationByWorktree, + [worktreeId]: operation + }, + ...(trackedUnchanged + ? {} + : { + trackedConflictPathsByWorktree: { + ...s.trackedConflictPathsByWorktree, + [worktreeId]: nextTracked + } + }) + } + }), gitBranchChangesByWorktree: {}, gitBranchCompareSummaryByWorktree: {}, gitBranchCompareRequestKeyByWorktree: {},