diff --git a/CLAUDE.md b/CLAUDE.md index beb24aa0..bc3d5bba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 && 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`: diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 88cab6e8..32e62915 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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 ] [--tab ] [--surface ] [--] + + 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). """ 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/AppDelegate.swift b/Sources/AppDelegate.swift index 9c0ad241..debc0e09 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 4578fdcc..86fead61 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -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) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6f308362..837b366a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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 } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index bda555bf..0578ef50 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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 diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index fdaa7b39..9ed42027 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -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 } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7984db48..6c84d402 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 @@ -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 } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3193df82..3fa0d18f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -18,6 +18,36 @@ class TerminalController { private var tabManager: TabManager? private var accessMode: SocketControlMode = .cmuxOnly private let myPid = getpid() + private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 + private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = [] + private nonisolated static let socketCommandPolicyLock = NSLock() + + private static let focusIntentV1Commands: Set<String> = [ + "focus_window", + "select_workspace", + "focus_surface", + "focus_pane", + "focus_surface_by_panel", + "focus_webview", + "focus_notification", + "activate_app" + ] + + private static let focusIntentV2Methods: Set<String> = [ + "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" + ] private enum V2HandleKind: String, CaseIterable { case window @@ -68,6 +98,153 @@ class TerminalController { private init() {} + nonisolated static func shouldSuppressSocketCommandActivation() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandPolicyDepth > 0 + } + + nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool { + allowsInAppFocusMutationsForActiveSocketCommand() + } + + private nonisolated static func allowsInAppFocusMutationsForActiveSocketCommand() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandFocusAllowanceStack.last ?? false + } + + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { + if isV2 { + return focusIntentV2Methods.contains(commandKey) + } + return focusIntentV1Commands.contains(commandKey) + } + + private func withSocketCommandPolicy<T>(commandKey: String, isV2: Bool, _ body: () -> T) -> T { + let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2) + Self.socketCommandPolicyLock.lock() + Self.socketCommandPolicyDepth += 1 + Self.socketCommandFocusAllowanceStack.append(allowsFocusMutation) + Self.socketCommandPolicyLock.unlock() + defer { + Self.socketCommandPolicyLock.lock() + if !Self.socketCommandFocusAllowanceStack.isEmpty { + _ = Self.socketCommandFocusAllowanceStack.popLast() + } + Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1) + Self.socketCommandPolicyLock.unlock() + } + return body() + } + + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + + nonisolated static func shouldReplaceStatusEntry( + current: SidebarStatusEntry?, + key: String, + value: String, + icon: String?, + color: String? + ) -> Bool { + guard let current else { return true } + return current.key != key || current.value != value || current.icon != icon || current.color != color + } + + nonisolated static func shouldReplaceProgress( + current: SidebarProgressState?, + value: Double, + label: String? + ) -> Bool { + guard let current else { return true } + return current.value != value || current.label != label + } + + nonisolated static func shouldReplaceGitBranch( + current: SidebarGitBranchState?, + branch: String, + isDirty: Bool + ) -> Bool { + guard let current else { return true } + return current.branch != branch || current.isDirty != isDirty + } + + nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { + let currentSorted = Array(Set(current ?? [])).sorted() + let nextSorted = Array(Set(next)).sorted() + return currentSorted != nextSorted + } + + private struct SocketSurfaceKey: Hashable { + let workspaceId: UUID + let panelId: UUID + } + + private final class SocketFastPathState: @unchecked Sendable { + private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") + private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] + private let maxTrackedDirectories = 4096 + + func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { + let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) + return queue.sync { + if lastReportedDirectories[key] == directory { + return false + } + if lastReportedDirectories.count >= maxTrackedDirectories { + lastReportedDirectories.removeAll(keepingCapacity: true) + } + lastReportedDirectories[key] = directory + return true + } + } + } + + private static let socketFastPathState = SocketFastPathState() + + nonisolated static func explicitSocketScope( + options: [String: String] + ) -> (workspaceId: UUID, panelId: UUID)? { + guard let tabRaw = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tabRaw.isEmpty, + let panelRaw = (options["panel"] ?? options["surface"])?.trimmingCharacters(in: .whitespacesAndNewlines), + !panelRaw.isEmpty, + let workspaceId = UUID(uuidString: tabRaw), + let panelId = UUID(uuidString: panelRaw) else { + return nil + } + return (workspaceId, panelId) + } + + nonisolated static func normalizeReportedDirectory(_ directory: String) -> String { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return directory } + if trimmed.hasPrefix("file://"), let url = URL(string: trimmed), !url.path.isEmpty { + return url.path + } + return trimmed + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -194,7 +371,14 @@ class TerminalController { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } let validSurfaceIds = Set(workspace.panels.keys) guard validSurfaceIds.contains(panelId) else { return } - workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports + let nextPorts = Array(Set(ports)).sorted() + let currentPorts = workspace.surfaceListeningPorts[panelId] ?? [] + guard currentPorts != nextPorts else { return } + if nextPorts.isEmpty { + workspace.surfaceListeningPorts.removeValue(forKey: panelId) + } else { + workspace.surfaceListeningPorts[panelId] = nextPorts + } workspace.recomputeListeningPorts() } } @@ -331,7 +515,8 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - switch cmd { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + switch cmd { case "ping": return "PONG" @@ -621,6 +806,7 @@ class TerminalController { default: return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." } + } } // MARK: - V2 JSON Socket Protocol @@ -655,7 +841,8 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - switch method { + return withSocketCommandPolicy(commandKey: method, isV2: true) { + switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": @@ -993,6 +1180,7 @@ class TerminalController { default: return v2Error(id: id, code: "method_not_found", message: "Unknown method") } + } } private func v2Capabilities() -> [String: Any] { @@ -1532,8 +1720,9 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // The new window should become key, but setActiveTabManager defensively. - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // Keep active routing stable unless this command is explicitly focus-intent. + if socketCommandAllowsInAppFocusMutations(), + let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -1593,7 +1782,7 @@ class TerminalController { var newId: UUID? v2MainSync { - let ws = tabManager.addWorkspace() + let ws = tabManager.addWorkspace(select: v2FocusAllowed()) newId = ws.id } @@ -1619,12 +1808,8 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - // If this workspace belongs to another window, bring it forward so focus is visible. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - tabManager.selectWorkspace(ws) + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) success = true } } @@ -1697,7 +1882,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -1817,10 +2002,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -1842,10 +2024,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -1867,10 +2046,7 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) v2MainSync { guard let before = tabManager.selectedTabId else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } + v2MaybeFocusWindow(for: tabManager) tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -2059,6 +2235,7 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } + let allowFocusMutation = v2FocusAllowed() let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { @@ -2188,7 +2365,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: true + focus: allowFocusMutation ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -2209,7 +2386,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -2236,7 +2413,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -2347,7 +2524,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -2423,15 +2600,8 @@ class TerminalController { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - - // Make sure the workspace is selected so focus effects apply to the visible UI. - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -2459,13 +2629,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { @@ -2477,7 +2642,12 @@ class TerminalController { return } - if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { + if let newId = tabManager.newSplit( + tabId: ws.id, + surfaceId: targetSurfaceId, + direction: direction, + focus: v2FocusAllowed() + ) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -2512,13 +2682,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { @@ -2535,9 +2700,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: true)?.id + newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id } else { - newPanelId = ws.newTerminalSurface(inPane: paneId, focus: true)?.id + newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id } guard let newPanelId else { @@ -2655,7 +2820,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -2766,16 +2931,15 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return } if focus { - _ = app.focusMainWindow(windowId: targetWindowId) - setActiveTabManager(targetTabManager) - targetTabManager.selectWorkspace(targetWorkspace) + v2MaybeFocusWindow(for: targetTabManager) + v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) } result = .ok([ @@ -3165,14 +3329,9 @@ class TerminalController { return } - // Ensure the flash is visible in the active UI. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + // Only explicit focus-intent commands may mutate selection state. + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { @@ -3253,13 +3412,8 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) @@ -3340,13 +3494,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard let focusedPanelId = ws.focusedPanelId else { result = .err(code: "not_found", message: "No focused surface to split", data: nil) return @@ -3354,9 +3503,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = ws.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: v2FocusAllowed() + )?.id } else { - newPanelId = ws.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = ws.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: v2FocusAllowed() + )?.id } guard let newPanelId else { @@ -3573,7 +3733,7 @@ class TerminalController { if sourcePaneUUID == targetPaneUUID { return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -3656,7 +3816,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2Bool(params, "focus") ?? true + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -3697,7 +3857,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace() + let destinationWorkspace = tabManager.addWorkspace(select: focus) guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -3705,7 +3865,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: true + focus: focus ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -3718,16 +3878,12 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: true + focus: focus ) } result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) return } - - if !focus { - tabManager.selectWorkspace(sourceWorkspace) - } let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -4264,13 +4420,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let sourceSurfaceId else { @@ -4288,11 +4439,16 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) + createdPanel = ws.newBrowserSplit( + from: sourceSurfaceId, + orientation: .horizontal, + url: url, + focus: v2FocusAllowed() + ) } guard let browserPanelId = createdPanel?.id else { @@ -5547,13 +5703,8 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -6663,7 +6814,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": panel.displayTitle, + "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -6708,7 +6859,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -7556,8 +7707,8 @@ class TerminalController { list_log [--limit=N] [--tab=X] - List log entries set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar clear_progress [--tab=X] - Clear progress bar - report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch - clear_git_branch [--tab=X] - Clear git branch + report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch + clear_git_branch [--tab=X] [--panel=Y] - Clear git branch report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel @@ -8543,7 +8694,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if socketCommandAllowsInAppFocusMutations(), + let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -8563,6 +8715,7 @@ class TerminalController { guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" } var ok = false + let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), @@ -8570,9 +8723,11 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: true) - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) + dstTM.attachWorkspace(ws, select: focus) + if focus { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) + } ok = true } @@ -8597,9 +8752,10 @@ class TerminalController { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } var newTabId: UUID? + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { - tabManager.addTab() - newTabId = tabManager.selectedTabId + let workspace = tabManager.addTab(select: focus) + newTabId = workspace.id } return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -8644,7 +8800,12 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { + if let newPanelId = tabManager.newSplit( + tabId: tabId, + surfaceId: targetSurface, + direction: direction, + focus: socketCommandAllowsInAppFocusMutations() + ) { result = "OK \(newPanelId.uuidString)" } } @@ -9804,6 +9965,7 @@ class TerminalController { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) + let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create browser panel" DispatchQueue.main.sync { @@ -9813,7 +9975,12 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { + if let browserPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: .horizontal, + url: url, + focus: shouldFocus + )?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -10215,6 +10382,7 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst + let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" DispatchQueue.main.sync { @@ -10226,9 +10394,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = tab.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: shouldFocus + )?.id } else { - newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = tab.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: shouldFocus + )?.id } if let id = newPanelId { @@ -10421,12 +10600,22 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } + guard Self.shouldReplaceStatusEntry( + current: tab.statusEntries[key], + key: key, + value: value, + icon: icon, + color: color + ) else { + return + } tab.statusEntries[key] = SidebarStatusEntry( key: key, value: value, icon: icon, color: color, - timestamp: Date()) + timestamp: Date() + ) } return result } @@ -10569,6 +10758,9 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } + guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else { + return + } tab.progress = SidebarProgressState(value: clamped, label: label) } return result @@ -10581,7 +10773,9 @@ class TerminalController { result = "ERROR: Tab not found" return } - tab.progress = nil + if tab.progress != nil { + tab.progress = nil + } } return result } @@ -10589,7 +10783,7 @@ class TerminalController { private func reportGitBranch(_ args: String) -> String { let parsed = parseOptions(args) guard let branch = parsed.positional.first else { - return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]" + return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" } let isDirty = parsed.options["status"]?.lowercased() == "dirty" @@ -10599,19 +10793,76 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty) } return result } private func clearGitBranch(_ args: String) -> String { + let parsed = parseOptions(args) var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - result = "ERROR: Tab not found" + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - tab.gitBranch = nil + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tab.clearPanelGitBranch(panelId: surfaceId) } return result } @@ -10628,6 +10879,7 @@ class TerminalController { } ports.append(port) } + let normalizedPorts = Array(Set(ports)).sorted() var result = "OK" DispatchQueue.main.sync { @@ -10664,20 +10916,43 @@ class TerminalController { return } - tab.surfaceListeningPorts[surfaceId] = ports + guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else { + return + } + + tab.surfaceListeningPorts[surfaceId] = normalizedPorts tab.recomputeListeningPorts() } return result } private func reportPwd(_ args: String) -> String { - guard let tabManager else { return "ERROR: TabManager not available" } let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]" } - let directory = parsed.positional.joined(separator: " ") + let directory = Self.normalizeReportedDirectory(parsed.positional.joined(separator: " ")) + + // Shell integration provides explicit UUID handles for cwd updates. + // Keep this hot path off-main and drop no-op reports before scheduling UI work. + if let scope = Self.explicitSocketScope(options: parsed.options) { + guard Self.socketFastPathState.shouldPublishDirectory( + workspaceId: scope.workspaceId, + panelId: scope.panelId, + directory: directory + ) else { + return "OK" + } + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } + tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory) + } + return "OK" + } + + guard let tabManager else { return "ERROR: TabManager not available" } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -10744,11 +11019,15 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - tab.surfaceListeningPorts.removeValue(forKey: surfaceId) + if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil { + tab.recomputeListeningPorts() + } } else { - tab.surfaceListeningPorts.removeAll() + if !tab.surfaceListeningPorts.isEmpty { + tab.surfaceListeningPorts.removeAll() + tab.recomputeListeningPorts() + } } - tab.recomputeListeningPorts() } return result } @@ -10759,6 +11038,17 @@ class TerminalController { return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]" } + // Shell integration always provides explicit UUID handles. + // Handle that common path off-main to avoid sync-hopping on every report. + if let scope = Self.explicitSocketScope(options: parsed.options) { + PortScanner.shared.registerTTY( + workspaceId: scope.workspaceId, + panelId: scope.panelId, + ttyName: ttyName + ) + return "OK" + } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -10792,6 +11082,7 @@ class TerminalController { return } + guard tab.surfaceTTYNames[surfaceId] != ttyName else { return } tab.surfaceTTYNames[surfaceId] = ttyName PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) } @@ -10799,15 +11090,22 @@ class TerminalController { } private func portsKick(_ args: String) -> String { + let parsed = parseOptions(args) + + // Shell integration always provides explicit UUID handles. + // Handle that common path off-main to keep prompt hooks from blocking UI work. + if let scope = Self.explicitSocketScope(options: parsed.options) { + PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) + return "OK" + } + var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - let parsed = parseOptions(args) result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - let parsed = parseOptions(args) let panelArg = parsed.options["panel"] ?? parsed.options["surface"] let surfaceId: UUID if let panelArg { @@ -10902,6 +11200,7 @@ class TerminalController { tab.logEntries.removeAll() tab.progress = nil tab.gitBranch = nil + tab.panelGitBranches.removeAll() tab.surfaceListeningPorts.removeAll() tab.listeningPorts.removeAll() } @@ -11023,6 +11322,7 @@ class TerminalController { var panelType: PanelType = .terminal var paneArg: String? = nil var url: URL? = nil + let shouldFocus = socketCommandAllowsInAppFocusMutations() let parts = args.split(separator: " ") for part in parts { @@ -11067,9 +11367,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id } if let id = newPanelId { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 6dffb098..a4d37f7c 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -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) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 54b8b203..31c709b9 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<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) } } 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 01946c44..709e964e 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)" @@ -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" + ) + } +} diff --git a/docs/socket-focus-steal-audit.todo.md b/docs/socket-focus-steal-audit.todo.md new file mode 100644 index 00000000..dd49450a --- /dev/null +++ b/docs/socket-focus-steal-audit.todo.md @@ -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). diff --git a/tests_v2/test_cli_non_focus_commands_preserve_workspace.py b/tests_v2/test_cli_non_focus_commands_preserve_workspace.py new file mode 100644 index 00000000..dbc28f9b --- /dev/null +++ b/tests_v2/test_cli_non_focus_commands_preserve_workspace.py @@ -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()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py new file mode 100644 index 00000000..a60055fa --- /dev/null +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -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()) diff --git a/tests_v2/test_rename_window_workspace_parity.py b/tests_v2/test_rename_window_workspace_parity.py index 13e564c1..6c33cdb5 100644 --- a/tests_v2/test_rename_window_workspace_parity.py +++ b/tests_v2/test_rename_window_workspace_parity.py @@ -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) diff --git a/vendor/bonsplit b/vendor/bonsplit index 198736e4..dd20247b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 198736e4e2db10931c263eb221b2592fc86e80e7 +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() { <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" diff --git a/web/app/components/nav-links.tsx b/web/app/components/nav-links.tsx index 9caa4829..bcf4c9b3 100644 --- a/web/app/components/nav-links.tsx +++ b/web/app/components/nav-links.tsx @@ -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>