Skip to content
Open
53 changes: 53 additions & 0 deletions Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import AppKit
import SwiftUI

final class BonsplitHostingView<Content: View>: NSHostingView<Content> {
private let zeroSafeAreaLayoutGuide = NSLayoutGuide()

override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
override var safeAreaRect: NSRect { bounds }
override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide }
override var mouseDownCanMoveWindow: Bool { false }
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
}

required init(rootView: Content) {
super.init(rootView: rootView)
addLayoutGuide(zeroSafeAreaLayoutGuide)
NSLayoutConstraint.activate([
zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor),
zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor),
zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor),
zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

final class BonsplitHostingController<Content: View>: NSViewController {
private let hostingView: BonsplitHostingView<Content>

var rootView: Content {
get { hostingView.rootView }
set { hostingView.rootView = newValue }
}

init(rootView: Content) {
hostingView = BonsplitHostingView(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func loadView() {
view = hostingView
}
}
146 changes: 142 additions & 4 deletions Sources/Bonsplit/Internal/Views/PaneContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,142 @@ import SwiftUI
import UniformTypeIdentifiers
import AppKit

private final class TabBarInteractionContainerView: NSView {
private var eventMonitor: Any?
private weak var monitoredWindow: NSWindow?
private var previousWindowMovableState: Bool?

override var mouseDownCanMoveWindow: Bool { false }
override func isAccessibilityElement() -> Bool { true }
override func accessibilityRole() -> NSAccessibility.Role? { .group }

deinit {
removeEventMonitor()
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the view detaches from its window, you clear monitoredWindow before restoring isMovable and still install a new event monitor. This can leave the old window stuck with dragging disabled and creates a detached monitor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Bonsplit/Internal/Views/PaneContainerView.swift, line 15:

<comment>When the view detaches from its window, you clear `monitoredWindow` before restoring `isMovable` and still install a new event monitor. This can leave the old window stuck with dragging disabled and creates a detached monitor.</comment>

<file context>
@@ -2,6 +2,142 @@ import SwiftUI
+    override func accessibilityRole() -> NSAccessibility.Role? { .group }
+
+    deinit {
+        removeEventMonitor()
+        restoreWindowDraggingIfNeeded()
+    }
</file context>
Fix with Cubic

restoreWindowDraggingIfNeeded()
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()

if window !== monitoredWindow {
removeEventMonitor()
monitoredWindow = window
installEventMonitor()
}

if window == nil {
Comment on lines +24 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore window movability before replacing monitored window

viewDidMoveToWindow swaps monitoredWindow before calling restoreWindowDraggingIfNeeded, so when this view detaches while a tab-bar click is suppressing drag (previousWindowMovableState != nil), the restore path writes to nil/the new window and never restores the old one. In that sequence the original window can be left with isMovable = false, which breaks normal window dragging until something else resets it.

Useful? React with 👍 / 👎.

restoreWindowDraggingIfNeeded()
}
Comment on lines +19 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore the old window before swapping monitoredWindow.

If previousWindowMovableState is set and this view detaches mid-click, Line 24 overwrites monitoredWindow before any restore happens. The later restore then targets nil/the new window instead of the old one, so the original window can stay non-movable after a rehost.

Possible fix
     override func viewDidMoveToWindow() {
         super.viewDidMoveToWindow()

         if window !== monitoredWindow {
+            let previousWindow = monitoredWindow
+            restoreWindowDraggingIfNeeded(on: previousWindow)
             removeEventMonitor()
             monitoredWindow = window
-            installEventMonitor()
-        }
-
-        if window == nil {
-            restoreWindowDraggingIfNeeded()
+            if window != nil {
+                installEventMonitor()
+            }
         }
     }
@@
-    private func restoreWindowDraggingIfNeeded() {
+    private func restoreWindowDraggingIfNeeded(on window: NSWindow? = nil) {
         guard let previousWindowMovableState else { return }
-        monitoredWindow?.isMovable = previousWindowMovableState
+        (window ?? monitoredWindow)?.isMovable = previousWindowMovableState
         self.previousWindowMovableState = nil
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Bonsplit/Internal/Views/PaneContainerView.swift` around lines 19 -
30, When swapping monitoredWindow in viewDidMoveToWindow(), restore the old
window's dragging state before overwriting monitoredWindow: if window !==
monitoredWindow, first call restoreWindowDraggingIfNeeded() (or a dedicated
restore-on(monitoredWindow) helper) while monitoredWindow is still the old
window, then call removeEventMonitor(), set monitoredWindow = window, and
installEventMonitor(); also keep the existing window == nil branch but ensure it
doesn't run after monitoredWindow was already cleared. This ensures
previousWindowMovableState is applied to the original window instead of nil or
the new window.

}

private func installEventMonitor() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { [weak self] event in
self?.handleLocalMouseEvent(event) ?? event
}
}

private func removeEventMonitor() {
guard let eventMonitor else { return }
NSEvent.removeMonitor(eventMonitor)
self.eventMonitor = nil
}

private func handleLocalMouseEvent(_ event: NSEvent) -> NSEvent? {
guard let window = monitoredWindow else {
return event
}

if event.window !== window {
if event.type == .leftMouseUp {
restoreWindowDraggingIfNeeded()
}
return event
}

let point = convert(event.locationInWindow, from: nil)
switch event.type {
case .leftMouseDown:
if bounds.contains(point), !hitTestRoutesToWindowDragRegion(at: point) {
suppressWindowDraggingIfNeeded(window: window)
Comment on lines +61 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire a live tab-bar drag region before suppressing movability

handleLocalMouseEvent now turns window.isMovable off for clicks inside the tab bar unless hitTestRoutesToWindowDragRegion finds a TabBarWindowDragRegionView, but this commit never mounts TabBarWindowDragRegion in the tab-bar view tree, so that predicate is effectively always false. In hidden-titlebar windows this removes the tab strip as a draggable surface and leaves users unable to drag the window from Bonsplit chrome. Please add the drag-region view to the hierarchy (or avoid suppressing movability when no drag region is present).

Useful? React with 👍 / 👎.

}
case .leftMouseDragged:
if previousWindowMovableState == nil, bounds.contains(point), !hitTestRoutesToWindowDragRegion(at: point) {
suppressWindowDraggingIfNeeded(window: window)
}
case .leftMouseUp:
restoreWindowDraggingIfNeeded()
default:
break
}

return event
}

private func hitTestRoutesToWindowDragRegion(at point: NSPoint) -> Bool {
guard let hitView = super.hitTest(point) else { return false }
var current: NSView? = hitView
while let view = current {
if view is TabBarWindowDragRegionView {
return true
}
current = view.superview
}
return false
}

private func suppressWindowDraggingIfNeeded(window: NSWindow) {
guard previousWindowMovableState == nil else { return }
previousWindowMovableState = window.isMovable
if window.isMovable {
window.isMovable = false
}
}

private func restoreWindowDraggingIfNeeded() {
guard let previousWindowMovableState else { return }
monitoredWindow?.isMovable = previousWindowMovableState
self.previousWindowMovableState = nil
}
}

private struct TabBarHostingWrapper<Content: View>: NSViewRepresentable {
let content: Content

func makeCoordinator() -> Coordinator {
Coordinator()
}

func makeNSView(context: Context) -> NSView {
let containerView = TabBarInteractionContainerView()
containerView.setAccessibilityElement(true)
containerView.setAccessibilityIdentifier("paneTabBar")

let hostingView = BonsplitHostingView(rootView: content)
hostingView.translatesAutoresizingMaskIntoConstraints = false
hostingView.setAccessibilityElement(false)
containerView.addSubview(hostingView)

NSLayoutConstraint.activate([
hostingView.topAnchor.constraint(equalTo: containerView.topAnchor),
hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])

context.coordinator.hostingView = hostingView
return containerView
}

func updateNSView(_ nsView: NSView, context: Context) {
context.coordinator.hostingView?.rootView = content
Comment on lines +128 to +150
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find BonsplitHostingView definition
fd -t f "\.swift$" | xargs rg -l "BonsplitHostingView" | head -20

Repository: manaflow-ai/bonsplit

Length of output: 177


🏁 Script executed:

# Search for BonsplitHostingView implementation
rg -A 20 "class BonsplitHostingView|struct BonsplitHostingView" --type swift

Repository: manaflow-ai/bonsplit

Length of output: 2319


🏁 Script executed:

# Check TabBarView's environment requirements
rg "@Environment.*BonsplitController|@Environment.*SplitViewController" --type swift -B 2 -A 2

Repository: manaflow-ai/bonsplit

Length of output: 2716


🏁 Script executed:

# Check how environments are provided at the view hierarchy level
rg "\.environment.*BonsplitController|\.environment.*SplitViewController" --type swift -B 2 -A 2

Repository: manaflow-ai/bonsplit

Length of output: 46


🏁 Script executed:

# Get full PaneContainerView struct definition
cat -n Sources/Bonsplit/Internal/Views/PaneContainerView.swift | head -100

Repository: manaflow-ai/bonsplit

Length of output: 4054


🏁 Script executed:

# Search for PaneContainerView instantiation
rg "PaneContainerView" --type swift -B 3 -A 3 | head -100

Repository: manaflow-ai/bonsplit

Length of output: 3058


🏁 Script executed:

# Check where environments are set on views
rg "\.environment\(" --type swift | head -50

Repository: manaflow-ai/bonsplit

Length of output: 894


🏁 Script executed:

# Get full PaneContainerView.swift file
wc -l Sources/Bonsplit/Internal/Views/PaneContainerView.swift

Repository: manaflow-ai/bonsplit

Length of output: 123


🏁 Script executed:

# Read the complete PaneContainerView.swift
cat -n Sources/Bonsplit/Internal/Views/PaneContainerView.swift

Repository: manaflow-ai/bonsplit

Length of output: 31387


🏁 Script executed:

# Check the test setup at lines 603-605 and surrounding context
sed -n '600,610p' Tests/BonsplitTests/BonsplitTests.swift

Repository: manaflow-ai/bonsplit

Length of output: 406


🏁 Script executed:

# Search for where BonsplitHostingView is used with environment modifiers
rg "BonsplitHostingView" --type swift -B 5 -A 10

Repository: manaflow-ai/bonsplit

Length of output: 6504


🏁 Script executed:

# Check if there's any environment setup in NSViewRepresentable context handling
rg "Context.*environment|\.environment" Sources/Bonsplit/Internal/Views/PaneContainerView.swift

Repository: manaflow-ai/bonsplit

Length of output: 116


🏁 Script executed:

# Check BonsplitHostingController to see if it handles environments
fd -t f "BonsplitHostingController" --type swift | xargs cat 2>/dev/null || rg "BonsplitHostingController" --type swift -A 20

Repository: manaflow-ai/bonsplit

Length of output: 236


🏁 Script executed:

# Get more context around the test setup
sed -n '590,620p' Tests/BonsplitTests/BonsplitTests.swift

Repository: manaflow-ai/bonsplit

Length of output: 1447


🏁 Script executed:

# Search for all usages of environment in BonsplitTests
rg "\.environment\(" Tests/BonsplitTests/BonsplitTests.swift -B 2 -A 2 | head -80

Repository: manaflow-ai/bonsplit

Length of output: 914


🏁 Script executed:

# Check if TabBarView is ever wrapped with .environment before passing to TabBarHostingWrapper
rg "TabBarView" --type swift -B 3 -A 3

Repository: manaflow-ai/bonsplit

Length of output: 3038


🏁 Script executed:

# Look for NSHostingView or environment propagation patterns in the codebase
rg "NSHostingView|NSViewRepresentable" --type swift -B 2 -A 8 | head -100

Repository: manaflow-ai/bonsplit

Length of output: 7121


Apply environments to TabBarView before wrapping in NSHostingView.

TabBarView requires @Environment(BonsplitController.self) and @Environment(SplitViewController.self), but TabBarHostingWrapper.makeNSView() passes content directly to BonsplitHostingView(rootView:) without re-injecting them. The test manually applies both environments when instantiating TabBarView directly (test pattern: .environment(controller).environment(controller.internalController)), confirming these are mandatory for TabBarView to function. The production code must apply the same modifiers before passing to TabBarHostingWrapper:

Fix needed in PaneContainerView.swift, around line 326-331
TabBarHostingWrapper(
    content: TabBarView(
        pane: pane,
        isFocused: isFocused,
        showSplitButtons: showSplitButtons
    )
    .environment(bonsplitController)
    .environment(controller)
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Bonsplit/Internal/Views/PaneContainerView.swift` around lines 128 -
150, PaneContainerView.makeNSView currently wraps `content` directly in
`BonsplitHostingView` (via `TabBarHostingWrapper`/`BonsplitHostingView`) so
`TabBarView` never receives required environment values; update the factory so
the `TabBarView` instance is first modified with
`.environment(bonsplitController)` and `.environment(controller)` (the same
modifiers used in tests) before passing it into `BonsplitHostingView(rootView:)`
(and ensure the same environment-updated view is used in updateNSView via
`context.coordinator.hostingView?.rootView`), locating the change around
`makeNSView`, `updateNSView`, `TabBarHostingWrapper`, and where
`hostingView.rootView` is assigned.

}

final class Coordinator {
var hostingView: BonsplitHostingView<Content>?
}
}

/// Drop zone positions for creating splits
public enum DropZone: Equatable {
case center
Expand Down Expand Up @@ -174,10 +310,12 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
var body: some View {
VStack(spacing: 0) {
// Tab bar
TabBarView(
pane: pane,
isFocused: isFocused,
showSplitButtons: showSplitButtons
TabBarHostingWrapper(
content: TabBarView(
pane: pane,
isFocused: isFocused,
showSplitButtons: showSplitButtons
)
)

// Content area with drop zones
Expand Down
18 changes: 6 additions & 12 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -355,14 +355,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl

// MARK: - Helpers

private func makeHostingController(for node: SplitNode) -> NSHostingController<AnyView> {
let hostingController = NSHostingController(rootView: AnyView(makeView(for: node)))
if #available(macOS 13.0, *) {
// NSSplitView owns pane geometry. Keep NSHostingController from publishing
// intrinsic-size constraints that force a minimum pane width.
hostingController.sizingOptions = []
}

private func makeHostingController(for node: SplitNode) -> BonsplitHostingController<AnyView> {
let hostingController = BonsplitHostingController(rootView: AnyView(makeView(for: node)))
let hostedView = hostingController.view
// NSSplitView lays out arranged subviews by setting frames. Leaving Auto Layout
// enabled on these NSHostingViews can allow them to compress to 0 during
Expand All @@ -378,7 +372,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
return hostingController
}

private func installHostingController(_ hostingController: NSHostingController<AnyView>, into container: NSView) {
private func installHostingController(_ hostingController: BonsplitHostingController<AnyView>, into container: NSView) {
let hostedView = hostingController.view
hostedView.frame = container.bounds
hostedView.autoresizingMask = [.width, .height]
Expand All @@ -391,7 +385,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
in container: NSView,
node: SplitNode,
nodeTypeChanged: Bool,
controller: inout NSHostingController<AnyView>?
controller: inout BonsplitHostingController<AnyView>?
) {
// Historically we recreated the NSHostingController when the child node type changed
// (pane <-> split) to force a full detach/reattach of native AppKit subviews.
Expand Down Expand Up @@ -468,8 +462,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
var firstNodeType: SplitNode.NodeType
var secondNodeType: SplitNode.NodeType
/// Retain hosting controllers so SwiftUI content stays alive
var firstHostingController: NSHostingController<AnyView>?
var secondHostingController: NSHostingController<AnyView>?
var firstHostingController: BonsplitHostingController<AnyView>?
var secondHostingController: BonsplitHostingController<AnyView>?

init(
splitState: SplitState,
Expand Down
8 changes: 5 additions & 3 deletions Sources/Bonsplit/Internal/Views/SplitNodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
}

/// Container NSView for a pane inside SinglePaneWrapper.
class PaneDragContainerView: NSView {}
class PaneDragContainerView: NSView {
override var mouseDownCanMoveWindow: Bool { false }
}

/// Wrapper that uses NSHostingController for proper AppKit layout constraints
struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable {
Expand All @@ -66,7 +68,7 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
showSplitButtons: showSplitButtons,
contentViewLifecycle: contentViewLifecycle
)
let hostingController = NSHostingController(rootView: paneView)
let hostingController = BonsplitHostingController(rootView: paneView)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false

let containerView = PaneDragContainerView()
Expand Down Expand Up @@ -110,6 +112,6 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
}

class Coordinator {
var hostingController: NSHostingController<PaneContainerView<Content, EmptyContent>>?
var hostingController: BonsplitHostingController<PaneContainerView<Content, EmptyContent>>?
}
}
Loading