Fix stale sidebar git branch after split close

This commit is contained in:
Lawrence Chen 2026-02-20 21:47:06 -08:00
parent 0e3e17ca75
commit 60e7aeeb16
5 changed files with 119 additions and 10 deletions

View file

@ -107,9 +107,9 @@ _cmux_prompt_command() {
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!

View file

@ -240,9 +240,9 @@ _cmux_precmd() {
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
} >/dev/null 2>&1 &!
_CMUX_GIT_JOB_PID=$!

View file

@ -7552,8 +7552,8 @@ class TerminalController {
list_log [--limit=N] [--tab=X] - List log entries
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
clear_progress [--tab=X] - Clear progress bar
report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch
clear_git_branch [--tab=X] - Clear git branch
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
@ -10585,7 +10585,7 @@ class TerminalController {
private func reportGitBranch(_ args: String) -> String {
let parsed = parseOptions(args)
guard let branch = parsed.positional.first else {
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]"
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]"
}
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
@ -10595,19 +10595,78 @@ class TerminalController {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty)
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty)
}
return result
}
private func clearGitBranch(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
tab.gitBranch = nil
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.clearPanelGitBranch(panelId: surfaceId)
}
return result
}
@ -10898,6 +10957,7 @@ class TerminalController {
tab.logEntries.removeAll()
tab.progress = nil
tab.gitBranch = nil
tab.panelGitBranches.removeAll()
tab.surfaceListeningPorts.removeAll()
tab.listeningPorts.removeAll()
}

View file

@ -94,6 +94,7 @@ final class Workspace: Identifiable, ObservableObject {
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: SidebarProgressState?
@Published var gitBranch: SidebarGitBranchState?
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:]
@ -513,6 +514,24 @@ final class Workspace: Identifiable, ObservableObject {
}
}
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
let existing = panelGitBranches[panelId]
if existing?.branch != branch || existing?.isDirty != isDirty {
panelGitBranches[panelId] = state
}
if panelId == focusedPanelId {
gitBranch = state
}
}
func clearPanelGitBranch(panelId: UUID) {
panelGitBranches.removeValue(forKey: panelId)
if panelId == focusedPanelId {
gitBranch = nil
}
}
@discardableResult
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
@ -557,6 +576,7 @@ final class Workspace: Identifiable, ObservableObject {
panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) }
pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) }
manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) }
panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
@ -1539,6 +1559,7 @@ extension Workspace: BonsplitDelegate {
if let dir = panelDirectories[panelId] {
currentDirectory = dir
}
gitBranch = panelGitBranches[panelId]
// Post notification
NotificationCenter.default.post(
@ -1667,6 +1688,7 @@ extension Workspace: BonsplitDelegate {
panels.removeValue(forKey: panelId)
surfaceIdToPanelId.removeValue(forKey: tabId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)

View file

@ -953,6 +953,33 @@ final class TabManagerSurfaceCreationTests: XCTestCase {
}
}
@MainActor
final class WorkspacePanelGitBranchTests: XCTestCase {
func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
let workspace = Workspace()
guard let firstPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else {
XCTFail("Expected split panel to be created")
return
}
workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true)
XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused")
XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix")
XCTAssertEqual(workspace.gitBranch?.isDirty, true)
XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed")
XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused")
XCTAssertEqual(workspace.gitBranch?.branch, "main")
XCTAssertEqual(workspace.gitBranch?.isDirty, false)
}
}
@MainActor
final class BrowserPanelAddressBarFocusRequestTests: XCTestCase {
func testRequestPersistsUntilAcknowledged() {