Merge remote-tracking branch 'origin/main' into fix-dragdrop-overlay
This commit is contained in:
commit
9388358914
15 changed files with 716 additions and 48 deletions
|
|
@ -15,6 +15,10 @@
|
|||
<img src="./docs/assets/main-first-image.png" alt="cmux screenshot" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -703,7 +703,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -317,6 +317,16 @@ final class WindowBrowserPortal: NSObject {
|
|||
entry.containerView?.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// Update the visibleInUI/zPriority state on an existing entry without rebinding.
|
||||
/// Used when a bind is deferred (host not yet in window) so stale portal syncs
|
||||
/// do not keep an old anchor visible.
|
||||
func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
entry.visibleInUI = visibleInUI
|
||||
entry.zPriority = zPriority
|
||||
entriesByWebViewId[webViewId] = entry
|
||||
}
|
||||
|
||||
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard ensureInstalled() else { return }
|
||||
|
||||
|
|
@ -853,6 +863,15 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.synchronizeWebViewForAnchor(anchorView)
|
||||
}
|
||||
|
||||
/// Update visibleInUI/zPriority on an existing portal entry without rebinding.
|
||||
/// Called when a bind is deferred because the new host is temporarily off-window.
|
||||
static func updateEntryVisibility(for webView: WKWebView, visibleInUI: Bool, zPriority: Int) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
}
|
||||
|
||||
static func detach(webView: WKWebView) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
|
||||
|
|
|
|||
|
|
@ -1490,6 +1490,12 @@ struct VerticalTabsSidebar: View {
|
|||
.accessibilityIdentifier("Sidebar")
|
||||
.ignoresSafeArea()
|
||||
.background(SidebarBackdrop().ignoresSafeArea())
|
||||
.background(
|
||||
WindowAccessor { window in
|
||||
commandKeyMonitor.setHostWindow(window)
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
.onAppear {
|
||||
commandKeyMonitor.start()
|
||||
draggedTabId = nil
|
||||
|
|
@ -1550,6 +1556,35 @@ enum SidebarCommandHintPolicy {
|
|||
static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool {
|
||||
modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
|
||||
}
|
||||
|
||||
static func isCurrentWindow(
|
||||
hostWindowNumber: Int?,
|
||||
hostWindowIsKey: Bool,
|
||||
eventWindowNumber: Int?,
|
||||
keyWindowNumber: Int?
|
||||
) -> Bool {
|
||||
guard let hostWindowNumber, hostWindowIsKey else { return false }
|
||||
if let eventWindowNumber {
|
||||
return eventWindowNumber == hostWindowNumber
|
||||
}
|
||||
return keyWindowNumber == hostWindowNumber
|
||||
}
|
||||
|
||||
static func shouldShowHints(
|
||||
for modifierFlags: NSEvent.ModifierFlags,
|
||||
hostWindowNumber: Int?,
|
||||
hostWindowIsKey: Bool,
|
||||
eventWindowNumber: Int?,
|
||||
keyWindowNumber: Int?
|
||||
) -> Bool {
|
||||
shouldShowHints(for: modifierFlags) &&
|
||||
isCurrentWindow(
|
||||
hostWindowNumber: hostWindowNumber,
|
||||
hostWindowIsKey: hostWindowIsKey,
|
||||
eventWindowNumber: eventWindowNumber,
|
||||
keyWindowNumber: keyWindowNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum ShortcutHintDebugSettings {
|
||||
|
|
@ -1794,28 +1829,63 @@ private struct SidebarExternalDropDelegate: DropDelegate {
|
|||
private final class SidebarCommandKeyMonitor: ObservableObject {
|
||||
@Published private(set) var isCommandPressed = false
|
||||
|
||||
private weak var hostWindow: NSWindow?
|
||||
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
|
||||
private var hostWindowDidResignKeyObserver: NSObjectProtocol?
|
||||
private var flagsMonitor: Any?
|
||||
private var keyDownMonitor: Any?
|
||||
private var resignObserver: NSObjectProtocol?
|
||||
private var appResignObserver: NSObjectProtocol?
|
||||
private var pendingShowWorkItem: DispatchWorkItem?
|
||||
|
||||
func setHostWindow(_ window: NSWindow?) {
|
||||
guard hostWindow !== window else { return }
|
||||
removeHostWindowObservers()
|
||||
hostWindow = window
|
||||
guard let window else {
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
return
|
||||
}
|
||||
|
||||
hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didBecomeKeyNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
}
|
||||
}
|
||||
|
||||
hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didResignKeyNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.cancelPendingHintShow(resetVisible: true)
|
||||
}
|
||||
}
|
||||
|
||||
update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard flagsMonitor == nil else {
|
||||
update(from: NSEvent.modifierFlags)
|
||||
update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
return
|
||||
}
|
||||
|
||||
flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
self?.update(from: event.modifierFlags)
|
||||
self?.update(from: event.modifierFlags, eventWindow: event.window)
|
||||
return event
|
||||
}
|
||||
|
||||
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
self?.cancelPendingHintShow(resetVisible: true)
|
||||
self?.handleKeyDown(event)
|
||||
return event
|
||||
}
|
||||
|
||||
resignObserver = NotificationCenter.default.addObserver(
|
||||
appResignObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
|
|
@ -1825,7 +1895,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
update(from: NSEvent.modifierFlags)
|
||||
update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
|
|
@ -1837,15 +1907,36 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
NSEvent.removeMonitor(keyDownMonitor)
|
||||
self.keyDownMonitor = nil
|
||||
}
|
||||
if let resignObserver {
|
||||
NotificationCenter.default.removeObserver(resignObserver)
|
||||
self.resignObserver = nil
|
||||
if let appResignObserver {
|
||||
NotificationCenter.default.removeObserver(appResignObserver)
|
||||
self.appResignObserver = nil
|
||||
}
|
||||
removeHostWindowObservers()
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
}
|
||||
|
||||
private func update(from modifierFlags: NSEvent.ModifierFlags) {
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else {
|
||||
private func handleKeyDown(_ event: NSEvent) {
|
||||
guard isCurrentWindow(eventWindow: event.window) else { return }
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
}
|
||||
|
||||
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: eventWindow?.windowNumber,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
)
|
||||
}
|
||||
|
||||
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
for: modifierFlags,
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: eventWindow?.windowNumber,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
) else {
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
return
|
||||
}
|
||||
|
|
@ -1860,7 +1951,13 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingShowWorkItem = nil
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return }
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
for: NSEvent.modifierFlags,
|
||||
hostWindowNumber: self.hostWindow?.windowNumber,
|
||||
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: nil,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
) else { return }
|
||||
self.isCommandPressed = true
|
||||
}
|
||||
|
||||
|
|
@ -1875,6 +1972,17 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
isCommandPressed = false
|
||||
}
|
||||
}
|
||||
|
||||
private func removeHostWindowObservers() {
|
||||
if let hostWindowDidBecomeKeyObserver {
|
||||
NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver)
|
||||
self.hostWindowDidBecomeKeyObserver = nil
|
||||
}
|
||||
if let hostWindowDidResignKeyObserver {
|
||||
NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver)
|
||||
self.hostWindowDidResignKeyObserver = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
|
|||
|
|
@ -1352,7 +1352,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize)
|
||||
}
|
||||
|
||||
let claudeHooksEnabled = UserDefaults.standard.object(forKey: "claudeCodeHooksEnabled") as? Bool ?? true
|
||||
let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled()
|
||||
if !claudeHooksEnabled {
|
||||
env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1589,14 +1589,21 @@ extension BrowserPanel {
|
|||
)
|
||||
#endif
|
||||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
let targetVisible = !visible
|
||||
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
preferredDeveloperToolsVisible = targetVisible
|
||||
if targetVisible {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
if visibleAfterToggle {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
} else {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
} else {
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ struct BrowserPanelView: View {
|
|||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
private let omnibarPillCornerRadius: CGFloat = 12
|
||||
private let addressBarButtonSize: CGFloat = 22
|
||||
private let addressBarButtonHitSize: CGFloat = 32
|
||||
private let devToolsButtonIconSize: CGFloat = 11
|
||||
|
||||
private var searchEngine: BrowserSearchEngine {
|
||||
|
|
@ -350,10 +351,10 @@ struct BrowserPanelView: View {
|
|||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.disabled(!panel.canGoBack)
|
||||
.opacity(panel.canGoBack ? 1.0 : 0.4)
|
||||
.help("Go Back")
|
||||
|
|
@ -366,10 +367,10 @@ struct BrowserPanelView: View {
|
|||
}) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.disabled(!panel.canGoForward)
|
||||
.opacity(panel.canGoForward ? 1.0 : 0.4)
|
||||
.help("Go Forward")
|
||||
|
|
@ -389,10 +390,10 @@ struct BrowserPanelView: View {
|
|||
}) {
|
||||
Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.help(panel.isLoading ? "Stop" : "Reload")
|
||||
}
|
||||
}
|
||||
|
|
@ -2773,6 +2774,15 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.lastPortalHostId = hostId
|
||||
}
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
} else {
|
||||
// Bind is deferred until host moves into a window. Keep the current
|
||||
// portal entry's desired state in sync so stale callbacks cannot keep
|
||||
// the previous anchor visible while this host is temporarily off-window.
|
||||
BrowserWindowPortalRegistry.updateEntryVisibility(
|
||||
for: webView,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
}
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
|
|
|||
|
|
@ -2927,20 +2927,187 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private enum V2PaneResizeDirection: String {
|
||||
case left
|
||||
case right
|
||||
case up
|
||||
case down
|
||||
|
||||
var splitOrientation: String {
|
||||
switch self {
|
||||
case .left, .right:
|
||||
return "horizontal"
|
||||
case .up, .down:
|
||||
return "vertical"
|
||||
}
|
||||
}
|
||||
|
||||
/// A split controls the target pane's right/bottom edge when target is first child,
|
||||
/// and left/top edge when target is second child.
|
||||
var requiresPaneInFirstChild: Bool {
|
||||
switch self {
|
||||
case .right, .down:
|
||||
return true
|
||||
case .left, .up:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive value moves divider toward second child (right/down).
|
||||
var dividerDeltaSign: CGFloat {
|
||||
requiresPaneInFirstChild ? 1 : -1
|
||||
}
|
||||
}
|
||||
|
||||
private struct V2PaneResizeCandidate {
|
||||
let splitId: UUID
|
||||
let orientation: String
|
||||
let paneInFirstChild: Bool
|
||||
let dividerPosition: CGFloat
|
||||
let axisPixels: CGFloat
|
||||
}
|
||||
|
||||
private struct V2PaneResizeTrace {
|
||||
let containsTarget: Bool
|
||||
let bounds: CGRect
|
||||
}
|
||||
|
||||
private func v2PaneResizeCollectCandidates(
|
||||
node: ExternalTreeNode,
|
||||
targetPaneId: String,
|
||||
candidates: inout [V2PaneResizeCandidate]
|
||||
) -> V2PaneResizeTrace {
|
||||
switch node {
|
||||
case .pane(let pane):
|
||||
let bounds = CGRect(
|
||||
x: pane.frame.x,
|
||||
y: pane.frame.y,
|
||||
width: pane.frame.width,
|
||||
height: pane.frame.height
|
||||
)
|
||||
return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds)
|
||||
|
||||
case .split(let split):
|
||||
let first = v2PaneResizeCollectCandidates(
|
||||
node: split.first,
|
||||
targetPaneId: targetPaneId,
|
||||
candidates: &candidates
|
||||
)
|
||||
let second = v2PaneResizeCollectCandidates(
|
||||
node: split.second,
|
||||
targetPaneId: targetPaneId,
|
||||
candidates: &candidates
|
||||
)
|
||||
|
||||
let combinedBounds = first.bounds.union(second.bounds)
|
||||
let containsTarget = first.containsTarget || second.containsTarget
|
||||
|
||||
if containsTarget,
|
||||
let splitUUID = UUID(uuidString: split.id) {
|
||||
let orientation = split.orientation.lowercased()
|
||||
let axisPixels: CGFloat = orientation == "horizontal"
|
||||
? combinedBounds.width
|
||||
: combinedBounds.height
|
||||
candidates.append(V2PaneResizeCandidate(
|
||||
splitId: splitUUID,
|
||||
orientation: orientation,
|
||||
paneInFirstChild: first.containsTarget,
|
||||
dividerPosition: CGFloat(split.dividerPosition),
|
||||
axisPixels: max(axisPixels, 1)
|
||||
))
|
||||
}
|
||||
|
||||
return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds)
|
||||
}
|
||||
}
|
||||
|
||||
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
|
||||
let direction = (v2String(params, "direction") ?? "").lowercased()
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
|
||||
let directionRaw = (v2String(params, "direction") ?? "").lowercased()
|
||||
let amount = v2Int(params, "amount") ?? 1
|
||||
guard ["left", "right", "up", "down"].contains(direction), amount > 0 else {
|
||||
guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else {
|
||||
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
|
||||
}
|
||||
return .err(
|
||||
code: "not_supported",
|
||||
message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API",
|
||||
data: [
|
||||
"direction": direction,
|
||||
"amount": amount
|
||||
]
|
||||
)
|
||||
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil)
|
||||
v2MainSync {
|
||||
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id
|
||||
guard let paneUUID else {
|
||||
result = .err(code: "not_found", message: "No focused pane", data: nil)
|
||||
return
|
||||
}
|
||||
guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else {
|
||||
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
|
||||
return
|
||||
}
|
||||
|
||||
let tree = ws.bonsplitController.treeSnapshot()
|
||||
var candidates: [V2PaneResizeCandidate] = []
|
||||
let trace = v2PaneResizeCollectCandidates(
|
||||
node: tree,
|
||||
targetPaneId: paneUUID.uuidString,
|
||||
candidates: &candidates
|
||||
)
|
||||
guard trace.containsTarget else {
|
||||
result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString])
|
||||
return
|
||||
}
|
||||
|
||||
let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation }
|
||||
guard !orientationMatches.isEmpty else {
|
||||
result = .err(
|
||||
code: "invalid_state",
|
||||
message: "No \(direction.splitOrientation) split ancestor for pane",
|
||||
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else {
|
||||
result = .err(
|
||||
code: "invalid_state",
|
||||
message: "Pane has no adjacent border in direction \(direction.rawValue)",
|
||||
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let delta = CGFloat(amount) / candidate.axisPixels
|
||||
let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta)
|
||||
let clamped = min(max(requested, 0.1), 0.9)
|
||||
guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else {
|
||||
result = .err(
|
||||
code: "internal_error",
|
||||
message: "Failed to set split divider position",
|
||||
data: ["split_id": candidate.splitId.uuidString]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
result = .ok([
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"pane_id": paneUUID.uuidString,
|
||||
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
||||
"split_id": candidate.splitId.uuidString,
|
||||
"direction": direction.rawValue,
|
||||
"amount": amount,
|
||||
"old_divider_position": candidate.dividerPosition,
|
||||
"new_divider_position": clamped
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
|
||||
|
|
|
|||
|
|
@ -276,6 +276,12 @@ struct TitlebarControlsView: View {
|
|||
controlsGroup(config: config)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, titlebarHintTrailingInset)
|
||||
.background(
|
||||
WindowAccessor { window in
|
||||
commandKeyMonitor.setHostWindow(window)
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||
shortcutRefreshTick &+= 1
|
||||
}
|
||||
|
|
@ -495,28 +501,63 @@ struct TitlebarControlsView: View {
|
|||
private final class TitlebarCommandKeyMonitor: ObservableObject {
|
||||
@Published private(set) var isCommandPressed = false
|
||||
|
||||
private weak var hostWindow: NSWindow?
|
||||
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
|
||||
private var hostWindowDidResignKeyObserver: NSObjectProtocol?
|
||||
private var flagsMonitor: Any?
|
||||
private var keyDownMonitor: Any?
|
||||
private var resignObserver: NSObjectProtocol?
|
||||
private var appResignObserver: NSObjectProtocol?
|
||||
private var pendingShowWorkItem: DispatchWorkItem?
|
||||
|
||||
func setHostWindow(_ window: NSWindow?) {
|
||||
guard hostWindow !== window else { return }
|
||||
removeHostWindowObservers()
|
||||
hostWindow = window
|
||||
guard let window else {
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
return
|
||||
}
|
||||
|
||||
hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didBecomeKeyNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
}
|
||||
}
|
||||
|
||||
hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didResignKeyNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.cancelPendingHintShow(resetVisible: true)
|
||||
}
|
||||
}
|
||||
|
||||
update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard flagsMonitor == nil else {
|
||||
update(from: NSEvent.modifierFlags)
|
||||
update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
return
|
||||
}
|
||||
|
||||
flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
self?.update(from: event.modifierFlags)
|
||||
self?.update(from: event.modifierFlags, eventWindow: event.window)
|
||||
return event
|
||||
}
|
||||
|
||||
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
self?.cancelPendingHintShow(resetVisible: true)
|
||||
self?.handleKeyDown(event)
|
||||
return event
|
||||
}
|
||||
|
||||
resignObserver = NotificationCenter.default.addObserver(
|
||||
appResignObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
|
|
@ -526,7 +567,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
update(from: NSEvent.modifierFlags)
|
||||
update(from: NSEvent.modifierFlags, eventWindow: nil)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
|
|
@ -538,15 +579,36 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
NSEvent.removeMonitor(keyDownMonitor)
|
||||
self.keyDownMonitor = nil
|
||||
}
|
||||
if let resignObserver {
|
||||
NotificationCenter.default.removeObserver(resignObserver)
|
||||
self.resignObserver = nil
|
||||
if let appResignObserver {
|
||||
NotificationCenter.default.removeObserver(appResignObserver)
|
||||
self.appResignObserver = nil
|
||||
}
|
||||
removeHostWindowObservers()
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
}
|
||||
|
||||
private func update(from modifierFlags: NSEvent.ModifierFlags) {
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else {
|
||||
private func handleKeyDown(_ event: NSEvent) {
|
||||
guard isCurrentWindow(eventWindow: event.window) else { return }
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
}
|
||||
|
||||
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: eventWindow?.windowNumber,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
)
|
||||
}
|
||||
|
||||
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
for: modifierFlags,
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: eventWindow?.windowNumber,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
) else {
|
||||
cancelPendingHintShow(resetVisible: true)
|
||||
return
|
||||
}
|
||||
|
|
@ -561,7 +623,13 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingShowWorkItem = nil
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return }
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
for: NSEvent.modifierFlags,
|
||||
hostWindowNumber: self.hostWindow?.windowNumber,
|
||||
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: nil,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
) else { return }
|
||||
self.isCommandPressed = true
|
||||
}
|
||||
|
||||
|
|
@ -576,6 +644,17 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
isCommandPressed = false
|
||||
}
|
||||
}
|
||||
|
||||
private func removeHostWindowObservers() {
|
||||
if let hostWindowDidBecomeKeyObserver {
|
||||
NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver)
|
||||
self.hostWindowDidBecomeKeyObserver = nil
|
||||
}
|
||||
if let hostWindowDidResignKeyObserver {
|
||||
NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver)
|
||||
self.hostWindowDidResignKeyObserver = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate {
|
||||
|
|
|
|||
|
|
@ -2398,13 +2398,26 @@ enum AppearanceSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum ClaudeCodeIntegrationSettings {
|
||||
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
||||
static let defaultHooksEnabled = false
|
||||
|
||||
static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: hooksEnabledKey) == nil {
|
||||
return defaultHooksEnabled
|
||||
}
|
||||
return defaults.bool(forKey: hooksEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
private let contentTopInset: CGFloat = 8
|
||||
private let pickerColumnWidth: CGFloat = 196
|
||||
|
||||
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
@AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true
|
||||
@AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
||||
@AppStorage("cmuxPortBase") private var cmuxPortBase = 9100
|
||||
@AppStorage("cmuxPortRange") private var cmuxPortRange = 10
|
||||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
|
|
@ -2818,7 +2831,7 @@ struct SettingsView: View {
|
|||
private func resetAllSettings() {
|
||||
appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
claudeCodeHooksEnabled = true
|
||||
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
||||
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
||||
|
|
|
|||
|
|
@ -500,6 +500,57 @@ final class SidebarCommandHintPolicyTests: XCTestCase {
|
|||
func testCommandHintUsesIntentionalHoldDelay() {
|
||||
XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25)
|
||||
}
|
||||
|
||||
func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() {
|
||||
XCTAssertTrue(
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
hostWindowNumber: 42,
|
||||
hostWindowIsKey: true,
|
||||
eventWindowNumber: 42,
|
||||
keyWindowNumber: 42
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
hostWindowNumber: 42,
|
||||
hostWindowIsKey: true,
|
||||
eventWindowNumber: 7,
|
||||
keyWindowNumber: 42
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
hostWindowNumber: 42,
|
||||
hostWindowIsKey: false,
|
||||
eventWindowNumber: 42,
|
||||
keyWindowNumber: 42
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() {
|
||||
XCTAssertTrue(
|
||||
SidebarCommandHintPolicy.shouldShowHints(
|
||||
for: [.command],
|
||||
hostWindowNumber: 42,
|
||||
hostWindowIsKey: true,
|
||||
eventWindowNumber: nil,
|
||||
keyWindowNumber: 42
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
SidebarCommandHintPolicy.shouldShowHints(
|
||||
for: [.command],
|
||||
hostWindowNumber: 42,
|
||||
hostWindowIsKey: true,
|
||||
eventWindowNumber: nil,
|
||||
keyWindowNumber: 7
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class ShortcutHintDebugSettingsTests: XCTestCase {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,37 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationDefaultsToDisabledWhenUnset() {
|
||||
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated user defaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationRespectsStoredPreference() {
|
||||
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated user defaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
defaults.set(true, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(false, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
private func rgb255(_ color: NSColor) -> RGB {
|
||||
let srgb = color.usingColorSpace(.sRGB)!
|
||||
var red: CGFloat = 0
|
||||
|
|
|
|||
|
|
@ -196,3 +196,45 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase {
|
|||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: ensure new terminal windows are born in full-size content mode so
|
||||
/// titlebar/content offsets are correct before the first resize.
|
||||
final class MainWindowLayoutStyleTests: XCTestCase {
|
||||
func testCreateMainWindowUsesFullSizeContentViewStyleMask() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
let appDelegateURL = projectRoot.appendingPathComponent("Sources/AppDelegate.swift")
|
||||
let source = try String(contentsOf: appDelegateURL, encoding: .utf8)
|
||||
|
||||
guard let start = source.range(of: "func createMainWindow("),
|
||||
let end = source.range(of: "@objc func checkForUpdates", range: start.upperBound..<source.endIndex) else {
|
||||
XCTFail("Could not locate createMainWindow block in Sources/AppDelegate.swift")
|
||||
return
|
||||
}
|
||||
|
||||
let block = String(source[start.lowerBound..<end.lowerBound])
|
||||
let regex = try NSRegularExpression(
|
||||
pattern: #"styleMask:\s*\[[^\]]*\.fullSizeContentView"#,
|
||||
options: [.dotMatchesLineSeparators]
|
||||
)
|
||||
let range = NSRange(block.startIndex..<block.endIndex, in: block)
|
||||
XCTAssertNotNil(
|
||||
regex.firstMatch(in: block, options: [], range: range),
|
||||
"""
|
||||
createMainWindow must include `.fullSizeContentView` in the NSWindow style mask.
|
||||
Without it, initial titlebar/content offsets can be wrong until a manual resize.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
private func findProjectRoot() -> URL {
|
||||
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
||||
if FileManager.default.fileExists(atPath: marker.path) {
|
||||
return dir
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
tests/test_browser_devtools_portal_regressions.py
Normal file
92
tests/test_browser_devtools_portal_regressions.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for browser DevTools/portal review fixes.
|
||||
|
||||
Guards two follow-up fixes:
|
||||
1) DevTools toggle path must retry restore when inspector show is transiently ignored.
|
||||
2) Browser portal visibility must propagate even if host is temporarily off-window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
toggle_block = extract_block(panel_source, "func toggleDeveloperTools() -> Bool")
|
||||
if "visibleAfterToggle" not in toggle_block:
|
||||
failures.append("toggleDeveloperTools() no longer re-checks inspector visibility")
|
||||
if "scheduleDeveloperToolsRestoreRetry()" not in toggle_block:
|
||||
failures.append("toggleDeveloperTools() no longer schedules a DevTools restore retry")
|
||||
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
view_source = view_path.read_text(encoding="utf-8")
|
||||
portal_update_block = extract_block(view_source, "private func updateUsingWindowPortal(")
|
||||
if "BrowserWindowPortalRegistry.updateEntryVisibility(" not in portal_update_block:
|
||||
failures.append("BrowserPanelView.updateUsingWindowPortal() is missing deferred portal visibility propagation")
|
||||
if "zPriority: coordinator.desiredPortalZPriority" not in portal_update_block:
|
||||
failures.append("BrowserPanelView deferred portal update no longer propagates zPriority")
|
||||
|
||||
portal_path = root / "Sources" / "BrowserWindowPortal.swift"
|
||||
portal_source = portal_path.read_text(encoding="utf-8")
|
||||
if not re.search(
|
||||
r"func\s+updateEntryVisibility\s*\(\s*forWebViewId\s+webViewId:\s*ObjectIdentifier,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)",
|
||||
portal_source,
|
||||
flags=re.MULTILINE,
|
||||
):
|
||||
failures.append("WindowBrowserPortal is missing updateEntryVisibility(forWebViewId:visibleInUI:zPriority:)")
|
||||
if not re.search(
|
||||
r"static\s+func\s+updateEntryVisibility\s*\(\s*for\s+webView:\s*WKWebView,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)",
|
||||
portal_source,
|
||||
flags=re.MULTILINE,
|
||||
):
|
||||
failures.append("BrowserWindowPortalRegistry is missing updateEntryVisibility(for:visibleInUI:zPriority:)")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser devtools/portal regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser devtools/portal regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -82,6 +82,46 @@ def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> boo
|
|||
return token in str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _layout_panes(c: cmux) -> List[dict]:
|
||||
layout_payload = c.layout_debug() or {}
|
||||
layout = layout_payload.get("layout") or {}
|
||||
panes = layout.get("panes") or []
|
||||
return list(panes)
|
||||
|
||||
|
||||
def _pane_extent(c: cmux, pane_id: str, axis: str) -> float:
|
||||
panes = _layout_panes(c)
|
||||
for pane in panes:
|
||||
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
|
||||
if pid != pane_id:
|
||||
continue
|
||||
frame = pane.get("frame") or {}
|
||||
return float(frame.get(axis) or 0.0)
|
||||
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
|
||||
|
||||
|
||||
def _pick_resize_target(c: cmux, pane_ids: List[str]) -> Tuple[str, str, str]:
|
||||
panes = [p for p in _layout_panes(c) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
|
||||
|
||||
def x_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("x") or 0.0)
|
||||
|
||||
def y_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("y") or 0.0)
|
||||
|
||||
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
|
||||
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
|
||||
|
||||
if x_span >= y_span:
|
||||
target = min(panes, key=x_of)
|
||||
return str(target.get("paneId") or target.get("pane_id") or ""), "-R", "width"
|
||||
|
||||
target = min(panes, key=y_of)
|
||||
return str(target.get("paneId") or target.get("pane_id") or ""), "-D", "height"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
stamp = int(time.time() * 1000)
|
||||
|
|
@ -206,8 +246,13 @@ def main() -> int:
|
|||
merged = f"{proc.stdout}\n{proc.stderr}".lower()
|
||||
_must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}")
|
||||
|
||||
resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False)
|
||||
_must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added")
|
||||
resize_target, resize_flag, resize_axis = _pick_resize_target(c, current_panes)
|
||||
pre_extent = _pane_extent(c, resize_target, resize_axis)
|
||||
_run_cli(cli, ["resize-pane", "--pane", resize_target, resize_flag, "--amount", "80"])
|
||||
_wait_for(
|
||||
lambda: _pane_extent(c, resize_target, resize_axis) > pre_extent + 1.0,
|
||||
timeout_s=3.0,
|
||||
)
|
||||
|
||||
buffer_token = f"TMUX_BUFFER_{stamp}"
|
||||
_run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue