From 60e7aeeb164da4a0fa34c1720bfbeb97ebc65c38 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:47:06 -0800 Subject: [PATCH 01/10] Fix stale sidebar git branch after split close --- .../cmux-bash-integration.bash | 4 +- .../cmux-zsh-integration.zsh | 4 +- Sources/TerminalController.swift | 72 +++++++++++++++++-- Sources/Workspace.swift | 22 ++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 27 +++++++ 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 1e110f91..4f8c832f 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -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=$! diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 3b5d00cc..6c9575f0 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -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=$! diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index d8724264..0526d593 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 [--status=dirty] [--tab=X] - Report git branch - clear_git_branch [--tab=X] - Clear git branch + report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch + clear_git_branch [--tab=X] [--panel=Y] - Clear git branch report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--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 [--status=dirty] [--tab=X]" + return "ERROR: Missing branch name — usage: report_git_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 [--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() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a530af87..94437799 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 548a979c..82e27d1c 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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() { From c533ebe5e574723d32984c52aff887fc982c06c4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:59:35 -0800 Subject: [PATCH 02/10] Render sidebar branches in split/tab display order --- Sources/ContentView.swift | 19 ++- Sources/Workspace.swift | 113 +++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 134 +++++++++++++----- 3 files changed, 226 insertions(+), 40 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6f308362..ec504198 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2341,7 +2341,7 @@ private struct TabItemView: View { // Branch + directory row if let dirRow = branchDirectoryRow { HStack(spacing: 3) { - if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon { + if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) @@ -2675,9 +2675,8 @@ private struct TabItemView: View { var parts: [String] = [] // Git branch (if enabled and available) - if sidebarShowGitBranch, let git = tab.gitBranch { - let dirty = git.isDirty ? "*" : "" - parts.append("\(git.branch)\(dirty)") + if sidebarShowGitBranch, let gitSummary = gitBranchSummaryText { + parts.append(gitSummary) } // Directory summary @@ -2689,12 +2688,22 @@ private struct TabItemView: View { return result.isEmpty ? nil : result } + private var gitBranchSummaryText: String? { + let branches = tab.sidebarGitBranchesInDisplayOrder() + guard !branches.isEmpty else { return nil } + return branches + .map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } + .joined(separator: " | ") + } + private var directorySummaryText: String? { guard !tab.panels.isEmpty else { return nil } let home = FileManager.default.homeDirectoryForCurrentUser.path var seen: Set = [] var entries: [String] = [] - for panelId in tab.panels.keys { + for panelId in tab.sidebarOrderedPanelIds() { let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory let shortened = shortenPath(directory, home: home) guard !shortened.isEmpty else { continue } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 94437799..d961cf52 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -37,6 +37,81 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + let isDirty: Bool + } + + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { + switch tree { + case .pane(let pane): + return [pane.id] + case .split(let split): + // Bonsplit split order matches visual order for both horizontal and vertical splits. + return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) + } + } + + static func orderedPanelIds( + tree: ExternalTreeNode, + paneTabs: [String: [UUID]], + fallbackPanelIds: [UUID] + ) -> [UUID] { + var ordered: [UUID] = [] + var seen: Set = [] + + for paneId in orderedPaneIds(tree: tree) { + for panelId in paneTabs[paneId] ?? [] { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } + } + } + + for panelId in fallbackPanelIds { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } + } + + return ordered + } + + static func orderedUniqueBranches( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + fallbackBranch: SidebarGitBranchState? + ) -> [BranchEntry] { + var orderedNames: [String] = [] + var branchDirty: [String: Bool] = [:] + + for panelId in orderedPanelIds { + guard let state = panelBranches[panelId] else { continue } + let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + + if branchDirty[name] == nil { + orderedNames.append(name) + branchDirty[name] = state.isDirty + } else if state.isDirty { + branchDirty[name] = true + } + } + + if orderedNames.isEmpty, let fallbackBranch { + let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] + } + } + + return orderedNames.map { name in + BranchEntry(name: name, isDirty: branchDirty[name] ?? false) + } + } +} + /// Workspace represents a sidebar tab. /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor @@ -587,6 +662,35 @@ final class Workspace: Identifiable, ObservableObject { listeningPorts = unique.sorted() } + func sidebarOrderedPanelIds() -> [UUID] { + let paneTabs: [String: [UUID]] = Dictionary( + uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in + let panelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + return (paneId.id.uuidString, panelIds) + } + ) + + let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + let tree = bonsplitController.treeSnapshot() + return SidebarBranchOrdering.orderedPanelIds( + tree: tree, + paneTabs: paneTabs, + fallbackPanelIds: fallbackPanelIds + ) + } + + func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + SidebarBranchOrdering + .orderedUniqueBranches( + orderedPanelIds: sidebarOrderedPanelIds(), + panelBranches: panelGitBranches, + fallbackBranch: gitBranch + ) + .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } + } + // MARK: - Panel Operations /// Create a new split with a terminal panel @@ -1357,6 +1461,10 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = targetPanel as? TerminalPanel { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) } + if let dir = panelDirectories[targetPanelId] { + currentDirectory = dir + } + gitBranch = panelGitBranches[targetPanelId] } /// Reconcile focus/first-responder convergence. @@ -1720,6 +1828,11 @@ extension Workspace: BonsplitDelegate { // frame where the pane has no selected content. bonsplitController.selectTab(selectTabId) applyTabSelection(tabId: selectTabId, inPane: pane) + } else if let focusedPane = bonsplitController.focusedPaneId, + let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + // When closing the last tab in a pane, Bonsplit may focus a different pane and skip + // emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync. + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) } if bonsplitController.allPaneIds.contains(pane) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 82e27d1c..992c257d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -783,53 +783,26 @@ final class AppearanceSettingsTests: XCTestCase { } final class UpdateChannelSettingsTests: XCTestCase { - func testDefaultNightlyPreferenceIsDisabled() { - XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) - } - - func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { - let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) - XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) + func testResolvedFeedFallsBackWhenInfoFeedMissing() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) XCTAssertFalse(resolved.isNightly) XCTAssertTrue(resolved.usedFallback) } func testResolvedFeedUsesInfoFeedForStableChannel() { - let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) XCTAssertEqual(resolved.url, infoFeed) XCTAssertFalse(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } - func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { - let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey) - let resolved = UpdateChannelSettings.resolvedFeedURLString( - infoFeedURL: "https://example.com/custom/appcast.xml", - defaults: defaults + func testResolvedFeedDetectsNightlyFromInfoFeedURL() { + let resolved = UpdateFeedResolver.resolvedFeedURLString( + infoFeedURL: "https://example.com/nightly/appcast.xml" ) - XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) + XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml") XCTAssertTrue(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } @@ -978,6 +951,97 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(workspace.gitBranch?.branch, "main") XCTAssertEqual(workspace.gitBranch?.isDirty, false) } + + func testSidebarGitBranchesFollowLeftToRightSplitOrder() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false) + guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true) + + let ordered = workspace.sidebarGitBranchesInDisplayOrder() + XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"]) + XCTAssertEqual(ordered.map(\.isDirty), [false, true]) + } + + func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() { + let workspace = Workspace() + guard let leftFirstPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), + let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), + let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { + XCTFail("Expected panes and panels for ordering test") + return + } + + XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0)) + XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1)) + XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0)) + XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1)) + + workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false) + workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true) + workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false) + + XCTAssertEqual( + workspace.sidebarOrderedPanelIds(), + [leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id] + ) + + let branches = workspace.sidebarGitBranchesInDisplayOrder() + XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"]) + XCTAssertEqual(branches.map(\.isDirty), [true, false, false]) + } +} + +final class SidebarBranchOrderingTests: XCTestCase { + + func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() { + let first = UUID() + let second = UUID() + let third = UUID() + + let branches = SidebarBranchOrdering.orderedUniqueBranches( + orderedPanelIds: [first, second, third], + panelBranches: [ + first: SidebarGitBranchState(branch: "main", isDirty: false), + second: SidebarGitBranchState(branch: "feature", isDirty: false), + third: SidebarGitBranchState(branch: "main", isDirty: true) + ], + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) + ) + + XCTAssertEqual( + branches, + [ + SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true), + SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false) + ] + ) + } + + func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() { + let branches = SidebarBranchOrdering.orderedUniqueBranches( + orderedPanelIds: [], + panelBranches: [:], + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true) + ) + + XCTAssertEqual( + branches, + [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)] + ) + } } @MainActor From e9da15d5633d72153b2ea99107640d9fcddc2e07 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:15:17 -0800 Subject: [PATCH 03/10] Clean up panel branch state when closing a pane --- Sources/Workspace.swift | 36 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 17 +++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d961cf52..c30b1dc3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -330,6 +330,9 @@ final class Workspace: Identifiable, ObservableObject { /// Deterministic tab selection to apply after a tab closes. /// Keyed by the closing tab ID, value is the tab ID we want to select next. private var postCloseSelectTabId: [TabID: TabID] = [:] + /// Panel IDs that were in a pane when a pane-close operation was approved. + /// Bonsplit pane-close does not emit per-tab didClose callbacks. + private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] private var isApplyingTabSelection = false private var pendingTabSelection: (tabId: TabID, pane: PaneID)? private var isReconcilingFocusState = false @@ -1880,7 +1883,36 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { - _ = paneId + let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] + + if !closedPanelIds.isEmpty { + for panelId in closedPanelIds { + panels[panelId]?.close() + panels.removeValue(forKey: panelId) + panelDirectories.removeValue(forKey: panelId) + panelGitBranches.removeValue(forKey: panelId) + panelTitles.removeValue(forKey: panelId) + panelCustomTitles.removeValue(forKey: panelId) + pinnedPanelIds.remove(panelId) + manualUnreadPanelIds.remove(panelId) + panelSubscriptions.removeValue(forKey: panelId) + surfaceTTYNames.removeValue(forKey: panelId) + surfaceListeningPorts.removeValue(forKey: panelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + } + + let closedSet = Set(closedPanelIds) + surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } + recomputeListeningPorts() + + if let focusedPane = bonsplitController.focusedPaneId, + let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) + } else { + scheduleFocusReconcile() + } + } + scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -1893,9 +1925,11 @@ extension Workspace: BonsplitDelegate { if let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId), terminalPanel.needsConfirmClose() { + pendingPaneClosePanelIds.removeValue(forKey: pane.id) return false } } + pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } return true } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 992c257d..fa85dee8 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1002,6 +1002,23 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"]) XCTAssertEqual(branches.map(\.isDirty), [true, false, false]) } + + func testClosingPaneDropsBranchesFromClosedSide() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected left/right split panes") + return + } + + workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false) + workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false) + + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"]) + XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId)) + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"]) + } } final class SidebarBranchOrderingTests: XCTestCase { From 5ca1616bd2c3a9b6c730959b4242073b1a43e4d0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:26:10 -0800 Subject: [PATCH 04/10] Add vertical sidebar branch layout setting --- Sources/ContentView.swift | 46 +++++++++++++++---- Sources/TabManager.swift | 12 +++++ Sources/cmuxApp.swift | 31 +++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 28 +++++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ec504198..e2da50e8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2164,6 +2164,7 @@ private struct TabItemView: View { @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true + @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @@ -2339,7 +2340,34 @@ private struct TabItemView: View { } // Branch + directory row - if let dirRow = branchDirectoryRow { + if sidebarBranchVerticalLayout { + if sidebarShowGitBranch, !gitBranchSummaryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(gitBranchSummaryLines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } + + if let dirs = directorySummaryText { + Text(dirs) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } else if let dirRow = branchDirectoryRow { HStack(spacing: 3) { if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { Image(systemName: "arrow.triangle.branch") @@ -2689,13 +2717,15 @@ private struct TabItemView: View { } private var gitBranchSummaryText: String? { - let branches = tab.sidebarGitBranchesInDisplayOrder() - guard !branches.isEmpty else { return nil } - return branches - .map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - .joined(separator: " | ") + let lines = gitBranchSummaryLines + guard !lines.isEmpty else { return nil } + return lines.joined(separator: " | ") + } + + private var gitBranchSummaryLines: [String] { + tab.sidebarGitBranchesInDisplayOrder().map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } } private var directorySummaryText: String? { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0ea116c6..62d7b1d2 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -51,6 +51,18 @@ enum WorkspaceAutoReorderSettings { } } +enum SidebarBranchLayoutSettings { + static let key = "sidebarBranchVerticalLayout" + static let defaultVerticalLayout = true + + static func usesVerticalLayout(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: key) == nil { + return defaultVerticalLayout + } + return defaults.bool(forKey: key) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 24841d43..0ae4a899 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1178,6 +1178,7 @@ private enum DebugWindowConfigSnapshot { sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000")) sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) + sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) @@ -1744,6 +1745,7 @@ private struct SidebarDebugView: View { @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @@ -1852,6 +1854,16 @@ private struct SidebarDebugView: View { .padding(.top, 2) } + GroupBox("Workspace Metadata") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout) + Text("When enabled, each branch appears on its own line in the sidebar.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.top, 2) + } + HStack(spacing: 12) { Button("Reset Tint") { sidebarTintOpacity = 0.62 @@ -1935,6 +1947,7 @@ private struct SidebarDebugView: View { sidebarTintHex=\(sidebarTintHex) sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) + sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) @@ -2428,6 +2441,7 @@ struct SettingsView: View { @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue + @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2518,6 +2532,22 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + + SettingsCardDivider() + + SettingsCardRow( + "Sidebar Branch Layout", + subtitle: sidebarBranchVerticalLayout + ? "Vertical: each branch appears on its own line." + : "Inline: all branches share one line." + ) { + Picker("", selection: $sidebarBranchVerticalLayout) { + Text("Vertical").tag(true) + Text("Inline").tag(false) + } + .labelsHidden() + .pickerStyle(.menu) + } } SettingsSectionHeader(title: "Automation") @@ -2869,6 +2899,7 @@ struct SettingsView: View { notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue + sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index fa85dee8..4beca450 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -765,6 +765,34 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase { } } +final class SidebarBranchLayoutSettingsTests: XCTestCase { + func testDefaultUsesVerticalLayout() { + let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + } + + func testStoredPreferenceOverridesDefault() { + let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: SidebarBranchLayoutSettings.key) + XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + + defaults.set(true, forKey: SidebarBranchLayoutSettings.key) + XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + } +} + final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" From b3c28a87238e1f14525b51217c53aac87e6176a4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:31:17 -0800 Subject: [PATCH 05/10] Render vertical sidebar rows as branch and directory --- Sources/ContentView.swift | 49 +++++++--- Sources/Workspace.swift | 92 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 76 +++++++++++++++ 3 files changed, 206 insertions(+), 11 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e2da50e8..7818bd91 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2341,15 +2341,15 @@ private struct TabItemView: View { // Branch + directory row if sidebarBranchVerticalLayout { - if sidebarShowGitBranch, !gitBranchSummaryLines.isEmpty { + if !branchDirectorySummaryLines.isEmpty { HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon { + if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) } VStack(alignment: .leading, spacing: 1) { - ForEach(Array(gitBranchSummaryLines.enumerated()), id: \.offset) { _, line in + ForEach(Array(branchDirectorySummaryLines.enumerated()), id: \.offset) { _, line in Text(line) .font(.system(size: 10, design: .monospaced)) .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) @@ -2359,14 +2359,6 @@ private struct TabItemView: View { } } } - - if let dirs = directorySummaryText { - Text(dirs) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) - } } else if let dirRow = branchDirectoryRow { HStack(spacing: 3) { if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { @@ -2728,6 +2720,41 @@ private struct TabItemView: View { } } + private var verticalBranchDirectoryEntries: [SidebarBranchOrdering.BranchDirectoryEntry] { + tab.sidebarBranchDirectoryEntriesInDisplayOrder() + } + + private var verticalRowsContainBranch: Bool { + sidebarShowGitBranch && verticalBranchDirectoryEntries.contains { $0.branch != nil } + } + + private var branchDirectorySummaryLines: [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return verticalBranchDirectoryEntries.compactMap { entry in + let branchText: String? = { + guard sidebarShowGitBranch, let branch = entry.branch else { return nil } + return "\(branch)\(entry.isDirty ? "*" : "")" + }() + + let directoryText: String? = { + guard let directory = entry.directory else { return nil } + let shortened = shortenPath(directory, home: home) + return shortened.isEmpty ? nil : shortened + }() + + switch (branchText, directoryText) { + case let (branch?, directory?): + return "\(branch) / \(directory)" + case let (branch?, nil): + return branch + case let (nil, directory?): + return directory + default: + return nil + } + } + } + private var directorySummaryText: String? { guard !tab.panels.isEmpty else { return nil } let home = FileManager.default.homeDirectoryForCurrentUser.path diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c30b1dc3..be5abe79 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -43,6 +43,12 @@ enum SidebarBranchOrdering { let isDirty: Bool } + struct BranchDirectoryEntry: Equatable { + let branch: String? + let isDirty: Bool + let directory: String? + } + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { switch tree { case .pane(let pane): @@ -110,6 +116,82 @@ enum SidebarBranchOrdering { BranchEntry(name: name, isDirty: branchDirty[name] ?? false) } } + + static func orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + panelDirectories: [UUID: String], + defaultDirectory: String?, + fallbackBranch: SidebarGitBranchState? + ) -> [BranchDirectoryEntry] { + struct EntryKey: Hashable { + let branch: String? + let directory: String? + } + + struct MutableEntry { + var branch: String? + var isDirty: Bool + var directory: String? + } + + func normalized(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + let normalizedFallbackBranch = normalized(fallbackBranch?.branch) + let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { + normalized(panelBranches[$0]?.branch) != nil + } + let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil + let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false + + var order: [EntryKey] = [] + var entries: [EntryKey: MutableEntry] = [:] + + for panelId in orderedPanelIds { + let panelBranch = normalized(panelBranches[panelId]?.branch) + let branch = panelBranch ?? defaultBranchForPanels + let directory = normalized(panelDirectories[panelId] ?? defaultDirectory) + guard branch != nil || directory != nil else { continue } + + let panelDirty = panelBranch != nil + ? (panelBranches[panelId]?.isDirty ?? false) + : defaultBranchDirty + + let key = EntryKey(branch: branch, directory: directory) + if entries[key] == nil { + order.append(key) + entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory) + } else if panelDirty { + entries[key]?.isDirty = true + } + } + + if order.isEmpty { + let fallbackDirectory = normalized(defaultDirectory) + if normalizedFallbackBranch != nil || fallbackDirectory != nil { + return [ + BranchDirectoryEntry( + branch: normalizedFallbackBranch, + isDirty: fallbackBranch?.isDirty ?? false, + directory: fallbackDirectory + ) + ] + } + } + + return order.compactMap { key in + guard let entry = entries[key] else { return nil } + return BranchDirectoryEntry( + branch: entry.branch, + isDirty: entry.isDirty, + directory: entry.directory + ) + } + } } /// Workspace represents a sidebar tab. @@ -694,6 +776,16 @@ final class Workspace: Identifiable, ObservableObject { .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } } + func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: sidebarOrderedPanelIds(), + panelBranches: panelGitBranches, + panelDirectories: panelDirectories, + defaultDirectory: currentDirectory, + fallbackBranch: gitBranch + ) + } + // MARK: - Panel Operations /// Create a new split with a terminal panel diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 4beca450..2dc8e56d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1087,6 +1087,82 @@ final class SidebarBranchOrderingTests: XCTestCase { [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)] ) } + + func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + let fifth = UUID() + + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [first, second, third, fourth, fifth], + panelBranches: [ + first: SidebarGitBranchState(branch: "main", isDirty: false), + second: SidebarGitBranchState(branch: "feature", isDirty: false), + third: SidebarGitBranchState(branch: "main", isDirty: true), + fourth: SidebarGitBranchState(branch: "main", isDirty: false) + ], + panelDirectories: [ + first: "/repo/a", + second: "/repo/b", + third: "/repo/a", + fourth: "/repo/d", + fifth: "/repo/e" + ], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) + ) + + XCTAssertEqual( + rows, + [ + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e") + ] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() { + let first = UUID() + let second = UUID() + + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [first, second], + panelBranches: [:], + panelDirectories: [ + first: "/repo/one", + second: "/repo/two" + ], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true) + ) + + XCTAssertEqual( + rows, + [ + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two") + ] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() { + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [], + panelBranches: [:], + panelDirectories: [:], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false) + ) + + XCTAssertEqual( + rows, + [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] + ) + } } @MainActor From 277e95d07e21874dcac71b934e733bd6eddbd72c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:35:14 -0800 Subject: [PATCH 06/10] Use non-path separator for branch directory rows --- Sources/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7818bd91..4da9e159 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2744,7 +2744,7 @@ private struct TabItemView: View { switch (branchText, directoryText) { case let (branch?, directory?): - return "\(branch) / \(directory)" + return "\(branch) @ \(directory)" case let (branch?, nil): return branch case let (nil, directory?): From 5ffae27d647e84764774bfdbcef21b85a837430d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:39:26 -0800 Subject: [PATCH 07/10] Use dot icon separator between branch and directory --- Sources/ContentView.swift | 45 ++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4da9e159..01b4c18d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2341,7 +2341,7 @@ private struct TabItemView: View { // Branch + directory row if sidebarBranchVerticalLayout { - if !branchDirectorySummaryLines.isEmpty { + if !verticalBranchDirectoryLines.isEmpty { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { Image(systemName: "arrow.triangle.branch") @@ -2349,12 +2349,28 @@ private struct TabItemView: View { .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) } VStack(alignment: .leading, spacing: 1) { - ForEach(Array(branchDirectorySummaryLines.enumerated()), id: \.offset) { _, line in - Text(line) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) + ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 4) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 4)) + .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } } } } @@ -2725,10 +2741,15 @@ private struct TabItemView: View { } private var verticalRowsContainBranch: Bool { - sidebarShowGitBranch && verticalBranchDirectoryEntries.contains { $0.branch != nil } + sidebarShowGitBranch && verticalBranchDirectoryLines.contains { $0.branch != nil } } - private var branchDirectorySummaryLines: [String] { + private struct VerticalBranchDirectoryLine { + let branch: String? + let directory: String? + } + + private var verticalBranchDirectoryLines: [VerticalBranchDirectoryLine] { let home = FileManager.default.homeDirectoryForCurrentUser.path return verticalBranchDirectoryEntries.compactMap { entry in let branchText: String? = { @@ -2744,11 +2765,11 @@ private struct TabItemView: View { switch (branchText, directoryText) { case let (branch?, directory?): - return "\(branch) @ \(directory)" + return VerticalBranchDirectoryLine(branch: branch, directory: directory) case let (branch?, nil): - return branch + return VerticalBranchDirectoryLine(branch: branch, directory: nil) case let (nil, directory?): - return directory + return VerticalBranchDirectoryLine(branch: nil, directory: directory) default: return nil } From 7cb1abca374ad881411c053f4942fc9930e47e8e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:20:58 -0800 Subject: [PATCH 08/10] Use smaller separator dot for branch directory rows --- Sources/ContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 01b4c18d..9c8c7e8c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2350,7 +2350,7 @@ private struct TabItemView: View { } VStack(alignment: .leading, spacing: 1) { ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 4) { + HStack(spacing: 3) { if let branch = line.branch { Text(branch) .font(.system(size: 10, design: .monospaced)) @@ -2360,7 +2360,7 @@ private struct TabItemView: View { } if line.branch != nil, line.directory != nil { Image(systemName: "circle.fill") - .font(.system(size: 4)) + .font(.system(size: 3)) .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) } if let directory = line.directory { From 868c2c9d1144f65de9d94e65872ec176bc1d1da9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:26:47 -0800 Subject: [PATCH 09/10] Tighten spacing around branch separator dot --- Sources/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9c8c7e8c..00d34d8b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2362,6 +2362,7 @@ private struct TabItemView: View { Image(systemName: "circle.fill") .font(.system(size: 3)) .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .padding(.horizontal, 1) } if let directory = line.directory { Text(directory) From 9a8005a57f87fa7b99e3b1f4439ae061079be8c9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:14:12 -0800 Subject: [PATCH 10/10] Update Discord invite links to new permanent URL (#238) Replace old discord.com/invite/QRxkhZgY with discord.gg/xsgFEVrWCZ across README, community page, and nav footer. --- README.md | 2 +- web/app/community/page.tsx | 2 +- web/app/components/nav-links.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 14cecfab..31578c73 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the ## Community -- [Discord](https://discord.com/invite/QRxkhZgY) +- [Discord](https://discord.gg/xsgFEVrWCZ) - [GitHub](https://github.com/manaflow-ai/cmux) - [X / Twitter](https://twitter.com/manaflowai) - [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) diff --git a/web/app/community/page.tsx b/web/app/community/page.tsx index a46fd614..b344ace8 100644 --- a/web/app/community/page.tsx +++ b/web/app/community/page.tsx @@ -54,7 +54,7 @@ export default function CommunityPage() {
Twitter - Discord + Discord Privacy Terms EULA