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/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/ContentView.swift b/Sources/ContentView.swift index 4ce19f3f..837b366a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2458,6 +2458,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 @@ -2633,9 +2634,45 @@ private struct TabItemView: View { } // Branch + directory row - if let dirRow = branchDirectoryRow { + if sidebarBranchVerticalLayout { + if !verticalBranchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + 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(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + 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: 3)) + .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .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 && 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) @@ -2969,9 +3006,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 @@ -2983,12 +3019,64 @@ private struct TabItemView: View { return result.isEmpty ? nil : result } + private var gitBranchSummaryText: String? { + 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 verticalBranchDirectoryEntries: [SidebarBranchOrdering.BranchDirectoryEntry] { + tab.sidebarBranchDirectoryEntriesInDisplayOrder() + } + + private var verticalRowsContainBranch: Bool { + sidebarShowGitBranch && verticalBranchDirectoryLines.contains { $0.branch != nil } + } + + 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? = { + 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 VerticalBranchDirectoryLine(branch: branch, directory: directory) + case let (branch?, nil): + return VerticalBranchDirectoryLine(branch: branch, directory: nil) + case let (nil, directory?): + return VerticalBranchDirectoryLine(branch: nil, directory: directory) + default: + return nil + } + } + } + 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/TabManager.swift b/Sources/TabManager.swift index ab3da8bd..016765cd 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/TerminalController.swift b/Sources/TerminalController.swift index 5d524703..3217b154 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -7648,8 +7648,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 @@ -10696,7 +10696,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" @@ -10706,24 +10706,76 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - guard Self.shouldReplaceGitBranch(current: tab.gitBranch, branch: branch, isDirty: isDirty) else { + 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.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) + + 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 } - if tab.gitBranch != nil { - 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 } @@ -11061,6 +11113,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 092ed8c7..6e8a71e9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -37,6 +37,163 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + 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): + 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) + } + } + + 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 + ) + } + } +} + struct ClosedBrowserPanelRestoreSnapshot { let workspaceId: UUID let url: URL? @@ -110,6 +267,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] = [:] @@ -270,6 +428,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 pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false private var pendingTabSelection: (tabId: TabID, pane: PaneID)? @@ -564,6 +725,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) @@ -608,6 +787,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) } manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } @@ -622,6 +802,45 @@ final class Workspace: Identifiable, ObservableObject { } } + 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) } + } + + 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 @@ -1504,6 +1723,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. @@ -1724,6 +1947,7 @@ extension Workspace: BonsplitDelegate { if let dir = panelDirectories[panelId] { currentDirectory = dir } + gitBranch = panelGitBranches[panelId] // Post notification NotificationCenter.default.post( @@ -1861,6 +2085,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) @@ -1893,6 +2118,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) { @@ -1940,7 +2170,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() } @@ -1953,9 +2212,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 } @@ -2181,9 +2442,6 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id) else { return } let shouldPin = !pinnedPanelIds.contains(panelId) setPanelPinned(panelId: panelId, pinned: shouldPin) - case .markAsRead: - guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - markPanelRead(panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 8dfe3bc8..48ec051c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1183,6 +1183,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))) @@ -1749,6 +1750,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 @@ -1857,6 +1859,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 @@ -1940,6 +1952,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))) @@ -2433,6 +2446,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? @@ -2523,6 +2537,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") @@ -2874,6 +2904,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 6f810a8e..f58a3c5e 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)" @@ -783,53 +811,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) } @@ -953,6 +954,217 @@ 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) + } + + 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]) + } + + 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 { + + 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)] + ) + } + + 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 final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { func testRequestPersistsUntilAcknowledged() { diff --git a/vendor/bonsplit b/vendor/bonsplit index 4ceff319..dd20247b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 4ceff31931f8b873e450c81f60148be3f8ce9cdb +Subproject commit dd20247b5536b4bd5b9b15cdf940e847daa1a18d 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