diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c51168..d34b7ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to cmux are documented here. +## [1.36.0] - 2026-02-17 + +### Fixed +- App hang when omnibar safety timeout failed to fire (blocked main thread) +- Tab drag/drop not working when multiple workspaces exist +- Clicking in browser WebView not focusing the browser tab + ## [1.35.0] - 2026-02-17 ### Fixed diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9f26f233..e160a865 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -682,7 +682,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -691,7 +691,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -721,7 +721,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -730,7 +730,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -784,10 +784,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -801,10 +801,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -818,10 +818,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -837,10 +837,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 566aad89..f4a73330 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -99,6 +99,13 @@ struct BrowserPanelView: View { .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in omnibarPillFrame = frame } + .onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in + // Only handle clicks from our own webview. + guard let webView = note.object as? CmuxWebView else { return false } + return webView === panel?.webView + }) { _ in + onRequestPanelFocus() + } .onAppear { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index a1627e7f..f4ac545d 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -32,6 +32,17 @@ final class CmuxWebView: WKWebView { super.keyDown(with: event) } + // MARK: - Focus on click + + // The SwiftUI Color.clear overlay (.onTapGesture) that focuses panes can't receive + // clicks when a WKWebView is underneath — AppKit delivers the click to the deepest + // NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so + // bonsplit focus tracks which pane the user clicked in. + override func mouseDown(with event: NSEvent) { + NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self) + super.mouseDown(with: event) + } + // MARK: - Drag-and-drop passthrough // WKWebView inherently calls registerForDraggedTypes with public.text (and others). diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0c9a24c2..ea7ee068 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2227,4 +2227,5 @@ extension Notification.Name { static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") + static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index d92e2b42..ea60d8e9 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -14,6 +14,10 @@ struct WorkspaceContentView: View { let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || workspace.panels.count > 1 + // Inactive workspaces are kept alive in a ZStack (for state preservation) but their + // AppKit-backed views can still intercept drags. Disable drop acceptance for them. + let _ = { workspace.bonsplitController.isInteractive = isTabActive }() + BonsplitView(controller: workspace.bonsplitController) { tab, paneId in // Content for each tab in bonsplit let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) diff --git a/docs-site/content/docs/changelog.mdx b/docs-site/content/docs/changelog.mdx index efaa6447..78e243b7 100644 --- a/docs-site/content/docs/changelog.mdx +++ b/docs-site/content/docs/changelog.mdx @@ -5,6 +5,13 @@ description: Release notes and version history for cmux All notable changes to cmux are documented here. +## [1.36.0] - 2026-02-17 + +### Fixed +- App hang when omnibar safety timeout failed to fire (blocked main thread) +- Tab drag/drop not working when multiple workspaces exist +- Clicking in browser WebView not focusing the browser tab + ## [1.35.0] - 2026-02-17 ### Fixed diff --git a/tests/test_multi_workspace_focus.py b/tests/test_multi_workspace_focus.py new file mode 100644 index 00000000..c179a9f8 --- /dev/null +++ b/tests/test_multi_workspace_focus.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +""" +Regression test: multi-workspace terminal and browser focus. + +Bug 1 (isHidden): When multiple workspaces exist in a ZStack, inactive workspaces' +AppKit NSViews (NSSplitView, NSHostingController containers) remain in the window's +view hierarchy and intercept events (drags, clicks) even when SwiftUI sets +.allowsHitTesting(false). Fix: set isHidden=true on NSView containers for inactive +workspaces via bonsplit's isInteractive flag. + +Bug 2 (webview click focus): Clicking inside a WKWebView didn't focus the browser +tab because AppKit delivers the click to WKWebView, not to the SwiftUI Color.clear +overlay used for focus tracking. Fix: CmuxWebView.mouseDown posts a notification +that BrowserPanelView listens for to call onRequestPanelFocus(). + +This test validates: + 1) Terminals in non-active workspaces remain responsive after switching back. + 2) Terminals in workspaces with splits remain responsive after cycling through + multiple workspaces (the isHidden toggle doesn't break view attachment). + 3) Browser panel can receive focus and the terminal can reclaim focus afterward. +""" + +import os +import sys +import time +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +MARKER_DIR = Path(tempfile.gettempdir()) + + +def _marker(name: str) -> Path: + return MARKER_DIR / f"cmux_mwf_{name}_{os.getpid()}" + + +def _clear(marker: Path): + marker.unlink(missing_ok=True) + + +def _wait_marker(marker: Path, timeout: float = 5.0) -> bool: + start = time.time() + while time.time() - start < timeout: + if marker.exists(): + return True + time.sleep(0.1) + return False + + +def _verify_responsive(c: cmux, marker: Path, surface_idx: int, retries: int = 3) -> bool: + """Send a touch command to a specific terminal surface and check the marker appears.""" + for attempt in range(retries): + _clear(marker) + try: + c.send_key_surface(surface_idx, "ctrl-c") + except Exception: + time.sleep(0.5) + continue + time.sleep(0.3) + try: + c.send_surface(surface_idx, f"touch {marker}\n") + except Exception: + time.sleep(0.5) + continue + if _wait_marker(marker, timeout=3.0): + return True + time.sleep(0.5) + return False + + +def _wait_terminal_in_window(c: cmux, surface_idx: int, timeout: float = 5.0) -> bool: + start = time.time() + while time.time() - start < timeout: + try: + health = c.surface_health() + except Exception: + health = [] + for h in health: + if h.get("index") == surface_idx and h.get("type") == "terminal" and h.get("in_window"): + return True + time.sleep(0.2) + return False + + +def test_multi_workspace_terminal_responsive(c: cmux) -> None: + """ + Create two workspaces with splits, cycle between them, and verify all terminals + in each workspace remain responsive. Before the isHidden fix, terminals in + workspace A would lose input when workspace B's NSViews sat on top in the + view hierarchy. + """ + # Workspace A + ws_a = c.new_workspace() + time.sleep(0.3) + c.new_split("right") + time.sleep(0.8) + _wait_terminal_in_window(c, 0, timeout=5.0) + _wait_terminal_in_window(c, 1, timeout=5.0) + + # Workspace B + ws_b = c.new_workspace() + time.sleep(0.3) + c.new_split("right") + time.sleep(0.8) + _wait_terminal_in_window(c, 0, timeout=5.0) + _wait_terminal_in_window(c, 1, timeout=5.0) + + # Verify workspace B terminals work (this is the "last" workspace) + m_b0 = _marker("wsb_0") + m_b1 = _marker("wsb_1") + try: + assert _verify_responsive(c, m_b0, 0), "Workspace B surface 0 not responsive" + assert _verify_responsive(c, m_b1, 1), "Workspace B surface 1 not responsive" + finally: + _clear(m_b0) + _clear(m_b1) + + # Switch back to workspace A + c.select_workspace(ws_a) + time.sleep(0.5) + _wait_terminal_in_window(c, 0, timeout=5.0) + _wait_terminal_in_window(c, 1, timeout=5.0) + + # Verify workspace A terminals work (this was the bug: non-last workspace lost input) + m_a0 = _marker("wsa_0") + m_a1 = _marker("wsa_1") + try: + assert _verify_responsive(c, m_a0, 0), \ + "Workspace A surface 0 not responsive after switching back (isHidden regression)" + assert _verify_responsive(c, m_a1, 1), \ + "Workspace A surface 1 not responsive after switching back (isHidden regression)" + finally: + _clear(m_a0) + _clear(m_a1) + + # Cycle back to B and verify + c.select_workspace(ws_b) + time.sleep(0.5) + _wait_terminal_in_window(c, 0, timeout=5.0) + m_b0_2 = _marker("wsb_0_2") + try: + assert _verify_responsive(c, m_b0_2, 0), \ + "Workspace B surface 0 not responsive after cycling" + finally: + _clear(m_b0_2) + + # Cleanup + c.close_workspace(ws_b) + time.sleep(0.3) + c.close_workspace(ws_a) + time.sleep(0.3) + + +def test_three_workspaces_non_last_responsive(c: cmux) -> None: + """ + Three workspaces: verify the FIRST workspace (furthest back in ZStack) is still + responsive. This is the worst case for the old bug since it has two inactive + workspaces' NSViews stacked above it. + """ + ws_a = c.new_workspace() + time.sleep(0.3) + c.new_split("down") + time.sleep(0.8) + + ws_b = c.new_workspace() + time.sleep(0.3) + + ws_c = c.new_workspace() + time.sleep(0.3) + + # Switch back to workspace A (furthest back in ZStack) + c.select_workspace(ws_a) + time.sleep(0.5) + _wait_terminal_in_window(c, 0, timeout=5.0) + _wait_terminal_in_window(c, 1, timeout=5.0) + + m0 = _marker("3ws_0") + m1 = _marker("3ws_1") + try: + assert _verify_responsive(c, m0, 0), \ + "First workspace surface 0 not responsive with 2 workspaces stacked above" + assert _verify_responsive(c, m1, 1), \ + "First workspace surface 1 not responsive with 2 workspaces stacked above" + finally: + _clear(m0) + _clear(m1) + + # Cleanup + c.close_workspace(ws_c) + time.sleep(0.2) + c.close_workspace(ws_b) + time.sleep(0.2) + c.close_workspace(ws_a) + time.sleep(0.2) + + +def test_rapid_workspace_switching_preserves_focus(c: cmux) -> None: + """ + Rapidly switch between workspaces and verify terminals remain responsive. + The isHidden toggle must not break view attachment or cause blank terminals. + """ + ws_a = c.new_workspace() + time.sleep(0.3) + c.new_split("right") + time.sleep(0.8) + + ws_b = c.new_workspace() + time.sleep(0.3) + + # Rapid switching + for _ in range(5): + c.select_workspace(ws_a) + time.sleep(0.15) + c.select_workspace(ws_b) + time.sleep(0.15) + + # Settle on workspace A and verify + c.select_workspace(ws_a) + time.sleep(0.5) + _wait_terminal_in_window(c, 0, timeout=5.0) + _wait_terminal_in_window(c, 1, timeout=5.0) + + m0 = _marker("rapid_0") + m1 = _marker("rapid_1") + try: + assert _verify_responsive(c, m0, 0), \ + "Surface 0 not responsive after rapid workspace switching" + assert _verify_responsive(c, m1, 1), \ + "Surface 1 not responsive after rapid workspace switching" + finally: + _clear(m0) + _clear(m1) + + # Cleanup + c.close_workspace(ws_b) + time.sleep(0.2) + c.close_workspace(ws_a) + time.sleep(0.2) + + +def test_browser_panel_focus_and_return(c: cmux) -> None: + """ + Create a terminal and a browser surface in the same pane, focus the browser, + then switch back to the terminal. Verifies focus routing works correctly for + browser panels. + """ + ws = c.new_workspace() + time.sleep(0.3) + + # Get the terminal panel ID + surfaces = c.list_pane_surfaces() + if not surfaces: + raise cmuxError("No surfaces after new_workspace") + term_panel_id = surfaces[0][1] + + # Create a browser surface in the same pane + browser_panel_id = c.new_surface(panel_type="browser", url="about:blank") + time.sleep(0.5) + + # Focus the browser and verify + c.focus_webview(browser_panel_id) + time.sleep(0.3) + assert c.is_webview_focused(browser_panel_id), \ + "Browser panel should have focus after focus_webview" + + # Switch back to terminal and verify it's responsive + c.focus_surface_by_panel(term_panel_id) + time.sleep(0.3) + + m = _marker("browser_return") + try: + # Use the focused terminal + _clear(m) + c.send_key("ctrl-c") + time.sleep(0.2) + c.send(f"touch {m}\n") + assert _wait_marker(m, timeout=3.0), \ + "Terminal not responsive after switching back from browser" + finally: + _clear(m) + + # Cleanup + c.close_workspace(ws) + time.sleep(0.2) + + +def test_browser_focus_across_workspaces(c: cmux) -> None: + """ + Workspace A has a terminal, workspace B has a browser. Switching between them + should correctly route focus to each panel type. + """ + ws_a = c.new_workspace() + time.sleep(0.3) + + ws_b = c.new_workspace() + time.sleep(0.3) + # Create a browser in workspace B + browser_panel_id = c.new_surface(panel_type="browser", url="about:blank") + time.sleep(0.5) + + # Focus browser in workspace B + c.focus_webview(browser_panel_id) + time.sleep(0.3) + assert c.is_webview_focused(browser_panel_id), \ + "Browser should have focus in workspace B" + + # Switch to workspace A (terminal) + c.select_workspace(ws_a) + time.sleep(0.5) + _wait_terminal_in_window(c, 0, timeout=5.0) + + m = _marker("cross_ws_term") + try: + assert _verify_responsive(c, m, 0), \ + "Terminal in workspace A not responsive after switching from browser workspace" + finally: + _clear(m) + + # Switch back to workspace B and verify browser still works + c.select_workspace(ws_b) + time.sleep(0.5) + c.focus_webview(browser_panel_id) + time.sleep(0.3) + assert c.is_webview_focused(browser_panel_id), \ + "Browser should regain focus after switching back to workspace B" + + # Cleanup + c.close_workspace(ws_b) + time.sleep(0.2) + c.close_workspace(ws_a) + time.sleep(0.2) + + +def main() -> int: + print("=" * 60) + print("Multi-Workspace Focus Regression Tests") + print("=" * 60) + print() + + tests = [ + ("Multi-workspace terminal responsive (isHidden regression)", test_multi_workspace_terminal_responsive), + ("Three workspaces non-last responsive", test_three_workspaces_non_last_responsive), + ("Rapid workspace switching preserves focus", test_rapid_workspace_switching_preserves_focus), + ("Browser panel focus and return", test_browser_panel_focus_and_return), + ("Browser focus across workspaces", test_browser_focus_across_workspaces), + ] + + with cmux() as c: + c.activate_app() + time.sleep(0.2) + + passed = 0 + failed = 0 + + for name, test_fn in tests: + print(f" {name}...", end=" ", flush=True) + try: + test_fn(c) + print("PASS") + passed += 1 + except (AssertionError, cmuxError) as e: + print(f"FAIL: {e}") + failed += 1 + except Exception as e: + print(f"ERROR: {type(e).__name__}: {e}") + failed += 1 + + print() + print(f"Results: {passed} passed, {failed} failed out of {passed + failed}") + + if failed == 0: + print("\nPASS: multi-workspace focus") + return 0 + else: + print(f"\nFAIL: {failed} test(s) failed") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index d8af8119..1d41e7b9 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit d8af81190c90a0ddb28f8dbd5ad79070564b2234 +Subproject commit 1d41e7b9ecfd310e6d780c2d93e74a34d759edc8