Merge origin/main and resolve reopen-focus conflicts
This commit is contained in:
commit
e9f25ef67f
23 changed files with 2152 additions and 278 deletions
16
CLAUDE.md
16
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 <submodule> && git merge-base --is-ancestor HEAD origin/main`.
|
||||
|
||||
## Socket command threading policy
|
||||
|
||||
- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates).
|
||||
- For telemetry hot paths:
|
||||
- Parse and validate arguments off-main.
|
||||
- Dedupe/coalesce off-main first.
|
||||
- Schedule minimal UI/model mutation with `DispatchQueue.main.async` only when needed.
|
||||
- Commands that directly manipulate AppKit/Ghostty UI state (focus/select/open/close/send key/input, list/current queries requiring exact synchronous snapshot) are allowed to run on main actor.
|
||||
- If adding a new socket command, default to off-main handling; require an explicit reason in code comments when main-thread execution is necessary.
|
||||
|
||||
## Socket focus policy
|
||||
|
||||
- Socket/CLI commands must not steal macOS app focus (no app activation/window raising side effects).
|
||||
- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents).
|
||||
- All non-focus commands should preserve current user focus context while still applying data/model changes.
|
||||
|
||||
## E2E mac UI tests
|
||||
|
||||
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
|
||||
|
|
|
|||
|
|
@ -589,6 +589,9 @@ struct CMUXCLI {
|
|||
case "tab-action":
|
||||
try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
|
||||
|
||||
case "rename-tab":
|
||||
try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
|
||||
|
||||
case "list-workspaces":
|
||||
let payload = try client.sendV2(method: "workspace.list")
|
||||
if jsonOutput {
|
||||
|
|
@ -1727,6 +1730,55 @@ struct CMUXCLI {
|
|||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
|
||||
}
|
||||
|
||||
private func runRenameTab(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat,
|
||||
windowOverride: String?
|
||||
) throws {
|
||||
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
|
||||
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
|
||||
let (titleOpt, rem3) = parseOption(rem2, name: "--title")
|
||||
|
||||
if rem3.contains("--action") {
|
||||
throw CLIError(message: "rename-tab does not accept --action (it always performs rename)")
|
||||
}
|
||||
if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
|
||||
throw CLIError(message: "rename-tab: unknown flag '\(unknown)'")
|
||||
}
|
||||
|
||||
let inferredTitle = rem3
|
||||
.dropFirst(rem3.first == "--" ? 1 : 0)
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard let title, !title.isEmpty else {
|
||||
throw CLIError(message: "rename-tab requires a title")
|
||||
}
|
||||
|
||||
var forwarded: [String] = ["--action", "rename", "--title", title]
|
||||
if let workspaceOpt {
|
||||
forwarded += ["--workspace", workspaceOpt]
|
||||
}
|
||||
if let tabOpt {
|
||||
forwarded += ["--tab", tabOpt]
|
||||
} else if let surfaceOpt {
|
||||
forwarded += ["--surface", surfaceOpt]
|
||||
}
|
||||
|
||||
try runTabAction(
|
||||
commandArgs: forwarded,
|
||||
client: client,
|
||||
jsonOutput: jsonOutput,
|
||||
idFormat: idFormat,
|
||||
windowOverride: windowOverride
|
||||
)
|
||||
}
|
||||
|
||||
private func runBrowserCommand(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
|
|
@ -3046,6 +3098,26 @@ struct CMUXCLI {
|
|||
cmux tab-action --action close-right
|
||||
cmux tab-action --tab tab:2 --action rename --title "build logs"
|
||||
"""
|
||||
case "rename-tab":
|
||||
return """
|
||||
Usage: cmux rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title>
|
||||
|
||||
Rename a tab (surface). Defaults to the focused tab, using:
|
||||
1) explicit --tab/--surface
|
||||
2) $CMUX_TAB_ID / $CMUX_SURFACE_ID
|
||||
3) focused tab in the resolved workspace context
|
||||
|
||||
Flags:
|
||||
--workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID)
|
||||
--tab <id|ref> Target tab (accepts tab:<n> or surface:<n>)
|
||||
--surface <id|ref> Alias for --tab
|
||||
--title <text> New title (or pass trailing title)
|
||||
|
||||
Example:
|
||||
cmux rename-tab "build logs"
|
||||
cmux rename-tab --tab tab:3 "staging server"
|
||||
cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run"
|
||||
"""
|
||||
case "new-workspace":
|
||||
return """
|
||||
Usage: cmux new-workspace
|
||||
|
|
@ -4320,6 +4392,7 @@ struct CMUXCLI {
|
|||
move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>]
|
||||
reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>)
|
||||
tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>]
|
||||
rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title>
|
||||
drag-surface-to-split --surface <id|ref> <left|right|up|down>
|
||||
refresh-surfaces
|
||||
surface-health [--workspace <id|ref>]
|
||||
|
|
@ -4410,7 +4483,7 @@ struct CMUXCLI {
|
|||
Environment:
|
||||
CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for
|
||||
ALL commands (send, list-panels, new-split, notify, etc.).
|
||||
CMUX_TAB_ID Optional alias used by `tab-action` as default --tab.
|
||||
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
|
||||
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
|
||||
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=$!
|
||||
|
|
|
|||
|
|
@ -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=$!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,18 @@ enum WorkspaceAutoReorderSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum SidebarBranchLayoutSettings {
|
||||
static let key = "sidebarBranchVerticalLayout"
|
||||
static let defaultVerticalLayout = true
|
||||
|
||||
static func usesVerticalLayout(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: key) == nil {
|
||||
return defaultVerticalLayout
|
||||
}
|
||||
return defaults.bool(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkspacePlacementSettings {
|
||||
static let placementKey = "newWorkspacePlacement"
|
||||
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
|
||||
|
|
@ -490,7 +502,7 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
@discardableResult
|
||||
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil) -> Workspace {
|
||||
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
|
|
@ -502,17 +514,19 @@ class TabManager: ObservableObject {
|
|||
} else {
|
||||
tabs.append(newWorkspace)
|
||||
}
|
||||
selectedTabId = newWorkspace.id
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
object: nil,
|
||||
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
|
||||
)
|
||||
if select {
|
||||
selectedTabId = newWorkspace.id
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
object: nil,
|
||||
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
|
||||
)
|
||||
}
|
||||
#if DEBUG
|
||||
UITestRecorder.incrementInt("addTabInvocations")
|
||||
UITestRecorder.record([
|
||||
"tabCount": String(tabs.count),
|
||||
"selectedTabId": newWorkspace.id.uuidString
|
||||
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
|
||||
])
|
||||
#endif
|
||||
return newWorkspace
|
||||
|
|
@ -520,7 +534,7 @@ class TabManager: ObservableObject {
|
|||
|
||||
// Keep addTab as convenience alias
|
||||
@discardableResult
|
||||
func addTab() -> Workspace { addWorkspace() }
|
||||
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
|
||||
|
||||
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
|
||||
guard let directory else { return nil }
|
||||
|
|
@ -1491,12 +1505,13 @@ class TabManager: ObservableObject {
|
|||
|
||||
/// Create a new split in the specified direction
|
||||
/// Returns the new panel's ID (which is also the surface ID for terminals)
|
||||
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? {
|
||||
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
return tab.newTerminalSplit(
|
||||
from: surfaceId,
|
||||
orientation: direction.orientation,
|
||||
insertFirst: direction.insertFirst
|
||||
insertFirst: direction.insertFirst,
|
||||
focus: focus
|
||||
)?.id
|
||||
}
|
||||
|
||||
|
|
@ -1547,14 +1562,16 @@ class TabManager: ObservableObject {
|
|||
fromPanelId: UUID,
|
||||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil
|
||||
url: URL? = nil,
|
||||
focus: Bool = true
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
return tab.newBrowserSplit(
|
||||
from: fromPanelId,
|
||||
orientation: orientation,
|
||||
insertFirst: insertFirst,
|
||||
url: url
|
||||
url: url,
|
||||
focus: focus
|
||||
)?.id
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
docs/socket-focus-steal-audit.todo.md
Normal file
76
docs/socket-focus-steal-audit.todo.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Socket/CLI No-Focus-Steal Todo
|
||||
|
||||
## Goal
|
||||
Ensure commands run through the cmux Unix socket/CLI do not steal user focus from the current UI workflow.
|
||||
|
||||
Policy target:
|
||||
- App activation/window raising from socket commands: **never**.
|
||||
- In-app focus mutation from socket commands: only for explicit focus-intent commands.
|
||||
- Non-focus commands must not move workspace/pane/surface focus as a side effect.
|
||||
|
||||
## Task Checklist
|
||||
- [x] Inventory all v1 + v2 socket command entrypoints.
|
||||
- [x] Add socket-command focus policy context in `TerminalController`.
|
||||
- [x] Suppress app activation for socket command path in `AppDelegate` (`focusMainWindow`, `createMainWindow`).
|
||||
- [x] Gate in-app focus mutation side-effects in v2 handlers.
|
||||
- [x] Gate in-app focus mutation side-effects in legacy v1 handlers.
|
||||
- [x] Add explicit CLI `rename-tab` command with env-default targeting.
|
||||
- [x] Update CLI help/usage/subcommand docs for `rename-tab`.
|
||||
- [x] Add regression tests for rename-tab and no-unintended-focus-side-effects.
|
||||
- [x] Run build + targeted tests.
|
||||
- [x] Open PR.
|
||||
|
||||
## Explicit Focus-Intent Allowlist
|
||||
These may mutate in-app focus/selection state:
|
||||
|
||||
v1:
|
||||
- `focus_window`
|
||||
- `select_workspace`
|
||||
- `focus_surface`
|
||||
- `focus_pane`
|
||||
- `focus_surface_by_panel`
|
||||
- `focus_webview`
|
||||
- `focus_notification` (debug)
|
||||
- `activate_app` (debug)
|
||||
|
||||
v2:
|
||||
- `window.focus`
|
||||
- `workspace.select`
|
||||
- `workspace.next`
|
||||
- `workspace.previous`
|
||||
- `workspace.last`
|
||||
- `surface.focus`
|
||||
- `pane.focus`
|
||||
- `pane.last`
|
||||
- `browser.focus_webview`
|
||||
- `browser.focus`
|
||||
- `browser.tab.switch`
|
||||
- `debug.notification.focus`
|
||||
- `debug.app.activate`
|
||||
|
||||
All other commands should preserve current user focus context.
|
||||
|
||||
## Command Coverage Matrix (All Command Families)
|
||||
- [x] v1 `ping`, `help`
|
||||
- [x] v1 window commands (`list_windows`, `current_window`, `focus_window`, `new_window`, `close_window`)
|
||||
- [x] v1 workspace commands (`move_workspace_to_window`, `list_workspaces`, `new_workspace`, `close_workspace`, `select_workspace`, `current_workspace`)
|
||||
- [x] v1 surface/pane commands (`new_split`, `list_surfaces`, `focus_surface`, `list_panes`, `list_pane_surfaces`, `focus_pane`, `focus_surface_by_panel`, `drag_surface_to_split`, `new_pane`, `new_surface`, `close_surface`, `refresh_surfaces`, `surface_health`)
|
||||
- [x] v1 input commands (`send`, `send_key`, `send_surface`, `send_key_surface`, `read_screen`)
|
||||
- [x] v1 notification/status/log/report commands (`notify*`, `list_notifications`, `clear_notifications`, `set_status`, `clear_status`, `list_status`, `log`, `clear_log`, `list_log`, `set_progress`, `clear_progress`, `report_*`, `ports_kick`, `sidebar_state`, `reset_sidebar`)
|
||||
- [x] v1 browser commands (`open_browser`, `navigate`, `browser_back`, `browser_forward`, `browser_reload`, `get_url`, `focus_webview`, `is_webview_focused`)
|
||||
- [x] v1 debug/test commands (shortcut, type, drop/pasteboard, overlay probes, focus checks, screenshots, render/layout/flash/panel snapshot)
|
||||
|
||||
- [x] v2 system methods (`system.*`)
|
||||
- [x] v2 window methods (`window.*`)
|
||||
- [x] v2 workspace methods (`workspace.*`)
|
||||
- [x] v2 surface methods (`surface.*`, `tab.action`)
|
||||
- [x] v2 pane methods (`pane.*`)
|
||||
- [x] v2 notification methods (`notification.*`)
|
||||
- [x] v2 app methods (`app.*`)
|
||||
- [x] v2 browser methods (full `browser.*` set including tab/network/trace/input)
|
||||
- [x] v2 debug methods (`debug.*`)
|
||||
|
||||
## CLI Coverage
|
||||
- [x] Ensure every top-level CLI command routes to non-focus-stealing socket behavior.
|
||||
- [x] Add/verify `rename-workspace` + `rename-window` behavior remains intact.
|
||||
- [x] Add explicit `rename-tab` command (defaults to `CMUX_TAB_ID` / `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID` when flags omitted).
|
||||
91
tests_v2/test_cli_non_focus_commands_preserve_workspace.py
Normal file
91
tests_v2/test_cli_non_focus_commands_preserve_workspace.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: non-focus CLI commands should not switch the selected workspace."""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str]) -> str:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _current_workspace(c: cmux) -> str:
|
||||
payload = c._call("workspace.current") or {}
|
||||
ws_id = str(payload.get("workspace_id") or "")
|
||||
if not ws_id:
|
||||
raise cmuxError(f"workspace.current returned no workspace_id: {payload}")
|
||||
return ws_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
baseline_ws = _current_workspace(c)
|
||||
|
||||
created = _run_cli(cli, ["new-workspace"])
|
||||
_must(created.startswith("OK "), f"new-workspace expected OK response, got: {created}")
|
||||
created_ws = created.removeprefix("OK ").strip()
|
||||
_must(bool(created_ws), f"new-workspace returned no workspace id: {created}")
|
||||
_must(_current_workspace(c) == baseline_ws, "new-workspace should not switch selected workspace")
|
||||
|
||||
_run_cli(cli, ["new-surface", "--workspace", created_ws])
|
||||
_must(_current_workspace(c) == baseline_ws, "new-surface --workspace should not switch selected workspace")
|
||||
|
||||
_run_cli(cli, ["new-pane", "--workspace", created_ws, "--direction", "right"])
|
||||
_must(_current_workspace(c) == baseline_ws, "new-pane --workspace should not switch selected workspace")
|
||||
|
||||
_run_cli(cli, ["tab-action", "--workspace", created_ws, "--action", "new-terminal-right"])
|
||||
_must(_current_workspace(c) == baseline_ws, "tab-action new-terminal-right should not switch selected workspace")
|
||||
|
||||
c.close_workspace(created_ws)
|
||||
|
||||
print("PASS: non-focus CLI commands preserve selected workspace")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
129
tests_v2/test_rename_tab_cli_parity.py
Normal file
129
tests_v2/test_rename_tab_cli_parity.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: explicit `rename-tab` CLI command parity with tab.action rename."""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> str:
|
||||
merged_env = dict(os.environ)
|
||||
merged_env.pop("CMUX_WORKSPACE_ID", None)
|
||||
merged_env.pop("CMUX_SURFACE_ID", None)
|
||||
merged_env.pop("CMUX_TAB_ID", None)
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=merged_env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
for row in payload.get("surfaces") or []:
|
||||
if str(row.get("id") or "") == surface_id:
|
||||
return str(row.get("title") or "")
|
||||
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
stamp = int(time.time() * 1000)
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
caps = c.capabilities() or {}
|
||||
methods = set(caps.get("methods") or [])
|
||||
_must("tab.action" in methods, f"Missing tab.action in capabilities: {sorted(methods)[:40]}")
|
||||
|
||||
created = c._call("workspace.create") or {}
|
||||
ws_id = str(created.get("workspace_id") or "")
|
||||
_must(bool(ws_id), f"workspace.create returned no workspace_id: {created}")
|
||||
|
||||
c._call("workspace.select", {"workspace_id": ws_id})
|
||||
current = c._call("surface.current", {"workspace_id": ws_id}) or {}
|
||||
surface_id = str(current.get("surface_id") or "")
|
||||
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
|
||||
|
||||
socket_title = f"socket rename {stamp}"
|
||||
c._call(
|
||||
"tab.action",
|
||||
{
|
||||
"workspace_id": ws_id,
|
||||
"surface_id": surface_id,
|
||||
"action": "rename",
|
||||
"title": socket_title,
|
||||
},
|
||||
)
|
||||
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
|
||||
|
||||
cli_title = f"cli rename {stamp}"
|
||||
_run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
|
||||
_must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title")
|
||||
|
||||
env_title = f"env rename {stamp}"
|
||||
_run_cli(
|
||||
cli,
|
||||
["rename-tab", env_title],
|
||||
env={
|
||||
"CMUX_WORKSPACE_ID": ws_id,
|
||||
"CMUX_TAB_ID": surface_id,
|
||||
},
|
||||
)
|
||||
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
|
||||
|
||||
invalid = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_TAB_ID"}},
|
||||
)
|
||||
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
|
||||
_must(invalid.returncode != 0, "Expected rename-tab without title to fail")
|
||||
_must("rename-tab requires a title" in invalid_output, f"Unexpected rename-tab error: {invalid_output!r}")
|
||||
|
||||
c.close_workspace(ws_id)
|
||||
|
||||
print("PASS: rename-tab CLI parity works with explicit and env-derived targets")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -7,7 +7,7 @@ import subprocess
|
|||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
|
@ -39,11 +39,13 @@ def _find_cli_binary() -> str:
|
|||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str]) -> str:
|
||||
def _run_cli(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> str:
|
||||
env = dict(os.environ)
|
||||
# Keep this test deterministic when running from inside another cmux shell.
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
if env_overrides:
|
||||
env.update(env_overrides)
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
|
|
@ -93,6 +95,17 @@ def main() -> int:
|
|||
"cmux rename-window without --workspace should target current workspace",
|
||||
)
|
||||
|
||||
env_title = f"tmux env {stamp}"
|
||||
_run_cli(
|
||||
cli,
|
||||
["rename-workspace", env_title],
|
||||
env_overrides={"CMUX_WORKSPACE_ID": ws_id},
|
||||
)
|
||||
_must(
|
||||
_workspace_title(c, ws_id) == env_title,
|
||||
"cmux rename-workspace should default to CMUX_WORKSPACE_ID",
|
||||
)
|
||||
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 198736e4e2db10931c263eb221b2592fc86e80e7
|
||||
Subproject commit dd20247b5536b4bd5b9b15cdf940e847daa1a18d
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue