Merge origin/main and resolve reopen-focus conflicts

This commit is contained in:
Lawrence Chen 2026-02-21 03:08:14 -08:00
commit e9f25ef67f
23 changed files with 2152 additions and 278 deletions

View file

@ -95,6 +95,22 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
## Socket command threading policy
- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates).
- For telemetry hot paths:
- Parse and validate arguments off-main.
- Dedupe/coalesce off-main first.
- Schedule minimal UI/model mutation with `DispatchQueue.main.async` only when needed.
- Commands that directly manipulate AppKit/Ghostty UI state (focus/select/open/close/send key/input, list/current queries requiring exact synchronous snapshot) are allowed to run on main actor.
- If adding a new socket command, default to off-main handling; require an explicit reason in code comments when main-thread execution is necessary.
## Socket focus policy
- Socket/CLI commands must not steal macOS app focus (no app activation/window raising side effects).
- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents).
- All non-focus commands should preserve current user focus context while still applying data/model changes.
## E2E mac UI tests
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:

View file

@ -589,6 +589,9 @@ struct CMUXCLI {
case "tab-action":
try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "rename-tab":
try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "list-workspaces":
let payload = try client.sendV2(method: "workspace.list")
if jsonOutput {
@ -1727,6 +1730,55 @@ struct CMUXCLI {
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
}
private func runRenameTab(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
let (titleOpt, rem3) = parseOption(rem2, name: "--title")
if rem3.contains("--action") {
throw CLIError(message: "rename-tab does not accept --action (it always performs rename)")
}
if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
throw CLIError(message: "rename-tab: unknown flag '\(unknown)'")
}
let inferredTitle = rem3
.dropFirst(rem3.first == "--" ? 1 : 0)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let title, !title.isEmpty else {
throw CLIError(message: "rename-tab requires a title")
}
var forwarded: [String] = ["--action", "rename", "--title", title]
if let workspaceOpt {
forwarded += ["--workspace", workspaceOpt]
}
if let tabOpt {
forwarded += ["--tab", tabOpt]
} else if let surfaceOpt {
forwarded += ["--surface", surfaceOpt]
}
try runTabAction(
commandArgs: forwarded,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
}
private func runBrowserCommand(
commandArgs: [String],
client: SocketClient,
@ -3046,6 +3098,26 @@ struct CMUXCLI {
cmux tab-action --action close-right
cmux tab-action --tab tab:2 --action rename --title "build logs"
"""
case "rename-tab":
return """
Usage: cmux rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title>
Rename a tab (surface). Defaults to the focused tab, using:
1) explicit --tab/--surface
2) $CMUX_TAB_ID / $CMUX_SURFACE_ID
3) focused tab in the resolved workspace context
Flags:
--workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--tab <id|ref> Target tab (accepts tab:<n> or surface:<n>)
--surface <id|ref> Alias for --tab
--title <text> New title (or pass trailing title)
Example:
cmux rename-tab "build logs"
cmux rename-tab --tab tab:3 "staging server"
cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run"
"""
case "new-workspace":
return """
Usage: cmux new-workspace
@ -4320,6 +4392,7 @@ struct CMUXCLI {
move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>]
reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>)
tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>]
rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title>
drag-surface-to-split --surface <id|ref> <left|right|up|down>
refresh-surfaces
surface-health [--workspace <id|ref>]
@ -4410,7 +4483,7 @@ struct CMUXCLI {
Environment:
CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for
ALL commands (send, list-panels, new-split, notify, etc.).
CMUX_TAB_ID Optional alias used by `tab-action` as default --tab.
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
"""

View file

@ -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)

View file

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

View file

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

View file

@ -562,6 +562,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
func focusMainWindow(windowId: UUID) -> Bool {
guard let window = windowForMainWindowId(windowId) else { return false }
if TerminalController.shouldSuppressSocketCommandActivation() {
if window.isMiniaturized {
window.deminiaturize(nil)
}
if TerminalController.socketCommandAllowsInAppFocusMutations() {
window.orderFront(nil)
setActiveMainWindow(window)
}
return true
}
bringToFront(window)
return true
}
@ -736,9 +746,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sidebarSelectionState: sidebarSelectionState
)
installFileDropOverlay(on: window, tabManager: tabManager)
window.makeKeyAndOrderFront(nil)
setActiveMainWindow(window)
NSApp.activate(ignoringOtherApps: true)
if TerminalController.shouldSuppressSocketCommandActivation() {
window.orderFront(nil)
if TerminalController.socketCommandAllowsInAppFocusMutations() {
setActiveMainWindow(window)
}
} else {
window.makeKeyAndOrderFront(nil)
setActiveMainWindow(window)
NSApp.activate(ignoringOtherApps: true)
}
return windowId
}

View file

@ -22,8 +22,12 @@ private func browserPortalDebugFrame(_ rect: NSRect) -> String {
final class WindowBrowserHostView: NSView {
override var isOpaque: Bool { false }
private var cachedSidebarDividerX: CGFloat?
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
@ -31,6 +35,30 @@ final class WindowBrowserHostView: NSView {
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone.
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// Ignore transient 0-origin slots during layout churn and preserve the last
// known-good divider edge.
let dividerCandidates = visibleSlots
.map(\.frame.minX)
.filter { $0 > 1 }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
return point.x >= regionMinX && point.x <= regionMaxX
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)

View file

@ -159,6 +159,15 @@ final class SidebarState: ObservableObject {
}
}
enum SidebarResizeInteraction {
static let handleWidth: CGFloat = 6
static let hitInset: CGFloat = 3
static var hitWidthPerSide: CGFloat {
hitInset + (handleWidth / 2)
}
}
// MARK: - File Drop Overlay
enum DragOverlayRoutingPolicy {
@ -272,6 +281,8 @@ final class FileDropOverlayView: NSView {
/// Fallback handler when no terminal is found under the drop point.
var onDrop: (([URL]) -> Bool)?
private var isForwardingMouseEvent = false
private weak var forwardedMouseDragTarget: NSView?
private var forwardedMouseDragButton: ForwardedMouseDragButton?
/// The WKWebView currently receiving forwarded drag events, so we can
/// synthesize draggingExited/draggingEntered as the cursor moves.
private weak var activeDragWebView: WKWebView?
@ -287,6 +298,43 @@ final class FileDropOverlayView: NSView {
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
private enum ForwardedMouseDragButton: Equatable {
case left
case right
case other(Int)
}
private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? {
switch event.type {
case .leftMouseDown, .leftMouseUp, .leftMouseDragged:
return .left
case .rightMouseDown, .rightMouseUp, .rightMouseDragged:
return .right
case .otherMouseDown, .otherMouseUp, .otherMouseDragged:
return .other(Int(event.buttonNumber))
default:
return nil
}
}
private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
return true
default:
return false
}
}
private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseUp, .rightMouseUp, .otherMouseUp:
return true
default:
return false
}
}
// MARK: Hit-testing participation is routed by DragOverlayRoutingPolicy so
// file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict.
@ -317,6 +365,7 @@ final class FileDropOverlayView: NSView {
private func forwardEvent(_ event: NSEvent) {
guard !isForwardingMouseEvent else { return }
guard let window, let contentView = window.contentView else { return }
let eventButton = dragButton(for: event)
isForwardingMouseEvent = true
isHidden = true
@ -325,9 +374,33 @@ final class FileDropOverlayView: NSView {
isForwardingMouseEvent = false
}
let point = contentView.convert(event.locationInWindow, from: nil)
let target = contentView.hitTest(point)
guard let target, target !== self else { return }
let target: NSView?
if let eventButton,
forwardedMouseDragButton == eventButton,
let activeTarget = forwardedMouseDragTarget,
activeTarget.window != nil {
// Preserve normal AppKit mouse-delivery semantics: once a drag starts,
// keep routing dragged/up events to the original mouseDown target.
target = activeTarget
} else {
let point = contentView.convert(event.locationInWindow, from: nil)
target = contentView.hitTest(point)
}
guard let target, target !== self else {
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
}
return
}
if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton {
forwardedMouseDragTarget = target
forwardedMouseDragButton = eventButton
}
switch event.type {
case .leftMouseDown: target.mouseDown(with: event)
@ -342,6 +415,13 @@ final class FileDropOverlayView: NSView {
case .scrollWheel: target.scrollWheel(with: event)
default: break
}
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
}
}
override func mouseDown(with event: NSEvent) { forwardEvent(event) }
@ -723,10 +803,9 @@ struct ContentView: View {
@EnvironmentObject var sidebarState: SidebarState
@EnvironmentObject var sidebarSelectionState: SidebarSelectionState
@State private var sidebarWidth: CGFloat = 200
@State private var sidebarMinX: CGFloat = 0
@State private var isResizerHovering = false
@State private var hoveredResizerHandles: Set<SidebarResizerHandle> = []
@State private var isResizerDragging = false
private let sidebarHandleWidth: CGFloat = 6
@State private var sidebarDragStartWidth: CGFloat?
@State private var selectedTabIds: Set<UUID> = []
@State private var mountedWorkspaceIds: [UUID] = []
@State private var lastSidebarSelectionIndex: Int? = nil
@ -742,6 +821,252 @@ struct ContentView: View {
@State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@State private var titlebarThemeUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem?
@State private var sidebarResizerPointerMonitor: Any?
@State private var isResizerBandActive = false
@State private var sidebarResizerCursorStabilizer: DispatchSourceTimer?
private static let fixedSidebarResizeCursor = NSCursor(
image: NSCursor.resizeLeftRight.image,
hotSpot: NSCursor.resizeLeftRight.hotSpot
)
private enum SidebarResizerHandle: Hashable {
case divider
}
private var sidebarResizerHitWidthPerSide: CGFloat {
SidebarResizeInteraction.hitWidthPerSide
}
private var maxSidebarWidth: CGFloat {
(NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
}
private func activateSidebarResizerCursor() {
sidebarResizerCursorReleaseWorkItem?.cancel()
sidebarResizerCursorReleaseWorkItem = nil
Self.fixedSidebarResizeCursor.set()
}
private func releaseSidebarResizerCursorIfNeeded(force: Bool = false) {
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
let shouldKeepCursor = !force
&& (isResizerDragging || isResizerBandActive || !hoveredResizerHandles.isEmpty || isLeftMouseButtonDown)
guard !shouldKeepCursor else { return }
NSCursor.arrow.set()
}
private func scheduleSidebarResizerCursorRelease(force: Bool = false, delay: TimeInterval = 0) {
sidebarResizerCursorReleaseWorkItem?.cancel()
let workItem = DispatchWorkItem {
sidebarResizerCursorReleaseWorkItem = nil
releaseSidebarResizerCursorIfNeeded(force: force)
}
sidebarResizerCursorReleaseWorkItem = workItem
if delay > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
} else {
DispatchQueue.main.async(execute: workItem)
}
}
private func dividerBandContains(pointInContent point: NSPoint, contentBounds: NSRect) -> Bool {
guard point.y >= contentBounds.minY, point.y <= contentBounds.maxY else { return false }
let minX = sidebarWidth - sidebarResizerHitWidthPerSide
let maxX = sidebarWidth + sidebarResizerHitWidthPerSide
return point.x >= minX && point.x <= maxX
}
private func updateSidebarResizerBandState(using event: NSEvent? = nil) {
guard sidebarState.isVisible,
let window = observedWindow,
let contentView = window.contentView else {
isResizerBandActive = false
scheduleSidebarResizerCursorRelease(force: true)
return
}
// Use live global pointer location instead of per-event coordinates.
// Overlapping tracking areas (notably WKWebView) can deliver stale/jittery
// event locations during cursor updates, which causes visible cursor flicker.
let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
let pointInContent = contentView.convert(pointInWindow, from: nil)
let isInDividerBand = dividerBandContains(pointInContent: pointInContent, contentBounds: contentView.bounds)
isResizerBandActive = isInDividerBand
if isInDividerBand || isResizerDragging {
activateSidebarResizerCursor()
startSidebarResizerCursorStabilizer()
// AppKit cursorUpdate handlers from overlapped portal/web views can run
// after our local monitor callback and temporarily reset the cursor.
// Re-assert on the next runloop turn to keep the resize cursor stable.
DispatchQueue.main.async {
Self.fixedSidebarResizeCursor.set()
}
} else {
stopSidebarResizerCursorStabilizer()
scheduleSidebarResizerCursorRelease()
}
}
private func startSidebarResizerCursorStabilizer() {
guard sidebarResizerCursorStabilizer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2))
timer.setEventHandler {
updateSidebarResizerBandState()
if isResizerBandActive || isResizerDragging {
Self.fixedSidebarResizeCursor.set()
} else {
stopSidebarResizerCursorStabilizer()
}
}
sidebarResizerCursorStabilizer = timer
timer.resume()
}
private func stopSidebarResizerCursorStabilizer() {
sidebarResizerCursorStabilizer?.cancel()
sidebarResizerCursorStabilizer = nil
}
private func installSidebarResizerPointerMonitorIfNeeded() {
guard sidebarResizerPointerMonitor == nil else { return }
observedWindow?.acceptsMouseMovedEvents = true
sidebarResizerPointerMonitor = NSEvent.addLocalMonitorForEvents(
matching: [
.mouseMoved,
.mouseEntered,
.mouseExited,
.cursorUpdate,
.appKitDefined,
.systemDefined,
.leftMouseDown,
.leftMouseUp,
.leftMouseDragged,
]
) { event in
updateSidebarResizerBandState(using: event)
let shouldOverrideCursorEvent: Bool = {
switch event.type {
case .cursorUpdate, .mouseMoved, .mouseEntered, .mouseExited, .appKitDefined, .systemDefined:
return true
default:
return false
}
}()
if shouldOverrideCursorEvent, (isResizerBandActive || isResizerDragging) {
// Consume hover motion in divider band so overlapped views cannot
// continuously reassert their own cursor while we are resizing.
activateSidebarResizerCursor()
Self.fixedSidebarResizeCursor.set()
return nil
}
return event
}
updateSidebarResizerBandState()
}
private func removeSidebarResizerPointerMonitor() {
if let monitor = sidebarResizerPointerMonitor {
NSEvent.removeMonitor(monitor)
sidebarResizerPointerMonitor = nil
}
isResizerBandActive = false
stopSidebarResizerCursorStabilizer()
scheduleSidebarResizerCursorRelease(force: true)
}
private func sidebarResizerHandleOverlay(
_ handle: SidebarResizerHandle,
width: CGFloat,
accessibilityIdentifier: String? = nil
) -> some View {
Color.clear
.frame(width: width)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.onHover { hovering in
if hovering {
hoveredResizerHandles.insert(handle)
activateSidebarResizerCursor()
} else {
hoveredResizerHandles.remove(handle)
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
if isLeftMouseButtonDown {
// Keep resize cursor pinned through mouse-down so AppKit
// cursorUpdate events from overlapping views do not flash arrow.
activateSidebarResizerCursor()
} else {
// Give mouse-down + drag-start callbacks time to establish state
// before any cursor pop is attempted.
scheduleSidebarResizerCursorRelease(delay: 0.05)
}
}
updateSidebarResizerBandState()
}
.onDisappear {
hoveredResizerHandles.remove(handle)
isResizerDragging = false
sidebarDragStartWidth = nil
isResizerBandActive = false
scheduleSidebarResizerCursorRelease(force: true)
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
sidebarDragStartWidth = sidebarWidth
#if DEBUG
dlog("sidebar.resizeDragStart")
#endif
}
activateSidebarResizerCursor()
let startWidth = sidebarDragStartWidth ?? sidebarWidth
let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width))
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
}
.onEnded { _ in
if isResizerDragging {
isResizerDragging = false
sidebarDragStartWidth = nil
}
activateSidebarResizerCursor()
scheduleSidebarResizerCursorRelease()
}
)
.modifier(SidebarResizerAccessibilityModifier(accessibilityIdentifier: accessibilityIdentifier))
}
private var sidebarResizerOverlay: some View {
GeometryReader { proxy in
let totalWidth = max(0, proxy.size.width)
let dividerX = min(max(sidebarWidth, 0), totalWidth)
let leadingWidth = max(0, dividerX - sidebarResizerHitWidthPerSide)
HStack(spacing: 0) {
Color.clear
.frame(width: leadingWidth)
.allowsHitTesting(false)
sidebarResizerHandleOverlay(
.divider,
width: sidebarResizerHitWidthPerSide * 2,
accessibilityIdentifier: "SidebarResizer"
)
Color.clear
.frame(maxWidth: .infinity)
.allowsHitTesting(false)
}
.frame(width: totalWidth, height: proxy.size.height, alignment: .leading)
}
}
private var sidebarView: some View {
VerticalTabsSidebar(
@ -751,64 +1076,6 @@ struct ContentView: View {
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(width: sidebarWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global))
})
.overlay(alignment: .trailing) {
Color.clear
.frame(width: sidebarHandleWidth)
.contentShape(Rectangle())
.accessibilityIdentifier("SidebarResizer")
.onHover { hovering in
if hovering {
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
}
} else if isResizerHovering {
if !isResizerDragging {
NSCursor.pop()
isResizerHovering = false
}
}
}
.onDisappear {
if isResizerHovering || isResizerDragging {
NSCursor.pop()
isResizerHovering = false
isResizerDragging = false
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
#if DEBUG
dlog("sidebar.resizeDragStart")
#endif
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
}
}
let maxSidebarWidth = (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
let nextWidth = max(186, min(maxSidebarWidth, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
}
.onEnded { _ in
if isResizerDragging {
isResizerDragging = false
if !isResizerHovering {
NSCursor.pop()
}
}
}
)
}
}
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
@ -998,10 +1265,11 @@ struct ContentView: View {
}
private var contentAndSidebarLayout: AnyView {
let layout: AnyView
if sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue {
// Overlay mode: terminal extends full width, sidebar on top
// This allows withinWindow blur to see the terminal content
return AnyView(
layout = AnyView(
ZStack(alignment: .leading) {
terminalContentWithSidebarDropOverlay
.padding(.leading, sidebarState.isVisible ? sidebarWidth : 0)
@ -1010,16 +1278,26 @@ struct ContentView: View {
}
}
)
} else {
// Standard HStack mode for behindWindow blur
layout = AnyView(
HStack(spacing: 0) {
if sidebarState.isVisible {
sidebarView
}
terminalContentWithSidebarDropOverlay
}
)
}
// Standard HStack mode for behindWindow blur
return AnyView(
HStack(spacing: 0) {
if sidebarState.isVisible {
sidebarView
layout
.overlay(alignment: .leading) {
if sidebarState.isVisible {
sidebarResizerOverlay
.zIndex(1000)
}
}
terminalContentWithSidebarDropOverlay
}
)
}
@ -1041,6 +1319,7 @@ struct ContentView: View {
tabManager.applyWindowBackgroundForSelectedTab()
reconcileMountedWorkspaceIds()
previousSelectedWorkspaceId = tabManager.selectedTabId
installSidebarResizerPointerMonitorIfNeeded()
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
@ -1155,10 +1434,6 @@ struct ContentView: View {
#endif
})
view = AnyView(view.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
sidebarMinX = frame.minX
})
view = AnyView(view.onChange(of: bgGlassTintHex) { _ in
updateWindowGlassTint()
})
@ -1183,8 +1458,20 @@ struct ContentView: View {
AppDelegate.shared?.fullscreenControlsViewModel = nil
})
view = AnyView(view.onChange(of: sidebarWidth) { _ in
updateSidebarResizerBandState()
})
view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in
updateSidebarResizerBandState()
})
view = AnyView(view.ignoresSafeArea())
view = AnyView(view.onDisappear {
removeSidebarResizerPointerMonitor()
})
view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
window.titlebarAppearsTransparent = true
@ -1198,6 +1485,8 @@ struct ContentView: View {
DispatchQueue.main.async {
observedWindow = window
isFullScreen = window.styleMask.contains(.fullScreen)
installSidebarResizerPointerMonitorIfNeeded()
updateSidebarResizerBandState()
}
}
@ -1414,6 +1703,19 @@ struct ContentView: View {
#endif
}
private struct SidebarResizerAccessibilityModifier: ViewModifier {
let accessibilityIdentifier: String?
@ViewBuilder
func body(content: Content) -> some View {
if let accessibilityIdentifier {
content.accessibilityIdentifier(accessibilityIdentifier)
} else {
content
}
}
}
struct VerticalTabsSidebar: View {
@ObservedObject var updateViewModel: UpdateViewModel
@EnvironmentObject var tabManager: TabManager
@ -2048,14 +2350,6 @@ private struct SidebarTopBlurEffect: NSViewRepresentable {
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
private struct SidebarFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct SidebarScrollViewResolver: NSViewRepresentable {
let onResolve: (NSScrollView?) -> Void
@ -2164,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
@ -2339,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)
@ -2675,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
@ -2689,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<String> = []
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 }

View file

@ -2633,6 +2633,27 @@ struct WebViewRepresentable: NSViewRepresentable {
super.setFrameSize(newSize)
onGeometryChanged?()
}
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
return super.hitTest(point)
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Pass through a narrow leading-edge band so the shared sidebar divider
// handle can receive hover/click even when WKWebView is attached here.
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else {
return false
}
guard let window, let contentView = window.contentView else {
return false
}
let hostRectInContent = contentView.convert(bounds, from: self)
return hostRectInContent.minX > 1
}
}
#if DEBUG

View file

@ -49,6 +49,7 @@ final class PortScanner: @unchecked Sendable {
func registerTTY(workspaceId: UUID, panelId: UUID, ttyName: String) {
queue.async { [self] in
let key = PanelKey(workspaceId: workspaceId, panelId: panelId)
guard ttyNames[key] != ttyName else { return }
ttyNames[key] = ttyName
}
}

View file

@ -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
@ -490,7 +502,7 @@ class TabManager: ObservableObject {
}
@discardableResult
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil) -> Workspace {
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
@ -502,17 +514,19 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
)
if select {
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
)
}
#if DEBUG
UITestRecorder.incrementInt("addTabInvocations")
UITestRecorder.record([
"tabCount": String(tabs.count),
"selectedTabId": newWorkspace.id.uuidString
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
])
#endif
return newWorkspace
@ -520,7 +534,7 @@ class TabManager: ObservableObject {
// Keep addTab as convenience alias
@discardableResult
func addTab() -> Workspace { addWorkspace() }
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
guard let directory else { return nil }
@ -1491,12 +1505,13 @@ class TabManager: ObservableObject {
/// Create a new split in the specified direction
/// Returns the new panel's ID (which is also the surface ID for terminals)
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? {
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst
insertFirst: direction.insertFirst,
focus: focus
)?.id
}
@ -1547,14 +1562,16 @@ class TabManager: ObservableObject {
fromPanelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil
url: URL? = nil,
focus: Bool = true
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSplit(
from: fromPanelId,
orientation: orientation,
insertFirst: insertFirst,
url: url
url: url,
focus: focus
)?.id
}

File diff suppressed because it is too large Load diff

View file

@ -21,11 +21,16 @@ private func portalDebugFrame(_ rect: NSRect) -> String {
final class WindowTerminalHostView: NSView {
override var isOpaque: Bool { false }
private var cachedSidebarDividerX: CGFloat?
#if DEBUG
private var lastDragRouteSignature: String?
#endif
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
@ -60,6 +65,32 @@ final class WindowTerminalHostView: NSView {
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// The sidebar resizer handle is implemented in SwiftUI. When terminals
// are portal-hosted, this AppKit host can otherwise sit above the handle
// and steal hover/mouse events.
let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// Ignore transient 0-origin hosts while layouts churn (e.g. workspace
// creation/switching). They can temporarily report minX=0 and would
// otherwise clear divider pass-through, causing hover flicker.
let dividerCandidates = visibleHostedViews
.map(\.frame.minX)
.filter { $0 > 1 }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
return point.x >= regionMinX && point.x <= regionMaxX
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)

View file

@ -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<UUID> = []
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)?
@ -468,6 +629,12 @@ final class Workspace: Identifiable, ObservableObject {
return surfaceKind(for: panel)
}
func panelTitle(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
let fallback = panelTitles[panelId] ?? panel.displayTitle
return resolvedPanelTitle(panelId: panelId, fallback: fallback)
}
func setPanelPinned(panelId: UUID, pinned: Bool) {
guard panels[panelId] != nil else { return }
let wasPinned = pinnedPanelIds.contains(panelId)
@ -559,11 +726,29 @@ final class Workspace: Identifiable, ObservableObject {
panelDirectories[panelId] = trimmed
}
// Update current directory if this is the focused panel
if panelId == focusedPanelId {
if panelId == focusedPanelId, currentDirectory != trimmed {
currentDirectory = trimmed
}
}
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 +793,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) }
@ -616,7 +802,49 @@ final class Workspace: Identifiable, ObservableObject {
func recomputeListeningPorts() {
let unique = Set(surfaceListeningPorts.values.flatMap { $0 })
listeningPorts = unique.sorted()
let next = unique.sorted()
if listeningPorts != next {
listeningPorts = next
}
}
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
@ -626,7 +854,8 @@ final class Workspace: Identifiable, ObservableObject {
func newTerminalSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false
insertFirst: Bool = false,
focus: Bool = true
) -> TerminalPanel? {
// Get inherited config from the source terminal when possible.
// If the split is initiated from a non-terminal panel (for example browser),
@ -699,10 +928,14 @@ final class Workspace: Identifiable, ObservableObject {
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
// stealing focus from the new panel and creating model/surface divergence.
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
}
return newPanel
@ -771,7 +1004,8 @@ final class Workspace: Identifiable, ObservableObject {
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil
url: URL? = nil,
focus: Bool = true
) -> BrowserPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
@ -815,10 +1049,14 @@ final class Workspace: Identifiable, ObservableObject {
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
}
installBrowserPanelSubscription(browserPanel)
@ -1501,6 +1739,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.
@ -1721,6 +1963,7 @@ extension Workspace: BonsplitDelegate {
if let dir = panelDirectories[panelId] {
currentDirectory = dir
}
gitBranch = panelGitBranches[panelId]
// Post notification
NotificationCenter.default.post(
@ -1858,6 +2101,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)
@ -1890,6 +2134,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) {
@ -1937,7 +2186,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()
}
@ -1950,9 +2228,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
}
@ -2178,12 +2458,12 @@ 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)
case .markAsRead:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
markPanelRead(panelId)
}
}

View file

@ -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()
}

View file

@ -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)"
@ -782,15 +810,15 @@ final class AppearanceSettingsTests: XCTestCase {
}
}
final class UpdateFeedResolverTests: XCTestCase {
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
final class UpdateChannelSettingsTests: XCTestCase {
func testResolvedFeedFallsBackWhenInfoFeedMissing() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
XCTAssertFalse(resolved.isNightly)
XCTAssertTrue(resolved.usedFallback)
}
func testResolvedFeedFallsBackToStableWhenInfoFeedEmpty() {
func testResolvedFeedFallsBackWhenInfoFeedEmpty() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "")
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
XCTAssertFalse(resolved.isNightly)
@ -805,10 +833,11 @@ final class UpdateFeedResolverTests: XCTestCase {
XCTAssertFalse(resolved.usedFallback)
}
func testResolvedFeedDetectsNightlyChannelFromFeedURL() {
let infoFeed = "https://example.com/nightly/appcast.xml"
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
XCTAssertEqual(resolved.url, infoFeed)
func testResolvedFeedDetectsNightlyFromInfoFeedURL() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(
infoFeedURL: "https://example.com/nightly/appcast.xml"
)
XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml")
XCTAssertTrue(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
@ -1024,6 +1053,217 @@ final class TabManagerReopenClosedBrowserFocusTests: 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() {
@ -3079,3 +3319,131 @@ final class BrowserHostWhitelistTests: XCTestCase {
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults))
}
}
final class TerminalControllerSidebarDedupeTests: XCTestCase {
func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() {
let current = SidebarStatusEntry(
key: "agent",
value: "idle",
icon: "bolt",
color: "#ffffff",
timestamp: Date(timeIntervalSince1970: 123)
)
XCTAssertFalse(
TerminalController.shouldReplaceStatusEntry(
current: current,
key: "agent",
value: "idle",
icon: "bolt",
color: "#ffffff"
)
)
}
func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() {
let current = SidebarStatusEntry(
key: "agent",
value: "idle",
icon: "bolt",
color: "#ffffff",
timestamp: Date(timeIntervalSince1970: 123)
)
XCTAssertTrue(
TerminalController.shouldReplaceStatusEntry(
current: current,
key: "agent",
value: "running",
icon: "bolt",
color: "#ffffff"
)
)
}
func testShouldReplaceProgressReturnsFalseForUnchangedPayload() {
XCTAssertFalse(
TerminalController.shouldReplaceProgress(
current: SidebarProgressState(value: 0.42, label: "indexing"),
value: 0.42,
label: "indexing"
)
)
}
func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() {
XCTAssertFalse(
TerminalController.shouldReplaceGitBranch(
current: SidebarGitBranchState(branch: "main", isDirty: true),
branch: "main",
isDirty: true
)
)
}
func testShouldReplacePortsIgnoresOrderAndDuplicates() {
XCTAssertFalse(
TerminalController.shouldReplacePorts(
current: [9229, 3000],
next: [3000, 9229, 3000]
)
)
XCTAssertTrue(
TerminalController.shouldReplacePorts(
current: [9229, 3000],
next: [3000]
)
)
}
func testExplicitSocketScopeParsesValidUUIDTabAndPanel() {
let workspaceId = UUID()
let panelId = UUID()
let scope = TerminalController.explicitSocketScope(
options: [
"tab": workspaceId.uuidString,
"panel": panelId.uuidString
]
)
XCTAssertEqual(scope?.workspaceId, workspaceId)
XCTAssertEqual(scope?.panelId, panelId)
}
func testExplicitSocketScopeAcceptsSurfaceAlias() {
let workspaceId = UUID()
let panelId = UUID()
let scope = TerminalController.explicitSocketScope(
options: [
"tab": workspaceId.uuidString,
"surface": panelId.uuidString
]
)
XCTAssertEqual(scope?.workspaceId, workspaceId)
XCTAssertEqual(scope?.panelId, panelId)
}
func testExplicitSocketScopeRejectsMissingOrInvalidValues() {
XCTAssertNil(TerminalController.explicitSocketScope(options: [:]))
XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString]))
XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"]))
}
func testNormalizeReportedDirectoryTrimsWhitespace() {
XCTAssertEqual(
TerminalController.normalizeReportedDirectory(" /Users/cmux/project "),
"/Users/cmux/project"
)
}
func testNormalizeReportedDirectoryResolvesFileURL() {
XCTAssertEqual(
TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"),
"/Users/cmux/project"
)
}
func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() {
XCTAssertEqual(
TerminalController.normalizeReportedDirectory(" file://bad host "),
"file://bad host"
)
}
}

View file

@ -0,0 +1,76 @@
# Socket/CLI No-Focus-Steal Todo
## Goal
Ensure commands run through the cmux Unix socket/CLI do not steal user focus from the current UI workflow.
Policy target:
- App activation/window raising from socket commands: **never**.
- In-app focus mutation from socket commands: only for explicit focus-intent commands.
- Non-focus commands must not move workspace/pane/surface focus as a side effect.
## Task Checklist
- [x] Inventory all v1 + v2 socket command entrypoints.
- [x] Add socket-command focus policy context in `TerminalController`.
- [x] Suppress app activation for socket command path in `AppDelegate` (`focusMainWindow`, `createMainWindow`).
- [x] Gate in-app focus mutation side-effects in v2 handlers.
- [x] Gate in-app focus mutation side-effects in legacy v1 handlers.
- [x] Add explicit CLI `rename-tab` command with env-default targeting.
- [x] Update CLI help/usage/subcommand docs for `rename-tab`.
- [x] Add regression tests for rename-tab and no-unintended-focus-side-effects.
- [x] Run build + targeted tests.
- [x] Open PR.
## Explicit Focus-Intent Allowlist
These may mutate in-app focus/selection state:
v1:
- `focus_window`
- `select_workspace`
- `focus_surface`
- `focus_pane`
- `focus_surface_by_panel`
- `focus_webview`
- `focus_notification` (debug)
- `activate_app` (debug)
v2:
- `window.focus`
- `workspace.select`
- `workspace.next`
- `workspace.previous`
- `workspace.last`
- `surface.focus`
- `pane.focus`
- `pane.last`
- `browser.focus_webview`
- `browser.focus`
- `browser.tab.switch`
- `debug.notification.focus`
- `debug.app.activate`
All other commands should preserve current user focus context.
## Command Coverage Matrix (All Command Families)
- [x] v1 `ping`, `help`
- [x] v1 window commands (`list_windows`, `current_window`, `focus_window`, `new_window`, `close_window`)
- [x] v1 workspace commands (`move_workspace_to_window`, `list_workspaces`, `new_workspace`, `close_workspace`, `select_workspace`, `current_workspace`)
- [x] v1 surface/pane commands (`new_split`, `list_surfaces`, `focus_surface`, `list_panes`, `list_pane_surfaces`, `focus_pane`, `focus_surface_by_panel`, `drag_surface_to_split`, `new_pane`, `new_surface`, `close_surface`, `refresh_surfaces`, `surface_health`)
- [x] v1 input commands (`send`, `send_key`, `send_surface`, `send_key_surface`, `read_screen`)
- [x] v1 notification/status/log/report commands (`notify*`, `list_notifications`, `clear_notifications`, `set_status`, `clear_status`, `list_status`, `log`, `clear_log`, `list_log`, `set_progress`, `clear_progress`, `report_*`, `ports_kick`, `sidebar_state`, `reset_sidebar`)
- [x] v1 browser commands (`open_browser`, `navigate`, `browser_back`, `browser_forward`, `browser_reload`, `get_url`, `focus_webview`, `is_webview_focused`)
- [x] v1 debug/test commands (shortcut, type, drop/pasteboard, overlay probes, focus checks, screenshots, render/layout/flash/panel snapshot)
- [x] v2 system methods (`system.*`)
- [x] v2 window methods (`window.*`)
- [x] v2 workspace methods (`workspace.*`)
- [x] v2 surface methods (`surface.*`, `tab.action`)
- [x] v2 pane methods (`pane.*`)
- [x] v2 notification methods (`notification.*`)
- [x] v2 app methods (`app.*`)
- [x] v2 browser methods (full `browser.*` set including tab/network/trace/input)
- [x] v2 debug methods (`debug.*`)
## CLI Coverage
- [x] Ensure every top-level CLI command routes to non-focus-stealing socket behavior.
- [x] Add/verify `rename-workspace` + `rename-window` behavior remains intact.
- [x] Add explicit `rename-tab` command (defaults to `CMUX_TAB_ID` / `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID` when flags omitted).

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Regression: non-focus CLI commands should not switch the selected workspace."""
import glob
import os
import subprocess
import sys
from pathlib import Path
from typing import List
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str]) -> str:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout.strip()
def _current_workspace(c: cmux) -> str:
payload = c._call("workspace.current") or {}
ws_id = str(payload.get("workspace_id") or "")
if not ws_id:
raise cmuxError(f"workspace.current returned no workspace_id: {payload}")
return ws_id
def main() -> int:
cli = _find_cli_binary()
with cmux(SOCKET_PATH) as c:
baseline_ws = _current_workspace(c)
created = _run_cli(cli, ["new-workspace"])
_must(created.startswith("OK "), f"new-workspace expected OK response, got: {created}")
created_ws = created.removeprefix("OK ").strip()
_must(bool(created_ws), f"new-workspace returned no workspace id: {created}")
_must(_current_workspace(c) == baseline_ws, "new-workspace should not switch selected workspace")
_run_cli(cli, ["new-surface", "--workspace", created_ws])
_must(_current_workspace(c) == baseline_ws, "new-surface --workspace should not switch selected workspace")
_run_cli(cli, ["new-pane", "--workspace", created_ws, "--direction", "right"])
_must(_current_workspace(c) == baseline_ws, "new-pane --workspace should not switch selected workspace")
_run_cli(cli, ["tab-action", "--workspace", created_ws, "--action", "new-terminal-right"])
_must(_current_workspace(c) == baseline_ws, "tab-action new-terminal-right should not switch selected workspace")
c.close_workspace(created_ws)
print("PASS: non-focus CLI commands preserve selected workspace")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Regression: explicit `rename-tab` CLI command parity with tab.action rename."""
import glob
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> str:
merged_env = dict(os.environ)
merged_env.pop("CMUX_WORKSPACE_ID", None)
merged_env.pop("CMUX_SURFACE_ID", None)
merged_env.pop("CMUX_TAB_ID", None)
if env:
merged_env.update(env)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=merged_env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout.strip()
def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str:
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
for row in payload.get("surfaces") or []:
if str(row.get("id") or "") == surface_id:
return str(row.get("title") or "")
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
def main() -> int:
cli = _find_cli_binary()
stamp = int(time.time() * 1000)
with cmux(SOCKET_PATH) as c:
caps = c.capabilities() or {}
methods = set(caps.get("methods") or [])
_must("tab.action" in methods, f"Missing tab.action in capabilities: {sorted(methods)[:40]}")
created = c._call("workspace.create") or {}
ws_id = str(created.get("workspace_id") or "")
_must(bool(ws_id), f"workspace.create returned no workspace_id: {created}")
c._call("workspace.select", {"workspace_id": ws_id})
current = c._call("surface.current", {"workspace_id": ws_id}) or {}
surface_id = str(current.get("surface_id") or "")
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
socket_title = f"socket rename {stamp}"
c._call(
"tab.action",
{
"workspace_id": ws_id,
"surface_id": surface_id,
"action": "rename",
"title": socket_title,
},
)
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
cli_title = f"cli rename {stamp}"
_run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
_must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title")
env_title = f"env rename {stamp}"
_run_cli(
cli,
["rename-tab", env_title],
env={
"CMUX_WORKSPACE_ID": ws_id,
"CMUX_TAB_ID": surface_id,
},
)
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
invalid = subprocess.run(
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],
capture_output=True,
text=True,
check=False,
env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_TAB_ID"}},
)
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
_must(invalid.returncode != 0, "Expected rename-tab without title to fail")
_must("rename-tab requires a title" in invalid_output, f"Unexpected rename-tab error: {invalid_output!r}")
c.close_workspace(ws_id)
print("PASS: rename-tab CLI parity works with explicit and env-derived targets")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -7,7 +7,7 @@ import subprocess
import sys
import time
from pathlib import Path
from typing import List
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
@ -39,11 +39,13 @@ def _find_cli_binary() -> str:
return candidates[0]
def _run_cli(cli: str, args: List[str]) -> str:
def _run_cli(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> str:
env = dict(os.environ)
# Keep this test deterministic when running from inside another cmux shell.
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
if env_overrides:
env.update(env_overrides)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
@ -93,6 +95,17 @@ def main() -> int:
"cmux rename-window without --workspace should target current workspace",
)
env_title = f"tmux env {stamp}"
_run_cli(
cli,
["rename-workspace", env_title],
env_overrides={"CMUX_WORKSPACE_ID": ws_id},
)
_must(
_workspace_title(c, ws_id) == env_title,
"cmux rename-workspace should default to CMUX_WORKSPACE_ID",
)
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 198736e4e2db10931c263eb221b2592fc86e80e7
Subproject commit dd20247b5536b4bd5b9b15cdf940e847daa1a18d

View file

@ -54,7 +54,7 @@ export default function CommunityPage() {
<div className="grid gap-4 sm:grid-cols-2">
<CommunityLink
href="https://discord.com/invite/QRxkhZgY"
href="https://discord.gg/xsgFEVrWCZ"
name="Discord"
action="Join our Discord"
description="Chat with the community, get help, and share feedback"

View file

@ -57,7 +57,7 @@ export function SiteFooter() {
GitHub
</a>
<a href="https://twitter.com/manaflowai" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Twitter</a>
<a href="https://discord.com/invite/QRxkhZgY" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a>
<a href="https://discord.gg/xsgFEVrWCZ" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a>
<Link href="/privacy-policy" className="hover:text-foreground transition-colors">Privacy</Link>
<Link href="/terms-of-service" className="hover:text-foreground transition-colors">Terms</Link>
<Link href="/eula" className="hover:text-foreground transition-colors">EULA</Link>