feat: show ShieldAlert icon for conflict-review tabs (#249)

* feat: show ShieldAlert icon for conflict-review tabs

Display a distinct orange ShieldAlert icon for tabs opened in
conflict-review mode, differentiating them from regular diff tabs.

* fix: clear stale Rebasing/Merging badge on non-active worktrees

The git status polling only runs for the active worktree. When a
non-active worktree finishes rebasing, its conflict operation was
never updated to 'unknown', leaving the badge stuck.

Add a lightweight git:conflictOperation IPC (fs-only, no git subprocess)
and poll it for non-active worktrees that have a non-unknown operation.
Once the operation clears, the worktree drops out of the poll list.
This commit is contained in:
Jinjing
2026-03-31 21:47:59 -07:00
committed by GitHub
parent 617f0c3487
commit 894f1bb6aa
7 changed files with 107 additions and 4 deletions
+1 -1
View File
@@ -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<GitConflictOperation> {
export async function detectConflictOperation(worktreePath: string): Promise<GitConflictOperation> {
const gitDir = await resolveGitDir(worktreePath)
const mergeHead = path.join(gitDir, 'MERGE_HEAD')
const rebaseHead = path.join(gitDir, 'REBASE_HEAD')
+13
View File
@@ -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<GitConflictOperation> => {
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
return detectConflictOperation(worktreePath)
}
)
ipcMain.handle(
'git:diff',
async (
+2
View File
@@ -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<GitStatusResult>
conflictOperation: (args: { worktreePath: string }) => Promise<GitConflictOperation>
diff: (args: {
worktreePath: string
filePath: string
+2
View File
@@ -260,6 +260,8 @@ const api = {
git: {
status: (args: { worktreePath: string }): Promise<unknown> =>
ipcRenderer.invoke('git:status', args),
conflictOperation: (args: { worktreePath: string }): Promise<unknown> =>
ipcRenderer.invoke('git:conflictOperation', args),
diff: (args: { worktreePath: string; filePath: string; staged: boolean }): Promise<unknown> =>
ipcRenderer.invoke('git:diff', args),
branchCompare: (args: { worktreePath: string; baseRef: string }): Promise<unknown> =>
@@ -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<void> => {
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])
}
@@ -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 ? (
<ShieldAlert
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
/>
) : isDiff ? (
<GitCompareArrows
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
+32
View File
@@ -182,6 +182,9 @@ export type EditorSlice = {
trackedConflictPathsByWorktree: Record<string, Record<string, GitConflictKind>>
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<string, GitBranchChangeEntry[]>
gitBranchCompareSummaryByWorktree: Record<string, GitBranchCompareSummary | null>
gitBranchCompareRequestKeyByWorktree: Record<string, string>
@@ -969,6 +972,35 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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: {},